Compare commits
29
Commits
dev
...
0c4dd72583
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c4dd72583 | ||
|
|
cf5332c281 | ||
|
|
fe2f459b03 | ||
|
|
8370851ed2 | ||
|
|
fc97c78300 | ||
|
|
eb5b63677f | ||
|
|
78c0c11774 | ||
|
|
5baed18b22 | ||
|
|
2bd53ee4a4 | ||
|
|
39e47ff0e6 | ||
|
|
040780cd2d | ||
|
|
a6d5a8b056 | ||
|
|
e5db0325cc | ||
|
|
41b75810fd | ||
|
|
6af97f5696 | ||
|
|
2eb590caab | ||
|
|
fc5eecb68a | ||
|
|
b107f4ef04 | ||
|
|
84401cc708 | ||
|
|
f0ff59fea7 | ||
|
|
923c108690 | ||
|
|
1edeaa0a48 | ||
|
|
f2701882dc | ||
|
|
c9311997a7 | ||
|
|
ebf666c004 | ||
|
|
d3987c7114 | ||
|
|
992bb04816 | ||
|
|
1e5573af75 | ||
|
|
b42d141927 |
@@ -42,6 +42,7 @@ help:
|
||||
@echo " migration-up Up migrations"
|
||||
@echo " migration-up ENV=production Up migrations (production DB)"
|
||||
@echo " migration-down Down last migration"
|
||||
@echo " wallet-migrate args=-dry-run Move legacy points/tokens into the wallet"
|
||||
@echo
|
||||
@echo " docker-up Up docker services"
|
||||
@echo " docker-down Down docker services"
|
||||
@@ -105,6 +106,13 @@ migration-down:
|
||||
migration-force:
|
||||
@migrate -database $(DB_URL) -path ./migrations force $(version)
|
||||
|
||||
# Move customer_points / customer_tokens into the wallet. Safe to run again.
|
||||
# Pass args=-dry-run to only report.
|
||||
|
||||
.SILENT: wallet-migrate
|
||||
wallet-migrate:
|
||||
ENV_MODE=$(ENV) go run ./cmd/wallet-migrate $(args)
|
||||
|
||||
.SILENT: seeder-create
|
||||
seeder-create:
|
||||
@migrate create -ext sql -dir ./seeders -seq $(name)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Command wallet-migrate moves customer_points and customer_tokens balances into the
|
||||
// EnakPoint & EnakCoin wallet (docs/prd-point-coin.md §10). Safe to run more than once:
|
||||
// each run credits only what earlier runs have not.
|
||||
//
|
||||
// ENV_MODE=staging go run ./cmd/wallet-migrate -dry-run
|
||||
// ENV_MODE=staging go run ./cmd/wallet-migrate
|
||||
//
|
||||
// It exits non-zero when the legacy totals and the migrated totals disagree.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"apskel-pos-be/config"
|
||||
"apskel-pos-be/internal/db"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dryRun := flag.Bool("dry-run", false, "report what would be credited without writing anything")
|
||||
batchSize := flag.Int("batch", 500, "customers read per batch")
|
||||
flag.Parse()
|
||||
|
||||
cfg := config.LoadConfig()
|
||||
pg, err := db.NewPostgres(cfg.Database)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
migrator := processor.NewWalletMigrationProcessor(
|
||||
repository.NewWalletMigrationRepository(pg),
|
||||
processor.NewWalletProcessor(repository.NewWalletRepository(pg)),
|
||||
repository.NewTxManager(pg),
|
||||
)
|
||||
report, err := migrator.Run(context.Background(), *dryRun, *batchSize)
|
||||
if err != nil {
|
||||
log.Fatalf("wallet migration failed: %v", err)
|
||||
}
|
||||
|
||||
verb := "credited"
|
||||
if report.DryRun {
|
||||
verb = "would credit"
|
||||
}
|
||||
t := report.Totals
|
||||
fmt.Printf("customers scanned: %d\n", report.CustomersScanned)
|
||||
fmt.Printf("EnakPoint: %s %d in %d rows\n", verb, report.PointsCredited, report.PointCredits)
|
||||
fmt.Printf("EnakCoin: %s %d in %d rows\n", verb, report.CoinsCredited, report.CoinCredits)
|
||||
fmt.Printf("\n%-10s %15s %15s %15s\n", "", "legacy", "migrated", "wallet balance")
|
||||
fmt.Printf("%-10s %15d %15d %15d\n", "EnakPoint", t.LegacyPoints, t.MigratedPoints, t.WalletPoints)
|
||||
fmt.Printf("%-10s %15d %15d %15d\n", "EnakCoin", t.LegacyCoins, t.MigratedCoins, t.WalletCoins)
|
||||
|
||||
if len(report.Discrepancies) > 0 {
|
||||
fmt.Printf("\n%d balances shrank in the legacy tables after they were migrated.\n", len(report.Discrepancies))
|
||||
fmt.Println("They were left as they are in the wallet; correct them with an adjustment if needed:")
|
||||
for _, d := range report.Discrepancies {
|
||||
fmt.Printf(" customer %s %s: legacy %d, migrated %d\n", d.CustomerID, d.Currency, d.Legacy, d.Migrated)
|
||||
}
|
||||
}
|
||||
|
||||
if report.DryRun {
|
||||
return
|
||||
}
|
||||
if !report.Balanced() {
|
||||
fmt.Println("\nNOT BALANCED: legacy and migrated totals differ")
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("\nbalanced: legacy totals = migrated totals")
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
# Integrasi Produk Timbangan — POS Mobile & Backoffice
|
||||
|
||||
**Migrasi:** `000089` · **Base URL:** `/api/v1` · **Kompatibilitas:** mundur penuh
|
||||
|
||||
Panduan untuk menjual produk per timbangan (ikan, daging, buah) dari sisi klien.
|
||||
Alasan di balik setiap keputusan desain ada di [`rfc-weight-based-products.md`](./rfc-weight-based-products.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Konsep inti
|
||||
|
||||
**Satu penimbangan = satu baris order.**
|
||||
|
||||
Pelanggan memesan Ikan Tude 4,2 ons, lalu memesan Ikan Tude lagi 5,6 ons. Itu **dua
|
||||
baris terpisah**, karena keduanya dua ikan berbeda yang ditimbang sendiri-sendiri.
|
||||
|
||||
| Bukan begini | Melainkan begini |
|
||||
|---|---|
|
||||
| `quantity: 9.8` | `quantity: 1, weight: 4.2`<br>`quantity: 1, weight: 5.6` |
|
||||
| Dua ikan hilang jejaknya, dan `quantity` bertipe integer sehingga pecahan ditolak | Tiap penimbangan berdiri sendiri, bisa di-void atau dibayar terpisah |
|
||||
|
||||
Empat aturan yang berlaku di seluruh dokumen ini:
|
||||
|
||||
1. `quantity` untuk produk timbangan **selalu 1**. Backend memaksanya, dan database
|
||||
menolak nilai lain lewat constraint `chk_order_items_weight_single_line`.
|
||||
2. `weight` menyimpan angka timbangan, dalam satuan produk itu sendiri (ons, kg, gram —
|
||||
apa pun yang dipilih saat setup).
|
||||
3. Harga baris dihitung `weight × unit_price`, bukan `quantity × unit_price`.
|
||||
`unit_price` tetap berarti harga per satu satuan (per ons).
|
||||
4. **Jangan pernah menggabungkan dua baris** produk timbangan menjadi satu, meski
|
||||
produknya sama.
|
||||
|
||||
Untuk produk biasa tidak ada yang berubah: `weight` tidak dikirim, `quantity` tetap
|
||||
cacah seperti sekarang.
|
||||
|
||||
---
|
||||
|
||||
## 2. Backoffice — setup produk
|
||||
|
||||
### 2.1 Pastikan satuannya ada
|
||||
|
||||
Satuan disimpan per organisasi. Buat sekali, pakai ulang untuk semua produk timbangan.
|
||||
|
||||
`POST /api/v1/units`
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Ons",
|
||||
"abbreviation": "ons",
|
||||
"is_active": true
|
||||
}
|
||||
```
|
||||
|
||||
`abbreviation` yang dipakai POS untuk mencetak `4,2 ons` di struk — isi dengan bentuk
|
||||
pendek yang benar-benar ingin ditampilkan. Daftar satuan dibaca lewat `GET /api/v1/units`.
|
||||
|
||||
### 2.2 Buat produk sebagai produk timbangan
|
||||
|
||||
`POST /api/v1/products`
|
||||
|
||||
```json
|
||||
{
|
||||
"category_id": "…",
|
||||
"name": "Ikan Tude",
|
||||
"price": 4500,
|
||||
"cost": 3000,
|
||||
"sell_by": "weight",
|
||||
"unit_id": "<id satuan Ons>"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Tipe | Keterangan |
|
||||
|---|---|---|
|
||||
| `sell_by` | `"unit"` \| `"weight"` | Opsional, default `"unit"`. Nilai lain **ditolak** dengan pesan jelas. |
|
||||
| `unit_id` | UUID | **Wajib** saat `sell_by: "weight"`, ditolak backend bila kosong. Opsional untuk produk satuan. |
|
||||
| `price` | number | Harga per satu satuan. Rp 4.500 per ons, bukan harga per ikan. |
|
||||
|
||||
Pada `PUT /api/v1/products/:id`, `unit_id` **tidak perlu dikirim ulang** bila produknya
|
||||
sudah punya satuan — mengubah `sell_by` menjadi `"weight"` saja sudah cukup. Yang ditolak
|
||||
adalah kondisi akhirnya: produk yang dijual per timbangan tanpa satuan.
|
||||
|
||||
Keduanya juga bisa diubah lewat `PUT /api/v1/products/:id` dengan bentuk yang sama, dan
|
||||
ikut terbaca di setiap response produk (`GET /api/v1/products`, `/products/all`,
|
||||
`/products/:id`).
|
||||
|
||||
### 2.3 Catatan UI
|
||||
|
||||
- Kunci `sell_by` **setelah produk punya transaksi**. Mengubah produk lama dari `unit`
|
||||
ke `weight` tidak mengubah baris order yang sudah ada — baris lama tetap dihitung per
|
||||
cacah — tapi akan membingungkan pengguna yang melihat riwayatnya.
|
||||
- Saat `weight` dipilih, jadikan pemilih satuan sebagai field **wajib** di form. Backend
|
||||
juga menolaknya, tapi ditangkap di form lebih baik daripada baru gagal saat simpan.
|
||||
- Ubah label harga mengikuti satuan yang dipilih: *"Harga per ons"*.
|
||||
- Untuk produk satuan, pemilih satuan boleh disembunyikan — `unit_id` opsional dan belum
|
||||
dikonsumsi apa pun di POS.
|
||||
|
||||
---
|
||||
|
||||
## 3. POS Mobile — transaksi
|
||||
|
||||
### 3.1 Bentuk input mengikuti `sell_by`
|
||||
|
||||
| `sell_by` | Input di POS | Yang dikirim |
|
||||
|---|---|---|
|
||||
| `"unit"` | Stepper − / + seperti sekarang | `quantity: n`, tanpa `weight` |
|
||||
| `"weight"` | Papan angka desimal, satuan dari `unit` produk | `quantity: 1` + `weight: 4.2` |
|
||||
|
||||
### 3.2 Mengirim order
|
||||
|
||||
`POST /api/v1/orders`
|
||||
|
||||
```json
|
||||
{
|
||||
"outlet_id": "…",
|
||||
"user_id": "…",
|
||||
"order_type": "dine_in",
|
||||
"order_items": [
|
||||
{ "product_id": "<ikan-tude>", "quantity": 1, "weight": 4.2 },
|
||||
{ "product_id": "<ikan-tude>", "quantity": 1, "weight": 5.6 },
|
||||
{ "product_id": "<nasi-goreng>", "quantity": 2 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Bentuk yang sama berlaku untuk `POST /api/v1/orders/:id/add-items` dan untuk pemesanan
|
||||
mandiri `POST /api/v1/self-order/orders`.
|
||||
|
||||
**Presisi.** Berat dibulatkan backend ke 3 desimal. Kirim `4.2` atau `4.237`; angka di
|
||||
bawah `0.001` membulat ke nol dan ditolak.
|
||||
|
||||
**Harga.** Field `unit_price` pada request **diabaikan** — harga selalu diambil backend
|
||||
dari master produk. Jangan mengirim harga hasil hitungan sendiri.
|
||||
|
||||
---
|
||||
|
||||
## 4. Menampilkan baris
|
||||
|
||||
Setiap `order_items[]` di response membawa empat field tambahan:
|
||||
|
||||
```json
|
||||
{
|
||||
"product_name": "Ikan Tude",
|
||||
"quantity": 1,
|
||||
"weight": 4.2,
|
||||
"unit_id": "…",
|
||||
"unit_name": "Ons",
|
||||
"unit_abbreviation": "ons",
|
||||
"unit_price": 4500,
|
||||
"total_price": 18900
|
||||
}
|
||||
```
|
||||
|
||||
Semuanya `null` atau absen untuk produk biasa, jadi cabangkan tampilan pada `weight`:
|
||||
|
||||
| Kondisi | Tampilkan |
|
||||
|---|---|
|
||||
| `weight == null` | `2 × Rp 25.000` |
|
||||
| `weight != null` | `4,2 ons × Rp 4.500` |
|
||||
|
||||
**Jangan menampilkan `quantity` untuk baris berbobot** — nilainya selalu 1 dan akan
|
||||
terbaca seperti "satu ons". Gunakan `weight` dengan `unit_abbreviation`, dan pakai koma
|
||||
desimal sesuai format Indonesia.
|
||||
|
||||
---
|
||||
|
||||
## 5. Void, refund, split bill
|
||||
|
||||
Baris berbobot bersifat **utuh**: dibatalkan seluruhnya atau tidak sama sekali. Karena
|
||||
`quantity`-nya 1, semua endpoint cukup dikirimi `1`, dan backend menghitung nilai
|
||||
rupiahnya dari `weight`.
|
||||
|
||||
| Aksi | Endpoint | Field untuk baris berbobot |
|
||||
|---|---|---|
|
||||
| Void per item | `POST /orders/void` | `items[].quantity: 1` |
|
||||
| Refund per item | `POST /orders/:id/refund` | `order_items[].refund_quantity: 1` (atau kosongkan) |
|
||||
| Split bill per item | `POST /orders/split-bill` | `items[].quantity: 1` = bayar baris itu penuh |
|
||||
|
||||
```json
|
||||
{
|
||||
"order_id": "…",
|
||||
"reason": "Salah timbang",
|
||||
"type": "ITEM",
|
||||
"items": [
|
||||
{ "order_item_id": "<baris 4,2 ons>", "quantity": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Untuk split bill, baris berbobot hanya bisa berstatus belum dibayar atau lunas — tidak
|
||||
ada nilai di antaranya. Sembunyikan stepper jumlah pada baris berbobot, ganti dengan
|
||||
tombol pilih baris.
|
||||
|
||||
**Batasan yang disengaja.** Mengembalikan *sebagian berat* — 1 ons dari baris 4,2 ons —
|
||||
tidak didukung. Koreksi salah timbang ditangani dengan void baris itu lalu input ulang,
|
||||
sehingga jejak auditnya tetap jujur.
|
||||
|
||||
---
|
||||
|
||||
## 6. Referensi error
|
||||
|
||||
Semua error mengikuti amplop standar. Pesan validasi baru muncul dengan kode `900`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [
|
||||
{ "code": "900", "entity": "ORDER",
|
||||
"cause": "product Ikan Tude is sold by weight and requires a weight" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Pesan (`cause`) | Penyebab | Perbaikan di klien |
|
||||
|---|---|---|
|
||||
| `… is sold by weight and requires a weight` | Produk `sell_by: "weight"` dikirim tanpa `weight` | Wajibkan input timbangan sebelum item masuk keranjang |
|
||||
| `… is not sold by weight and must not carry a weight` | `weight` dikirim untuk produk satuan | Kirim `weight` hanya bila `sell_by == "weight"` |
|
||||
| `weight for … must be greater than 0` | Berat nol, negatif, atau membulat ke nol | Validasi minimal `0.001` di keypad |
|
||||
| `quantity for … must be at least 1` | Produk satuan dengan `quantity` ≤ 0 | Perilaku lama, tidak berubah |
|
||||
|
||||
Pesan menyebut **nama produk**, sehingga bisa ditampilkan apa adanya ke kasir.
|
||||
|
||||
### Setup produk (Backoffice)
|
||||
|
||||
| Pesan (`cause`) | Penyebab | Perbaikan di klien |
|
||||
|---|---|---|
|
||||
| `unit_id is required when sell_by is 'weight'` | Produk timbangan dibuat tanpa satuan | Wajibkan pemilih satuan saat Timbangan dipilih |
|
||||
| `sell_by must be either 'unit' or 'weight'` | Nilai `sell_by` di luar dua itu | Kirim persis `"unit"` atau `"weight"` |
|
||||
| `product '…' is sold by weight and requires a unit_id` | Update membuat produk jadi timbangan tanpa satuan | Kirim `unit_id` bersama perubahan `sell_by` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Kompatibilitas mundur
|
||||
|
||||
- Semua produk lama otomatis `sell_by: "unit"`. Perilakunya identik dengan sebelumnya.
|
||||
- `weight` opsional di request. Klien yang tidak mengenalnya tetap berfungsi penuh untuk
|
||||
produk satuan.
|
||||
- Field baru di response semuanya `omitempty` — tidak muncul untuk baris biasa, jadi
|
||||
parser lama tidak terganggu.
|
||||
- `quantity` tetap **integer** di seluruh API. Tidak ada field yang berubah tipe.
|
||||
|
||||
Yang tidak berfungsi di klien lama hanyalah menjual produk timbangan itu sendiri —
|
||||
permintaannya ditolak dengan pesan jelas, bukan gagal diam-diam.
|
||||
|
||||
---
|
||||
|
||||
## 8. Batasan yang diketahui
|
||||
|
||||
- **Pembulatan uang ke 2 desimal.** `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`,
|
||||
bukan dibulatkan ke rupiah utuh. Bila kasir harus menerima rupiah penuh, ini perlu
|
||||
diputuskan dan diubah di backend lebih dulu (`RoundMoney`, satu tempat).
|
||||
- **Stok belum otomatis berkurang** saat penjualan — untuk produk timbangan maupun
|
||||
produk biasa. Pengurangan stok belum tersambung di backend, jadi jangan menampilkan
|
||||
sisa stok yang mengandaikan itu berjalan.
|
||||
|
||||
---
|
||||
|
||||
## 9. Checklist per tim
|
||||
|
||||
**Backoffice Website**
|
||||
|
||||
- [ ] CRUD satuan tersedia di menu master data
|
||||
- [ ] Form produk punya pilihan cara jual: Satuan / Timbangan
|
||||
- [ ] Pemilih satuan menjadi wajib saat Timbangan dipilih
|
||||
- [ ] Label harga ikut satuan — "Harga per ons"
|
||||
- [ ] `sell_by` dikunci untuk produk yang sudah bertransaksi
|
||||
- [ ] Daftar produk menandai mana yang dijual per timbangan
|
||||
- [ ] Laporan harian menampilkan kolom Berat
|
||||
|
||||
**POS Mobile**
|
||||
|
||||
- [ ] Menu membaca `sell_by` tiap produk
|
||||
- [ ] Papan angka desimal untuk produk timbangan
|
||||
- [ ] Kirim `quantity: 1` + `weight`
|
||||
- [ ] Dua penimbangan menjadi dua baris, tidak digabung
|
||||
- [ ] Keranjang & struk menampilkan `4,2 ons × Rp 4.500`
|
||||
- [ ] Void & refund baris berbobot bersifat utuh
|
||||
- [ ] Split bill: pilih baris, bukan stepper jumlah
|
||||
- [ ] Pesan error validasi ditampilkan ke kasir
|
||||
@@ -0,0 +1,362 @@
|
||||
# RFC: Produk Timbangan (Weight-Based Products)
|
||||
|
||||
**Status:** Diimplementasikan (migrasi `000089`)
|
||||
**Tanggal:** 2026-09-05, diperbarui 2026-09-06
|
||||
**Scope:** Product, Order, Void/Refund, Report
|
||||
**Out of scope:** Inventory / pengurangan stok otomatis (lihat §8)
|
||||
|
||||
---
|
||||
|
||||
## 1. Masalah
|
||||
|
||||
Sistem mengasumsikan setiap produk dijual dalam satuan diskrit. `order_items.quantity`
|
||||
bertipe `INTEGER` dengan `CHECK (quantity > 0)`, dan harga dihitung
|
||||
`quantity × unit_price` di seluruh jalur order, void, refund, dan split bill.
|
||||
|
||||
Produk seperti Ikan Tude dijual per timbangan. Pelanggan memesan Ikan Tude 4,2 ons,
|
||||
lalu memesan Ikan Tude lagi 5,6 ons. Keduanya adalah **dua ikan berbeda yang ditimbang
|
||||
terpisah** — bukan satu baris berisi 9,8.
|
||||
|
||||
Angka 4,2 itu **berat**, bukan cacah. Sistem belum punya tempat untuk menyimpannya.
|
||||
|
||||
> **Catatan satuan.** RFC ini tidak mengasumsikan satuan tertentu. Satuan produk
|
||||
> ditentukan `products.unit_id` yang merujuk tabel `units` — bisa ons, kg, gram, atau
|
||||
> apa pun yang didefinisikan organisasi. Contoh memakai **ons** karena itu kasus yang
|
||||
> sedang dikerjakan; tidak ada bagian desain ini yang bergantung padanya.
|
||||
|
||||
---
|
||||
|
||||
## 2. Keputusan Inti
|
||||
|
||||
**Satu penimbangan = satu baris `order_items`.**
|
||||
|
||||
| | Baris 1 | Baris 2 |
|
||||
|---|---|---|
|
||||
| Ikan Tude 4,2 ons | `quantity = 1`, `weight = 4.2` | |
|
||||
| Ikan Tude 5,6 ons | | `quantity = 1`, `weight = 5.6` |
|
||||
|
||||
`quantity` tetap `INTEGER` dan tetap berarti "berapa banyak barang". Berat masuk ke
|
||||
kolom baru. Dua baris tidak pernah digabung menjadi `9.8`, karena keduanya memang dua
|
||||
ikan yang berbeda.
|
||||
|
||||
### Kenapa bukan `quantity = 4.2`
|
||||
|
||||
Alternatif yang sempat dipertimbangkan adalah mengubah `quantity` menjadi
|
||||
`DECIMAL(12,3)`. Model itu ditolak karena tiga alasan:
|
||||
|
||||
1. **Menghapus jejak barang.** `4.2` dan `5.6` yang digabung jadi `9.8` kehilangan
|
||||
informasi bahwa ada dua ikan. Tidak bisa direkonstruksi.
|
||||
2. **Merusak agregasi lintas produk.** `SUM(quantity)` untuk laporan
|
||||
"total item terjual" akan menjumlahkan ons dengan porsi — angka tanpa arti, yang
|
||||
bahkan berubah nilainya bila satuan produk diganti dari ons ke kg tanpa ada apa pun
|
||||
yang berubah di dunia nyata.
|
||||
3. **Membawa masalah presisi float ke seluruh sistem.** Perbandingan quantity dipakai
|
||||
di void, refund, dan split bill. Dengan float, `1,4 + 1,4 + 1,4` tidak sama dengan
|
||||
`4,2` — split bill "bagi rata bertiga" akan gagal menandai item lunas meski uang
|
||||
sudah diterima penuh. Semua itu tidak terjadi bila `quantity` tetap integer.
|
||||
|
||||
Konsekuensi langsung dari keputusan ini: **tidak diperlukan helper perbandingan
|
||||
epsilon.** Berat tidak pernah dibandingkan, hanya dikalikan.
|
||||
|
||||
---
|
||||
|
||||
## 3. Prinsip
|
||||
|
||||
**P1 — Baris transaksi adalah snapshot yang beku.**
|
||||
`order_items` sudah menyimpan `unit_price` dan `unit_cost` sebagai salinan, bukan join
|
||||
ke `products`. Satuan mendapat perlakuan sama: mengubah master data tidak boleh
|
||||
mengubah arti transaksi yang sudah terjadi.
|
||||
|
||||
**P2 — Perhitungan harga baris hanya ada di satu tempat.**
|
||||
Setelah RFC ini ada dua rumus (`quantity × harga` dan `weight × harga`). Tidak boleh
|
||||
ada perkalian harga yang tersebar; semuanya memanggil satu fungsi.
|
||||
|
||||
**P3 — Harga tetap otoritas backend.**
|
||||
Klien tidak pernah mengirim harga. Backend membacanya dari `products` /
|
||||
`product_outlet_prices` seperti sekarang.
|
||||
|
||||
**P4 — Berat boleh dijumlahkan dalam satu produk, tidak boleh antar produk.**
|
||||
`SUM(weight)` untuk satu produk bermakna ("terjual 47,3 ons"). Lintas produk dengan
|
||||
satuan berbeda tidak bermakna.
|
||||
|
||||
---
|
||||
|
||||
## 4. Perubahan Skema
|
||||
|
||||
```sql
|
||||
-- Products: cara jual
|
||||
ALTER TABLE products
|
||||
ADD COLUMN sell_by VARCHAR(20) NOT NULL DEFAULT 'unit'
|
||||
CHECK (sell_by IN ('unit', 'weight'));
|
||||
|
||||
-- Order items: berat + snapshot satuan
|
||||
ALTER TABLE order_items
|
||||
ADD COLUMN weight DECIMAL(12,3),
|
||||
ADD COLUMN unit_id UUID REFERENCES units(id) ON DELETE RESTRICT;
|
||||
|
||||
ALTER TABLE order_items
|
||||
ADD CONSTRAINT chk_order_items_weight_positive
|
||||
CHECK (weight IS NULL OR weight > 0),
|
||||
ADD CONSTRAINT chk_order_items_weight_single_line
|
||||
CHECK (weight IS NULL OR quantity = 1);
|
||||
```
|
||||
|
||||
**Catatan:**
|
||||
|
||||
- `weight` **nullable**. `NULL` berarti produk satuan biasa — seluruh data lama valid
|
||||
tanpa backfill, dan perilakunya tidak berubah sama sekali.
|
||||
- `chk_order_items_weight_single_line` menegakkan keputusan §2 di level database:
|
||||
baris berbobot selalu `quantity = 1`. Ini yang membuat `BillableQuantity()` tidak
|
||||
ambigu dan membuat void otomatis bersifat utuh (§6).
|
||||
- `quantity` **tidak berubah tipe**. `CHECK (quantity > 0)` yang sudah ada tetap
|
||||
berlaku.
|
||||
- `DECIMAL(12,3)` konsisten dengan `inventory_movements.quantity` yang sudah memakai
|
||||
presisi sama.
|
||||
- Tidak ada `weighed_unit`. Karena satu baris memang satu barang, "ikan curah" dan
|
||||
"ikan per ekor" berperilaku identik — pembedaan itu tidak punya konsekuensi.
|
||||
|
||||
---
|
||||
|
||||
## 5. Perhitungan Harga
|
||||
|
||||
Satu-satunya tempat yang boleh mengalikan harga (P2):
|
||||
|
||||
```go
|
||||
// BillableQuantity mengembalikan pengali harga untuk baris ini:
|
||||
// berat bila produk dijual per timbangan, jumlah bila dijual per satuan.
|
||||
// Baris berbobot dijamin quantity = 1 oleh constraint DB.
|
||||
func (oi *OrderItem) BillableQuantity() float64 {
|
||||
if oi.Weight != nil {
|
||||
return *oi.Weight
|
||||
}
|
||||
return float64(oi.Quantity)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalPrice() {
|
||||
oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalCost() {
|
||||
oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost)
|
||||
}
|
||||
```
|
||||
|
||||
`unit_price` tetap berarti **harga per satu satuan produk** (per ons). Tidak ada faktor
|
||||
konversi yang menyelinap ke perhitungan uang.
|
||||
|
||||
### Titik yang harus diganti
|
||||
|
||||
Ini bagian paling berisiko dari RFC. Setiap perkalian harga yang terlewat akan
|
||||
menghitung `1 × harga_per_ons` — ikan 4,2 ons ditagih seharga 1 ons. **Salah uang,
|
||||
bukan salah tampilan**, dan tidak memicu error apa pun.
|
||||
|
||||
| Lokasi | Sekarang |
|
||||
|---|---|
|
||||
| `processor/order_processor.go:197-198` | buat order |
|
||||
| `processor/order_processor.go:330-331` | tambah item ke order |
|
||||
| `processor/order_processor.go:605-606` | jumlah & HPP yang di-void |
|
||||
| `processor/order_processor.go:723` | jumlah refund |
|
||||
| `processor/split_bill_processor.go:143` | hitung jumlah split |
|
||||
| `processor/split_bill_processor.go:189` | catat pembayaran |
|
||||
| `processor/split_bill_processor.go:231` | metadata pembayaran |
|
||||
| `repository/order_item_repository.go:113` | jumlah void penuh |
|
||||
|
||||
Implementasi menemukan **lima titik tambahan** di luar daftar di atas, semuanya di jalur
|
||||
inventory movement dan resep bahan yang tidak terlihat saat RFC ini ditulis:
|
||||
|
||||
| Lokasi | Status |
|
||||
|---|---|
|
||||
| `order_processor.go:1056` `createInventoryMovement` | mati (0 pemanggil), tetap diperbaiki |
|
||||
| `order_processor.go:1354` `prepareProductInventoryMovement` | **hidup** |
|
||||
| `order_processor.go:1420` `prepareIngredientRecipeItem` | **hidup** |
|
||||
| `order_processor.go:1518` `prepareRefundProductInventoryMovement` | mati (0 pemanggil), tetap diperbaiki |
|
||||
| `order_processor.go:1584` `prepareRefundedIngredientRecipeItem` | **hidup** |
|
||||
|
||||
Tiga yang hidup penting: tanpa perbaikan, konsumsi bahan untuk ikan 4,2 ons akan
|
||||
dihitung sebagai 1 satuan resep.
|
||||
|
||||
### Verifikasi
|
||||
|
||||
```bash
|
||||
grep -rn "Quantity) \* \|Quantity \* " --include=*.go internal/ \
|
||||
| grep -iE "price|cost" | grep -v BillableQuantity | grep -v totalIngredientQuantity
|
||||
```
|
||||
|
||||
Hasilnya **tidak kosong** — tersisa tujuh baris, semuanya sudah diperiksa dan aman:
|
||||
|
||||
- `mappers/inventory_movement_mapper.go:129` dan
|
||||
`processor/inventory_movement_processor.go:69` — penyesuaian stok manual, bukan
|
||||
baris order.
|
||||
- `repository/order_item_repository.go:144,146,147,165,166` — cabang void sebagian,
|
||||
yang baris berbobot tidak pernah jangkau karena dijaga `orderItem.IsWeighed()`.
|
||||
|
||||
Bila daftar ini bertambah di kemudian hari, baris barunya harus diperiksa satu per satu.
|
||||
|
||||
---
|
||||
|
||||
## 6. Void, Refund, Split Bill
|
||||
|
||||
**Tidak ada perubahan logika.** Ini konsekuensi menyenangkan dari `quantity` yang tetap
|
||||
integer.
|
||||
|
||||
**Void.** `VoidOrderItem` (`repository/order_item_repository.go:104`) bercabang pada
|
||||
`voidQuantity >= orderItem.Quantity`. Untuk baris berbobot, `quantity` dijamin `1` dan
|
||||
`voidQuantity` minimal `1`, sehingga **selalu** masuk cabang void penuh. Cabang
|
||||
pemecahan baris tidak pernah tersentuh, sehingga tidak mungkin lahir baris sisa
|
||||
berbobot nol. Yang berubah hanya perhitungan `voidedAmount` di baris 113 (§5).
|
||||
|
||||
**Refund.** Sama — refund baris berbobot bersifat utuh. Hanya `refundAmount` di
|
||||
`order_processor.go:723` yang perlu memakai `BillableQuantity()`.
|
||||
|
||||
**Split bill.** `payment_order_items.quantity` tetap `INTEGER`. Untuk baris berbobot
|
||||
nilainya `0` atau `1` — bayar penuh atau tidak sama sekali. Seluruh perbandingan di
|
||||
`split_bill_processor.go` tetap aritmatika bilangan bulat, sehingga masalah presisi
|
||||
float tidak pernah muncul. Hanya perhitungan `itemAmount` (baris 143 dan 189) yang
|
||||
berubah.
|
||||
|
||||
**Batasan yang diterima:** refund atau void **sebagian berat** (mengembalikan 1 ons
|
||||
dari baris 4,2 ons) tidak didukung. Untuk barang yang sudah ditimbang dan diserahkan,
|
||||
koreksi sebagian pada praktiknya berarti salah timbang — yang penanganan benarnya
|
||||
adalah void baris itu lalu input ulang, bukan mengubah berat baris yang sudah tercatat.
|
||||
Ini menjaga jejak audit tetap jujur.
|
||||
|
||||
---
|
||||
|
||||
## 7. Validasi & Tampilan
|
||||
|
||||
### 7.1 Aturan validasi
|
||||
|
||||
Divalidasi di processor saat membuat / menambah item, di mana produk sudah dimuat:
|
||||
|
||||
| `products.sell_by` | Aturan |
|
||||
|---|---|
|
||||
| `unit` | `weight` harus kosong. Bila dikirim → tolak. |
|
||||
| `weight` | `weight` wajib ada dan `> 0`. `quantity` dipaksa `1`. |
|
||||
|
||||
`unit_id` di `order_items` diisi dari `products.unit_id` saat baris dibuat (P1) —
|
||||
bukan dibaca lewat join saat ditampilkan.
|
||||
|
||||
Berat dibulatkan ke 3 desimal saat masuk, agar nilai tersimpan selalu sama dengan
|
||||
nilai yang divalidasi.
|
||||
|
||||
### 7.2 Tampilan
|
||||
|
||||
`templates/daily_transaction.html:539` mencetak `{{$item.Quantity}}`. Untuk baris
|
||||
berbobot ini akan menampilkan `1`, bukan `4,2 ons`. Perlu bercabang pada `weight`.
|
||||
|
||||
Response API menambah `weight` dan `unit` pada item, agar frontend dan struk dapat
|
||||
menampilkan `4,2 ons × Rp 4.500` alih-alih `1 × Rp 4.500`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Report
|
||||
|
||||
**Tidak ada perubahan yang wajib.** Karena `quantity` tetap integer dan tetap berarti
|
||||
"berapa banyak barang":
|
||||
|
||||
- `SUM(oi.quantity)` sebagai `total_items` tetap bermakna dan tetap konsisten lintas
|
||||
produk — 2 ikan tetap dihitung 2, bukan 9,8 ons.
|
||||
- `QuantitySold` tetap `int64`. Tidak ada pemotongan pecahan.
|
||||
- `average_price = SUM(total_price) / SUM(quantity)` menjadi "rata-rata harga per
|
||||
ekor", yang tetap merupakan angka bermakna.
|
||||
|
||||
**Tambahan opsional** — melaporkan berat terjual, hanya pada laporan **per produk**
|
||||
(P4):
|
||||
|
||||
```sql
|
||||
COALESCE(SUM(oi.weight), 0) AS weight_sold
|
||||
```
|
||||
|
||||
Tidak boleh dipakai pada agregat lintas produk, karena akan menjumlahkan satuan yang
|
||||
berbeda.
|
||||
|
||||
---
|
||||
|
||||
## 9. Di Luar Scope
|
||||
|
||||
**Pengurangan stok otomatis.** `adjustInventoryWithTransaction`
|
||||
(`order_processor.go:1177`) dan `adjustIngredientInventoryWithTransaction`
|
||||
(`order_processor.go:920`) terdefinisi tetapi **tidak pernah dipanggil dari mana pun** —
|
||||
sudah diverifikasi se-repo. Endpoint CRUD inventory berfungsi; pengurangan stok saat
|
||||
penjualan tidak tersambung.
|
||||
|
||||
Konsekuensi untuk RFC ini: `inventory.quantity` yang masih `int` tidak menghalangi
|
||||
apa pun.
|
||||
|
||||
Catatan untuk nanti bila jalur stok disambungkan:
|
||||
|
||||
- Stok produk timbangan harus berkurang sebesar `weight`, bukan `quantity` — kalau
|
||||
tidak, menjual ikan 4,2 ons hanya mengurangi stok sebanyak 1.
|
||||
- `inventory.quantity` perlu menjadi `DECIMAL(12,3)` lebih dulu. Biayanya hampir nol
|
||||
sekarang (3 call site, tanpa data historis); jauh lebih mahal setelah berjalan.
|
||||
- `order_processor.go:946` berisi `deltaInt := int(delta)` yang memotong pecahan.
|
||||
Kode ini mati, jadi bukan kebocoran aktif — tetapi bila disambungkan tanpa
|
||||
diperbaiki, konsumsi bahan di bawah 1 unit akan hilang diam-diam.
|
||||
|
||||
Kedua fungsi mati itu sebaiknya **dihapus atau disambungkan**, jangan dibiarkan
|
||||
menggantung — komentar di dalamnya ditulis seolah-olah aktif.
|
||||
|
||||
---
|
||||
|
||||
## 10. Temuan Sampingan: `unit_price` pada request diabaikan
|
||||
|
||||
`CreateOrderItemRequest.UnitPrice` (`contract/order_contract.go:46`) berkomentar
|
||||
*"Optional, will use database price if not provided"*. Kenyataannya field ini **tidak
|
||||
pernah dipakai** — satu-satunya yang menyentuhnya adalah validasi `< 0` di
|
||||
`service/order_service.go:431` dan `:474`. Processor selalu membaca harga dari
|
||||
`products` / `product_outlet_prices`.
|
||||
|
||||
Perilaku sekarang sudah benar dan sesuai P3. Yang salah hanya komentarnya, yang
|
||||
menyiratkan klien bisa mengirim harga. Sebaiknya field itu **dihapus** dari contract,
|
||||
atau komentarnya dikoreksi menjadi keterangan bahwa harga selalu diambil dari database.
|
||||
|
||||
Dibiarkan seperti sekarang, ini mengundang frontend mengirim harga dan menyangka
|
||||
berhasil, padahal diabaikan diam-diam.
|
||||
|
||||
---
|
||||
|
||||
## 11. Urutan Implementasi
|
||||
|
||||
1. **Migrasi skema** (§4). Aman: semua kolom nullable atau ber-default, data lama
|
||||
tidak tersentuh.
|
||||
2. **`BillableQuantity()` + `CalculateTotalPrice()` / `CalculateTotalCost()`** (§5).
|
||||
3. **Ganti 8 titik perkalian harga** (§5) lalu jalankan dua `grep` verifikasi.
|
||||
4. **Field kontrak**: `weight` pada request order & self-order, `weight` + `unit` pada
|
||||
response.
|
||||
5. **Validasi `sell_by`** (§7.1).
|
||||
6. **Template & tampilan struk** (§7.2).
|
||||
7. *(Opsional)* `weight_sold` pada laporan per produk (§8).
|
||||
|
||||
Langkah 1-4 membuat produk timbangan dapat dijual dengan harga yang benar. Langkah 5
|
||||
mencegah data tidak konsisten masuk. Langkah 6 membuat struk terbaca benar.
|
||||
|
||||
---
|
||||
|
||||
## 12. Risiko
|
||||
|
||||
| Risiko | Dampak | Mitigasi |
|
||||
|---|---|---|
|
||||
| Satu titik perkalian harga terlewat | Ikan 4,2 ons ditagih seharga 1 ons — salah uang, tanpa error | Dua `grep` verifikasi di §5; uji satu order timbangan lewat setiap jalur (create, tambah item, void, refund, split bill) |
|
||||
| `weight` dikirim untuk produk `unit` | Harga baris salah total | Validasi §7.1 + constraint DB |
|
||||
| `quantity > 1` pada baris berbobot | `BillableQuantity()` ambigu | Dicegah `chk_order_items_weight_single_line` di level DB |
|
||||
| Klien lama tidak mengirim `weight` | Produk timbangan ditagih 1 satuan | Validasi §7.1 menolak, bukan mendiamkan |
|
||||
| Struk menampilkan `1` alih-alih `4,2 ons` | Pelanggan bingung, kasir kehilangan kepercayaan | §7.2 |
|
||||
|
||||
---
|
||||
|
||||
## 13. Pertanyaan Terbuka
|
||||
|
||||
1. **Pembulatan uang — diputuskan sementara, perlu konfirmasi.** `RoundMoney`
|
||||
membulatkan ke **2 desimal**, mengikuti presisi kolom `decimal(10,2)` yang sudah
|
||||
dipakai semua nilai uang. Jadi `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`.
|
||||
|
||||
Ini pilihan paling tidak mengejutkan dan konsisten dengan data lama, tetapi **bukan**
|
||||
pembulatan ke rupiah utuh. Bila kasir harus menerima uang dalam rupiah penuh (atau
|
||||
kelipatan Rp 100/500), ubah `RoundMoney` di `entities/order_item.go` — satu tempat,
|
||||
dan lakukan **sebelum** ada transaksi timbangan, karena setelahnya data lama dan
|
||||
baru akan mengikuti aturan berbeda.
|
||||
2. **Presisi input berat.** Apakah `4,237 ons` (resolusi 0,1 gram) valid, atau input
|
||||
harus dibatasi ke kelipatan tertentu sesuai resolusi timbangan? Bila perlu dibatasi,
|
||||
tambahkan `products.min_weight_increment`.
|
||||
3. **Sumber angka timbangan** — kasir mengetik manual atau timbangan tersambung? Bila
|
||||
tersambung, ada urusan tara dan pembacaan stabil yang berada di luar RFC ini.
|
||||
@@ -76,6 +76,7 @@ require (
|
||||
github.com/subosito/gotenv v1.4.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
github.com/zeebo/errs v1.4.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect
|
||||
@@ -107,6 +108,7 @@ require (
|
||||
|
||||
require (
|
||||
firebase.google.com/go/v4 v4.19.0
|
||||
github.com/alicebob/miniredis/v2 v2.39.0
|
||||
github.com/aws/aws-sdk-go v1.55.7
|
||||
github.com/boombuler/barcode v1.1.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3
|
||||
|
||||
@@ -74,6 +74,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
|
||||
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||
github.com/alicebob/miniredis/v2 v2.39.0 h1:M7WbmV5BmV56L8KTG0rw6vEQ+woTOghpDgin2xv4A0g=
|
||||
github.com/alicebob/miniredis/v2 v2.39.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE=
|
||||
github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
@@ -349,6 +351,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
|
||||
+65
-3
@@ -31,6 +31,8 @@ type App struct {
|
||||
router *router.Router
|
||||
shutdown chan os.Signal
|
||||
omsetScheduler *service.OmsetMilestoneScheduler
|
||||
walletRecon *service.WalletReconciliationJob
|
||||
earningRetry *service.EarningBackfillJob
|
||||
}
|
||||
|
||||
func NewApp(db *gorm.DB, redisClient *redis.Client) *App {
|
||||
@@ -53,6 +55,15 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
processors.notificationProcessor,
|
||||
)
|
||||
|
||||
// Checks that wallet balances, ledger and lots agree (docs/prd-point-coin.md §7.5)
|
||||
a.walletRecon = service.NewWalletReconciliationJob(
|
||||
repository.NewWalletReconciliationRepository(a.db),
|
||||
repos.userRepo,
|
||||
processors.notificationProcessor,
|
||||
)
|
||||
// Earns for paid orders whose earning failed at payment time (docs/prd-point-coin.md F3)
|
||||
a.earningRetry = service.NewEarningBackfillJob(processors.earningProcessor)
|
||||
|
||||
services := a.initServices(processors, repos, cfg)
|
||||
validators := a.initValidators()
|
||||
middleware := a.initMiddleware(services, cfg)
|
||||
@@ -142,6 +153,10 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
validators.expenseValidator,
|
||||
services.cashAdvanceService,
|
||||
validators.cashAdvanceValidator,
|
||||
services.walletAdminService,
|
||||
validators.walletValidator,
|
||||
services.loyaltySettingsService,
|
||||
services.customerPinService,
|
||||
a.redisClient,
|
||||
)
|
||||
|
||||
@@ -153,6 +168,12 @@ func (a *App) Start(port string) error {
|
||||
if a.omsetScheduler != nil {
|
||||
a.omsetScheduler.Start(5 * time.Minute)
|
||||
}
|
||||
if a.walletRecon != nil {
|
||||
a.walletRecon.Start(6 * time.Hour)
|
||||
}
|
||||
if a.earningRetry != nil {
|
||||
a.earningRetry.Start(30 * time.Minute)
|
||||
}
|
||||
|
||||
engine := a.router.Init()
|
||||
|
||||
@@ -192,6 +213,12 @@ func (a *App) Shutdown() {
|
||||
if a.omsetScheduler != nil {
|
||||
a.omsetScheduler.Stop()
|
||||
}
|
||||
if a.walletRecon != nil {
|
||||
a.walletRecon.Stop()
|
||||
}
|
||||
if a.earningRetry != nil {
|
||||
a.earningRetry.Stop()
|
||||
}
|
||||
close(a.shutdown)
|
||||
}
|
||||
|
||||
@@ -247,6 +274,9 @@ type repositories struct {
|
||||
productOutletPriceRepo *repository.ProductOutletPriceRepositoryImpl
|
||||
expenseRepo *repository.ExpenseRepositoryImpl
|
||||
cashAdvanceRepo *repository.CashAdvanceRepositoryImpl
|
||||
walletRepo repository.WalletRepository
|
||||
walletQueryRepo repository.WalletQueryRepository
|
||||
loyaltySettingsRepo repository.LoyaltySettingsRepository
|
||||
}
|
||||
|
||||
func (a *App) initRepositories() *repositories {
|
||||
@@ -302,6 +332,9 @@ func (a *App) initRepositories() *repositories {
|
||||
productOutletPriceRepo: repository.NewProductOutletPriceRepositoryImpl(a.db),
|
||||
expenseRepo: repository.NewExpenseRepositoryImpl(a.db),
|
||||
cashAdvanceRepo: repository.NewCashAdvanceRepositoryImpl(a.db),
|
||||
walletRepo: repository.NewWalletRepository(a.db),
|
||||
walletQueryRepo: repository.NewWalletQueryRepository(a.db),
|
||||
loyaltySettingsRepo: repository.NewLoyaltySettingsRepository(a.db),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,14 +383,29 @@ type processors struct {
|
||||
productOutletPriceProcessor processor.ProductOutletPriceProcessor
|
||||
expenseProcessor *processor.ExpenseProcessorImpl
|
||||
cashAdvanceProcessor *processor.CashAdvanceProcessorImpl
|
||||
walletProcessor *processor.WalletProcessor
|
||||
walletAdminProcessor *processor.WalletAdminProcessor
|
||||
loyaltySettingsProcessor *processor.LoyaltySettingsProcessor
|
||||
earningProcessor *processor.EarningProcessor
|
||||
customerPinProcessor *processor.CustomerPinProcessor
|
||||
paymentCodeProcessor *processor.PaymentCodeProcessor
|
||||
}
|
||||
|
||||
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
||||
fileClient := client.NewFileClient(cfg.S3Config)
|
||||
fonnteClient := client.NewFonnteClient(cfg.GetFonnte())
|
||||
otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo)
|
||||
// Customer PIN (docs/prd-point-coin.md F11)
|
||||
customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, otpProcessor)
|
||||
paymentCodeProcessor := processor.NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(a.redisClient), customerPinProcessor)
|
||||
inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo)
|
||||
|
||||
orderProcessor := processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo)
|
||||
loyaltySettingsProcessor := processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager)
|
||||
// Earn EnakPoint and EnakCoin when an order becomes fully paid (docs/prd-point-coin.md F3)
|
||||
earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
|
||||
orderProcessor.SetLoyalty(earningProcessor)
|
||||
|
||||
return &processors{
|
||||
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo),
|
||||
organizationProcessor: processor.NewOrganizationProcessorImpl(repos.organizationRepo, repos.outletRepo, repos.userRepo),
|
||||
@@ -367,8 +415,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
productProcessor: processor.NewProductProcessorImpl(repos.productRepo, repos.categoryRepo, repos.productVariantRepo, repos.inventoryRepo, repos.outletRepo, repos.productOutletPriceRepo),
|
||||
productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo),
|
||||
inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo, repos.ingredientRepo, repos.inventoryMovementRepo),
|
||||
orderProcessor: processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo),
|
||||
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo),
|
||||
orderProcessor: orderProcessor,
|
||||
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo, loyaltySettingsProcessor),
|
||||
fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient),
|
||||
customerProcessor: processor.NewCustomerProcessor(repos.customerRepo),
|
||||
analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo, repos.expenseRepo),
|
||||
@@ -394,7 +442,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo),
|
||||
campaignRuleProcessor: processor.NewCampaignRuleProcessor(repos.campaignRuleRepo),
|
||||
customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()),
|
||||
customerPointsProcessor: processor.NewCustomerPointsProcessor(repos.customerPointsRepo, repos.gameRepo),
|
||||
customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager)), repos.gameRepo),
|
||||
otpProcessor: otpProcessor,
|
||||
fileClient: fileClient,
|
||||
inventoryMovementService: inventoryMovementService,
|
||||
@@ -403,6 +451,12 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo),
|
||||
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo),
|
||||
cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo),
|
||||
walletProcessor: processor.NewWalletProcessor(repos.walletRepo),
|
||||
loyaltySettingsProcessor: loyaltySettingsProcessor,
|
||||
earningProcessor: earningProcessor,
|
||||
customerPinProcessor: customerPinProcessor,
|
||||
paymentCodeProcessor: paymentCodeProcessor,
|
||||
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,6 +499,9 @@ type services struct {
|
||||
productOutletPriceService service.ProductOutletPriceService
|
||||
expenseService *service.ExpenseServiceImpl
|
||||
cashAdvanceService *service.CashAdvanceServiceImpl
|
||||
walletAdminService *service.WalletAdminServiceImpl
|
||||
loyaltySettingsService *service.LoyaltySettingsServiceImpl
|
||||
customerPinService *service.CustomerPinServiceImpl
|
||||
}
|
||||
|
||||
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
||||
@@ -526,6 +583,9 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor),
|
||||
expenseService: service.NewExpenseService(processors.expenseProcessor),
|
||||
cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor),
|
||||
walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor),
|
||||
loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo),
|
||||
customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,6 +631,7 @@ type validators struct {
|
||||
productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl
|
||||
expenseValidator *validator.ExpenseValidatorImpl
|
||||
cashAdvanceValidator *validator.CashAdvanceValidatorImpl
|
||||
walletValidator *validator.WalletValidatorImpl
|
||||
}
|
||||
|
||||
func (a *App) initValidators() *validators {
|
||||
@@ -604,6 +665,7 @@ func (a *App) initValidators() *validators {
|
||||
productOutletPriceValidator: validator.NewProductOutletPriceValidator(),
|
||||
expenseValidator: validator.NewExpenseValidator(),
|
||||
cashAdvanceValidator: validator.NewCashAdvanceValidator(),
|
||||
walletValidator: validator.NewWalletValidator(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ const (
|
||||
ValidationErrorCode = "304"
|
||||
InvalidFieldErrorCode = "305"
|
||||
NotFoundErrorCode = "404"
|
||||
// PIN outcomes the customer app tells apart (docs/prd-point-coin.md §9).
|
||||
PinNotSetErrorCode = "PIN_NOT_SET"
|
||||
PinInvalidErrorCode = "PIN_INVALID"
|
||||
PinLockedErrorCode = "PIN_LOCKED"
|
||||
TransferBlockedErrorCode = "TRANSFER_BLOCKED"
|
||||
TooManyRequestsErrorCode = "429"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -63,15 +69,23 @@ const (
|
||||
ProductOutletPriceServiceEntity = "product_outlet_price_service"
|
||||
ExpenseServiceEntity = "expense_service"
|
||||
CashAdvanceServiceEntity = "cash_advance_service"
|
||||
WalletServiceEntity = "wallet_service"
|
||||
LoyaltySettingsServiceEntity = "loyalty_settings_service"
|
||||
CustomerPinServiceEntity = "customer_pin_service"
|
||||
)
|
||||
|
||||
var HttpErrorMap = map[string]int{
|
||||
InternalServerErrorCode: http.StatusInternalServerError,
|
||||
MissingFieldErrorCode: http.StatusBadRequest,
|
||||
MalformedFieldErrorCode: http.StatusBadRequest,
|
||||
ValidationErrorCode: http.StatusBadRequest,
|
||||
InvalidFieldErrorCode: http.StatusBadRequest,
|
||||
NotFoundErrorCode: http.StatusNotFound,
|
||||
InternalServerErrorCode: http.StatusInternalServerError,
|
||||
MissingFieldErrorCode: http.StatusBadRequest,
|
||||
MalformedFieldErrorCode: http.StatusBadRequest,
|
||||
ValidationErrorCode: http.StatusBadRequest,
|
||||
InvalidFieldErrorCode: http.StatusBadRequest,
|
||||
NotFoundErrorCode: http.StatusNotFound,
|
||||
PinNotSetErrorCode: http.StatusForbidden,
|
||||
PinInvalidErrorCode: http.StatusBadRequest,
|
||||
PinLockedErrorCode: http.StatusLocked,
|
||||
TransferBlockedErrorCode: http.StatusForbidden,
|
||||
TooManyRequestsErrorCode: http.StatusTooManyRequests,
|
||||
}
|
||||
|
||||
// Error messages
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package constants
|
||||
|
||||
// Loyalty setting keys (docs/prd-point-coin.md F1, F2, F12). Outlet keys live in
|
||||
// outlet_settings and organization keys in organization_settings. A key that was
|
||||
// never set takes the default in the PRD.
|
||||
|
||||
// Per outlet (F1): what an order earns, and whether EnakPoint can pay.
|
||||
const (
|
||||
LoyaltyPointEnabledKey = "loyalty.point.enabled"
|
||||
LoyaltyPointEarnPerAmountKey = "loyalty.point.earn_per_amount"
|
||||
LoyaltyPointEarnValueKey = "loyalty.point.earn_value"
|
||||
LoyaltyPointMinOrderAmountKey = "loyalty.point.min_order_amount"
|
||||
LoyaltyPointMaxPerOrderKey = "loyalty.point.max_per_order"
|
||||
|
||||
LoyaltyCoinEnabledKey = "loyalty.coin.enabled"
|
||||
LoyaltyCoinEarnPerAmountKey = "loyalty.coin.earn_per_amount"
|
||||
LoyaltyCoinEarnValueKey = "loyalty.coin.earn_value"
|
||||
LoyaltyCoinMinOrderAmountKey = "loyalty.coin.min_order_amount"
|
||||
LoyaltyCoinMaxPerOrderKey = "loyalty.coin.max_per_order"
|
||||
|
||||
LoyaltyPointAcceptPaymentKey = "loyalty.point.accept_payment"
|
||||
LoyaltyPointMinPaymentPointsKey = "loyalty.point.min_payment_points"
|
||||
LoyaltyPointMaxPaymentPercentKey = "loyalty.point.max_payment_percent"
|
||||
)
|
||||
|
||||
// Per organization (F2, F12): the value of EnakPoint, the exchange rate, transfers and
|
||||
// expiry, which must be the same in every outlet.
|
||||
const (
|
||||
LoyaltyPointValueKey = "loyalty.point.value"
|
||||
|
||||
LoyaltyExchangeCoinAmountKey = "loyalty.exchange.coin_amount"
|
||||
LoyaltyExchangePointAmountKey = "loyalty.exchange.point_amount"
|
||||
|
||||
LoyaltyTransferEnabledKey = "loyalty.transfer.enabled"
|
||||
LoyaltyTransferMinAmountKey = "loyalty.transfer.min_amount"
|
||||
LoyaltyTransferMaxPerTransactionKey = "loyalty.transfer.max_per_transaction"
|
||||
LoyaltyTransferDailyLimitKey = "loyalty.transfer.daily_limit"
|
||||
|
||||
// Suffixes after "loyalty.point." or "loyalty.coin.".
|
||||
LoyaltyExpiryEnabledSuffix = "expiry_enabled"
|
||||
LoyaltyExpiryPeriodSuffix = "expiry_period"
|
||||
LoyaltyExpiryUnitSuffix = "expiry_unit"
|
||||
LoyaltyExpiryEndOfMonthSuffix = "expiry_end_of_month"
|
||||
LoyaltyExpiryReminderDaysSuffix = "expiry_reminder_days"
|
||||
)
|
||||
|
||||
// Units of loyalty.{point,coin}.expiry_unit.
|
||||
const (
|
||||
LoyaltyExpiryUnitDay = "DAY"
|
||||
LoyaltyExpiryUnitMonth = "MONTH"
|
||||
)
|
||||
|
||||
// Defaults from the PRD.
|
||||
const (
|
||||
LoyaltyPointEarnPerAmountDefault = int64(100)
|
||||
LoyaltyCoinEarnPerAmountDefault = int64(25000)
|
||||
LoyaltyEarnValueDefault = int64(1)
|
||||
|
||||
LoyaltyMinPaymentPointsDefault = int64(1)
|
||||
LoyaltyMaxPaymentPercentDefault = int64(100)
|
||||
|
||||
LoyaltyPointValueDefault = int64(1)
|
||||
LoyaltyExchangeAmountDefault = int64(1)
|
||||
|
||||
LoyaltyTransferEnabledDefault = true
|
||||
LoyaltyTransferMinAmountDefault = int64(1)
|
||||
|
||||
LoyaltyExpiryPeriodDefault = int64(12)
|
||||
LoyaltyExpiryUnitDefault = LoyaltyExpiryUnitMonth
|
||||
LoyaltyExpiryReminderDaysDefault = int64(7)
|
||||
)
|
||||
@@ -8,6 +8,9 @@ const (
|
||||
PaymentMethodTypeDigitalWallet PaymentMethodType = "digital_wallet"
|
||||
PaymentMethodTypeQR PaymentMethodType = "qr"
|
||||
PaymentMethodTypeEDC PaymentMethodType = "edc"
|
||||
// Paying with EnakPoint (docs/prd-point-coin.md F9). Not accepted as a payment method
|
||||
// type until that phase ships.
|
||||
PaymentMethodTypePoint PaymentMethodType = "point"
|
||||
)
|
||||
|
||||
type PaymentStatus string
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package constants
|
||||
|
||||
// SellBy describes how a product's quantity is expressed on an order line.
|
||||
type SellBy = string
|
||||
|
||||
const (
|
||||
// SellByUnit is the default: the line counts discrete items (2 portions, 3 bottles).
|
||||
SellByUnit SellBy = "unit"
|
||||
// SellByWeight means the line carries a weighed amount. One weighing is one line,
|
||||
// so quantity stays 1 and the measured value lives in order_items.weight.
|
||||
SellByWeight SellBy = "weight"
|
||||
)
|
||||
|
||||
// IsValidSellBy reports whether s is a supported sell_by value.
|
||||
func IsValidSellBy(s string) bool {
|
||||
return s == SellByUnit || s == SellByWeight
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package constants
|
||||
|
||||
// The two balances a customer wallet holds (docs/prd-point-coin.md). EnakPoint pays
|
||||
// for orders; EnakCoin is spent on games and can be exchanged into EnakPoint.
|
||||
const (
|
||||
WalletCurrencyPoint = "POINT"
|
||||
WalletCurrencyCoin = "COIN"
|
||||
)
|
||||
|
||||
func IsValidWalletCurrency(currency string) bool {
|
||||
return currency == WalletCurrencyPoint || currency == WalletCurrencyCoin
|
||||
}
|
||||
|
||||
// Ledger row types. §8.1 of the PRD lists, per type, which currency it may use, which
|
||||
// way it moves the balance, and which reference it must carry.
|
||||
const (
|
||||
WalletTxTypeEarn = "EARN"
|
||||
WalletTxTypeEarnReversal = "EARN_REVERSAL"
|
||||
WalletTxTypePayment = "PAYMENT"
|
||||
WalletTxTypePaymentRefund = "PAYMENT_REFUND"
|
||||
WalletTxTypeExchangeOut = "EXCHANGE_OUT"
|
||||
WalletTxTypeExchangeIn = "EXCHANGE_IN"
|
||||
WalletTxTypeTransferOut = "TRANSFER_OUT"
|
||||
WalletTxTypeTransferIn = "TRANSFER_IN"
|
||||
WalletTxTypeGameSpend = "GAME_SPEND"
|
||||
WalletTxTypeExpire = "EXPIRE"
|
||||
WalletTxTypeAdjustment = "ADJUSTMENT"
|
||||
WalletTxTypeMigration = "MIGRATION"
|
||||
WalletTxTypeRewardRedeem = "REWARD_REDEEM"
|
||||
)
|
||||
|
||||
// What a ledger row's reference_id points at: where the value came from for a
|
||||
// credit, or where it went for a debit.
|
||||
const (
|
||||
WalletRefTypeOrder = "ORDER"
|
||||
WalletRefTypePayment = "PAYMENT"
|
||||
WalletRefTypeWalletTx = "WALLET_TX"
|
||||
WalletRefTypeGamePlay = "GAME_PLAY"
|
||||
WalletRefTypeLot = "LOT"
|
||||
WalletRefTypeUser = "USER"
|
||||
WalletRefTypeLegacyPoints = "LEGACY_POINTS"
|
||||
WalletRefTypeLegacyTokens = "LEGACY_TOKENS"
|
||||
WalletRefTypeRewardRedemption = "REWARD_REDEMPTION"
|
||||
)
|
||||
@@ -198,6 +198,7 @@ type ProductAnalyticsData struct {
|
||||
CategoryName string `json:"category_name"`
|
||||
CategoryOrder int `json:"category_order"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
WeightSold float64 `json:"weight_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
@@ -324,6 +325,7 @@ type ParentCategoryAnalyticsProductData struct {
|
||||
ProductSku string `json:"product_sku"`
|
||||
ProductPrice float64 `json:"product_price"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
WeightSold float64 `json:"weight_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
type CreateCampaignRequest struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=150"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS TOKENS MIXED"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"`
|
||||
StartDate time.Time `json:"start_date" binding:"required"`
|
||||
EndDate time.Time `json:"end_date" binding:"required"`
|
||||
IsActive bool `json:"is_active"`
|
||||
@@ -25,7 +25,7 @@ type UpdateCampaignRequest struct {
|
||||
ID uuid.UUID `json:"id" binding:"required"`
|
||||
Name string `json:"name" binding:"required,min=1,max=150"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS TOKENS MIXED"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"`
|
||||
StartDate time.Time `json:"start_date" binding:"required"`
|
||||
EndDate time.Time `json:"end_date" binding:"required"`
|
||||
IsActive bool `json:"is_active"`
|
||||
@@ -58,7 +58,7 @@ type CreateCampaignRuleRequest struct {
|
||||
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
|
||||
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
|
||||
ConditionValue *string `json:"condition_value,omitempty"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
|
||||
RewardValue *int64 `json:"reward_value,omitempty"`
|
||||
RewardSubtype *string `json:"reward_subtype,omitempty"`
|
||||
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
|
||||
@@ -70,7 +70,7 @@ type UpdateCampaignRuleRequest struct {
|
||||
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
|
||||
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
|
||||
ConditionValue *string `json:"condition_value,omitempty"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
|
||||
RewardValue *int64 `json:"reward_value,omitempty"`
|
||||
RewardSubtype *string `json:"reward_subtype,omitempty"`
|
||||
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
|
||||
@@ -142,7 +142,7 @@ type ListCampaignRulesResponse struct {
|
||||
type CampaignRuleStruct struct {
|
||||
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
|
||||
ConditionValue *string `json:"condition_value,omitempty"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
|
||||
RewardValue *int64 `json:"reward_value,omitempty"`
|
||||
RewardSubtype *string `json:"reward_subtype,omitempty"`
|
||||
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package contract
|
||||
|
||||
// Requests of /customer/pin and /marketing/customers/:id/pin (docs/prd-point-coin.md
|
||||
// F11). PINs are strings so a leading zero is kept.
|
||||
|
||||
type RequestPinOtpRequest struct {
|
||||
// pin_setup or pin_reset.
|
||||
Purpose string `json:"purpose" binding:"required"`
|
||||
}
|
||||
|
||||
type CreateCustomerPinRequest struct {
|
||||
OtpToken string `json:"otp_token" binding:"required"`
|
||||
OtpCode string `json:"otp_code" binding:"required"`
|
||||
Pin string `json:"pin" binding:"required"`
|
||||
ConfirmPin string `json:"confirm_pin" binding:"required"`
|
||||
}
|
||||
|
||||
type ChangeCustomerPinRequest struct {
|
||||
OldPin string `json:"old_pin" binding:"required"`
|
||||
Pin string `json:"pin" binding:"required"`
|
||||
ConfirmPin string `json:"confirm_pin" binding:"required"`
|
||||
}
|
||||
|
||||
type ResetCustomerPinRequest = CreateCustomerPinRequest
|
||||
|
||||
type RemoveCustomerPinRequest struct {
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
|
||||
// IssuePaymentCodeRequest is POST /customer/wallet/payment-code.
|
||||
type IssuePaymentCodeRequest struct {
|
||||
Pin string `json:"pin" binding:"required"`
|
||||
}
|
||||
@@ -40,13 +40,16 @@ type UpdateOrderRequest struct {
|
||||
}
|
||||
|
||||
type CreateOrderItemRequest struct {
|
||||
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
|
||||
Quantity int `json:"quantity" validate:"required,min=1"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Optional, will use database price if not provided
|
||||
Modifiers []map[string]interface{} `json:"modifiers,omitempty"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
|
||||
Quantity int `json:"quantity" validate:"required,min=1"`
|
||||
// Weight is required for products with sell_by = "weight" and must be absent otherwise.
|
||||
// One weighing is one line, so quantity stays 1 and the line is priced weight * unit_price.
|
||||
Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Ignored: price always comes from the database
|
||||
Modifiers []map[string]interface{} `json:"modifiers,omitempty"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateOrderItemRequest struct {
|
||||
@@ -89,29 +92,38 @@ type OrderResponse struct {
|
||||
TotalPaid float64 `json:"total_paid"`
|
||||
PaymentCount int `json:"payment_count"`
|
||||
SplitType *string `json:"split_type,omitempty"`
|
||||
// EnakPoint and EnakCoin the order earned, for the order screen and the receipt
|
||||
// (docs/prd-point-coin.md F3). Zero when it earned nothing.
|
||||
PointsEarned int64 `json:"points_earned"`
|
||||
CoinsEarned int64 `json:"coins_earned"`
|
||||
}
|
||||
|
||||
type OrderItemResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrderID uuid.UUID `json:"order_id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id"`
|
||||
ProductVariantName *string `json:"product_variant_name,omitempty"`
|
||||
CategoryID *uuid.UUID `json:"category_id,omitempty"`
|
||||
CategoryName *string `json:"category_name,omitempty"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
Modifiers []map[string]interface{} `json:"modifiers"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PrinterType string `json:"printer_type"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
PaidQuantity int `json:"paid_quantity"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrderID uuid.UUID `json:"order_id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id"`
|
||||
ProductVariantName *string `json:"product_variant_name,omitempty"`
|
||||
CategoryID *uuid.UUID `json:"category_id,omitempty"`
|
||||
CategoryName *string `json:"category_name,omitempty"`
|
||||
Quantity int `json:"quantity"`
|
||||
// Weight is set only for products sold by weight; the line is priced weight * unit_price.
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
UnitName *string `json:"unit_name,omitempty"`
|
||||
UnitAbbreviation *string `json:"unit_abbreviation,omitempty"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
Modifiers []map[string]interface{} `json:"modifiers"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PrinterType string `json:"printer_type"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
PaidQuantity int `json:"paid_quantity"`
|
||||
}
|
||||
|
||||
type ListOrdersQuery struct {
|
||||
|
||||
@@ -18,7 +18,7 @@ type CreatePaymentMethodRequest struct {
|
||||
|
||||
type UpdatePaymentMethodRequest struct {
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"`
|
||||
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc"`
|
||||
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc point"`
|
||||
Processor *string `json:"processor,omitempty" validate:"omitempty,max=100"`
|
||||
Configuration map[string]interface{} `json:"configuration,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
@@ -38,11 +38,13 @@ type PaymentMethodResponse struct {
|
||||
|
||||
type ListPaymentMethodsRequest struct {
|
||||
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
|
||||
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
// When set, EnakPoint is left out unless the outlet accepts it (F9).
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc point"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
}
|
||||
|
||||
type ListPaymentMethodsResponse struct {
|
||||
|
||||
@@ -18,6 +18,8 @@ type CreateProductRequest struct {
|
||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,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"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Variants []CreateProductVariantRequest `json:"variants,omitempty"`
|
||||
@@ -38,6 +40,8 @@ type UpdateProductRequest struct {
|
||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,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"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
|
||||
@@ -73,6 +77,8 @@ type ProductResponse struct {
|
||||
BusinessType string `json:"business_type"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
PrinterType string `json:"printer_type"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
SellBy string `json:"sell_by"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
IsActive bool `json:"is_active"`
|
||||
|
||||
@@ -58,6 +58,7 @@ type SelfOrderCreateOrderItem struct {
|
||||
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
|
||||
Quantity int `json:"quantity" validate:"required,min=1"`
|
||||
Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package contract
|
||||
|
||||
// AdjustCustomerWalletRequest is POST /marketing/customers/:id/wallet/adjust
|
||||
// (docs/prd-point-coin.md F7).
|
||||
type AdjustCustomerWalletRequest struct {
|
||||
// POINT or COIN.
|
||||
Currency string `json:"currency"`
|
||||
// Signed: positive adds to the balance, negative takes from it. Never zero.
|
||||
Amount int64 `json:"amount"`
|
||||
Reason string `json:"reason"`
|
||||
// Optional. Sending the same key again returns the first result instead of
|
||||
// adjusting twice, so a retried request is safe.
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
}
|
||||
@@ -114,6 +114,7 @@ type ProductAnalytics struct {
|
||||
CategoryName string `json:"category_name"`
|
||||
CategoryOrder int `json:"category_order"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
WeightSold float64 `json:"weight_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
|
||||
@@ -12,10 +12,31 @@ type CampaignType string
|
||||
const (
|
||||
CampaignTypeReward CampaignType = "REWARD"
|
||||
CampaignTypePoints CampaignType = "POINTS"
|
||||
CampaignTypeTokens CampaignType = "TOKENS"
|
||||
CampaignTypeCoins CampaignType = "COINS"
|
||||
CampaignTypeMixed CampaignType = "MIXED"
|
||||
)
|
||||
|
||||
// legacyCampaignTokens is what COINS was called before tokens became EnakCoin
|
||||
// (docs/prd-point-coin.md §10). Requests may still send it; it is stored as COINS.
|
||||
const legacyCampaignTokens = "TOKENS"
|
||||
|
||||
// NormalizeCampaignType maps the legacy TOKENS to COINS and leaves anything else as is.
|
||||
func NormalizeCampaignType(t string) CampaignType {
|
||||
if t == legacyCampaignTokens {
|
||||
return CampaignTypeCoins
|
||||
}
|
||||
return CampaignType(t)
|
||||
}
|
||||
|
||||
// NormalizeCampaignRewardType maps the legacy TOKENS to COINS and leaves anything
|
||||
// else as is.
|
||||
func NormalizeCampaignRewardType(t string) CampaignRewardType {
|
||||
if t == legacyCampaignTokens {
|
||||
return CampaignRewardTypeCoins
|
||||
}
|
||||
return CampaignRewardType(t)
|
||||
}
|
||||
|
||||
type RuleType string
|
||||
|
||||
const (
|
||||
@@ -31,7 +52,7 @@ type CampaignRewardType string
|
||||
|
||||
const (
|
||||
CampaignRewardTypePoints CampaignRewardType = "POINTS"
|
||||
CampaignRewardTypeTokens CampaignRewardType = "TOKENS"
|
||||
CampaignRewardTypeCoins CampaignRewardType = "COINS"
|
||||
CampaignRewardTypeReward CampaignRewardType = "REWARD"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNormalizeCampaignTypesMapsLegacyTokensToCoins(t *testing.T) {
|
||||
assert.Equal(t, CampaignTypeCoins, NormalizeCampaignType("TOKENS"))
|
||||
assert.Equal(t, CampaignTypeCoins, NormalizeCampaignType("COINS"))
|
||||
assert.Equal(t, CampaignTypePoints, NormalizeCampaignType("POINTS"))
|
||||
assert.Equal(t, CampaignType(""), NormalizeCampaignType(""), "an empty filter stays empty")
|
||||
|
||||
assert.Equal(t, CampaignRewardTypeCoins, NormalizeCampaignRewardType("TOKENS"))
|
||||
assert.Equal(t, CampaignRewardTypeReward, NormalizeCampaignRewardType("REWARD"))
|
||||
assert.Equal(t, CampaignRewardType(""), NormalizeCampaignRewardType(""))
|
||||
}
|
||||
@@ -44,6 +44,12 @@ func GetAllEntities() []interface{} {
|
||||
&ProductOutletPrice{},
|
||||
&Expense{},
|
||||
&CashAdvance{},
|
||||
// Wallet entities
|
||||
&CustomerWallet{},
|
||||
&WalletTransaction{},
|
||||
&WalletLot{},
|
||||
&WalletLotAllocation{},
|
||||
&LoyaltySettingChange{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LoyaltySettingChange records one change to a loyalty setting: which key, from what,
|
||||
// to what, by whom (docs/prd-point-coin.md F2). Append-only.
|
||||
type LoyaltySettingChange struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
// Nil for an organization setting.
|
||||
OutletID *uuid.UUID `gorm:"type:uuid" json:"outlet_id"`
|
||||
Key string `gorm:"not null;size:100" json:"key"`
|
||||
// Nil when the key had no stored value, or has none any more.
|
||||
OldValue *string `gorm:"type:text" json:"old_value"`
|
||||
NewValue *string `gorm:"type:text" json:"new_value"`
|
||||
ChangedBy uuid.UUID `gorm:"type:uuid;not null" json:"changed_by"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (c *LoyaltySettingChange) BeforeCreate(tx *gorm.DB) error {
|
||||
if c.ID == uuid.Nil {
|
||||
c.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (LoyaltySettingChange) TableName() string {
|
||||
return "loyalty_setting_changes"
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -47,6 +48,8 @@ type OrderItem struct {
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id" validate:"required"`
|
||||
ProductVariantID *uuid.UUID `gorm:"type:uuid;index" json:"product_variant_id"`
|
||||
Quantity int `gorm:"not null" json:"quantity" validate:"required,min=1"`
|
||||
Weight *float64 `gorm:"type:decimal(12,3)" json:"weight,omitempty"`
|
||||
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id,omitempty"`
|
||||
UnitPrice float64 `gorm:"type:decimal(10,2);not null" json:"unit_price" validate:"required,min=0"`
|
||||
TotalPrice float64 `gorm:"type:decimal(10,2);not null" json:"total_price" validate:"required,min=0"`
|
||||
UnitCost float64 `gorm:"type:decimal(10,2);default:0.00" json:"unit_cost"`
|
||||
@@ -68,6 +71,7 @@ type OrderItem struct {
|
||||
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||
ProductVariant *ProductVariant `gorm:"foreignKey:ProductVariantID" json:"product_variant,omitempty"`
|
||||
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
func (oi *OrderItem) BeforeCreate(tx *gorm.DB) error {
|
||||
@@ -81,8 +85,57 @@ func (OrderItem) TableName() string {
|
||||
return "order_items"
|
||||
}
|
||||
|
||||
// IsWeighed reports whether this line was sold by weight rather than by count.
|
||||
func (oi *OrderItem) IsWeighed() bool {
|
||||
return oi.Weight != nil
|
||||
}
|
||||
|
||||
// BillableQuantity is the multiplier for unit price and unit cost on this line:
|
||||
// the weighed amount for weight-based products, the item count otherwise.
|
||||
//
|
||||
// This is the ONLY place that decides between the two. Every price or cost
|
||||
// calculation must go through it — multiplying by Quantity directly would bill a
|
||||
// 4.2 ons fish as a single ons.
|
||||
//
|
||||
// Weighed lines are pinned to quantity = 1 by chk_order_items_weight_single_line,
|
||||
// so there is no ambiguity between counting and weighing.
|
||||
func (oi *OrderItem) BillableQuantity() float64 {
|
||||
if oi.Weight != nil {
|
||||
return *oi.Weight
|
||||
}
|
||||
return float64(oi.Quantity)
|
||||
}
|
||||
|
||||
// BillableQuantityFor is BillableQuantity for a partial action on this line, such as
|
||||
// voiding or refunding `units` of it.
|
||||
//
|
||||
// Weighed lines are always acted on in full — quantity is 1, so any void or refund
|
||||
// covers the whole weighing — hence the full weight is returned regardless of units.
|
||||
func (oi *OrderItem) BillableQuantityFor(units int) float64 {
|
||||
if oi.Weight != nil {
|
||||
return *oi.Weight
|
||||
}
|
||||
return float64(units)
|
||||
}
|
||||
|
||||
// RoundMoney rounds to the 2 decimals that every monetary column stores, so the
|
||||
// value written back always matches what the database keeps.
|
||||
func RoundMoney(amount float64) float64 {
|
||||
return math.Round(amount*100) / 100
|
||||
}
|
||||
|
||||
// RoundWeight rounds to the 3 decimals of order_items.weight, so a validated value
|
||||
// is identical to the stored one.
|
||||
func RoundWeight(weight float64) float64 {
|
||||
return math.Round(weight*1000) / 1000
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalPrice() {
|
||||
oi.TotalPrice = float64(oi.Quantity) * oi.UnitPrice
|
||||
oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalCost() {
|
||||
oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CanBeModified() bool {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package entities
|
||||
|
||||
import "testing"
|
||||
|
||||
func weightPtr(v float64) *float64 { return &v }
|
||||
|
||||
func TestBillableQuantity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item OrderItem
|
||||
want float64
|
||||
}{
|
||||
{"unit line counts items", OrderItem{Quantity: 3}, 3},
|
||||
{"weighed line uses the weight", OrderItem{Quantity: 1, Weight: weightPtr(4.2)}, 4.2},
|
||||
{"weight of zero is still a weighed line", OrderItem{Quantity: 1, Weight: weightPtr(0)}, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.item.BillableQuantity(); got != tt.want {
|
||||
t.Errorf("BillableQuantity() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A weighed line is voided or refunded whole, so the units asked for must not shrink
|
||||
// the amount: refunding "1" of a 4.2 ons line refunds all 4.2 ons.
|
||||
func TestBillableQuantityForIgnoresUnitsOnWeighedLines(t *testing.T) {
|
||||
weighed := OrderItem{Quantity: 1, Weight: weightPtr(5.6)}
|
||||
if got := weighed.BillableQuantityFor(1); got != 5.6 {
|
||||
t.Errorf("weighed BillableQuantityFor(1) = %v, want 5.6", got)
|
||||
}
|
||||
|
||||
counted := OrderItem{Quantity: 3}
|
||||
if got := counted.BillableQuantityFor(2); got != 2 {
|
||||
t.Errorf("unit BillableQuantityFor(2) = %v, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The regression this whole feature exists to prevent: a 4.2 ons fish priced per ons
|
||||
// must not be billed as a single ons.
|
||||
func TestCalculateTotalPriceForWeighedLine(t *testing.T) {
|
||||
item := OrderItem{Quantity: 1, Weight: weightPtr(4.2), UnitPrice: 4500, UnitCost: 3000}
|
||||
item.CalculateTotalPrice()
|
||||
item.CalculateTotalCost()
|
||||
|
||||
if item.TotalPrice != 18900 {
|
||||
t.Errorf("TotalPrice = %v, want 18900", item.TotalPrice)
|
||||
}
|
||||
if item.TotalCost != 12600 {
|
||||
t.Errorf("TotalCost = %v, want 12600", item.TotalCost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateTotalPriceForUnitLine(t *testing.T) {
|
||||
item := OrderItem{Quantity: 3, UnitPrice: 25000, UnitCost: 10000}
|
||||
item.CalculateTotalPrice()
|
||||
item.CalculateTotalCost()
|
||||
|
||||
if item.TotalPrice != 75000 {
|
||||
t.Errorf("TotalPrice = %v, want 75000", item.TotalPrice)
|
||||
}
|
||||
if item.TotalCost != 30000 {
|
||||
t.Errorf("TotalCost = %v, want 30000", item.TotalCost)
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-rupiah residue must not survive into a decimal(10,2) column.
|
||||
func TestCalculateTotalPriceRoundsToStoredPrecision(t *testing.T) {
|
||||
item := OrderItem{Quantity: 1, Weight: weightPtr(4.237), UnitPrice: 4500}
|
||||
item.CalculateTotalPrice()
|
||||
|
||||
if item.TotalPrice != 19066.5 {
|
||||
t.Errorf("TotalPrice = %v, want 19066.5", item.TotalPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWeighed(t *testing.T) {
|
||||
if (&OrderItem{Quantity: 1}).IsWeighed() {
|
||||
t.Error("unit line reported as weighed")
|
||||
}
|
||||
if !(&OrderItem{Quantity: 1, Weight: weightPtr(1.5)}).IsWeighed() {
|
||||
t.Error("weighed line reported as unit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundWeight(t *testing.T) {
|
||||
tests := []struct {
|
||||
in float64
|
||||
want float64
|
||||
}{
|
||||
{4.2, 4.2},
|
||||
{4.2374, 4.237},
|
||||
{4.2375, 4.238},
|
||||
{0.0004, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := RoundWeight(tt.in); got != tt.want {
|
||||
t.Errorf("RoundWeight(%v) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,16 @@ const (
|
||||
PaymentMethodTypeCash PaymentMethodType = "cash"
|
||||
PaymentMethodTypeCard PaymentMethodType = "card"
|
||||
PaymentMethodTypeDigitalWallet PaymentMethodType = "digital_wallet"
|
||||
// The system method for paying with EnakPoint (docs/prd-point-coin.md F9). One per
|
||||
// organization; it cannot be created, deleted or retyped through the API.
|
||||
PaymentMethodTypePoint PaymentMethodType = "point"
|
||||
)
|
||||
|
||||
type PaymentMethod struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
|
||||
Name string `gorm:"not null;size:100" json:"name" validate:"required,min=1,max=100"`
|
||||
Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet"`
|
||||
Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet point"`
|
||||
Processor *string `gorm:"size:100" json:"processor"`
|
||||
Configuration Metadata `gorm:"type:jsonb;default:'{}'" json:"configuration"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
@@ -69,12 +72,16 @@ type Payment struct {
|
||||
SplitType *SplitType `gorm:"size:20" json:"split_type,omitempty"`
|
||||
SplitDescription *string `gorm:"size:255" json:"split_description,omitempty"`
|
||||
RefundAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"refund_amount"`
|
||||
RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"`
|
||||
RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"`
|
||||
RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
// Set only for a payment with EnakPoint: how many were used, and the rupiah value of
|
||||
// one then, frozen so a refund returns exactly what was used.
|
||||
PointsUsed *int64 `json:"points_used,omitempty"`
|
||||
PointValue *float64 `gorm:"type:decimal(10,2)" json:"point_value,omitempty"`
|
||||
RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"`
|
||||
RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"`
|
||||
RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||
PaymentMethod PaymentMethod `gorm:"foreignKey:PaymentMethodID" json:"payment_method,omitempty"`
|
||||
|
||||
@@ -20,6 +20,7 @@ type Product struct {
|
||||
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"`
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CustomerWallet holds a customer's EnakPoint and EnakCoin balances. The row is also
|
||||
// the lock every wallet operation for the customer takes first, so concurrent
|
||||
// operations on one customer queue up instead of spending the same balance twice.
|
||||
//
|
||||
// Balances are never written directly: they only move together with a ledger row, and
|
||||
// only through the wallet processor.
|
||||
type CustomerWallet struct {
|
||||
CustomerID uuid.UUID `gorm:"type:uuid;primary_key" json:"customer_id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
PointBalance int64 `gorm:"not null;default:0" json:"point_balance"`
|
||||
CoinBalance int64 `gorm:"not null;default:0" json:"coin_balance"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (CustomerWallet) TableName() string {
|
||||
return "customer_wallets"
|
||||
}
|
||||
|
||||
// WalletTransaction is one ledger row. The ledger is append-only: a correction is a
|
||||
// new row pointing at the one it corrects, never an update.
|
||||
type WalletTransaction struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
CustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"`
|
||||
Currency string `gorm:"not null;size:10" json:"currency"`
|
||||
Type string `gorm:"not null;size:30" json:"type"`
|
||||
// Signed: positive credits the wallet, negative debits it.
|
||||
Amount int64 `gorm:"not null" json:"amount"`
|
||||
BalanceAfter int64 `gorm:"not null" json:"balance_after"`
|
||||
GroupID *uuid.UUID `gorm:"type:uuid" json:"group_id"`
|
||||
|
||||
// Where the value came from (credit) or went to (debit).
|
||||
ReferenceType string `gorm:"not null;size:30" json:"reference_type"`
|
||||
ReferenceID uuid.UUID `gorm:"type:uuid;not null" json:"reference_id"`
|
||||
|
||||
CounterpartyCustomerID *uuid.UUID `gorm:"type:uuid" json:"counterparty_customer_id"`
|
||||
ReversesTransactionID *uuid.UUID `gorm:"type:uuid" json:"reverses_transaction_id"`
|
||||
OutletID *uuid.UUID `gorm:"type:uuid" json:"outlet_id"`
|
||||
CreatedByUser *uuid.UUID `gorm:"type:uuid" json:"created_by_user"`
|
||||
Reason *string `gorm:"size:255" json:"reason"`
|
||||
|
||||
// Frozen at creation, so later renames do not rewrite history.
|
||||
Description string `gorm:"not null;size:255" json:"description"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
IdempotencyKey *string `gorm:"size:100;unique" json:"idempotency_key"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (t *WalletTransaction) BeforeCreate(tx *gorm.DB) error {
|
||||
if t.ID == uuid.Nil {
|
||||
t.ID = uuid.New()
|
||||
}
|
||||
// A nil map would be stored as JSON null rather than an empty object.
|
||||
if t.Metadata == nil {
|
||||
t.Metadata = Metadata{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (WalletTransaction) TableName() string {
|
||||
return "wallet_transactions"
|
||||
}
|
||||
|
||||
// WalletLot is one credited piece of balance with its own expiry (K9). Debits draw from
|
||||
// the lots that expire soonest. A lot created by a transfer, exchange or refund carries
|
||||
// the expiry of the lot it came from and points back at it through OriginLotID.
|
||||
type WalletLot struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
CustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"`
|
||||
Currency string `gorm:"not null;size:10" json:"currency"`
|
||||
SourceTransactionID uuid.UUID `gorm:"type:uuid;not null" json:"source_transaction_id"`
|
||||
OriginLotID *uuid.UUID `gorm:"type:uuid" json:"origin_lot_id"`
|
||||
OriginalAmount int64 `gorm:"not null" json:"original_amount"`
|
||||
// A cache of OriginalAmount minus the lot's allocations, and the only wallet column
|
||||
// that is ever updated.
|
||||
RemainingAmount int64 `gorm:"not null" json:"remaining_amount"`
|
||||
// Nil means the lot never expires.
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (l *WalletLot) BeforeCreate(tx *gorm.DB) error {
|
||||
if l.ID == uuid.Nil {
|
||||
l.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (WalletLot) TableName() string {
|
||||
return "wallet_lots"
|
||||
}
|
||||
|
||||
// WalletLotAllocation records how much a debit ledger row drew from one lot.
|
||||
type WalletLotAllocation struct {
|
||||
TransactionID uuid.UUID `gorm:"type:uuid;primary_key" json:"transaction_id"`
|
||||
LotID uuid.UUID `gorm:"type:uuid;primary_key" json:"lot_id"`
|
||||
Amount int64 `gorm:"not null" json:"amount"`
|
||||
}
|
||||
|
||||
func (WalletLotAllocation) TableName() string {
|
||||
return "wallet_lot_allocations"
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// CustomerPinHandler serves /customer/pin and the dashboard's PIN endpoints
|
||||
// (docs/prd-point-coin.md F11). Request bodies hold PINs, so nothing here logs a body,
|
||||
// and binding errors are reported without the values sent.
|
||||
type CustomerPinHandler struct {
|
||||
pinService service.CustomerPinService
|
||||
}
|
||||
|
||||
func NewCustomerPinHandler(pinService service.CustomerPinService) *CustomerPinHandler {
|
||||
return &CustomerPinHandler{pinService: pinService}
|
||||
}
|
||||
|
||||
func (h *CustomerPinHandler) Status(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::Status")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.Status(c.Request.Context(), customerID), "CustomerPinHandler::Status")
|
||||
}
|
||||
|
||||
func (h *CustomerPinHandler) RequestOtp(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::RequestOtp")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.RequestPinOtpRequest
|
||||
if !bindPinRequest(c, &req, "CustomerPinHandler::RequestOtp") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.RequestOtp(c.Request.Context(), customerID, &req), "CustomerPinHandler::RequestOtp")
|
||||
}
|
||||
|
||||
func (h *CustomerPinHandler) CreatePin(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::CreatePin")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.CreateCustomerPinRequest
|
||||
if !bindPinRequest(c, &req, "CustomerPinHandler::CreatePin") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.CreatePin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::CreatePin")
|
||||
}
|
||||
|
||||
func (h *CustomerPinHandler) ChangePin(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::ChangePin")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.ChangeCustomerPinRequest
|
||||
if !bindPinRequest(c, &req, "CustomerPinHandler::ChangePin") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.ChangePin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::ChangePin")
|
||||
}
|
||||
|
||||
func (h *CustomerPinHandler) ResetPin(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::ResetPin")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.ResetCustomerPinRequest
|
||||
if !bindPinRequest(c, &req, "CustomerPinHandler::ResetPin") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.ResetPin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::ResetPin")
|
||||
}
|
||||
|
||||
// RemovePin is DELETE /marketing/customers/:id/pin.
|
||||
func (h *CustomerPinHandler) RemovePin(c *gin.Context) {
|
||||
customerID, ok := parseUUIDParam(c, "id", "CustomerPinHandler::RemovePin")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.RemoveCustomerPinRequest
|
||||
if !bindPinRequest(c, &req, "CustomerPinHandler::RemovePin") {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.RemovePin(ctx, appcontext.FromGinContext(ctx), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::RemovePin")
|
||||
}
|
||||
|
||||
// ListSecurityEvents is GET /marketing/customers/:id/security-events.
|
||||
func (h *CustomerPinHandler) ListSecurityEvents(c *gin.Context) {
|
||||
customerID, ok := parseUUIDParam(c, "id", "CustomerPinHandler::ListSecurityEvents")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.ListSecurityEvents(ctx, appcontext.FromGinContext(ctx), customerID, page, limit), "CustomerPinHandler::ListSecurityEvents")
|
||||
}
|
||||
|
||||
// bindPinRequest binds a JSON body. The error it reports names what is wrong, never the
|
||||
// values, since those can be PINs.
|
||||
func bindPinRequest(c *gin.Context, req interface{}, method string) bool {
|
||||
if err := c.ShouldBindJSON(req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "invalid request body: required fields are missing or have the wrong type"),
|
||||
}), method)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// customerIDFromGin reads the customer set by CustomerAuthMiddleware.
|
||||
func customerIDFromGin(c *gin.Context, method string) (uuid.UUID, bool) {
|
||||
raw, _ := c.Get("customer_id")
|
||||
s, _ := raw.(string)
|
||||
id, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"),
|
||||
}), method)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func pinRequestInfo(c *gin.Context) models.CustomerPinRequestInfo {
|
||||
return models.CustomerPinRequestInfo{IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent()}
|
||||
}
|
||||
|
||||
// IssuePaymentCode is POST /customer/wallet/payment-code.
|
||||
func (h *CustomerPinHandler) IssuePaymentCode(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::IssuePaymentCode")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.IssuePaymentCodeRequest
|
||||
if !bindPinRequest(c, &req, "CustomerPinHandler::IssuePaymentCode") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.IssuePaymentCode(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::IssuePaymentCode")
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
|
||||
@@ -46,7 +51,7 @@ func (h *CustomerPointsHandler) GetCustomerPoints(c *gin.Context) {
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerPoints -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
|
||||
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerPoints")
|
||||
return
|
||||
}
|
||||
@@ -80,7 +85,7 @@ func (h *CustomerPointsHandler) GetCustomerTokens(c *gin.Context) {
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerTokens -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
|
||||
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerTokens")
|
||||
return
|
||||
}
|
||||
@@ -114,7 +119,7 @@ func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) {
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWallet -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
|
||||
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerWallet")
|
||||
return
|
||||
}
|
||||
@@ -151,3 +156,51 @@ func (h *CustomerPointsHandler) GetFerrisWheelGame(c *gin.Context) {
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetFerrisWheelGame")
|
||||
}
|
||||
|
||||
// GetCustomerWalletTransactions is GET /customer/wallet/transactions: the ledger, newest
|
||||
// first, filterable by currency, type and date (docs/prd-point-coin.md F6).
|
||||
func (h *CustomerPointsHandler) GetCustomerWalletTransactions(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
customerID, ok := c.Get("customer_id")
|
||||
customerIDStr, isString := customerID.(string)
|
||||
if !ok || !isString {
|
||||
logger.FromContext(ctx).Error("Customer ID not found in context")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"),
|
||||
}), "CustomerPointsHandler::GetCustomerWalletTransactions")
|
||||
return
|
||||
}
|
||||
|
||||
var query models.ListCustomerWalletTransactionsQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerWalletTransactions")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.customerPointsService.GetCustomerWalletTransactions(ctx, customerIDStr, query)
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWalletTransactions -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerWalletTransactions")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWalletTransactions")
|
||||
}
|
||||
|
||||
// walletErrorCode tells a bad request apart from a server failure on the wallet
|
||||
// endpoints.
|
||||
func walletErrorCode(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, processor.ErrInvalidWalletQuery):
|
||||
return constants.ValidationErrorCode
|
||||
case errors.Is(err, repository.ErrWalletNotFound):
|
||||
return constants.NotFoundErrorCode
|
||||
default:
|
||||
return constants.InternalServerErrorCode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
applogger "apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/service"
|
||||
)
|
||||
|
||||
// Drives the customer wallet endpoints over HTTP down to Postgres, to show the app
|
||||
// gets the right numbers from the new endpoints and from the old /points, /tokens
|
||||
// and /wallet shapes. Needs TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
applogger.Setup("fatal", "json") // the handlers log their errors; the server sets this up at startup
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
|
||||
org, customer, other := uuid.New(), uuid.New(), uuid.New()
|
||||
customers := []uuid.UUID{customer, other}
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet api test', 'basic')`, org)
|
||||
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'A'), (?, ?, 'B')`, customer, org, other, org)
|
||||
exec(`INSERT INTO organization_settings (organization_id, key, value) VALUES (?, ?, '100')`, org, constants.LoyaltyPointValueKey)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
|
||||
// Balance: 300 earned in two lots that expire on the same Jakarta day but on
|
||||
// different UTC days, 8 coins, 50 paid, and 20 in a lot that has expired but
|
||||
// that the expiry job has not processed.
|
||||
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
txm := repository.NewTxManager(db)
|
||||
next := time.Now().Year() + 1
|
||||
lateNightUTC := time.Date(next, 7, 1, 17, 30, 0, 0, time.UTC) // 2 July 00:30 WIB
|
||||
afternoonUTC := time.Date(next, 7, 2, 10, 0, 0, 0, time.UTC) // 2 July 17:00 WIB
|
||||
expired := time.Now().Add(-time.Hour)
|
||||
outlet, order, payment := uuid.New(), uuid.New(), uuid.New()
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
if _, err := wallet.Credit(ctx, processor.WalletCreditInput{
|
||||
WalletEntry: processor.WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypeEarn, Amount: 300, ReferenceType: constants.WalletRefTypeOrder,
|
||||
ReferenceID: order, OutletID: &outlet, Description: "Belanja #ORD-1"},
|
||||
Lots: []processor.WalletLotInput{{Amount: 200, ExpiresAt: &afternoonUTC}, {Amount: 100, ExpiresAt: &lateNightUTC}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: customer, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration,
|
||||
Amount: 8, ReferenceType: constants.WalletRefTypeLegacyTokens, ReferenceID: customer,
|
||||
Description: "Saldo awal dari sistem lama"}}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := wallet.Debit(ctx, processor.WalletDebitInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment,
|
||||
Amount: 50, ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, OutletID: &outlet,
|
||||
Description: "Bayar #ORD-2"}}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := wallet.Credit(ctx, processor.WalletCreditInput{
|
||||
WalletEntry: processor.WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypeEarn, Amount: 20, ReferenceType: constants.WalletRefTypeOrder,
|
||||
ReferenceID: uuid.New(), OutletID: &outlet, Description: "Belanja #ORD-0"},
|
||||
Lots: []processor.WalletLotInput{{Amount: 20, ExpiresAt: &expired}},
|
||||
})
|
||||
return err
|
||||
}))
|
||||
|
||||
h := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor(
|
||||
processor.NewWalletQueryProcessor(repository.NewWalletQueryRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))),
|
||||
nil,
|
||||
)))
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
as := func(id uuid.UUID) gin.HandlerFunc {
|
||||
return func(c *gin.Context) { c.Set("customer_id", id.String()) }
|
||||
}
|
||||
for _, who := range []struct {
|
||||
prefix string
|
||||
id uuid.UUID
|
||||
}{{"/a", customer}, {"/b", other}, {"/gone", uuid.New()}} {
|
||||
g := router.Group(who.prefix, as(who.id))
|
||||
g.GET("/points", h.GetCustomerPoints)
|
||||
g.GET("/tokens", h.GetCustomerTokens)
|
||||
g.GET("/wallet", h.GetCustomerWallet)
|
||||
g.GET("/wallet/transactions", h.GetCustomerWalletTransactions)
|
||||
}
|
||||
get := func(path string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body), rec.Body.String())
|
||||
return rec.Code, body
|
||||
}
|
||||
// The balance endpoints wrap their data in {status, message, data}.
|
||||
inner := func(body map[string]any) map[string]any {
|
||||
return body["data"].(map[string]any)["data"].(map[string]any)
|
||||
}
|
||||
|
||||
// Old app: /points and /tokens keep their shape and show the spendable balance.
|
||||
status, body := get("/a/points")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 250, inner(body)["total_points"], "300 - 50; the expired 20 is not spendable")
|
||||
assert.Len(t, inner(body)["points_history"], 3)
|
||||
|
||||
status, body = get("/a/tokens")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 8, inner(body)["total_tokens"])
|
||||
|
||||
// /wallet: new fields, with the old ones still there.
|
||||
status, body = get("/a/wallet")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
w := inner(body)
|
||||
assert.EqualValues(t, 250, w["point_balance"])
|
||||
assert.EqualValues(t, 8, w["coin_balance"])
|
||||
assert.EqualValues(t, 100, w["point_value"])
|
||||
assert.EqualValues(t, 25000, w["point_discount_value"])
|
||||
nearest := w["nearest_expiring"].(map[string]any)
|
||||
// The payment took 50 from the lot expiring first, leaving 50 + 200.
|
||||
assert.Equal(t, map[string]any{"amount": float64(250), "date": time.Date(next, 7, 2, 0, 0, 0, 0, time.UTC).Format("2006-01-02")},
|
||||
nearest["point"], "both lots fall on 2 July in Jakarta although they are on different UTC days")
|
||||
assert.Nil(t, nearest["coin"])
|
||||
for _, legacy := range []string{"total_points", "total_tokens", "points_history", "tokens_history", "last_updated"} {
|
||||
assert.Contains(t, w, legacy)
|
||||
}
|
||||
assert.EqualValues(t, 250, w["total_points"])
|
||||
|
||||
recent := w["recent_transactions"].([]any)
|
||||
require.Len(t, recent, 4)
|
||||
for _, raw := range recent {
|
||||
tx := raw.(map[string]any)
|
||||
assert.NotContains(t, tx, "counterparty_customer_id")
|
||||
assert.NotContains(t, tx, "metadata")
|
||||
if tx["amount"].(float64) > 0 {
|
||||
assert.Contains(t, tx, "source", tx["type"])
|
||||
assert.NotContains(t, tx, "destination")
|
||||
} else {
|
||||
assert.Equal(t, map[string]any{"type": "PAYMENT", "id": payment.String()}, tx["destination"])
|
||||
assert.NotContains(t, tx, "source")
|
||||
}
|
||||
}
|
||||
|
||||
// History with filters.
|
||||
status, body = get("/a/wallet/transactions?currency=POINT&type=EARN&limit=1&page=2")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
page := body["data"].(map[string]any)
|
||||
assert.Equal(t, map[string]any{"page": float64(2), "limit": float64(1), "total_count": float64(2), "total_pages": float64(2)}, page["pagination"])
|
||||
rows := page["data"].([]any)
|
||||
require.Len(t, rows, 1)
|
||||
first := rows[0].(map[string]any)
|
||||
assert.Equal(t, "Belanja #ORD-1", first["description"], "newest first, so page 2 is the older earning")
|
||||
assert.Equal(t, map[string]any{"type": "ORDER", "id": order.String()}, first["source"])
|
||||
assert.Equal(t, lateNightUTC.Format(time.RFC3339), parseTime(t, first["expires_at"]).UTC().Format(time.RFC3339))
|
||||
assert.Len(t, first["lots"], 2)
|
||||
|
||||
today := time.Now().In(time.FixedZone("WIB", 7*3600)).Format("2006-01-02")
|
||||
status, body = get("/a/wallet/transactions?from=" + today + "&to=" + today + "&type=PAYMENT,MIGRATION")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 2, body["data"].(map[string]any)["pagination"].(map[string]any)["total_count"])
|
||||
|
||||
status, _ = get("/a/wallet/transactions?currency=GOLD")
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
status, _ = get("/a/wallet/transactions?limit=abc")
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
|
||||
// A customer who has never had a wallet sees zeros, not an error.
|
||||
status, body = get("/b/wallet")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 0, inner(body)["point_balance"])
|
||||
assert.Equal(t, []any{}, inner(body)["recent_transactions"])
|
||||
status, body = get("/b/wallet/transactions")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, []any{}, body["data"].(map[string]any)["data"])
|
||||
|
||||
// A token for a customer that no longer exists.
|
||||
status, _ = get("/gone/wallet")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
}
|
||||
|
||||
func parseTime(t *testing.T, v any) time.Time {
|
||||
t.Helper()
|
||||
s, ok := v.(string)
|
||||
require.True(t, ok, "expected a time string, got %v", v)
|
||||
parsed, err := time.Parse(time.RFC3339Nano, s)
|
||||
require.NoError(t, err)
|
||||
return parsed
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
applogger "apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/middleware"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/service"
|
||||
)
|
||||
|
||||
// Drives GET/PUT /outlets/:id/loyalty-settings over HTTP down to Postgres. Needs
|
||||
// TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
applogger.Setup("fatal", "json")
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
|
||||
org, otherOrg, outlet, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'A', 'basic'), (?, 'B', 'basic')`, org, otherOrg)
|
||||
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg})
|
||||
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet)
|
||||
db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet)
|
||||
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg})
|
||||
})
|
||||
|
||||
settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))
|
||||
h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings, repository.NewWalletQueryRepository(db)))
|
||||
auth := middleware.NewAuthMiddleware(nil)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
as := func(orgID uuid.UUID, role string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, orgID.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserRoleKey, role)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
}
|
||||
for prefix, who := range map[string]struct {
|
||||
org uuid.UUID
|
||||
role string
|
||||
}{"/manager": {org, "manager"}, "/purchasing": {org, "purchasing"}, "/other": {otherOrg, "admin"}} {
|
||||
g := router.Group(prefix, as(who.org, who.role))
|
||||
g.GET("/outlets/:outlet_id/loyalty-settings", h.GetOutletSettings)
|
||||
g.PUT("/outlets/:outlet_id/loyalty-settings", auth.RequireLoyaltyManager(), h.UpdateOutletSettings)
|
||||
}
|
||||
call := func(method, path string, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
|
||||
return rec.Code, out
|
||||
}
|
||||
path := "/outlets/" + outlet.String() + "/loyalty-settings"
|
||||
data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) }
|
||||
|
||||
// An outlet without settings shows every default and 1% cashback.
|
||||
status, body := call(http.MethodGet, "/manager"+path, "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
got := data(body)
|
||||
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(100), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"])
|
||||
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(25000), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"])
|
||||
assert.Equal(t, map[string]any{"accept_payment": false, "min_payment_points": float64(1), "max_payment_percent": float64(100)}, got["point_payment"])
|
||||
assert.EqualValues(t, 1, got["point_value"])
|
||||
assert.EqualValues(t, 1, got["point_cashback_percent"])
|
||||
|
||||
// A partial update keeps everything it does not mention.
|
||||
status, body = call(http.MethodPut, "/manager"+path, `{"point": {"enabled": true, "earn_per_amount": 1000}, "coin": {"max_per_order": 3}}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
got = data(body)
|
||||
assert.Equal(t, true, got["point"].(map[string]any)["enabled"])
|
||||
assert.EqualValues(t, 1000, got["point"].(map[string]any)["earn_per_amount"])
|
||||
assert.EqualValues(t, 1, got["point"].(map[string]any)["earn_value"], "untouched")
|
||||
assert.EqualValues(t, 25000, got["coin"].(map[string]any)["earn_per_amount"], "untouched")
|
||||
assert.EqualValues(t, 3, got["coin"].(map[string]any)["max_per_order"])
|
||||
assert.EqualValues(t, 0.1, got["point_cashback_percent"], "1 point per Rp 1.000 at Rp 1 a point")
|
||||
assert.Len(t, got["changes"], 3)
|
||||
|
||||
// The cashback follows the organization's point value.
|
||||
orgSettings, err := settings.Organization(context.Background(), org)
|
||||
require.NoError(t, err)
|
||||
orgSettings.PointValue = 5
|
||||
_, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings)
|
||||
require.NoError(t, err)
|
||||
status, body = call(http.MethodGet, "/manager"+path, "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 0.5, data(body)["point_cashback_percent"])
|
||||
|
||||
// Sending back what GET returned changes nothing.
|
||||
echo, err := json.Marshal(data(body))
|
||||
require.NoError(t, err)
|
||||
status, body = call(http.MethodPut, "/manager"+path, string(echo))
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Empty(t, data(body)["changes"])
|
||||
|
||||
// null clears a limit.
|
||||
status, body = call(http.MethodPut, "/manager"+path, `{"coin": {"max_per_order": null}}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Nil(t, data(body)["coin"].(map[string]any)["max_per_order"])
|
||||
|
||||
// Values out of bounds, unknown fields and bad JSON are refused and change nothing.
|
||||
for name, bad := range map[string]string{
|
||||
"earn_per_amount 0": `{"point": {"earn_per_amount": 0}}`,
|
||||
"negative earn_value": `{"coin": {"earn_value": -1}}`,
|
||||
"negative min_order": `{"point": {"min_order_amount": -5}}`,
|
||||
"negative max_per_order": `{"point": {"max_per_order": -1}}`,
|
||||
"payment percent over 100": `{"point_payment": {"max_payment_percent": 101}}`,
|
||||
"unknown field": `{"point": {"earn_per_amout": 50}}`,
|
||||
"wrong type": `{"point": {"enabled": "yes"}}`,
|
||||
"not json": `enabled=true`,
|
||||
} {
|
||||
status, _ = call(http.MethodPut, "/manager"+path, bad)
|
||||
assert.Equal(t, http.StatusBadRequest, status, name)
|
||||
}
|
||||
status, body = call(http.MethodGet, "/manager"+path, "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 1000, data(body)["point"].(map[string]any)["earn_per_amount"])
|
||||
|
||||
// Purchasing staff can read but not change; another organization sees nothing.
|
||||
status, _ = call(http.MethodPut, "/purchasing"+path, `{"point": {"enabled": false}}`)
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
status, _ = call(http.MethodGet, "/other"+path, "")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
status, _ = call(http.MethodPut, "/other"+path, `{"point": {"enabled": false}}`)
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
status, _ = call(http.MethodGet, "/manager/outlets/not-a-uuid/loyalty-settings", "")
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
|
||||
// Every change is in the history with who made it.
|
||||
history, err := settings.ListChanges(context.Background(), org, &outlet, 1, 100)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 4, history.Pagination.Total, "three keys, then the limit cleared")
|
||||
for _, c := range history.Data {
|
||||
assert.Equal(t, admin, c.ChangedBy)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// loyaltySettingsBodyLimit caps a settings body; a real one is well under 2 KB.
|
||||
const loyaltySettingsBodyLimit = 64 << 10
|
||||
|
||||
// LoyaltySettingsHandler serves the loyalty settings (docs/prd-point-coin.md F1, F2).
|
||||
type LoyaltySettingsHandler struct {
|
||||
loyaltySettingsService service.LoyaltySettingsService
|
||||
}
|
||||
|
||||
func NewLoyaltySettingsHandler(loyaltySettingsService service.LoyaltySettingsService) *LoyaltySettingsHandler {
|
||||
return &LoyaltySettingsHandler{loyaltySettingsService: loyaltySettingsService}
|
||||
}
|
||||
|
||||
func (h *LoyaltySettingsHandler) GetOutletSettings(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
outletID, ok := parseUUIDParam(c, "outlet_id", "LoyaltySettingsHandler::GetOutletSettings")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response := h.loyaltySettingsService.GetOutletSettings(ctx, appcontext.FromGinContext(ctx), outletID)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::GetOutletSettings -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::GetOutletSettings")
|
||||
}
|
||||
|
||||
func (h *LoyaltySettingsHandler) UpdateOutletSettings(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
outletID, ok := parseUUIDParam(c, "outlet_id", "LoyaltySettingsHandler::UpdateOutletSettings")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, loyaltySettingsBodyLimit))
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "unable to read request body"),
|
||||
}), "LoyaltySettingsHandler::UpdateOutletSettings")
|
||||
return
|
||||
}
|
||||
response := h.loyaltySettingsService.UpdateOutletSettings(ctx, appcontext.FromGinContext(ctx), outletID, body)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::UpdateOutletSettings -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::UpdateOutletSettings")
|
||||
}
|
||||
|
||||
// parseUUIDParam reads a UUID path parameter, answering 400 itself when it is not one.
|
||||
func parseUUIDParam(c *gin.Context, name, method string) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(c.Param(name))
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid "+name),
|
||||
}), method)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (h *LoyaltySettingsHandler) GetOrganizationSettings(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.loyaltySettingsService.GetOrganizationSettings(ctx, appcontext.FromGinContext(ctx)), "LoyaltySettingsHandler::GetOrganizationSettings")
|
||||
}
|
||||
|
||||
// UpdateOrganizationSettings is PUT /marketing/loyalty-settings. With ?dry_run=true it
|
||||
// saves nothing and returns what would change and what the balances in circulation
|
||||
// would be worth, for the warning the dashboard shows before saving (F2).
|
||||
func (h *LoyaltySettingsHandler) UpdateOrganizationSettings(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, loyaltySettingsBodyLimit))
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "unable to read request body"),
|
||||
}), "LoyaltySettingsHandler::UpdateOrganizationSettings")
|
||||
return
|
||||
}
|
||||
response := h.loyaltySettingsService.UpdateOrganizationSettings(ctx, appcontext.FromGinContext(ctx), body, c.Query("dry_run") == "true")
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::UpdateOrganizationSettings -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::UpdateOrganizationSettings")
|
||||
}
|
||||
|
||||
// ListHistory is GET /marketing/loyalty-settings/history, optionally for one outlet
|
||||
// (?outlet_id=).
|
||||
func (h *LoyaltySettingsHandler) ListHistory(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var outletID *uuid.UUID
|
||||
if raw := c.Query("outlet_id"); raw != "" {
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet_id"),
|
||||
}), "LoyaltySettingsHandler::ListHistory")
|
||||
return
|
||||
}
|
||||
outletID = &id
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
util.HandleResponse(c.Writer, c.Request, h.loyaltySettingsService.ListHistory(ctx, appcontext.FromGinContext(ctx), outletID, page, limit), "LoyaltySettingsHandler::ListHistory")
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
applogger "apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/middleware"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/service"
|
||||
)
|
||||
|
||||
// Drives GET/PUT /marketing/loyalty-settings and its history over HTTP down to
|
||||
// Postgres. Needs TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestOrganizationLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
applogger.Setup("fatal", "json")
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
|
||||
org, admin, customer := uuid.New(), uuid.New(), uuid.New()
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'A', 'basic')`, org)
|
||||
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
|
||||
// Balances in circulation: 50.000 EnakPoint and 105 EnakCoin.
|
||||
txm := repository.NewTxManager(db)
|
||||
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
for _, c := range []struct {
|
||||
currency, ref string
|
||||
amount int64
|
||||
}{{"POINT", "LEGACY_POINTS", 50000}, {"COIN", "LEGACY_TOKENS", 105}} {
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
_, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: customer, Currency: c.currency, Type: "MIGRATION", Amount: c.amount,
|
||||
ReferenceType: c.ref, ReferenceID: uuid.New(), Description: "Saldo awal",
|
||||
Metadata: map[string]interface{}{"frozen": true}}})
|
||||
return err
|
||||
}))
|
||||
}
|
||||
|
||||
settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
|
||||
h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings, repository.NewWalletQueryRepository(db)))
|
||||
auth := middleware.NewAuthMiddleware(nil)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
for prefix, role := range map[string]string{"/manager": "manager", "/purchasing": "purchasing"} {
|
||||
role := role
|
||||
g := router.Group(prefix, func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserRoleKey, role)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
})
|
||||
g.GET("/loyalty-settings", h.GetOrganizationSettings)
|
||||
g.PUT("/loyalty-settings", auth.RequireLoyaltyManager(), h.UpdateOrganizationSettings)
|
||||
g.GET("/loyalty-settings/history", h.ListHistory)
|
||||
}
|
||||
call := func(method, path, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
|
||||
return rec.Code, out
|
||||
}
|
||||
data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) }
|
||||
|
||||
status, body := call(http.MethodGet, "/manager/loyalty-settings", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
got := data(body)
|
||||
assert.EqualValues(t, 1, got["point_value"])
|
||||
assert.Equal(t, map[string]any{"coin_amount": float64(1), "point_amount": float64(1)}, got["exchange"])
|
||||
assert.Equal(t, true, got["transfer"].(map[string]any)["enabled"])
|
||||
assert.Equal(t, "MONTH", got["point_expiry"].(map[string]any)["unit"])
|
||||
impact := got["impact"].(map[string]any)
|
||||
assert.EqualValues(t, 50000, impact["outstanding_points"])
|
||||
assert.EqualValues(t, impact["point_rupiah_before"], impact["point_rupiah_after"])
|
||||
|
||||
// A dry run shows what the balances in circulation would be worth, and saves nothing.
|
||||
change := `{"point_value": 100, "exchange": {"coin_amount": 10, "point_amount": 1}}`
|
||||
status, body = call(http.MethodPut, "/manager/loyalty-settings?dry_run=true", change)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
got = data(body)
|
||||
assert.Equal(t, true, got["dry_run"])
|
||||
impact = got["impact"].(map[string]any)
|
||||
assert.EqualValues(t, 50000, impact["point_rupiah_before"])
|
||||
assert.EqualValues(t, 5000000, impact["point_rupiah_after"])
|
||||
assert.EqualValues(t, 105, impact["coins_as_points_before"])
|
||||
assert.EqualValues(t, 10, impact["coins_as_points_after"])
|
||||
assert.Len(t, got["changes"], 2)
|
||||
status, body = call(http.MethodGet, "/manager/loyalty-settings", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 1, data(body)["point_value"], "a dry run saves nothing")
|
||||
status, body = call(http.MethodGet, "/manager/loyalty-settings/history", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 0, data(body)["pagination"].(map[string]any)["total_count"])
|
||||
|
||||
// Saving records every change with who made it.
|
||||
status, body = call(http.MethodPut, "/manager/loyalty-settings", change)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, false, data(body)["dry_run"])
|
||||
assert.EqualValues(t, 100, data(body)["point_value"])
|
||||
status, body = call(http.MethodGet, "/manager/loyalty-settings/history", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
history := data(body)["data"].([]any)
|
||||
require.Len(t, history, 2)
|
||||
var keys []string
|
||||
for _, raw := range history {
|
||||
row := raw.(map[string]any)
|
||||
keys = append(keys, row["key"].(string))
|
||||
assert.Equal(t, admin.String(), row["changed_by"])
|
||||
assert.Nil(t, row["old_value"], "was on its default")
|
||||
assert.Nil(t, row["outlet_id"])
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"loyalty.point.value", "loyalty.exchange.coin_amount"}, keys)
|
||||
|
||||
// Nothing already written changes: balances and ledger rows keep their numbers.
|
||||
var rows []struct {
|
||||
Amount int64
|
||||
Metadata string
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT amount, metadata::text AS metadata FROM wallet_transactions WHERE customer_id = ? ORDER BY currency`, customer).Scan(&rows).Error)
|
||||
require.Len(t, rows, 2)
|
||||
assert.Equal(t, int64(105), rows[0].Amount)
|
||||
assert.Equal(t, int64(50000), rows[1].Amount)
|
||||
for _, r := range rows {
|
||||
assert.Contains(t, r.Metadata, `"frozen": true`)
|
||||
}
|
||||
|
||||
// Out of bounds, unknown fields and the wrong role are refused.
|
||||
for name, bad := range map[string]string{
|
||||
"point value 0": `{"point_value": 0}`,
|
||||
"exchange 0": `{"exchange": {"coin_amount": 0}}`,
|
||||
"expiry unit YEAR": `{"coin_expiry": {"unit": "YEAR"}}`,
|
||||
"unknown field": `{"point_valeu": 5}`,
|
||||
} {
|
||||
status, _ = call(http.MethodPut, "/manager/loyalty-settings", bad)
|
||||
assert.Equal(t, http.StatusBadRequest, status, name)
|
||||
status, _ = call(http.MethodPut, "/manager/loyalty-settings?dry_run=true", bad)
|
||||
assert.Equal(t, http.StatusBadRequest, status, name+" (dry run)")
|
||||
}
|
||||
status, _ = call(http.MethodPut, "/purchasing/loyalty-settings", `{"point_value": 5}`)
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
status, body = call(http.MethodGet, "/manager/loyalty-settings", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 100, data(body)["point_value"])
|
||||
}
|
||||
@@ -113,6 +113,16 @@ func (h *PaymentMethodHandler) ListPaymentMethods(c *gin.Context) {
|
||||
|
||||
req.OrganizationID = &contextInfo.OrganizationID
|
||||
|
||||
// At the cashier, EnakPoint is listed only where the outlet accepts it (F9).
|
||||
if outletStr := c.Query("outlet_id"); outletStr != "" {
|
||||
if outletID, err := uuid.Parse(outletStr); err == nil {
|
||||
req.OutletID = &outletID
|
||||
}
|
||||
} else if contextInfo.OutletID != uuid.Nil {
|
||||
outletID := contextInfo.OutletID
|
||||
req.OutletID = &outletID
|
||||
}
|
||||
|
||||
if isActiveStr := c.Query("is_active"); isActiveStr != "" {
|
||||
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
|
||||
req.IsActive = &isActive
|
||||
|
||||
@@ -344,6 +344,7 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
|
||||
ProductID: item.ProductID,
|
||||
ProductVariantID: item.ProductVariantID,
|
||||
Quantity: item.Quantity,
|
||||
Weight: item.Weight,
|
||||
Notes: item.Notes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
applogger "apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/validator"
|
||||
)
|
||||
|
||||
// Drives the dashboard wallet endpoints over HTTP down to Postgres. Needs
|
||||
// TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
applogger.Setup("fatal", "json")
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
|
||||
orgA, orgB := uuid.New(), uuid.New()
|
||||
adminA, adminB := uuid.New(), uuid.New()
|
||||
customer := uuid.New()
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'A', 'basic'), (?, 'B', 'basic')`, orgA, orgB)
|
||||
exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES
|
||||
(?, ?, 'Admin Satu', ?, 'x', 'admin'), (?, ?, 'Admin Lain', ?, 'x', 'admin')`,
|
||||
adminA, orgA, adminA.String()+"@test", adminB, orgB, adminB.String()+"@test")
|
||||
exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Budi Santoso', '081234567890')`, customer, orgA)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id = ?)`, customer)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
|
||||
db.Exec(`DELETE FROM users WHERE id IN ?`, []uuid.UUID{adminA, adminB})
|
||||
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{orgA, orgB})
|
||||
})
|
||||
|
||||
walletRepo := repository.NewWalletRepository(db)
|
||||
queryRepo := repository.NewWalletQueryRepository(db)
|
||||
txm := repository.NewTxManager(db)
|
||||
wallet := processor.NewWalletProcessor(walletRepo)
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
outlet := uuid.New()
|
||||
_, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn,
|
||||
Amount: 100, ReferenceType: constants.WalletRefTypeOrder, ReferenceID: uuid.New(), OutletID: &outlet,
|
||||
Description: "Belanja #ORD-1"}})
|
||||
return err
|
||||
}))
|
||||
|
||||
h := NewWalletAdminHandler(
|
||||
service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, txm)),
|
||||
validator.NewWalletValidator(),
|
||||
)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
as := func(org, user uuid.UUID) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserIDKey, user.String())
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
}
|
||||
for prefix, who := range map[string][2]uuid.UUID{"/a": {orgA, adminA}, "/b": {orgB, adminB}} {
|
||||
g := router.Group(prefix, as(who[0], who[1]))
|
||||
g.GET("/customers/:id/wallet", h.GetCustomerWallet)
|
||||
g.POST("/customers/:id/wallet/adjust", h.AdjustCustomerWallet)
|
||||
}
|
||||
call := func(method, path string, body any) (int, map[string]any) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
require.NoError(t, json.NewEncoder(&buf).Encode(body))
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
|
||||
return rec.Code, out
|
||||
}
|
||||
adjust := func(prefix string, body map[string]any) (int, map[string]any) {
|
||||
return call(http.MethodPost, prefix+"/customers/"+customer.String()+"/wallet/adjust", body)
|
||||
}
|
||||
spendable := func() float64 {
|
||||
t.Helper()
|
||||
status, body := call(http.MethodGet, "/a/customers/"+customer.String()+"/wallet", nil)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
return body["data"].(map[string]any)["spendable_point_balance"].(float64)
|
||||
}
|
||||
|
||||
// Add 50.
|
||||
status, body := adjust("/a", map[string]any{"currency": "point", "amount": 50, "reason": "komplain #45"})
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
tx := body["data"].(map[string]any)["transaction"].(map[string]any)
|
||||
assert.Equal(t, "ADJUSTMENT", tx["type"])
|
||||
assert.EqualValues(t, 50, tx["amount"])
|
||||
assert.Equal(t, "komplain #45", tx["reason"])
|
||||
assert.Equal(t, "Koreksi oleh admin: komplain #45", tx["description"])
|
||||
assert.Equal(t, map[string]any{"id": adminA.String(), "name": "Admin Satu"}, tx["created_by"])
|
||||
assert.Equal(t, map[string]any{"type": "USER", "id": adminA.String()}, tx["source"])
|
||||
assert.EqualValues(t, 150, body["data"].(map[string]any)["spendable_point_balance"])
|
||||
|
||||
// Taking more than the customer can spend is refused and changes nothing.
|
||||
status, body = adjust("/a", map[string]any{"currency": "POINT", "amount": -151, "reason": "salah input"})
|
||||
assert.Equal(t, http.StatusBadRequest, status, body)
|
||||
assert.Equal(t, float64(150), spendable())
|
||||
|
||||
// Taking away, retried with the same key, happens once.
|
||||
for i, wantReplayed := range []bool{false, true} {
|
||||
status, body = adjust("/a", map[string]any{"currency": "POINT", "amount": -30, "reason": "salah input", "idempotency_key": "req-1"})
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, wantReplayed, body["data"].(map[string]any)["replayed"], "call %d", i+1)
|
||||
}
|
||||
assert.Equal(t, float64(120), spendable())
|
||||
|
||||
// Bad requests.
|
||||
for name, req := range map[string]map[string]any{
|
||||
"cash-out": {"currency": "POINT", "amount": -10, "reason": "pencairan saldo"},
|
||||
"zero amount": {"currency": "POINT", "amount": 0, "reason": "x"},
|
||||
"no reason": {"currency": "POINT", "amount": 10},
|
||||
"bad currency": {"currency": "GOLD", "amount": 10, "reason": "x"},
|
||||
} {
|
||||
status, _ = adjust("/a", req)
|
||||
assert.Equal(t, http.StatusBadRequest, status, name)
|
||||
}
|
||||
status, _ = call(http.MethodPost, "/a/customers/not-a-uuid/wallet/adjust", map[string]any{"currency": "POINT", "amount": 1, "reason": "x"})
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
assert.Equal(t, float64(120), spendable())
|
||||
|
||||
// Another organization's admin cannot see or touch this customer.
|
||||
status, _ = call(http.MethodGet, "/b/customers/"+customer.String()+"/wallet", nil)
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
status, _ = adjust("/b", map[string]any{"currency": "POINT", "amount": 1000, "reason": "x"})
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
assert.Equal(t, float64(120), spendable())
|
||||
|
||||
// The dashboard view.
|
||||
status, body = call(http.MethodGet, "/a/customers/"+customer.String()+"/wallet?type=ADJUSTMENT", nil)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
w := body["data"].(map[string]any)
|
||||
assert.Equal(t, map[string]any{"id": customer.String(), "name": "Budi Santoso", "phone": "081234567890"}, w["customer"])
|
||||
assert.EqualValues(t, 120, w["point_balance"])
|
||||
assert.EqualValues(t, 120, w["spendable_point_balance"])
|
||||
lots := w["lots"].([]any)
|
||||
var lotTotal float64
|
||||
for _, l := range lots {
|
||||
lotTotal += l.(map[string]any)["remaining_amount"].(float64)
|
||||
}
|
||||
assert.Equal(t, float64(120), lotTotal)
|
||||
|
||||
rows := w["transactions"].(map[string]any)["data"].([]any)
|
||||
require.Len(t, rows, 2, "the two adjustments, newest first")
|
||||
newest := rows[0].(map[string]any)
|
||||
assert.EqualValues(t, -30, newest["amount"])
|
||||
assert.Equal(t, "salah input", newest["reason"])
|
||||
assert.Equal(t, map[string]any{"id": adminA.String(), "name": "Admin Satu"}, newest["created_by"])
|
||||
assert.Equal(t, map[string]any{"type": "USER", "id": adminA.String()}, newest["destination"])
|
||||
|
||||
// The customer's own history shows the adjustment too, with the reason in the
|
||||
// description and without the admin's identity fields.
|
||||
customerView := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor(
|
||||
processor.NewWalletQueryProcessor(queryRepo, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))), nil)))
|
||||
crouter := gin.New()
|
||||
crouter.GET("/wallet/transactions", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerView.GetCustomerWalletTransactions)
|
||||
rec := httptest.NewRecorder()
|
||||
crouter.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/wallet/transactions?type=ADJUSTMENT", nil))
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
var cbody map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &cbody))
|
||||
crows := cbody["data"].(map[string]any)["data"].([]any)
|
||||
require.Len(t, crows, 2)
|
||||
first := crows[0].(map[string]any)
|
||||
assert.Equal(t, "Koreksi oleh admin: salah input", first["description"])
|
||||
assert.NotContains(t, first, "created_by")
|
||||
assert.NotContains(t, first, "reason")
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
"apskel-pos-be/internal/validator"
|
||||
)
|
||||
|
||||
// WalletAdminHandler serves /marketing/customers/:id/wallet (docs/prd-point-coin.md F7).
|
||||
type WalletAdminHandler struct {
|
||||
walletAdminService service.WalletAdminService
|
||||
walletValidator validator.WalletValidator
|
||||
}
|
||||
|
||||
func NewWalletAdminHandler(walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator) *WalletAdminHandler {
|
||||
return &WalletAdminHandler{walletAdminService: walletAdminService, walletValidator: walletValidator}
|
||||
}
|
||||
|
||||
func (h *WalletAdminHandler) GetCustomerWallet(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
customerID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid customer ID"),
|
||||
}), "WalletAdminHandler::GetCustomerWallet")
|
||||
return
|
||||
}
|
||||
|
||||
var query models.ListCustomerWalletTransactionsQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()),
|
||||
}), "WalletAdminHandler::GetCustomerWallet")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.walletAdminService.GetCustomerWallet(ctx, contextInfo, customerID, query)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::GetCustomerWallet -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::GetCustomerWallet")
|
||||
}
|
||||
|
||||
func (h *WalletAdminHandler) AdjustCustomerWallet(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
customerID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid customer ID"),
|
||||
}), "WalletAdminHandler::AdjustCustomerWallet")
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.AdjustCustomerWalletRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()),
|
||||
}), "WalletAdminHandler::AdjustCustomerWallet")
|
||||
return
|
||||
}
|
||||
if validationError, code := h.walletValidator.ValidateAdjustRequest(&req); validationError != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(code, constants.RequestEntity, validationError.Error()),
|
||||
}), "WalletAdminHandler::AdjustCustomerWallet")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.walletAdminService.AdjustCustomerWallet(ctx, contextInfo, customerID, &req)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::AdjustCustomerWallet -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::AdjustCustomerWallet")
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func ToCampaignEntity(request *contract.CreateCampaignRequest) *entities.Campaig
|
||||
return &entities.Campaign{
|
||||
Name: request.Name,
|
||||
Description: request.Description,
|
||||
Type: entities.CampaignType(request.Type),
|
||||
Type: entities.NormalizeCampaignType(request.Type),
|
||||
StartDate: request.StartDate,
|
||||
EndDate: request.EndDate,
|
||||
IsActive: request.IsActive,
|
||||
@@ -91,7 +91,7 @@ func ToCampaignEntityFromUpdate(request *contract.UpdateCampaignRequest) *entiti
|
||||
ID: request.ID,
|
||||
Name: request.Name,
|
||||
Description: request.Description,
|
||||
Type: entities.CampaignType(request.Type),
|
||||
Type: entities.NormalizeCampaignType(request.Type),
|
||||
StartDate: request.StartDate,
|
||||
EndDate: request.EndDate,
|
||||
IsActive: request.IsActive,
|
||||
@@ -118,7 +118,7 @@ func ToCampaignRuleEntity(request *contract.CampaignRuleStruct, campaignID uuid.
|
||||
CampaignID: campaignID,
|
||||
RuleType: entities.RuleType(request.RuleType),
|
||||
ConditionValue: request.ConditionValue,
|
||||
RewardType: entities.CampaignRewardType(request.RewardType),
|
||||
RewardType: entities.NormalizeCampaignRewardType(request.RewardType),
|
||||
RewardValue: request.RewardValue,
|
||||
RewardSubtype: rewardSubtype,
|
||||
RewardRefID: request.RewardRefID,
|
||||
@@ -142,7 +142,7 @@ func ToCampaignRuleEntityFromUpdate(request *contract.CampaignRuleStruct, campai
|
||||
CampaignID: campaignID,
|
||||
RuleType: entities.RuleType(request.RuleType),
|
||||
ConditionValue: request.ConditionValue,
|
||||
RewardType: entities.CampaignRewardType(request.RewardType),
|
||||
RewardType: entities.NormalizeCampaignRewardType(request.RewardType),
|
||||
RewardValue: request.RewardValue,
|
||||
RewardSubtype: rewardSubtype,
|
||||
RewardRefID: request.RewardRefID,
|
||||
|
||||
@@ -115,12 +115,23 @@ func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *mo
|
||||
}
|
||||
}
|
||||
|
||||
// Unit is only meaningful for weighed lines, but carry it whenever it was captured.
|
||||
var unitName, unitAbbreviation *string
|
||||
if item.Unit != nil {
|
||||
unitName = &item.Unit.Name
|
||||
unitAbbreviation = item.Unit.Abbreviation
|
||||
}
|
||||
|
||||
response := &models.OrderItemResponse{
|
||||
ID: item.ID,
|
||||
OrderID: item.OrderID,
|
||||
ProductID: item.ProductID,
|
||||
ProductVariantID: item.ProductVariantID,
|
||||
Quantity: item.Quantity,
|
||||
Weight: item.Weight,
|
||||
UnitID: item.UnitID,
|
||||
UnitName: unitName,
|
||||
UnitAbbreviation: unitAbbreviation,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: item.TotalPrice,
|
||||
UnitCost: item.UnitCost,
|
||||
|
||||
@@ -134,6 +134,7 @@ func ListPaymentMethodsContractToModel(req *contract.ListPaymentMethodsRequest)
|
||||
|
||||
return &models.ListPaymentMethodsRequest{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
Type: paymentMethodType,
|
||||
IsActive: req.IsActive,
|
||||
Search: req.Search,
|
||||
|
||||
@@ -25,6 +25,9 @@ func ProductEntityToModel(entity *entities.Product) *models.Product {
|
||||
BusinessType: constants.BusinessType(entity.BusinessType),
|
||||
ImageURL: entity.ImageURL,
|
||||
PrinterType: entity.PrinterType,
|
||||
UnitID: entity.UnitID,
|
||||
SellBy: entity.SellBy,
|
||||
HasIngredients: entity.HasIngredients,
|
||||
Metadata: map[string]interface{}(entity.Metadata),
|
||||
IsActive: entity.IsActive,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
@@ -49,6 +52,9 @@ func ProductModelToEntity(model *models.Product) *entities.Product {
|
||||
BusinessType: string(model.BusinessType),
|
||||
ImageURL: model.ImageURL,
|
||||
PrinterType: model.PrinterType,
|
||||
UnitID: model.UnitID,
|
||||
SellBy: model.SellBy,
|
||||
HasIngredients: model.HasIngredients,
|
||||
Metadata: entities.Metadata(model.Metadata),
|
||||
IsActive: model.IsActive,
|
||||
CreatedAt: model.CreatedAt,
|
||||
@@ -76,6 +82,11 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
||||
printerType = *req.PrinterType
|
||||
}
|
||||
|
||||
sellBy := constants.SellByUnit
|
||||
if constants.IsValidSellBy(req.SellBy) {
|
||||
sellBy = req.SellBy
|
||||
}
|
||||
|
||||
metadata := entities.Metadata{}
|
||||
if req.Metadata != nil {
|
||||
metadata = entities.Metadata(req.Metadata)
|
||||
@@ -92,6 +103,8 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
||||
BusinessType: businessType,
|
||||
ImageURL: req.ImageURL,
|
||||
PrinterType: printerType,
|
||||
UnitID: req.UnitID,
|
||||
SellBy: sellBy,
|
||||
Metadata: metadata,
|
||||
IsActive: true, // Default to active
|
||||
}
|
||||
@@ -140,6 +153,8 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
|
||||
BusinessType: constants.BusinessType(entity.BusinessType),
|
||||
ImageURL: entity.ImageURL,
|
||||
PrinterType: entity.PrinterType,
|
||||
UnitID: entity.UnitID,
|
||||
SellBy: entity.SellBy,
|
||||
Metadata: map[string]interface{}(entity.Metadata),
|
||||
IsActive: entity.IsActive,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
@@ -185,6 +200,14 @@ func UpdateProductEntityFromRequest(entity *entities.Product, req *models.Update
|
||||
entity.PrinterType = *req.PrinterType
|
||||
}
|
||||
|
||||
if req.UnitID != nil {
|
||||
entity.UnitID = req.UnitID
|
||||
}
|
||||
|
||||
if req.SellBy != nil && constants.IsValidSellBy(*req.SellBy) {
|
||||
entity.SellBy = *req.SellBy
|
||||
}
|
||||
|
||||
if req.Metadata != nil {
|
||||
if entity.Metadata == nil {
|
||||
entity.Metadata = make(entities.Metadata)
|
||||
|
||||
@@ -85,6 +85,14 @@ func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
|
||||
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||
}
|
||||
|
||||
// RequireLoyaltyManager guards what moves or prices EnakPoint and EnakCoin: loyalty
|
||||
// settings and manual wallet adjustments (docs/prd-point-coin.md F1, F2, F7). Only
|
||||
// admins and managers may do these; unlike RequireAdminOrManager it does not let the
|
||||
// purchasing role through.
|
||||
func (m *AuthMiddleware) RequireLoyaltyManager() gin.HandlerFunc {
|
||||
return m.RequireRole("superadmin", "admin", "manager", "owner")
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequireAdminOrManagerOrPurchasing() gin.HandlerFunc {
|
||||
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||
}
|
||||
|
||||
@@ -233,6 +233,7 @@ type ProductAnalyticsData struct {
|
||||
CategoryName string `json:"category_name"`
|
||||
CategoryOrder int `json:"category_order"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
WeightSold float64 `json:"weight_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
@@ -359,6 +360,7 @@ type ParentCategoryAnalyticsProductData struct {
|
||||
ProductSku string `json:"product_sku"`
|
||||
ProductPrice float64 `json:"product_price"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
WeightSold float64 `json:"weight_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
type CreateCampaignRequest struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=150"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS TOKENS MIXED"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"`
|
||||
StartDate time.Time `json:"start_date" binding:"required"`
|
||||
EndDate time.Time `json:"end_date" binding:"required"`
|
||||
IsActive bool `json:"is_active"`
|
||||
@@ -25,7 +25,7 @@ type UpdateCampaignRequest struct {
|
||||
ID uuid.UUID `json:"id" binding:"required"`
|
||||
Name string `json:"name" binding:"required,min=1,max=150"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS TOKENS MIXED"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"`
|
||||
StartDate time.Time `json:"start_date" binding:"required"`
|
||||
EndDate time.Time `json:"end_date" binding:"required"`
|
||||
IsActive bool `json:"is_active"`
|
||||
@@ -88,7 +88,7 @@ type CreateCampaignRuleRequest struct {
|
||||
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
|
||||
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
|
||||
ConditionValue *string `json:"condition_value,omitempty"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
|
||||
RewardValue *int64 `json:"reward_value,omitempty"`
|
||||
RewardSubtype *string `json:"reward_subtype,omitempty"`
|
||||
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
|
||||
@@ -100,7 +100,7 @@ type UpdateCampaignRuleRequest struct {
|
||||
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
|
||||
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
|
||||
ConditionValue *string `json:"condition_value,omitempty"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
|
||||
RewardValue *int64 `json:"reward_value,omitempty"`
|
||||
RewardSubtype *string `json:"reward_subtype,omitempty"`
|
||||
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
|
||||
@@ -126,7 +126,7 @@ type ListCampaignRulesResponse struct {
|
||||
type CampaignRuleStruct struct {
|
||||
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
|
||||
ConditionValue *string `json:"condition_value,omitempty"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
|
||||
RewardValue *int64 `json:"reward_value,omitempty"`
|
||||
RewardSubtype *string `json:"reward_subtype,omitempty"`
|
||||
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CustomerPinStatus is GET /customer/pin/status.
|
||||
type CustomerPinStatus struct {
|
||||
HasPin bool `json:"has_pin"`
|
||||
LockedUntil *time.Time `json:"locked_until"`
|
||||
TransferBlockedUntil *time.Time `json:"transfer_blocked_until"`
|
||||
}
|
||||
|
||||
// CustomerPinOtp is what POST /customer/pin/otp returns: the token to send back with
|
||||
// the code the customer received.
|
||||
type CustomerPinOtp struct {
|
||||
Purpose string `json:"purpose"`
|
||||
OtpToken string `json:"otp_token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// CustomerSecurityEventView is one row of GET /marketing/customers/:id/security-events.
|
||||
type CustomerSecurityEventView struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Event string `json:"event"`
|
||||
ActorUser *uuid.UUID `json:"actor_user,omitempty"`
|
||||
Reason *string `json:"reason,omitempty"`
|
||||
IPAddress *string `json:"ip_address,omitempty"`
|
||||
UserAgent *string `json:"user_agent,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CustomerPinRequestInfo is where a PIN request came from, for the security log.
|
||||
type CustomerPinRequestInfo struct {
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// PaymentCode is what POST /customer/wallet/payment-code returns: a one-time code the
|
||||
// customer shows the cashier, as digits or as a QR of QRPayload.
|
||||
type PaymentCode struct {
|
||||
Code string `json:"code"`
|
||||
QRPayload string `json:"qr_payload"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
@@ -109,7 +109,20 @@ type GetCustomerWalletResponse struct {
|
||||
Data *GetCustomerWalletResponseData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// GetCustomerWalletResponseData is GET /customer/wallet (docs/prd-point-coin.md F6).
|
||||
type GetCustomerWalletResponseData struct {
|
||||
// Balances the customer can spend now.
|
||||
PointBalance int64 `json:"point_balance"`
|
||||
CoinBalance int64 `json:"coin_balance"`
|
||||
// Rupiah value of one EnakPoint, and of the whole point balance. EnakPoint only
|
||||
// ever pays for orders, so the app shows this as "setara potongan Rp …" (K7).
|
||||
PointValue int64 `json:"point_value"`
|
||||
PointDiscountValue int64 `json:"point_discount_value"`
|
||||
NearestExpiring CustomerWalletNearestExpiring `json:"nearest_expiring"`
|
||||
RecentTransactions []CustomerWalletTransaction `json:"recent_transactions"`
|
||||
|
||||
// Deprecated: the shape of this endpoint before the wallet, kept for app versions
|
||||
// that still read it. Filled from the wallet like the fields above.
|
||||
TotalPoints int64 `json:"total_points"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
PointsHistory []PointsHistoryItem `json:"points_history,omitempty"`
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// OutletLoyaltySettings are an outlet's loyalty settings (docs/prd-point-coin.md F1).
|
||||
type OutletLoyaltySettings struct {
|
||||
Point LoyaltyEarnSettings `json:"point"`
|
||||
Coin LoyaltyEarnSettings `json:"coin"`
|
||||
// Paying with EnakPoint. EnakCoin cannot pay, so it has no counterpart.
|
||||
PointPayment LoyaltyPointPaymentSettings `json:"point_payment"`
|
||||
}
|
||||
|
||||
// LoyaltyEarnSettings is how much of one currency an order earns:
|
||||
// floor(basis / EarnPerAmount) × EarnValue, nothing below MinOrderAmount, and at most
|
||||
// MaxPerOrder when set.
|
||||
type LoyaltyEarnSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
EarnPerAmount int64 `json:"earn_per_amount"`
|
||||
EarnValue int64 `json:"earn_value"`
|
||||
MinOrderAmount int64 `json:"min_order_amount"`
|
||||
MaxPerOrder *int64 `json:"max_per_order"`
|
||||
}
|
||||
|
||||
type LoyaltyPointPaymentSettings struct {
|
||||
AcceptPayment bool `json:"accept_payment"`
|
||||
MinPaymentPoints int64 `json:"min_payment_points"`
|
||||
// Largest share of the order total, 0–100, that EnakPoint may pay.
|
||||
MaxPaymentPercent int64 `json:"max_payment_percent"`
|
||||
}
|
||||
|
||||
// OrganizationLoyaltySettings are the loyalty settings shared by every outlet of an
|
||||
// organization (docs/prd-point-coin.md F2, F12).
|
||||
type OrganizationLoyaltySettings struct {
|
||||
// Rupiah value of one EnakPoint when paying.
|
||||
PointValue int64 `json:"point_value"`
|
||||
// CoinAmount EnakCoin exchange into PointAmount EnakPoint.
|
||||
Exchange LoyaltyExchangeSettings `json:"exchange"`
|
||||
Transfer LoyaltyTransferSettings `json:"transfer"`
|
||||
PointExpiry LoyaltyExpirySettings `json:"point_expiry"`
|
||||
CoinExpiry LoyaltyExpirySettings `json:"coin_expiry"`
|
||||
}
|
||||
|
||||
type LoyaltyExchangeSettings struct {
|
||||
CoinAmount int64 `json:"coin_amount"`
|
||||
PointAmount int64 `json:"point_amount"`
|
||||
}
|
||||
|
||||
type LoyaltyTransferSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MinAmount int64 `json:"min_amount"`
|
||||
MaxPerTransaction *int64 `json:"max_per_transaction"`
|
||||
DailyLimit *int64 `json:"daily_limit"`
|
||||
}
|
||||
|
||||
// LoyaltyExpirySettings is how long one currency lasts once received. The expiry
|
||||
// model is still open (note N4); these are only the stored settings.
|
||||
type LoyaltyExpirySettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Period int64 `json:"period"`
|
||||
// DAY or MONTH.
|
||||
Unit string `json:"unit"`
|
||||
EndOfMonth bool `json:"end_of_month"`
|
||||
ReminderDays int64 `json:"reminder_days"`
|
||||
}
|
||||
|
||||
// LoyaltySettingChange is one row of the loyalty settings history.
|
||||
type LoyaltySettingChange struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
Key string `json:"key"`
|
||||
// Nil when the key had no stored value, that is it was on its default.
|
||||
OldValue *string `json:"old_value"`
|
||||
NewValue *string `json:"new_value"`
|
||||
ChangedBy uuid.UUID `json:"changed_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// OutletLoyaltySettingsView is GET and PUT /outlets/:id/loyalty-settings.
|
||||
type OutletLoyaltySettingsView struct {
|
||||
OutletID uuid.UUID `json:"outlet_id"`
|
||||
OutletLoyaltySettings
|
||||
// The organization's rupiah value of one EnakPoint, which the cashback depends on.
|
||||
PointValue int64 `json:"point_value"`
|
||||
// Effective EnakPoint cashback in percent: earn_value × point_value /
|
||||
// earn_per_amount × 100. Shown next to the setting so an owner cannot misread the
|
||||
// scale (F1).
|
||||
PointCashbackPercent float64 `json:"point_cashback_percent"`
|
||||
// Set on PUT: the keys that changed.
|
||||
Changes []LoyaltySettingChange `json:"changes,omitempty"`
|
||||
}
|
||||
|
||||
// LoyaltyCashbackPercent is earnValue × pointValue / earnPerAmount as a percentage,
|
||||
// rounded to two decimals.
|
||||
func LoyaltyCashbackPercent(earnValue, pointValue, earnPerAmount int64) float64 {
|
||||
if earnPerAmount <= 0 {
|
||||
return 0
|
||||
}
|
||||
return math.Round(float64(earnValue)*float64(pointValue)*10000/float64(earnPerAmount)) / 100
|
||||
}
|
||||
|
||||
// OrganizationLoyaltySettingsView is GET and PUT /marketing/loyalty-settings.
|
||||
type OrganizationLoyaltySettingsView struct {
|
||||
OrganizationLoyaltySettings
|
||||
// What the balances in circulation are worth, before and after the change.
|
||||
Impact LoyaltySettingsImpact `json:"impact"`
|
||||
// On PUT, the keys that changed; on a dry run, the keys that would.
|
||||
Changes []LoyaltySettingChange `json:"changes"`
|
||||
// True when nothing was saved.
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
// LoyaltySettingsImpact shows how a change of point value or exchange rate changes what
|
||||
// the balances in circulation are worth (F2). Before and after are equal when neither
|
||||
// changes.
|
||||
type LoyaltySettingsImpact struct {
|
||||
OutstandingPoints int64 `json:"outstanding_points"`
|
||||
OutstandingCoins int64 `json:"outstanding_coins"`
|
||||
|
||||
PointValueBefore int64 `json:"point_value_before"`
|
||||
PointValueAfter int64 `json:"point_value_after"`
|
||||
PointRupiahBefore int64 `json:"point_rupiah_before"`
|
||||
PointRupiahAfter int64 `json:"point_rupiah_after"`
|
||||
|
||||
// The coins in circulation exchanged at the rate, in EnakPoint and in rupiah.
|
||||
CoinsAsPointsBefore int64 `json:"coins_as_points_before"`
|
||||
CoinsAsPointsAfter int64 `json:"coins_as_points_after"`
|
||||
CoinRupiahBefore int64 `json:"coin_rupiah_before"`
|
||||
CoinRupiahAfter int64 `json:"coin_rupiah_after"`
|
||||
}
|
||||
|
||||
// NewLoyaltySettingsImpact computes the impact of moving from one organization setting
|
||||
// to another on the balances in circulation.
|
||||
func NewLoyaltySettingsImpact(points, coins int64, before, after OrganizationLoyaltySettings) LoyaltySettingsImpact {
|
||||
asPoints := func(s OrganizationLoyaltySettings) int64 {
|
||||
if s.Exchange.CoinAmount <= 0 {
|
||||
return 0
|
||||
}
|
||||
return coins * s.Exchange.PointAmount / s.Exchange.CoinAmount
|
||||
}
|
||||
impact := LoyaltySettingsImpact{
|
||||
OutstandingPoints: points,
|
||||
OutstandingCoins: coins,
|
||||
PointValueBefore: before.PointValue,
|
||||
PointValueAfter: after.PointValue,
|
||||
PointRupiahBefore: points * before.PointValue,
|
||||
PointRupiahAfter: points * after.PointValue,
|
||||
CoinsAsPointsBefore: asPoints(before),
|
||||
CoinsAsPointsAfter: asPoints(after),
|
||||
}
|
||||
impact.CoinRupiahBefore = impact.CoinsAsPointsBefore * before.PointValue
|
||||
impact.CoinRupiahAfter = impact.CoinsAsPointsAfter * after.PointValue
|
||||
return impact
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLoyaltyCashbackPercent(t *testing.T) {
|
||||
assert.Equal(t, 1.0, LoyaltyCashbackPercent(1, 1, 100))
|
||||
assert.Equal(t, 0.1, LoyaltyCashbackPercent(1, 1, 1000))
|
||||
assert.Equal(t, 0.33, LoyaltyCashbackPercent(1, 1, 300), "rounded to two decimals")
|
||||
assert.Equal(t, 0.67, LoyaltyCashbackPercent(2, 1, 300), "rounded, not cut")
|
||||
assert.Equal(t, 0.0, LoyaltyCashbackPercent(1, 1, 0))
|
||||
}
|
||||
|
||||
func TestNewLoyaltySettingsImpact(t *testing.T) {
|
||||
before := OrganizationLoyaltySettings{PointValue: 1, Exchange: LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1}}
|
||||
after := OrganizationLoyaltySettings{PointValue: 100, Exchange: LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1}}
|
||||
|
||||
got := NewLoyaltySettingsImpact(50000, 105, before, after)
|
||||
assert.Equal(t, LoyaltySettingsImpact{
|
||||
OutstandingPoints: 50000, OutstandingCoins: 105,
|
||||
PointValueBefore: 1, PointValueAfter: 100,
|
||||
PointRupiahBefore: 50000, PointRupiahAfter: 5000000,
|
||||
CoinsAsPointsBefore: 105, CoinsAsPointsAfter: 10, // floor(105 / 10)
|
||||
CoinRupiahBefore: 105, CoinRupiahAfter: 1000,
|
||||
}, got)
|
||||
|
||||
same := NewLoyaltySettingsImpact(10, 10, before, before)
|
||||
assert.Equal(t, same.PointRupiahBefore, same.PointRupiahAfter)
|
||||
}
|
||||
@@ -44,6 +44,8 @@ type OrderItem struct {
|
||||
ProductID uuid.UUID
|
||||
ProductVariantID *uuid.UUID
|
||||
Quantity int
|
||||
Weight *float64
|
||||
UnitID *uuid.UUID
|
||||
UnitPrice float64
|
||||
TotalPrice float64
|
||||
UnitCost float64
|
||||
@@ -99,7 +101,8 @@ type CreateOrderItemRequest struct {
|
||||
ProductID uuid.UUID `validate:"required"`
|
||||
ProductVariantID *uuid.UUID `validate:"omitempty"`
|
||||
Quantity int `validate:"required,min=1"`
|
||||
UnitPrice *float64 `validate:"omitempty,min=0"` // Optional, will use database price if not provided
|
||||
Weight *float64 `validate:"omitempty,gt=0"`
|
||||
UnitPrice *float64 `validate:"omitempty,min=0"` // Ignored: price always comes from the database
|
||||
Modifiers []map[string]interface{} `validate:"omitempty"`
|
||||
Notes *string `validate:"omitempty,max=500"`
|
||||
Metadata map[string]interface{} `validate:"omitempty"`
|
||||
@@ -179,6 +182,9 @@ type OrderResponse struct {
|
||||
TotalPaid float64
|
||||
PaymentCount int
|
||||
SplitType *string
|
||||
// What the order earned (docs/prd-point-coin.md F3), from its EARN rows.
|
||||
PointsEarned int64
|
||||
CoinsEarned int64
|
||||
}
|
||||
|
||||
type OrderItemResponse struct {
|
||||
@@ -191,6 +197,10 @@ type OrderItemResponse struct {
|
||||
CategoryID *uuid.UUID
|
||||
CategoryName *string
|
||||
Quantity int
|
||||
Weight *float64
|
||||
UnitID *uuid.UUID
|
||||
UnitName *string
|
||||
UnitAbbreviation *string
|
||||
UnitPrice float64
|
||||
TotalPrice float64
|
||||
UnitCost float64
|
||||
|
||||
@@ -51,11 +51,13 @@ type PaymentMethodResponse struct {
|
||||
|
||||
type ListPaymentMethodsRequest struct {
|
||||
OrganizationID *uuid.UUID
|
||||
Type *constants.PaymentMethodType
|
||||
IsActive *bool
|
||||
Search string
|
||||
Page int `validate:"min=1"`
|
||||
Limit int `validate:"min=1,max=100"`
|
||||
// When set, EnakPoint is left out unless the outlet accepts it (F9).
|
||||
OutletID *uuid.UUID
|
||||
Type *constants.PaymentMethodType
|
||||
IsActive *bool
|
||||
Search string
|
||||
Page int `validate:"min=1"`
|
||||
Limit int `validate:"min=1,max=100"`
|
||||
}
|
||||
|
||||
type ListPaymentMethodsResponse struct {
|
||||
|
||||
@@ -19,6 +19,7 @@ type Product struct {
|
||||
BusinessType constants.BusinessType
|
||||
ImageURL *string
|
||||
PrinterType string
|
||||
SellBy string
|
||||
UnitID *uuid.UUID
|
||||
HasIngredients bool
|
||||
Metadata map[string]interface{}
|
||||
@@ -52,6 +53,7 @@ type CreateProductRequest struct {
|
||||
PrinterType *string `validate:"omitempty,max=50"`
|
||||
PrintToChecker *bool `validate:"omitempty"`
|
||||
UnitID *uuid.UUID `validate:"omitempty"`
|
||||
SellBy string `validate:"omitempty,oneof=unit weight"`
|
||||
HasIngredients bool `validate:"omitempty"`
|
||||
Metadata map[string]interface{}
|
||||
Variants []CreateProductVariantRequest `validate:"omitempty,dive"`
|
||||
@@ -73,6 +75,7 @@ type UpdateProductRequest struct {
|
||||
PrinterType *string `validate:"omitempty,max=50"`
|
||||
PrintToChecker *bool `validate:"omitempty"`
|
||||
UnitID *uuid.UUID `validate:"omitempty"`
|
||||
SellBy *string `validate:"omitempty,oneof=unit weight"`
|
||||
HasIngredients *bool `validate:"omitempty"`
|
||||
Metadata map[string]interface{}
|
||||
IsActive *bool
|
||||
@@ -110,6 +113,7 @@ type ProductResponse struct {
|
||||
BusinessType constants.BusinessType
|
||||
ImageURL *string
|
||||
PrinterType string
|
||||
SellBy string
|
||||
PrintToChecker bool
|
||||
UnitID *uuid.UUID
|
||||
HasIngredients bool
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CustomerWalletTransaction is one ledger row as the customer app shows it
|
||||
// (docs/prd-point-coin.md F6).
|
||||
type CustomerWalletTransaction struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Currency string `json:"currency"`
|
||||
Type string `json:"type"`
|
||||
// Signed: positive added to the balance, negative taken from it.
|
||||
Amount int64 `json:"amount"`
|
||||
BalanceAfter int64 `json:"balance_after"`
|
||||
Description string `json:"description"`
|
||||
// Where the value came from, set on additions.
|
||||
Source *CustomerWalletTransactionRef `json:"source,omitempty"`
|
||||
// Where the value went, set on deductions.
|
||||
Destination *CustomerWalletTransactionRef `json:"destination,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ReversesTransactionID *uuid.UUID `json:"reverses_transaction_id,omitempty"`
|
||||
// Shared by the two rows of an exchange or a transfer.
|
||||
GroupID *uuid.UUID `json:"group_id,omitempty"`
|
||||
// Additions only: the earliest expiry among the lots it created, nil when none of
|
||||
// them expire, and the lots themselves.
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
Lots []CustomerWalletLot `json:"lots,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CustomerWalletTransactionRef points at what a ledger row came from or went to, as
|
||||
// listed in §8.1: ORDER, PAYMENT, WALLET_TX, GAME_PLAY, LOT, USER and so on.
|
||||
type CustomerWalletTransactionRef struct {
|
||||
Type string `json:"type"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
type CustomerWalletLot struct {
|
||||
Amount int64 `json:"amount"`
|
||||
Remaining int64 `json:"remaining"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// CustomerWalletExpiring is how much expires on one day.
|
||||
type CustomerWalletExpiring struct {
|
||||
Amount int64 `json:"amount"`
|
||||
// YYYY-MM-DD, Asia/Jakarta.
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
// CustomerWalletNearestExpiring is the next day each currency loses balance, nil when
|
||||
// nothing is due to expire.
|
||||
type CustomerWalletNearestExpiring struct {
|
||||
Point *CustomerWalletExpiring `json:"point"`
|
||||
Coin *CustomerWalletExpiring `json:"coin"`
|
||||
}
|
||||
|
||||
// ListCustomerWalletTransactionsQuery is GET /customer/wallet/transactions.
|
||||
type ListCustomerWalletTransactionsQuery struct {
|
||||
Page int `form:"page"`
|
||||
Limit int `form:"limit"`
|
||||
Currency string `form:"currency"`
|
||||
// One type, or several separated by commas.
|
||||
Type string `form:"type"`
|
||||
// Inclusive calendar dates, YYYY-MM-DD, Asia/Jakarta.
|
||||
From string `form:"from"`
|
||||
To string `form:"to"`
|
||||
}
|
||||
|
||||
// AdminCustomerWallet is GET /marketing/customers/:id/wallet (docs/prd-point-coin.md
|
||||
// F7). Unlike the customer's own view it shows the raw balances next to the spendable
|
||||
// ones, every lot that still holds something, and the real names behind each row.
|
||||
type AdminCustomerWallet struct {
|
||||
Customer AdminWalletCustomer `json:"customer"`
|
||||
// Balances as the ledger has them.
|
||||
PointBalance int64 `json:"point_balance"`
|
||||
CoinBalance int64 `json:"coin_balance"`
|
||||
// What can be spent now. Lower than the ledger balance only while lots that have
|
||||
// expired wait for the expiry job.
|
||||
SpendablePointBalance int64 `json:"spendable_point_balance"`
|
||||
SpendableCoinBalance int64 `json:"spendable_coin_balance"`
|
||||
Lots []AdminWalletLot `json:"lots"`
|
||||
Transactions PaginatedResponse[AdminWalletTransaction] `json:"transactions"`
|
||||
}
|
||||
|
||||
type AdminWalletCustomer struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
}
|
||||
|
||||
type AdminWalletLot struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Currency string `json:"currency"`
|
||||
OriginalAmount int64 `json:"original_amount"`
|
||||
RemainingAmount int64 `json:"remaining_amount"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
Expired bool `json:"expired"`
|
||||
SourceTransactionID uuid.UUID `json:"source_transaction_id"`
|
||||
OriginLotID *uuid.UUID `json:"origin_lot_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AdminWalletTransaction is a ledger row with the names the customer does not see:
|
||||
// the real counterparty of a transfer, the admin behind an adjustment, the cashier who
|
||||
// took a payment, and the outlet.
|
||||
type AdminWalletTransaction struct {
|
||||
CustomerWalletTransaction
|
||||
Counterparty *AdminWalletNamedRef `json:"counterparty,omitempty"`
|
||||
CreatedBy *AdminWalletNamedRef `json:"created_by,omitempty"`
|
||||
Outlet *AdminWalletNamedRef `json:"outlet,omitempty"`
|
||||
Reason *string `json:"reason,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type AdminWalletNamedRef struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// WalletAdjustment is a manual correction by an admin.
|
||||
type WalletAdjustment struct {
|
||||
Currency string
|
||||
// Signed: positive adds, negative takes away.
|
||||
Amount int64
|
||||
Reason string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
// AdminWalletAdjustmentResult is what POST /marketing/customers/:id/wallet/adjust returns.
|
||||
type AdminWalletAdjustmentResult struct {
|
||||
Transaction AdminWalletTransaction `json:"transaction"`
|
||||
SpendablePointBalance int64 `json:"spendable_point_balance"`
|
||||
SpendableCoinBalance int64 `json:"spendable_coin_balance"`
|
||||
// True when the idempotency key had been used before and nothing changed.
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
@@ -322,6 +322,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
|
||||
CategoryName: data.CategoryName,
|
||||
CategoryOrder: data.CategoryOrder,
|
||||
QuantitySold: data.QuantitySold,
|
||||
WeightSold: data.WeightSold,
|
||||
Revenue: data.Revenue,
|
||||
AveragePrice: data.AveragePrice,
|
||||
OrderCount: data.OrderCount,
|
||||
@@ -447,6 +448,7 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
||||
ProductSku: product.ProductSku,
|
||||
ProductPrice: product.ProductPrice,
|
||||
QuantitySold: product.QuantitySold,
|
||||
WeightSold: product.WeightSold,
|
||||
Revenue: product.Revenue,
|
||||
AveragePrice: product.AveragePrice,
|
||||
OrderCount: product.OrderCount,
|
||||
|
||||
@@ -70,7 +70,7 @@ func (p *campaignProcessor) ListCampaigns(ctx context.Context, req *contract.Lis
|
||||
Page: req.Page,
|
||||
Limit: req.Limit,
|
||||
Search: req.Search,
|
||||
Type: req.Type,
|
||||
Type: string(entities.NormalizeCampaignType(req.Type)),
|
||||
IsActive: req.IsActive,
|
||||
ShowOnApp: req.ShowOnApp,
|
||||
StartDate: req.StartDate,
|
||||
@@ -178,7 +178,7 @@ func (p *campaignRuleProcessor) CreateCampaignRule(ctx context.Context, req *con
|
||||
CampaignID: req.CampaignID,
|
||||
RuleType: entities.RuleType(req.RuleType),
|
||||
ConditionValue: req.ConditionValue,
|
||||
RewardType: entities.CampaignRewardType(req.RewardType),
|
||||
RewardType: entities.NormalizeCampaignRewardType(req.RewardType),
|
||||
RewardValue: req.RewardValue,
|
||||
RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype),
|
||||
RewardRefID: req.RewardRefID,
|
||||
@@ -218,7 +218,7 @@ func (p *campaignRuleProcessor) ListCampaignRules(ctx context.Context, req *cont
|
||||
Limit: req.Limit,
|
||||
CampaignID: req.CampaignID,
|
||||
RuleType: req.RuleType,
|
||||
RewardType: req.RewardType,
|
||||
RewardType: string(entities.NormalizeCampaignRewardType(req.RewardType)),
|
||||
}
|
||||
|
||||
// Get from repository
|
||||
@@ -247,7 +247,7 @@ func (p *campaignRuleProcessor) UpdateCampaignRule(ctx context.Context, req *con
|
||||
CampaignID: req.CampaignID,
|
||||
RuleType: entities.RuleType(req.RuleType),
|
||||
ConditionValue: req.ConditionValue,
|
||||
RewardType: entities.CampaignRewardType(req.RewardType),
|
||||
RewardType: entities.NormalizeCampaignRewardType(req.RewardType),
|
||||
RewardValue: req.RewardValue,
|
||||
RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype),
|
||||
RewardRefID: req.RewardRefID,
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// PIN rules (docs/prd-point-coin.md F11, Q16, Q17).
|
||||
const (
|
||||
pinLength = 6
|
||||
pinMaxAttempts = 5
|
||||
pinLockDuration = 30 * time.Minute
|
||||
pinTransferHold = 24 * time.Hour
|
||||
pinSecurityReasonN = 255
|
||||
|
||||
PinOtpPurposeSetup = "pin_setup"
|
||||
PinOtpPurposeReset = "pin_reset"
|
||||
)
|
||||
|
||||
// Security log events.
|
||||
const (
|
||||
PinEventSet = "PIN_SET"
|
||||
PinEventChanged = "PIN_CHANGED"
|
||||
PinEventReset = "PIN_RESET"
|
||||
PinEventFailed = "PIN_FAILED"
|
||||
PinEventLocked = "PIN_LOCKED"
|
||||
PinEventRemovedByAdmin = "PIN_REMOVED_BY_ADMIN"
|
||||
)
|
||||
|
||||
// What a PIN approves. Only a transfer is held after a reset.
|
||||
type PinAction string
|
||||
|
||||
const (
|
||||
PinActionPay PinAction = "PAY"
|
||||
PinActionExchange PinAction = "EXCHANGE"
|
||||
PinActionTransfer PinAction = "TRANSFER"
|
||||
)
|
||||
|
||||
// Codes of PinError, which the apps tell apart (docs/prd-point-coin.md §9).
|
||||
const (
|
||||
PinErrNotSet = "PIN_NOT_SET"
|
||||
PinErrInvalid = "PIN_INVALID"
|
||||
PinErrLocked = "PIN_LOCKED"
|
||||
PinErrTransferBlocked = "TRANSFER_BLOCKED"
|
||||
)
|
||||
|
||||
// PinError is why a PIN did not approve an action.
|
||||
type PinError struct {
|
||||
Code string
|
||||
// Set for PIN_INVALID: attempts left before the PIN locks.
|
||||
RemainingAttempts int
|
||||
// Set for PIN_LOCKED and TRANSFER_BLOCKED.
|
||||
Until *time.Time
|
||||
}
|
||||
|
||||
func (e *PinError) Error() string {
|
||||
switch e.Code {
|
||||
case PinErrNotSet:
|
||||
return "PIN has not been set"
|
||||
case PinErrInvalid:
|
||||
return fmt.Sprintf("wrong PIN, %d attempts left", e.RemainingAttempts)
|
||||
case PinErrLocked:
|
||||
return fmt.Sprintf("PIN is locked until %s", e.Until.Format(time.RFC3339))
|
||||
case PinErrTransferBlocked:
|
||||
return fmt.Sprintf("transfers are on hold after a PIN reset until %s", e.Until.Format(time.RFC3339))
|
||||
}
|
||||
return e.Code
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrInvalidPinInput wraps a PIN that is malformed, weak, or not confirmed. The
|
||||
// message never contains the PIN.
|
||||
ErrInvalidPinInput = errors.New("invalid PIN")
|
||||
// ErrPinAlreadySet means a first PIN was requested for a customer who has one.
|
||||
ErrPinAlreadySet = errors.New("PIN has already been set")
|
||||
// ErrPinOtpInvalid means the OTP was wrong, expired, used, for another purpose, or
|
||||
// sent to another number.
|
||||
ErrPinOtpInvalid = errors.New("invalid or expired OTP")
|
||||
// ErrPinOtpTooSoon means an OTP was requested again too quickly.
|
||||
ErrPinOtpTooSoon = errors.New("an OTP was sent recently; wait before asking again")
|
||||
// ErrPinNoPhone means the customer has no phone number to send an OTP to.
|
||||
ErrPinNoPhone = errors.New("customer has no phone number")
|
||||
)
|
||||
|
||||
type pinOtpSender interface {
|
||||
CanResendOtp(ctx context.Context, phoneNumber string, purpose string) (bool, int, error)
|
||||
CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error)
|
||||
SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error
|
||||
ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error)
|
||||
}
|
||||
|
||||
// pinAlerter tells a customer their PIN was locked. There is no push channel to
|
||||
// customers yet, so the app sends it by WhatsApp.
|
||||
type pinAlerter interface {
|
||||
SendWhatsAppMessage(phoneNumber, message string) error
|
||||
}
|
||||
|
||||
// CustomerPinProcessor manages customer PINs (docs/prd-point-coin.md F11). Every flow
|
||||
// that moves balance on the customer's request calls VerifyPin first (K8).
|
||||
type CustomerPinProcessor struct {
|
||||
repo repository.CustomerPinRepository
|
||||
otp pinOtpSender
|
||||
alerter pinAlerter
|
||||
now func() time.Time
|
||||
cost int
|
||||
}
|
||||
|
||||
func NewCustomerPinProcessor(repo repository.CustomerPinRepository, otp pinOtpSender, alerter pinAlerter) *CustomerPinProcessor {
|
||||
return &CustomerPinProcessor{repo: repo, otp: otp, alerter: alerter, now: time.Now, cost: bcrypt.DefaultCost}
|
||||
}
|
||||
|
||||
func (p *CustomerPinProcessor) Status(ctx context.Context, customerID uuid.UUID) (*models.CustomerPinStatus, error) {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := p.now()
|
||||
status := &models.CustomerPinStatus{HasPin: state.PinHash != nil}
|
||||
if state.LockedUntil != nil && state.LockedUntil.After(now) {
|
||||
status.LockedUntil = state.LockedUntil
|
||||
}
|
||||
if state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(now) {
|
||||
status.TransferBlockedUntil = state.TransferBlockedUntil
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// RequestOtp sends an OTP to the customer's own phone number, for creating a first PIN
|
||||
// (pin_setup) or resetting a forgotten one (pin_reset).
|
||||
func (p *CustomerPinProcessor) RequestOtp(ctx context.Context, customerID uuid.UUID, purpose string) (*models.CustomerPinOtp, error) {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch purpose {
|
||||
case PinOtpPurposeSetup:
|
||||
if state.PinHash != nil {
|
||||
return nil, ErrPinAlreadySet
|
||||
}
|
||||
case PinOtpPurposeReset:
|
||||
if state.PinHash == nil {
|
||||
return nil, &PinError{Code: PinErrNotSet}
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: purpose must be %s or %s", ErrInvalidPinInput, PinOtpPurposeSetup, PinOtpPurposeReset)
|
||||
}
|
||||
if state.PhoneNumber == nil || *state.PhoneNumber == "" {
|
||||
return nil, ErrPinNoPhone
|
||||
}
|
||||
|
||||
canSend, _, err := p.otp.CanResendOtp(ctx, *state.PhoneNumber, purpose)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !canSend {
|
||||
return nil, ErrPinOtpTooSoon
|
||||
}
|
||||
session, err := p.otp.CreateOtpSession(ctx, *state.PhoneNumber, purpose)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.otp.SendOtpViaWhatsApp(*state.PhoneNumber, session.Code, purpose); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &models.CustomerPinOtp{Purpose: purpose, OtpToken: session.Token, ExpiresAt: session.ExpiresAt}, nil
|
||||
}
|
||||
|
||||
// CreatePin sets a customer's first PIN, approved by an OTP to their phone so it is set
|
||||
// by the owner of the number and not by whoever holds a logged-in phone.
|
||||
func (p *CustomerPinProcessor) CreatePin(ctx context.Context, customerID uuid.UUID, otpToken, otpCode, pin, confirmPin string, info models.CustomerPinRequestInfo) error {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.PinHash != nil {
|
||||
return ErrPinAlreadySet
|
||||
}
|
||||
// Check the PIN before spending the OTP, so a weak PIN does not cost a new code.
|
||||
if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.checkOtp(ctx, state, otpToken, otpCode, PinOtpPurposeSetup); err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := p.hash(pin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.repo.SetPin(ctx, customerID, hash, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
p.logEvent(ctx, customerID, PinEventSet, nil, nil, info)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangePin replaces the PIN after checking the old one, which counts toward the lock
|
||||
// like any other attempt. A transfer hold from an earlier reset stays.
|
||||
func (p *CustomerPinProcessor) ChangePin(ctx context.Context, customerID uuid.UUID, oldPin, pin, confirmPin string, info models.CustomerPinRequestInfo) error {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.verify(ctx, state, oldPin, PinActionPay, info); err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := p.hash(pin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.repo.SetPin(ctx, customerID, hash, p.activeHold(state)); err != nil {
|
||||
return err
|
||||
}
|
||||
p.logEvent(ctx, customerID, PinEventChanged, nil, nil, info)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetPin sets a new PIN for a customer who forgot theirs, approved by an OTP. It also
|
||||
// lifts a lock, and holds outgoing transfers for 24 hours in case the phone number was
|
||||
// taken over (Q16).
|
||||
func (p *CustomerPinProcessor) ResetPin(ctx context.Context, customerID uuid.UUID, otpToken, otpCode, pin, confirmPin string, info models.CustomerPinRequestInfo) error {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.PinHash == nil {
|
||||
return &PinError{Code: PinErrNotSet}
|
||||
}
|
||||
if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.checkOtp(ctx, state, otpToken, otpCode, PinOtpPurposeReset); err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := p.hash(pin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hold := p.now().Add(pinTransferHold)
|
||||
if err := p.repo.SetPin(ctx, customerID, hash, &hold); err != nil {
|
||||
return err
|
||||
}
|
||||
p.logEvent(ctx, customerID, PinEventReset, nil, nil, info)
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyPin checks the PIN before an action that moves balance. It returns a *PinError
|
||||
// with the code the apps act on: PIN_NOT_SET, PIN_INVALID (with the attempts left),
|
||||
// PIN_LOCKED or TRANSFER_BLOCKED (with until when).
|
||||
func (p *CustomerPinProcessor) VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.verify(ctx, state, pin, action, info)
|
||||
}
|
||||
|
||||
func (p *CustomerPinProcessor) verify(ctx context.Context, state *repository.CustomerPinState, pin string, action PinAction, info models.CustomerPinRequestInfo) error {
|
||||
if state.PinHash == nil {
|
||||
return &PinError{Code: PinErrNotSet}
|
||||
}
|
||||
now := p.now()
|
||||
// A locked PIN is refused before it is compared, even when it is right.
|
||||
if state.LockedUntil != nil && state.LockedUntil.After(now) {
|
||||
until := *state.LockedUntil
|
||||
return &PinError{Code: PinErrLocked, Until: &until}
|
||||
}
|
||||
// A held transfer is refused before the PIN is compared, so it costs no attempt.
|
||||
if action == PinActionTransfer && state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(now) {
|
||||
until := *state.TransferBlockedUntil
|
||||
return &PinError{Code: PinErrTransferBlocked, Until: &until}
|
||||
}
|
||||
|
||||
if bcrypt.CompareHashAndPassword([]byte(*state.PinHash), []byte(pin)) != nil {
|
||||
attempts, lockedUntil, err := p.repo.RecordFailure(ctx, state.CustomerID, pinMaxAttempts, now, now.Add(pinLockDuration))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.logEvent(ctx, state.CustomerID, PinEventFailed, nil, nil, info)
|
||||
if lockedUntil != nil && lockedUntil.After(now) {
|
||||
// Only the attempt that reached the limit logs the lock and tells the
|
||||
// customer; attempts racing it just see the lock.
|
||||
if attempts == pinMaxAttempts {
|
||||
p.logEvent(ctx, state.CustomerID, PinEventLocked, nil, nil, info)
|
||||
p.alertLocked(state, *lockedUntil)
|
||||
}
|
||||
return &PinError{Code: PinErrLocked, Until: lockedUntil}
|
||||
}
|
||||
return &PinError{Code: PinErrInvalid, RemainingAttempts: pinMaxAttempts - attempts}
|
||||
}
|
||||
if state.FailedAttempts > 0 || state.LockedUntil != nil {
|
||||
if err := p.repo.ClearFailures(ctx, state.CustomerID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovePinByAdmin deletes a customer's PIN, for example when they lost access to it,
|
||||
// so they have to create a new one through OTP. Admins can never set or read a PIN.
|
||||
func (p *CustomerPinProcessor) RemovePinByAdmin(ctx context.Context, organizationID, customerID, adminID uuid.UUID, reason string, info models.CustomerPinRequestInfo) error {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
return fmt.Errorf("%w: a reason is required", ErrInvalidPinInput)
|
||||
}
|
||||
if adminID == uuid.Nil {
|
||||
return fmt.Errorf("%w: the admin is unknown", ErrInvalidPinInput)
|
||||
}
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.OrganizationID != organizationID {
|
||||
return repository.ErrPinCustomerNotFound
|
||||
}
|
||||
if state.PinHash == nil {
|
||||
return &PinError{Code: PinErrNotSet}
|
||||
}
|
||||
if err := p.repo.RemovePin(ctx, customerID); err != nil {
|
||||
return err
|
||||
}
|
||||
reason = truncateRunes(reason, pinSecurityReasonN)
|
||||
p.logEvent(ctx, customerID, PinEventRemovedByAdmin, &adminID, &reason, info)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListEvents returns a page of a customer's PIN security log for the dashboard.
|
||||
func (p *CustomerPinProcessor) ListEvents(ctx context.Context, organizationID, customerID uuid.UUID, page, limit int) (*models.PaginatedResponse[models.CustomerSecurityEventView], error) {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if state.OrganizationID != organizationID {
|
||||
return nil, repository.ErrPinCustomerNotFound
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
rows, total, err := p.repo.ListEvents(ctx, customerID, (page-1)*limit, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events := make([]models.CustomerSecurityEventView, 0, len(rows))
|
||||
for _, e := range rows {
|
||||
events = append(events, models.CustomerSecurityEventView{
|
||||
ID: e.ID, Event: e.Event, ActorUser: e.ActorUser, Reason: e.Reason,
|
||||
IPAddress: e.IPAddress, UserAgent: e.UserAgent, CreatedAt: e.CreatedAt,
|
||||
})
|
||||
}
|
||||
return &models.PaginatedResponse[models.CustomerSecurityEventView]{
|
||||
Data: events,
|
||||
Pagination: models.Pagination{
|
||||
Page: page, Limit: limit, Total: total, TotalPages: int((total + int64(limit) - 1) / int64(limit)),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// checkOtp validates an OTP and that it was issued for this purpose to this customer's
|
||||
// own phone number. Without those checks an OTP from the login flow, or one sent to
|
||||
// another number, could approve a PIN change.
|
||||
func (p *CustomerPinProcessor) checkOtp(ctx context.Context, state *repository.CustomerPinState, token, code, purpose string) error {
|
||||
if token == "" || code == "" || state.PhoneNumber == nil {
|
||||
return ErrPinOtpInvalid
|
||||
}
|
||||
session, err := p.otp.ValidateOtpSession(ctx, token, code)
|
||||
if err != nil || session == nil {
|
||||
return ErrPinOtpInvalid
|
||||
}
|
||||
if session.Purpose != purpose || session.PhoneNumber != *state.PhoneNumber {
|
||||
return ErrPinOtpInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CustomerPinProcessor) hash(pin string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(pin), p.cost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash PIN: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func (p *CustomerPinProcessor) activeHold(state *repository.CustomerPinState) *time.Time {
|
||||
if state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(p.now()) {
|
||||
return state.TransferBlockedUntil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// logEvent records a security event. The log is best effort: failing to write it must
|
||||
// not undo what the customer just did, so a failure is logged instead.
|
||||
func (p *CustomerPinProcessor) logEvent(ctx context.Context, customerID uuid.UUID, event string, actor *uuid.UUID, reason *string, info models.CustomerPinRequestInfo) {
|
||||
e := repository.CustomerSecurityEvent{CustomerID: customerID, Event: event, ActorUser: actor, Reason: reason}
|
||||
if info.IPAddress != "" {
|
||||
ip := truncateRunes(info.IPAddress, 45)
|
||||
e.IPAddress = &ip
|
||||
}
|
||||
if info.UserAgent != "" {
|
||||
ua := truncateRunes(info.UserAgent, 255)
|
||||
e.UserAgent = &ua
|
||||
}
|
||||
if err := p.repo.InsertEvent(ctx, e); err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Could not record %s for customer %s", event, customerID), err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CustomerPinProcessor) alertLocked(state *repository.CustomerPinState, until time.Time) {
|
||||
if p.alerter == nil || state.PhoneNumber == nil {
|
||||
return
|
||||
}
|
||||
message := fmt.Sprintf("PIN EnakPoint kamu terkunci sampai %s karena salah dimasukkan %d kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.",
|
||||
until.In(walletDisplayLocation).Format("02 Jan 2006 15:04 WIB"), pinMaxAttempts)
|
||||
if err := p.alerter.SendWhatsAppMessage(*state.PhoneNumber, message); err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s their PIN is locked", state.CustomerID), err)
|
||||
}
|
||||
}
|
||||
|
||||
// checkNewPin rejects a PIN that is not 6 digits, does not match its confirmation, or
|
||||
// is easy to guess: one digit repeated, a run up or down, or the birth date as DDMMYY
|
||||
// or YYMMDD.
|
||||
func checkNewPin(pin, confirm string, birthDate *time.Time) error {
|
||||
if len(pin) != pinLength {
|
||||
return fmt.Errorf("%w: a PIN is %d digits", ErrInvalidPinInput, pinLength)
|
||||
}
|
||||
for _, r := range pin {
|
||||
if r < '0' || r > '9' {
|
||||
return fmt.Errorf("%w: a PIN is digits only", ErrInvalidPinInput)
|
||||
}
|
||||
}
|
||||
if pin != confirm {
|
||||
return fmt.Errorf("%w: the PIN and its confirmation differ", ErrInvalidPinInput)
|
||||
}
|
||||
same, up, down := true, true, true
|
||||
for i := 1; i < len(pin); i++ {
|
||||
d := int(pin[i]) - int(pin[i-1])
|
||||
same = same && d == 0
|
||||
up = up && d == 1
|
||||
down = down && d == -1
|
||||
}
|
||||
if same || up || down {
|
||||
return fmt.Errorf("%w: the PIN is too easy to guess", ErrInvalidPinInput)
|
||||
}
|
||||
if birthDate != nil {
|
||||
for _, layout := range []string{"020106", "060102"} {
|
||||
if pin == birthDate.Format(layout) {
|
||||
return fmt.Errorf("%w: the PIN must not be your birth date", ErrInvalidPinInput)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// otpFake keeps OTP sessions in memory with the checks the real one makes.
|
||||
type otpFake struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*entities.OtpSession
|
||||
sent []string
|
||||
}
|
||||
|
||||
func (f *otpFake) CanResendOtp(context.Context, string, string) (bool, int, error) {
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
func (f *otpFake) CreateOtpSession(_ context.Context, phone, purpose string) (*entities.OtpSession, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
s := &entities.OtpSession{Token: uuid.NewString(), Code: "246810", PhoneNumber: phone, Purpose: purpose, ExpiresAt: time.Now().Add(5 * time.Minute)}
|
||||
f.sessions[s.Token] = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (f *otpFake) SendOtpViaWhatsApp(phone, code, purpose string) error {
|
||||
f.sent = append(f.sent, purpose)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *otpFake) ValidateOtpSession(_ context.Context, token, code string) (*entities.OtpSession, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
s := f.sessions[token]
|
||||
if s == nil || s.IsUsed || s.Code != code {
|
||||
return nil, errors.New("invalid OTP")
|
||||
}
|
||||
s.IsUsed = true
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// issue creates a session as if it had been sent, for any purpose and number.
|
||||
func (f *otpFake) issue(phone, purpose string) *entities.OtpSession {
|
||||
s, _ := f.CreateOtpSession(context.Background(), phone, purpose)
|
||||
return s
|
||||
}
|
||||
|
||||
type alerterFake struct {
|
||||
mu sync.Mutex
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (f *alerterFake) SendWhatsAppMessage(_ string, message string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.messages = append(f.messages, message)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Needs TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestCustomerPin_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
|
||||
org, otherOrg, customer, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
phone := "0812" + customer.String()[:8]
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'pin test', 'basic'), (?, 'other', 'basic')`, org, otherOrg)
|
||||
exec(`INSERT INTO customers (id, organization_id, name, phone_number, birth_date) VALUES (?, ?, 'Budi', ?, '1990-03-14')`, customer, org, phone)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM customer_security_events WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
|
||||
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg})
|
||||
})
|
||||
|
||||
otp := &otpFake{sessions: map[string]*entities.OtpSession{}}
|
||||
alerts := &alerterFake{}
|
||||
p := NewCustomerPinProcessor(repository.NewCustomerPinRepository(db), otp, alerts)
|
||||
p.cost = bcrypt.MinCost
|
||||
clock := time.Now()
|
||||
var clockMu sync.Mutex
|
||||
p.now = func() time.Time { clockMu.Lock(); defer clockMu.Unlock(); return clock }
|
||||
advance := func(d time.Duration) { clockMu.Lock(); clock = clock.Add(d); clockMu.Unlock() }
|
||||
info := models.CustomerPinRequestInfo{IPAddress: "10.0.0.7", UserAgent: "EnakApp/2.0"}
|
||||
const pin, newPin, resetPin = "482913", "572039", "613408"
|
||||
|
||||
pinErr := func(err error) *PinError {
|
||||
t.Helper()
|
||||
var pe *PinError
|
||||
require.True(t, errors.As(err, &pe), "want a PinError, got %v", err)
|
||||
for _, secret := range []string{pin, newPin, resetPin} {
|
||||
assert.NotContains(t, err.Error(), secret, "an error must never contain a PIN")
|
||||
}
|
||||
return pe
|
||||
}
|
||||
events := func() []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
require.NoError(t, db.Raw(`SELECT event FROM customer_security_events WHERE customer_id = ? ORDER BY created_at, id`, customer).Scan(&out).Error)
|
||||
return out
|
||||
}
|
||||
|
||||
// No PIN yet: nothing can be approved.
|
||||
status, err := p.Status(ctx, customer)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, status.HasPin)
|
||||
assert.Equal(t, PinErrNotSet, pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info)).Code)
|
||||
|
||||
// Creating the first PIN takes an OTP sent to the customer's own number, for this
|
||||
// purpose.
|
||||
sent, err := p.RequestOtp(ctx, customer, PinOtpPurposeSetup)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{PinOtpPurposeSetup}, otp.sent)
|
||||
|
||||
loginOtp := otp.issue(phone, "login")
|
||||
assert.ErrorIs(t, p.CreatePin(ctx, customer, loginOtp.Token, loginOtp.Code, pin, pin, info), ErrPinOtpInvalid, "an OTP for another purpose")
|
||||
strangerOtp := otp.issue("0899999999", PinOtpPurposeSetup)
|
||||
assert.ErrorIs(t, p.CreatePin(ctx, customer, strangerOtp.Token, strangerOtp.Code, pin, pin, info), ErrPinOtpInvalid, "an OTP sent to another number")
|
||||
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "000000", pin, pin, info), ErrPinOtpInvalid, "a wrong code")
|
||||
|
||||
// A weak PIN is refused before the OTP is used, so the same OTP still works after.
|
||||
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", "123456", "123456", info), ErrInvalidPinInput)
|
||||
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", "140390", "140390", info), ErrInvalidPinInput, "birth date")
|
||||
require.NoError(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", pin, pin, info))
|
||||
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", pin, pin, info), ErrPinAlreadySet)
|
||||
|
||||
var stored string
|
||||
require.NoError(t, db.Raw(`SELECT pin_hash FROM customers WHERE id = ?`, customer).Scan(&stored).Error)
|
||||
assert.NotContains(t, stored, pin, "only a hash is stored")
|
||||
assert.True(t, strings.HasPrefix(stored, "$2"), "bcrypt")
|
||||
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
||||
|
||||
// Four wrong attempts count down; the fifth locks for 30 minutes.
|
||||
for left := 4; left >= 1; left-- {
|
||||
pe := pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
|
||||
assert.Equal(t, PinErrInvalid, pe.Code)
|
||||
assert.Equal(t, left, pe.RemainingAttempts)
|
||||
}
|
||||
pe := pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
|
||||
assert.Equal(t, PinErrLocked, pe.Code)
|
||||
assert.WithinDuration(t, clock.Add(30*time.Minute), *pe.Until, time.Second)
|
||||
assert.Len(t, alerts.messages, 1, "the customer is told the PIN locked")
|
||||
|
||||
// While locked even the right PIN is refused.
|
||||
pe = pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
||||
assert.Equal(t, PinErrLocked, pe.Code)
|
||||
status, err = p.Status(ctx, customer)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, status.LockedUntil)
|
||||
|
||||
// Once the lock runs out a wrong PIN starts a new series of five.
|
||||
advance(31 * time.Minute)
|
||||
pe = pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
|
||||
assert.Equal(t, PinErrInvalid, pe.Code)
|
||||
assert.Equal(t, 4, pe.RemainingAttempts)
|
||||
// The right PIN resets the count.
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
||||
pe = pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
|
||||
assert.Equal(t, 4, pe.RemainingAttempts)
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
||||
|
||||
// Wrong attempts made at once all count: none slips past the lock.
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); _ = p.VerifyPin(ctx, customer, "000001", PinActionPay, info) }()
|
||||
}
|
||||
wg.Wait()
|
||||
pe = pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
||||
assert.Equal(t, PinErrLocked, pe.Code)
|
||||
|
||||
// Resetting through OTP lifts the lock and holds transfers for 24 hours.
|
||||
_, err = p.RequestOtp(ctx, customer, PinOtpPurposeReset)
|
||||
require.NoError(t, err)
|
||||
setupOtp := otp.issue(phone, PinOtpPurposeSetup)
|
||||
assert.ErrorIs(t, p.ResetPin(ctx, customer, setupOtp.Token, setupOtp.Code, resetPin, resetPin, info), ErrPinOtpInvalid, "a setup OTP cannot reset")
|
||||
resetOtp := otp.issue(phone, PinOtpPurposeReset)
|
||||
require.NoError(t, p.ResetPin(ctx, customer, resetOtp.Token, resetOtp.Code, resetPin, resetPin, info))
|
||||
status, err = p.Status(ctx, customer)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, status.LockedUntil, "the lock is lifted")
|
||||
require.NotNil(t, status.TransferBlockedUntil)
|
||||
assert.WithinDuration(t, clock.Add(24*time.Hour), *status.TransferBlockedUntil, time.Second)
|
||||
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, resetPin, PinActionPay, info), "paying still works")
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, resetPin, PinActionExchange, info), "exchanging still works")
|
||||
pe = pinErr(p.VerifyPin(ctx, customer, resetPin, PinActionTransfer, info))
|
||||
assert.Equal(t, PinErrTransferBlocked, pe.Code)
|
||||
var failed int
|
||||
require.NoError(t, db.Raw(`SELECT pin_failed_attempts FROM customers WHERE id = ?`, customer).Scan(&failed).Error)
|
||||
assert.Zero(t, failed, "a held transfer costs no attempt")
|
||||
|
||||
// Changing the PIN needs the old one and keeps the transfer hold.
|
||||
assert.Equal(t, PinErrInvalid, pinErr(p.ChangePin(ctx, customer, "000001", newPin, newPin, info)).Code)
|
||||
require.NoError(t, p.ChangePin(ctx, customer, resetPin, newPin, newPin, info))
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, newPin, PinActionPay, info))
|
||||
assert.Equal(t, PinErrTransferBlocked, pinErr(p.VerifyPin(ctx, customer, newPin, PinActionTransfer, info)).Code)
|
||||
advance(25 * time.Hour)
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, newPin, PinActionTransfer, info), "the hold ends after 24 hours")
|
||||
|
||||
// An admin can remove the PIN, only in their own organization and with a reason.
|
||||
assert.ErrorIs(t, p.RemovePinByAdmin(ctx, otherOrg, customer, admin, "hilang HP", info), repository.ErrPinCustomerNotFound)
|
||||
assert.ErrorIs(t, p.RemovePinByAdmin(ctx, org, customer, admin, " ", info), ErrInvalidPinInput)
|
||||
require.NoError(t, p.RemovePinByAdmin(ctx, org, customer, admin, "hilang HP", info))
|
||||
status, err = p.Status(ctx, customer)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, status.HasPin)
|
||||
assert.Equal(t, PinErrNotSet, pinErr(p.VerifyPin(ctx, customer, newPin, PinActionPay, info)).Code)
|
||||
|
||||
// Every event is in the security log, with where it came from.
|
||||
got := events()
|
||||
for _, want := range []string{PinEventSet, PinEventFailed, PinEventLocked, PinEventReset, PinEventChanged, PinEventRemovedByAdmin} {
|
||||
assert.Contains(t, got, want)
|
||||
}
|
||||
page, err := p.ListEvents(ctx, org, customer, 1, 100)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, len(got), page.Pagination.Total)
|
||||
removed := page.Data[0]
|
||||
assert.Equal(t, PinEventRemovedByAdmin, removed.Event)
|
||||
assert.Equal(t, &admin, removed.ActorUser)
|
||||
assert.Equal(t, "hilang HP", *removed.Reason)
|
||||
assert.Equal(t, "10.0.0.7", *removed.IPAddress)
|
||||
_, err = p.ListEvents(ctx, otherOrg, customer, 1, 10)
|
||||
assert.ErrorIs(t, err, repository.ErrPinCustomerNotFound)
|
||||
|
||||
var locked int
|
||||
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM customer_security_events WHERE customer_id = ? AND event = ?`, customer, PinEventLocked).Scan(&locked).Error)
|
||||
assert.Equal(t, locked, len(alerts.messages), "one alert per lock")
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCheckNewPin(t *testing.T) {
|
||||
birth := time.Date(1990, 3, 14, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
for _, ok := range []string{"482913", "019283", "135790", "112233"} {
|
||||
assert.NoError(t, checkNewPin(ok, ok, &birth), ok)
|
||||
}
|
||||
for name, c := range map[string][2]string{
|
||||
"too short": {"12345", "12345"},
|
||||
"too long": {"1234567", "1234567"},
|
||||
"not digits": {"12a456", "12a456"},
|
||||
"confirmation": {"482913", "482914"},
|
||||
"one digit": {"111111", "111111"},
|
||||
"zeros": {"000000", "000000"},
|
||||
"run up": {"123456", "123456"},
|
||||
"run up from 4": {"456789", "456789"},
|
||||
"run down": {"654321", "654321"},
|
||||
"run down from 9": {"987654", "987654"},
|
||||
"birth date DDMMYY": {"140390", "140390"},
|
||||
"birth date YYMMDD": {"900314", "900314"},
|
||||
} {
|
||||
err := checkNewPin(c[0], c[1], &birth)
|
||||
assert.ErrorIs(t, err, ErrInvalidPinInput, name)
|
||||
assert.NotContains(t, err.Error(), c[0], "%s: the message must not echo the PIN", name)
|
||||
}
|
||||
// Without a birth date only the other rules apply.
|
||||
assert.NoError(t, checkNewPin("140390", "140390", nil))
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package processor
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
@@ -12,14 +11,14 @@ import (
|
||||
)
|
||||
|
||||
type CustomerPointsProcessor struct {
|
||||
customerPointsRepo repository.CustomerPointsRepository
|
||||
gameRepo *repository.GameRepository
|
||||
walletQuery *WalletQueryProcessor
|
||||
gameRepo *repository.GameRepository
|
||||
}
|
||||
|
||||
func NewCustomerPointsProcessor(customerPointsRepo repository.CustomerPointsRepository, gameRepo *repository.GameRepository) *CustomerPointsProcessor {
|
||||
func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor, gameRepo *repository.GameRepository) *CustomerPointsProcessor {
|
||||
return &CustomerPointsProcessor{
|
||||
customerPointsRepo: customerPointsRepo,
|
||||
gameRepo: gameRepo,
|
||||
walletQuery: walletQuery,
|
||||
gameRepo: gameRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,159 +69,74 @@ func (p *CustomerPointsProcessor) DeductPoints(ctx context.Context, customerID u
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
// The three endpoints below are the customer app's balance screens. They read the
|
||||
// EnakPoint & EnakCoin wallet; customer_points and customer_tokens are no longer used
|
||||
// for balances (docs/prd-point-coin.md §9).
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerTotalPointsAPI(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) {
|
||||
// Get total points
|
||||
totalPoints, err := p.customerPointsRepo.GetCustomerTotalPoints(ctx, customerID)
|
||||
id, err := parseWalletCustomerID(customerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer total points: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get points history (last 10 records)
|
||||
pointsHistory, err := p.customerPointsRepo.GetCustomerPointsHistory(ctx, customerID, 10)
|
||||
data, err := p.walletQuery.LegacyPoints(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer points history: %w", err)
|
||||
return nil, fmt.Errorf("failed to get customer points: %w", err)
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
var historyItems []models.PointsHistoryItem
|
||||
|
||||
for _, point := range pointsHistory {
|
||||
historyItems = append(historyItems, models.PointsHistoryItem{
|
||||
ID: point.ID.String(),
|
||||
Points: point.Balance,
|
||||
Type: "BALANCE",
|
||||
Description: "Points balance",
|
||||
CreatedAt: point.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
var lastUpdated time.Time
|
||||
if len(pointsHistory) > 0 {
|
||||
lastUpdated = pointsHistory[0].CreatedAt
|
||||
}
|
||||
|
||||
return &models.GetCustomerPointsResponse{
|
||||
Status: "SUCCESS",
|
||||
Message: "Customer points retrieved successfully.",
|
||||
Data: &models.GetCustomerPointsResponseData{
|
||||
TotalPoints: totalPoints,
|
||||
PointsHistory: historyItems,
|
||||
LastUpdated: lastUpdated,
|
||||
},
|
||||
Data: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerTotalTokensAPI(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) {
|
||||
// Get total tokens
|
||||
totalTokens, err := p.customerPointsRepo.GetCustomerTotalTokens(ctx, customerID)
|
||||
id, err := parseWalletCustomerID(customerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer total tokens: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get tokens history (last 10 records)
|
||||
tokensHistory, err := p.customerPointsRepo.GetCustomerTokensHistory(ctx, customerID, 10)
|
||||
data, err := p.walletQuery.LegacyTokens(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer tokens history: %w", err)
|
||||
return nil, fmt.Errorf("failed to get customer tokens: %w", err)
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
var historyItems []models.TokensHistoryItem
|
||||
|
||||
for _, token := range tokensHistory {
|
||||
historyItems = append(historyItems, models.TokensHistoryItem{
|
||||
ID: token.ID.String(),
|
||||
Tokens: token.Balance,
|
||||
Type: string(token.TokenType),
|
||||
Description: "Tokens balance",
|
||||
CreatedAt: token.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
var lastUpdated time.Time
|
||||
if len(tokensHistory) > 0 {
|
||||
lastUpdated = tokensHistory[0].CreatedAt
|
||||
}
|
||||
|
||||
return &models.GetCustomerTokensResponse{
|
||||
Status: "SUCCESS",
|
||||
Message: "Customer tokens retrieved successfully.",
|
||||
Data: &models.GetCustomerTokensResponseData{
|
||||
TotalTokens: totalTokens,
|
||||
TokensHistory: historyItems,
|
||||
LastUpdated: lastUpdated,
|
||||
},
|
||||
Data: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerWalletAPI(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) {
|
||||
// Get total points
|
||||
totalPoints, err := p.customerPointsRepo.GetCustomerTotalPoints(ctx, customerID)
|
||||
id, err := parseWalletCustomerID(customerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer total points: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get total tokens
|
||||
totalTokens, err := p.customerPointsRepo.GetCustomerTotalTokens(ctx, customerID)
|
||||
data, err := p.walletQuery.Summary(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer total tokens: %w", err)
|
||||
return nil, fmt.Errorf("failed to get customer wallet: %w", err)
|
||||
}
|
||||
|
||||
// Get points history (last 5 records)
|
||||
pointsHistory, err := p.customerPointsRepo.GetCustomerPointsHistory(ctx, customerID, 5)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer points history: %w", err)
|
||||
}
|
||||
|
||||
// Get tokens history (last 5 records)
|
||||
tokensHistory, err := p.customerPointsRepo.GetCustomerTokensHistory(ctx, customerID, 5)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer tokens history: %w", err)
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
var pointsHistoryItems []models.PointsHistoryItem
|
||||
var tokensHistoryItems []models.TokensHistoryItem
|
||||
var lastUpdated time.Time
|
||||
|
||||
for _, point := range pointsHistory {
|
||||
pointsHistoryItems = append(pointsHistoryItems, models.PointsHistoryItem{
|
||||
ID: point.ID.String(),
|
||||
Points: point.Balance,
|
||||
Type: "BALANCE",
|
||||
Description: "Points balance",
|
||||
CreatedAt: point.CreatedAt,
|
||||
})
|
||||
if point.CreatedAt.After(lastUpdated) {
|
||||
lastUpdated = point.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
for _, token := range tokensHistory {
|
||||
tokensHistoryItems = append(tokensHistoryItems, models.TokensHistoryItem{
|
||||
ID: token.ID.String(),
|
||||
Tokens: token.Balance,
|
||||
Type: string(token.TokenType),
|
||||
Description: "Tokens balance",
|
||||
CreatedAt: token.CreatedAt,
|
||||
})
|
||||
if token.CreatedAt.After(lastUpdated) {
|
||||
lastUpdated = token.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
return &models.GetCustomerWalletResponse{
|
||||
Status: "SUCCESS",
|
||||
Message: "Customer wallet retrieved successfully.",
|
||||
Data: &models.GetCustomerWalletResponseData{
|
||||
TotalPoints: totalPoints,
|
||||
TotalTokens: totalTokens,
|
||||
PointsHistory: pointsHistoryItems,
|
||||
TokensHistory: tokensHistoryItems,
|
||||
LastUpdated: lastUpdated,
|
||||
},
|
||||
Data: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerWalletTransactionsAPI(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) {
|
||||
id, err := parseWalletCustomerID(customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.walletQuery.Transactions(ctx, id, query)
|
||||
}
|
||||
|
||||
func parseWalletCustomerID(customerID string) (uuid.UUID, error) {
|
||||
id, err := uuid.Parse(customerID)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("%w: invalid customer id", ErrInvalidWalletQuery)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetCustomerGamesAPI gets active SPIN games for customers
|
||||
func (p *CustomerPointsProcessor) GetCustomerGamesAPI(ctx context.Context) (*models.GetCustomerGamesResponse, error) {
|
||||
// Get active SPIN games
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// EarningLine is what an order earns in one currency, and the settings that produced
|
||||
// it. The settings are kept so the ledger row can freeze them: a later change of
|
||||
// setting must not change what an earning meant, and a reversal is computed with the
|
||||
// same numbers (docs/prd-point-coin.md F3).
|
||||
type EarningLine struct {
|
||||
Amount int64
|
||||
Settings models.LoyaltyEarnSettings
|
||||
// True when max_per_order cut the amount down.
|
||||
Capped bool
|
||||
}
|
||||
|
||||
// EarningResult is what an order earns.
|
||||
type EarningResult struct {
|
||||
// subtotal − discount − the part paid with EnakPoint, in rupiah, never negative.
|
||||
// Tax and anything else added on top of the subtotal are not part of it (Q1).
|
||||
Basis float64
|
||||
Point EarningLine
|
||||
Coin EarningLine
|
||||
}
|
||||
|
||||
// Metadata is the snapshot stored on the currency's EARN ledger row.
|
||||
func (r EarningResult) Metadata(line EarningLine) entities.Metadata {
|
||||
m := entities.Metadata{
|
||||
"basis": r.Basis,
|
||||
"earn_per_amount": line.Settings.EarnPerAmount,
|
||||
"earn_value": line.Settings.EarnValue,
|
||||
"min_order_amount": line.Settings.MinOrderAmount,
|
||||
"capped": line.Capped,
|
||||
}
|
||||
if line.Settings.MaxPerOrder != nil {
|
||||
m["max_per_order"] = *line.Settings.MaxPerOrder
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// CalculateEarning applies the earning formula of docs/prd-point-coin.md F1:
|
||||
//
|
||||
// basis = subtotal − discount_amount − paid with EnakPoint
|
||||
// amount = 0 if basis < min_order_amount
|
||||
// amount = floor(basis / earn_per_amount) × earn_value
|
||||
// amount = min(amount, max_per_order) if max_per_order is set
|
||||
//
|
||||
// The part paid with EnakPoint earns nothing (Q10). Money is handled in whole cents so
|
||||
// floor never lands one short on a value like 87500.00 that float64 cannot hold
|
||||
// exactly. It has no side effects.
|
||||
func CalculateEarning(order *entities.Order, pointPaidAmount float64, settings models.OutletLoyaltySettings) EarningResult {
|
||||
basisCents := toCents(order.Subtotal) - toCents(order.DiscountAmount) - toCents(pointPaidAmount)
|
||||
if basisCents < 0 {
|
||||
basisCents = 0
|
||||
}
|
||||
return EarningResult{
|
||||
Basis: float64(basisCents) / 100,
|
||||
Point: earnLine(basisCents, settings.Point),
|
||||
Coin: earnLine(basisCents, settings.Coin),
|
||||
}
|
||||
}
|
||||
|
||||
func earnLine(basisCents int64, s models.LoyaltyEarnSettings) EarningLine {
|
||||
line := EarningLine{Settings: s}
|
||||
if !s.Enabled || s.EarnPerAmount <= 0 || s.EarnValue <= 0 {
|
||||
return line
|
||||
}
|
||||
if basisCents < s.MinOrderAmount*100 {
|
||||
return line
|
||||
}
|
||||
line.Amount = basisCents / (s.EarnPerAmount * 100) * s.EarnValue
|
||||
if s.MaxPerOrder != nil && line.Amount > *s.MaxPerOrder {
|
||||
line.Amount = *s.MaxPerOrder
|
||||
line.Capped = true
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func toCents(rupiah float64) int64 {
|
||||
return int64(math.Round(rupiah * 100))
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// 1 EnakPoint per Rp 100 and 1 EnakCoin per Rp 25.000, as in the F1 example.
|
||||
func prdEarningSettings() models.OutletLoyaltySettings {
|
||||
return models.OutletLoyaltySettings{
|
||||
Point: models.LoyaltyEarnSettings{Enabled: true, EarnPerAmount: 100, EarnValue: 1},
|
||||
Coin: models.LoyaltyEarnSettings{Enabled: true, EarnPerAmount: 25000, EarnValue: 1},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateEarning_PRDExample(t *testing.T) {
|
||||
// Subtotal after discount Rp 87.500, paid in full in cash.
|
||||
order := &entities.Order{Subtotal: 97500, DiscountAmount: 10000, TaxAmount: 9625, TotalAmount: 97125}
|
||||
|
||||
got := CalculateEarning(order, 0, prdEarningSettings())
|
||||
assert.Equal(t, 87500.0, got.Basis)
|
||||
assert.Equal(t, int64(875), got.Point.Amount)
|
||||
assert.Equal(t, int64(3), got.Coin.Amount)
|
||||
|
||||
// Rp 20.000 of it paid with EnakPoint earns nothing.
|
||||
got = CalculateEarning(order, 20000, prdEarningSettings())
|
||||
assert.Equal(t, 67500.0, got.Basis)
|
||||
assert.Equal(t, int64(675), got.Point.Amount)
|
||||
assert.Equal(t, int64(2), got.Coin.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_TaxIsNotPartOfTheBasis(t *testing.T) {
|
||||
withoutTax := &entities.Order{Subtotal: 50000}
|
||||
withTax := &entities.Order{Subtotal: 50000, TaxAmount: 5500, TotalAmount: 55500}
|
||||
assert.Equal(t, CalculateEarning(withoutTax, 0, prdEarningSettings()), CalculateEarning(withTax, 0, prdEarningSettings()))
|
||||
assert.Equal(t, int64(500), CalculateEarning(withTax, 0, prdEarningSettings()).Point.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_BelowMinimum(t *testing.T) {
|
||||
s := prdEarningSettings()
|
||||
s.Point.MinOrderAmount = 50000
|
||||
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Point.Amount)
|
||||
assert.Equal(t, int64(500), CalculateEarning(&entities.Order{Subtotal: 50000}, 0, s).Point.Amount, "the minimum itself earns")
|
||||
// The minimum applies to the basis, after discount and EnakPoint.
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 60000}, 15000, s).Point.Amount)
|
||||
// Coin has its own minimum.
|
||||
assert.Equal(t, int64(1), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Coin.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_MaxPerOrder(t *testing.T) {
|
||||
s := prdEarningSettings()
|
||||
max := int64(300)
|
||||
s.Point.MaxPerOrder = &max
|
||||
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
|
||||
assert.Equal(t, int64(300), got.Point.Amount)
|
||||
assert.True(t, got.Point.Capped)
|
||||
assert.Equal(t, int64(300), got.Metadata(got.Point)["max_per_order"])
|
||||
|
||||
got = CalculateEarning(&entities.Order{Subtotal: 20000}, 0, s)
|
||||
assert.Equal(t, int64(200), got.Point.Amount)
|
||||
assert.False(t, got.Point.Capped)
|
||||
|
||||
zero := int64(0)
|
||||
s.Point.MaxPerOrder = &zero
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_DisabledEarnsNothing(t *testing.T) {
|
||||
s := prdEarningSettings()
|
||||
s.Point.Enabled = false
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
|
||||
assert.Equal(t, int64(0), got.Point.Amount)
|
||||
assert.Equal(t, int64(3), got.Coin.Amount, "each currency is switched on its own")
|
||||
|
||||
s.Coin.Enabled = false
|
||||
got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
|
||||
assert.Equal(t, int64(0), got.Coin.Amount)
|
||||
assert.Equal(t, 87500.0, got.Basis, "the basis is still reported")
|
||||
|
||||
// The defaults of an outlet that never set anything earn nothing.
|
||||
var defaults models.OutletLoyaltySettings
|
||||
loadLoyaltyFields(outletLoyaltyFields(&defaults), nil, "test")
|
||||
got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, defaults)
|
||||
assert.Equal(t, int64(0), got.Point.Amount)
|
||||
assert.Equal(t, int64(0), got.Coin.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_EdgeCases(t *testing.T) {
|
||||
s := prdEarningSettings()
|
||||
|
||||
// floor, not round.
|
||||
assert.Equal(t, int64(875), CalculateEarning(&entities.Order{Subtotal: 87599.99}, 0, s).Point.Amount)
|
||||
// Values float64 cannot hold exactly do not lose a point: computed in float64 this
|
||||
// basis divides to 4956.999…, which a naive floor turns into 4956.
|
||||
per250 := prdEarningSettings()
|
||||
per250.Point.EarnPerAmount = 250
|
||||
assert.Equal(t, int64(4957), CalculateEarning(&entities.Order{Subtotal: 1240155.48, DiscountAmount: 749.11}, 156.37, per250).Point.Amount)
|
||||
// Paying more with EnakPoint than the basis leaves nothing, never a negative amount.
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 10000}, 15000, s)
|
||||
assert.Equal(t, 0.0, got.Basis)
|
||||
assert.Equal(t, int64(0), got.Point.Amount)
|
||||
// earn_value multiplies.
|
||||
s.Point.EarnValue = 5
|
||||
assert.Equal(t, int64(4375), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||
// A zero earn_value earns nothing even when enabled.
|
||||
s.Point.EarnValue = 0
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_MetadataSnapshot(t *testing.T) {
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, prdEarningSettings())
|
||||
assert.Equal(t, entities.Metadata{
|
||||
"basis": 87500.0, "earn_per_amount": int64(100), "earn_value": int64(1), "min_order_amount": int64(0), "capped": false,
|
||||
}, got.Metadata(got.Point))
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// Why an order earned nothing.
|
||||
const (
|
||||
EarningSkipNotPaid = "NOT_PAID"
|
||||
EarningSkipVoid = "VOID"
|
||||
EarningSkipNoCustomer = "NO_CUSTOMER"
|
||||
EarningSkipDefaultCustomer = "DEFAULT_CUSTOMER"
|
||||
EarningSkipInactiveCustomer = "INACTIVE_CUSTOMER"
|
||||
EarningSkipNothingToEarn = "NOTHING_TO_EARN"
|
||||
)
|
||||
|
||||
// EarningOutcome is what earning did for one order.
|
||||
type EarningOutcome struct {
|
||||
Points int64
|
||||
Coins int64
|
||||
// Set when the order earned nothing, to say why.
|
||||
Skipped string
|
||||
}
|
||||
|
||||
type outletSettingsReader interface {
|
||||
Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error)
|
||||
}
|
||||
|
||||
// EarningProcessor credits EnakPoint and EnakCoin for paid orders
|
||||
// (docs/prd-point-coin.md F3).
|
||||
type EarningProcessor struct {
|
||||
orders repository.EarningRepository
|
||||
settings outletSettingsReader
|
||||
wallet *WalletProcessor
|
||||
tx TxRunner
|
||||
}
|
||||
|
||||
func NewEarningProcessor(orders repository.EarningRepository, settings outletSettingsReader, wallet *WalletProcessor, tx TxRunner) *EarningProcessor {
|
||||
return &EarningProcessor{orders: orders, settings: settings, wallet: wallet, tx: tx}
|
||||
}
|
||||
|
||||
// OnOrderPaid is called once an order has become fully paid and the payment has
|
||||
// committed. It never fails the caller: a failed earning is logged and picked up later
|
||||
// by EarnMissing, and the idempotency keys make that retry safe.
|
||||
func (p *EarningProcessor) OnOrderPaid(ctx context.Context, orderID uuid.UUID) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Earning for order %s panicked; it will be retried", orderID), fmt.Errorf("%v", r))
|
||||
}
|
||||
}()
|
||||
if _, err := p.EarnForOrder(ctx, orderID); err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Earning for order %s failed; it will be retried", orderID), err)
|
||||
}
|
||||
}
|
||||
|
||||
// EarnForOrder credits what a paid order earns. Calling it again for the same order
|
||||
// credits nothing more.
|
||||
func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID) (*EarningOutcome, error) {
|
||||
order, err := p.orders.GetOrderForEarning(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if skip := earningSkipReason(order); skip != "" {
|
||||
return &EarningOutcome{Skipped: skip}, nil
|
||||
}
|
||||
|
||||
settings, err := p.settings.Outlet(ctx, order.OutletID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pointPaid, err := p.orders.PointPaidAmount(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := CalculateEarning(&entities.Order{Subtotal: order.Subtotal, DiscountAmount: order.DiscountAmount}, pointPaid, *settings)
|
||||
if result.Point.Amount == 0 && result.Coin.Amount == 0 {
|
||||
return &EarningOutcome{Skipped: EarningSkipNothingToEarn}, nil
|
||||
}
|
||||
|
||||
outcome := &EarningOutcome{}
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
for _, c := range []struct {
|
||||
currency string
|
||||
line EarningLine
|
||||
total *int64
|
||||
}{
|
||||
{constants.WalletCurrencyPoint, result.Point, &outcome.Points},
|
||||
{constants.WalletCurrencyCoin, result.Coin, &outcome.Coins},
|
||||
} {
|
||||
if c.line.Amount == 0 {
|
||||
continue
|
||||
}
|
||||
outletID := order.OutletID
|
||||
res, err := p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{
|
||||
CustomerID: *order.CustomerID,
|
||||
Currency: c.currency,
|
||||
Type: constants.WalletTxTypeEarn,
|
||||
Amount: c.line.Amount,
|
||||
ReferenceType: constants.WalletRefTypeOrder,
|
||||
ReferenceID: order.ID,
|
||||
OutletID: &outletID,
|
||||
Description: earningDescription(order),
|
||||
Metadata: result.Metadata(c.line),
|
||||
IdempotencyKey: fmt.Sprintf("earn:%s:%s", order.ID, c.currency),
|
||||
// Lots never expire until the expiry model is decided (F12, note N4).
|
||||
}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("crediting %s: %w", c.currency, err)
|
||||
}
|
||||
*c.total = res.Transaction.Amount
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// EarnMissing is the safety net behind OnOrderPaid: it looks for orders paid since the
|
||||
// given time that should have earned and have no EARN row, and earns for them. It
|
||||
// returns how many orders it looked at and how many now earned. One order failing does
|
||||
// not stop the others.
|
||||
func (p *EarningProcessor) EarnMissing(ctx context.Context, since time.Time, maxOrders int) (checked, earned int, err error) {
|
||||
const page = 200
|
||||
var after *repository.EarningCursor
|
||||
for checked < maxOrders {
|
||||
batch, err := p.orders.ListPaidOrdersWithoutEarning(ctx, since, after, page)
|
||||
if err != nil {
|
||||
return checked, earned, err
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
for _, candidate := range batch {
|
||||
checked++
|
||||
outcome, err := p.EarnForOrder(ctx, candidate.ID)
|
||||
if err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Earning retry for order %s failed", candidate.ID), err)
|
||||
continue
|
||||
}
|
||||
if outcome.Skipped == "" {
|
||||
earned++
|
||||
}
|
||||
}
|
||||
last := batch[len(batch)-1]
|
||||
after = &last
|
||||
}
|
||||
return checked, earned, nil
|
||||
}
|
||||
|
||||
func earningSkipReason(order *repository.EarningOrder) string {
|
||||
switch {
|
||||
case order.PaymentStatus != string(entities.PaymentStatusCompleted):
|
||||
return EarningSkipNotPaid
|
||||
case order.IsVoid:
|
||||
return EarningSkipVoid
|
||||
case order.CustomerID == nil || order.CustomerIsDefault == nil:
|
||||
return EarningSkipNoCustomer
|
||||
case *order.CustomerIsDefault:
|
||||
return EarningSkipDefaultCustomer
|
||||
case order.CustomerIsActive == nil || !*order.CustomerIsActive:
|
||||
return EarningSkipInactiveCustomer
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func earningDescription(order *repository.EarningOrder) string {
|
||||
description := "Belanja #" + order.OrderNumber
|
||||
if order.OutletName != "" {
|
||||
description += " di " + order.OutletName
|
||||
}
|
||||
return truncateRunes(description, walletDescriptionLimit)
|
||||
}
|
||||
|
||||
// EarnedByOrders returns what each order earned, as written in its EARN rows, for the
|
||||
// order screen and the receipt (F3). Orders that earned nothing are absent.
|
||||
func (p *EarningProcessor) EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]OrderEarned, error) {
|
||||
rows, err := p.orders.EarnedByOrders(ctx, orderIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[uuid.UUID]OrderEarned, len(rows))
|
||||
for id, byCurrency := range rows {
|
||||
out[id] = OrderEarned{Points: byCurrency[constants.WalletCurrencyPoint], Coins: byCurrency[constants.WalletCurrencyCoin]}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// failingSettings fails for the outlet settings until healed, to stand in for the
|
||||
// database being unreachable right after a payment.
|
||||
type failingSettings struct {
|
||||
real outletSettingsReader
|
||||
mu sync.Mutex
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (f *failingSettings) Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) {
|
||||
f.mu.Lock()
|
||||
fail := f.fail
|
||||
f.mu.Unlock()
|
||||
if fail {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
return f.real.Outlet(ctx, outletID)
|
||||
}
|
||||
|
||||
// Needs TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestEarningProcessor_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
|
||||
org, user := uuid.New(), uuid.New()
|
||||
earningOutlet, quietOutlet := uuid.New(), uuid.New()
|
||||
regular, inactive := uuid.New(), uuid.New()
|
||||
var walkIn uuid.UUID
|
||||
var customers []uuid.UUID
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'earning test', 'basic')`, org)
|
||||
exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, user, org, user.String()+"@test")
|
||||
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang'), (?, ?, 'Tanpa Poin')`, earningOutlet, org, quietOutlet, org)
|
||||
exec(`INSERT INTO customers (id, organization_id, name, is_default, is_active) VALUES
|
||||
(?, ?, 'Budi', false, true), (?, ?, 'Nonaktif', false, false)`, regular, org, inactive, org)
|
||||
// Creating the organization created its walk-in customer (trigger_create_default_customer).
|
||||
var walkInID string
|
||||
require.NoError(t, db.Raw(`SELECT id::text FROM customers WHERE organization_id = ? AND is_default`, org).Scan(&walkInID).Error)
|
||||
walkIn = uuid.MustParse(walkInID)
|
||||
customers = []uuid.UUID{regular, walkIn, inactive}
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM orders WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id IN ?`, []uuid.UUID{earningOutlet, quietOutlet})
|
||||
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM outlets WHERE id IN ?`, []uuid.UUID{earningOutlet, quietOutlet})
|
||||
db.Exec(`DELETE FROM users WHERE id = ?`, user)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
|
||||
txm := repository.NewTxManager(db)
|
||||
settingsProcessor := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
|
||||
outletSettings, err := settingsProcessor.Outlet(ctx, earningOutlet)
|
||||
require.NoError(t, err)
|
||||
outletSettings.Point.Enabled = true
|
||||
outletSettings.Coin.Enabled = true
|
||||
_, err = settingsProcessor.UpdateOutlet(ctx, org, earningOutlet, user, *outletSettings)
|
||||
require.NoError(t, err)
|
||||
|
||||
settings := &failingSettings{real: settingsProcessor}
|
||||
earning := NewEarningProcessor(repository.NewEarningRepository(db), settings, NewWalletProcessor(repository.NewWalletRepository(db)), txm)
|
||||
|
||||
orderNo := 0
|
||||
newOrder := func(outlet uuid.UUID, customer *uuid.UUID, paymentStatus string, isVoid bool, subtotal, discount float64) uuid.UUID {
|
||||
t.Helper()
|
||||
orderNo++
|
||||
id := uuid.New()
|
||||
exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type,
|
||||
subtotal, discount_amount, tax_amount, total_amount, payment_status, is_void)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'dine_in', ?, ?, 0, ?, ?, ?)`,
|
||||
id, org, outlet, user, customer, id.String()[:8]+"-"+string(rune('A'+orderNo)), subtotal, discount, subtotal-discount, paymentStatus, isVoid)
|
||||
return id
|
||||
}
|
||||
earnRows := func(orderID uuid.UUID) map[string]int64 {
|
||||
t.Helper()
|
||||
var rows []struct {
|
||||
Currency string
|
||||
Amount int64
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT currency, amount FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, orderID).Scan(&rows).Error)
|
||||
out := map[string]int64{}
|
||||
for _, r := range rows {
|
||||
out[r.Currency] += r.Amount
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Paid in full: the PRD example, 875 EnakPoint and 3 EnakCoin.
|
||||
paid := newOrder(earningOutlet, ®ular, "completed", false, 97500, 10000)
|
||||
earning.OnOrderPaid(ctx, paid)
|
||||
assert.Equal(t, map[string]int64{"POINT": 875, "COIN": 3}, earnRows(paid))
|
||||
|
||||
var row struct {
|
||||
Description string
|
||||
Metadata string
|
||||
OutletID string
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT description, metadata::text AS metadata, outlet_id::text AS outlet_id FROM wallet_transactions
|
||||
WHERE reference_id = ? AND currency = 'POINT'`, paid).Scan(&row).Error)
|
||||
assert.Contains(t, row.Description, "di Kemang")
|
||||
assert.Contains(t, row.Metadata, `"earn_per_amount": 100`, "the settings used are frozen on the row")
|
||||
assert.Contains(t, row.Metadata, `"basis": 87500`)
|
||||
assert.Equal(t, earningOutlet.String(), row.OutletID)
|
||||
|
||||
// Called again, and five times at once, it is still one earning.
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); earning.OnOrderPaid(ctx, paid) }()
|
||||
}
|
||||
wg.Wait()
|
||||
outcome, err := earning.EarnForOrder(ctx, paid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(875), outcome.Points, "a repeat reports the first earning")
|
||||
assert.Equal(t, map[string]int64{"POINT": 875, "COIN": 3}, earnRows(paid))
|
||||
|
||||
// A self-order goes through the same payment path and the same rules.
|
||||
selfOrder := newOrder(earningOutlet, ®ular, "completed", false, 25000, 0)
|
||||
earning.OnOrderPaid(ctx, selfOrder)
|
||||
assert.Equal(t, map[string]int64{"POINT": 250, "COIN": 1}, earnRows(selfOrder))
|
||||
|
||||
// A split bill earns once, on the payment that settles it: while partial, nothing.
|
||||
split := newOrder(earningOutlet, ®ular, "partial", false, 60000, 0)
|
||||
earning.OnOrderPaid(ctx, split)
|
||||
assert.Empty(t, earnRows(split))
|
||||
exec(`UPDATE orders SET payment_status = 'completed' WHERE id = ?`, split)
|
||||
earning.OnOrderPaid(ctx, split)
|
||||
assert.Equal(t, map[string]int64{"POINT": 600, "COIN": 2}, earnRows(split))
|
||||
|
||||
// Orders that must not earn.
|
||||
for name, c := range map[string]struct {
|
||||
id uuid.UUID
|
||||
skip string
|
||||
}{
|
||||
"walk-in customer": {newOrder(earningOutlet, &walkIn, "completed", false, 50000, 0), EarningSkipDefaultCustomer},
|
||||
"inactive customer": {newOrder(earningOutlet, &inactive, "completed", false, 50000, 0), EarningSkipInactiveCustomer},
|
||||
"no customer": {newOrder(earningOutlet, nil, "completed", false, 50000, 0), EarningSkipNoCustomer},
|
||||
"void": {newOrder(earningOutlet, ®ular, "completed", true, 50000, 0), EarningSkipVoid},
|
||||
"unpaid": {newOrder(earningOutlet, ®ular, "pending", false, 50000, 0), EarningSkipNotPaid},
|
||||
"outlet not earning": {newOrder(quietOutlet, ®ular, "completed", false, 50000, 0), EarningSkipNothingToEarn},
|
||||
} {
|
||||
outcome, err := earning.EarnForOrder(ctx, c.id)
|
||||
require.NoError(t, err, name)
|
||||
assert.Equal(t, c.skip, outcome.Skipped, name)
|
||||
assert.Empty(t, earnRows(c.id), name)
|
||||
}
|
||||
|
||||
// An earning that fails does not surface to the payment, and the job picks it up.
|
||||
settings.mu.Lock()
|
||||
settings.fail = true
|
||||
settings.mu.Unlock()
|
||||
missed := newOrder(earningOutlet, ®ular, "completed", false, 40000, 0)
|
||||
assert.NotPanics(t, func() { earning.OnOrderPaid(ctx, missed) })
|
||||
assert.Empty(t, earnRows(missed))
|
||||
|
||||
settings.mu.Lock()
|
||||
settings.fail = false
|
||||
settings.mu.Unlock()
|
||||
since := time.Now().Add(-time.Hour)
|
||||
checked, earned, err := earning.EarnMissing(ctx, since, 1000)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, earned, 1)
|
||||
assert.GreaterOrEqual(t, checked, earned)
|
||||
assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed))
|
||||
|
||||
// The job only looks at orders that could earn and have not.
|
||||
candidates, err := repository.NewEarningRepository(db).ListPaidOrdersWithoutEarning(ctx, since, nil, 1000)
|
||||
require.NoError(t, err)
|
||||
var ours []uuid.UUID
|
||||
for _, c := range candidates {
|
||||
var n int64
|
||||
db.Raw(`SELECT COUNT(*) FROM orders WHERE id = ? AND organization_id = ?`, c.ID, org).Scan(&n)
|
||||
if n > 0 {
|
||||
ours = append(ours, c.ID)
|
||||
}
|
||||
}
|
||||
assert.Empty(t, ours, "every eligible order of ours has earned; walk-in, inactive, void, unpaid and non-earning outlets are never candidates")
|
||||
|
||||
// A second run finds nothing more to do for these orders.
|
||||
_, _, err = earning.EarnMissing(ctx, since, 1000)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed))
|
||||
|
||||
// What the order screen and receipt show is what the ledger has.
|
||||
shown, err := earning.EarnedByOrders(ctx, []uuid.UUID{paid, split, missed, uuid.New()})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, OrderEarned{Points: 875, Coins: 3}, shown[paid])
|
||||
assert.Equal(t, OrderEarned{Points: 600, Coins: 2}, shown[split])
|
||||
assert.Equal(t, OrderEarned{Points: 400, Coins: 1}, shown[missed])
|
||||
assert.Len(t, shown, 3, "an order without earning is absent, so it shows 0")
|
||||
|
||||
balance := struct{ PointBalance, CoinBalance int64 }{}
|
||||
require.NoError(t, db.Raw(`SELECT point_balance, coin_balance FROM customer_wallets WHERE customer_id = ?`, regular).Scan(&balance).Error)
|
||||
assert.Equal(t, int64(875+250+600+400), balance.PointBalance)
|
||||
assert.Equal(t, int64(3+1+2+1), balance.CoinBalance)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// ReversalOutcome is what a reversal took back, and what it could not.
|
||||
type ReversalOutcome struct {
|
||||
Points int64
|
||||
Coins int64
|
||||
PointShortfall int64
|
||||
CoinShortfall int64
|
||||
}
|
||||
|
||||
// OnOrderRefunded is called after an order was voided or (partly) refunded and that
|
||||
// has committed. It never fails the caller: a refund is never blocked by the loyalty
|
||||
// balance (Q3), so errors are logged.
|
||||
func (p *EarningProcessor) OnOrderRefunded(ctx context.Context, orderID uuid.UUID) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Earning reversal for order %s panicked", orderID), fmt.Errorf("%v", r))
|
||||
}
|
||||
}()
|
||||
if _, err := p.ReverseForOrder(ctx, orderID); err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Earning reversal for order %s failed", orderID), err)
|
||||
}
|
||||
}
|
||||
|
||||
// ReverseForOrder takes back what an order earned, as far as it has been voided or
|
||||
// refunded (docs/prd-point-coin.md F10):
|
||||
//
|
||||
// - void: everything the order earned;
|
||||
// - refund: floor(earned × refunded / basis), never more than was earned, with the
|
||||
// refunded amount being the order's cumulative refund.
|
||||
//
|
||||
// Only the part not asked back yet is taken, so calling it again, or after each of
|
||||
// several partial refunds, never takes more than the order earned. It draws from the
|
||||
// lots the EARN created first, then from the others in K9 order, and takes what the
|
||||
// balance has when it is short, recording the rest as shortfall (Q3).
|
||||
func (p *EarningProcessor) ReverseForOrder(ctx context.Context, orderID uuid.UUID) (*ReversalOutcome, error) {
|
||||
order, err := p.orders.GetOrderForEarning(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
earns, err := p.orders.ListEarnTransactions(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outcome := &ReversalOutcome{}
|
||||
if len(earns) == 0 {
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
for _, earn := range earns {
|
||||
target := earningReversalTarget(order, earn)
|
||||
requested, err := p.orders.ReversalRequested(ctx, earn.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
amount := target - requested
|
||||
if amount <= 0 {
|
||||
continue
|
||||
}
|
||||
lots, err := p.orders.LotIDsCreatedBy(ctx, earn.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reason := "REFUND"
|
||||
if order.IsVoid {
|
||||
reason = "VOID"
|
||||
}
|
||||
earnID := earn.ID
|
||||
res, err := p.wallet.DebitUpTo(ctx, WalletDebitInput{
|
||||
WalletEntry: WalletEntry{
|
||||
CustomerID: earn.CustomerID,
|
||||
Currency: earn.Currency,
|
||||
Type: constants.WalletTxTypeEarnReversal,
|
||||
Amount: amount,
|
||||
ReferenceType: constants.WalletRefTypeOrder,
|
||||
ReferenceID: order.ID,
|
||||
ReversesTransactionID: &earnID,
|
||||
OutletID: earn.OutletID,
|
||||
Description: earningReversalDescription(order),
|
||||
Metadata: entities.Metadata{
|
||||
"reason": reason,
|
||||
"refund_amount": order.RefundAmount,
|
||||
"target": target,
|
||||
},
|
||||
// The target only grows with each refund, so each refund gets its own
|
||||
// key while a retry of the same one replays.
|
||||
IdempotencyKey: fmt.Sprintf("reverse:%s:%d", earn.ID, target),
|
||||
},
|
||||
PreferredLotIDs: lots,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("reversing %s: %w", earn.Currency, err)
|
||||
}
|
||||
|
||||
var taken int64
|
||||
if res.Transaction != nil {
|
||||
taken = -res.Transaction.Amount
|
||||
} else {
|
||||
// Nothing to take: the ledger has no row to carry the shortfall.
|
||||
logger.NonContext.WarnWithFields("Earning reversal found an empty balance; the whole amount is shortfall", map[string]interface{}{
|
||||
"order_id": order.ID.String(), "customer_id": earn.CustomerID.String(),
|
||||
"currency": earn.Currency, "shortfall": res.Shortfall,
|
||||
}, nil)
|
||||
}
|
||||
switch earn.Currency {
|
||||
case constants.WalletCurrencyPoint:
|
||||
outcome.Points += taken
|
||||
outcome.PointShortfall += res.Shortfall
|
||||
case constants.WalletCurrencyCoin:
|
||||
outcome.Coins += taken
|
||||
outcome.CoinShortfall += res.Shortfall
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// earningReversalTarget is how much of an EARN row should have been taken back in
|
||||
// total, given the order's void flag and cumulative refund. It works in cents like
|
||||
// CalculateEarning, with the basis frozen on the EARN row.
|
||||
func earningReversalTarget(order *repository.EarningOrder, earn entities.WalletTransaction) int64 {
|
||||
earned := earn.Amount
|
||||
if order.IsVoid {
|
||||
return earned
|
||||
}
|
||||
refundCents := toCents(order.RefundAmount)
|
||||
if refundCents <= 0 {
|
||||
return 0
|
||||
}
|
||||
basis, _ := earn.Metadata["basis"].(float64)
|
||||
basisCents := toCents(basis)
|
||||
// A refund can include tax, which the basis does not, so it can reach past it.
|
||||
if basisCents <= 0 || refundCents >= basisCents {
|
||||
return earned
|
||||
}
|
||||
return earned * refundCents / basisCents
|
||||
}
|
||||
|
||||
func earningReversalDescription(order *repository.EarningOrder) string {
|
||||
verb := "Refund"
|
||||
if order.IsVoid {
|
||||
verb = "Batal"
|
||||
}
|
||||
description := verb + " #" + order.OrderNumber
|
||||
if order.OutletName != "" {
|
||||
description += " di " + order.OutletName
|
||||
}
|
||||
return truncateRunes(description, walletDescriptionLimit)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// Needs TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestEarningReversal_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
|
||||
org, user, outlet := uuid.New(), uuid.New(), uuid.New()
|
||||
voider, refunder, spender, loyal := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
customers := []uuid.UUID{voider, refunder, spender, loyal}
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'reversal test', 'basic')`, org)
|
||||
exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, user, org, user.String()+"@test")
|
||||
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org)
|
||||
for _, c := range customers {
|
||||
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'c')`, c, org)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM orders WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet)
|
||||
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet)
|
||||
db.Exec(`DELETE FROM users WHERE id = ?`, user)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
|
||||
txm := repository.NewTxManager(db)
|
||||
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
|
||||
s, err := settings.Outlet(ctx, outlet)
|
||||
require.NoError(t, err)
|
||||
s.Point.Enabled, s.Coin.Enabled = true, true
|
||||
_, err = settings.UpdateOutlet(ctx, org, outlet, user, *s)
|
||||
require.NoError(t, err)
|
||||
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
earning := NewEarningProcessor(repository.NewEarningRepository(db), settings, wallet, txm)
|
||||
|
||||
// Each order is the PRD example: basis Rp 87.500, 875 EnakPoint and 3 EnakCoin.
|
||||
paidOrder := func(customer uuid.UUID) uuid.UUID {
|
||||
t.Helper()
|
||||
id := uuid.New()
|
||||
exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type,
|
||||
subtotal, discount_amount, tax_amount, total_amount, payment_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'dine_in', 97500, 10000, 8750, 96250, 'completed')`,
|
||||
id, org, outlet, user, customer, "ORD-"+id.String()[:8])
|
||||
outcome, err := earning.EarnForOrder(ctx, id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(875), outcome.Points)
|
||||
return id
|
||||
}
|
||||
balance := func(customer uuid.UUID) (points, coins int64) {
|
||||
t.Helper()
|
||||
var b struct{ PointBalance, CoinBalance int64 }
|
||||
require.NoError(t, db.Raw(`SELECT point_balance, coin_balance FROM customer_wallets WHERE customer_id = ?`, customer).Scan(&b).Error)
|
||||
return b.PointBalance, b.CoinBalance
|
||||
}
|
||||
reversed := func(order uuid.UUID, currency string) int64 {
|
||||
t.Helper()
|
||||
var total int64
|
||||
require.NoError(t, db.Raw(`SELECT COALESCE(SUM(-amount), 0) FROM wallet_transactions
|
||||
WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = ?`, order, currency).Scan(&total).Error)
|
||||
return total
|
||||
}
|
||||
|
||||
// Void: everything comes back.
|
||||
voided := paidOrder(voider)
|
||||
exec(`UPDATE orders SET is_void = true WHERE id = ?`, voided)
|
||||
earning.OnOrderRefunded(ctx, voided)
|
||||
assert.Equal(t, int64(875), reversed(voided, "POINT"))
|
||||
assert.Equal(t, int64(3), reversed(voided, "COIN"))
|
||||
p, c := balance(voider)
|
||||
assert.Equal(t, [2]int64{0, 0}, [2]int64{p, c})
|
||||
|
||||
var rev struct {
|
||||
ReversesTransactionID string
|
||||
Description string
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT reverses_transaction_id::text AS reverses_transaction_id, description FROM wallet_transactions
|
||||
WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = 'POINT'`, voided).Scan(&rev).Error)
|
||||
var earnID string
|
||||
require.NoError(t, db.Raw(`SELECT id::text FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN' AND currency = 'POINT'`, voided).Scan(&earnID).Error)
|
||||
assert.Equal(t, earnID, rev.ReversesTransactionID, "the reversal points at the EARN it reverses")
|
||||
assert.Contains(t, rev.Description, "Batal #ORD-")
|
||||
|
||||
// Repeating it takes nothing more.
|
||||
earning.OnOrderRefunded(ctx, voided)
|
||||
assert.Equal(t, int64(875), reversed(voided, "POINT"))
|
||||
|
||||
// Partial refunds are proportional and add up to at most what was earned.
|
||||
refunded := paidOrder(refunder)
|
||||
for _, step := range []struct {
|
||||
refundAmount float64
|
||||
wantTotal int64
|
||||
}{
|
||||
{20000, 200}, // floor(875 × 20000 / 87500)
|
||||
{20000, 200}, // same refund again: nothing more
|
||||
{50000, 500}, // cumulative refund grew
|
||||
{96250, 875}, // the whole total, tax included, cannot exceed the earning
|
||||
{96250, 875},
|
||||
} {
|
||||
exec(`UPDATE orders SET refund_amount = ? WHERE id = ?`, step.refundAmount, refunded)
|
||||
outcome, err := earning.ReverseForOrder(ctx, refunded)
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, outcome.PointShortfall)
|
||||
assert.Equal(t, step.wantTotal, reversed(refunded, "POINT"), "after a refund of %.0f", step.refundAmount)
|
||||
}
|
||||
assert.Equal(t, int64(3), reversed(refunded, "COIN"))
|
||||
|
||||
// Balance already spent: what is left is taken, the rest recorded as shortfall,
|
||||
// and the refund is not blocked.
|
||||
spent := paidOrder(spender)
|
||||
committed(t, txm, func(ctx context.Context) (*WalletResult, error) {
|
||||
return wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
|
||||
CustomerID: spender, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment,
|
||||
Amount: 800, ReferenceType: constants.WalletRefTypePayment, ReferenceID: uuid.New(), OutletID: &outlet,
|
||||
Description: "Bayar"}})
|
||||
})
|
||||
exec(`UPDATE orders SET is_void = true WHERE id = ?`, spent)
|
||||
outcome, err := earning.ReverseForOrder(ctx, spent)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(75), outcome.Points)
|
||||
assert.Equal(t, int64(800), outcome.PointShortfall)
|
||||
p, _ = balance(spender)
|
||||
assert.Equal(t, int64(0), p, "never negative")
|
||||
var metadata string
|
||||
require.NoError(t, db.Raw(`SELECT metadata::text FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = 'POINT'`, spent).Scan(&metadata).Error)
|
||||
assert.Contains(t, metadata, `"shortfall": 800`)
|
||||
assert.Contains(t, metadata, `"requested_amount": 875`)
|
||||
// Asked back in full already, shortfall included: a repeat takes nothing.
|
||||
outcome, err = earning.ReverseForOrder(ctx, spent)
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, outcome.Points)
|
||||
|
||||
// The EARN's own lots are drawn first, even when an older lot would come first.
|
||||
older := committed(t, txm, func(ctx context.Context) (*WalletResult, error) {
|
||||
return wallet.Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{
|
||||
CustomerID: loyal, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration,
|
||||
Amount: 500, ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}})
|
||||
})
|
||||
loyalOrder := paidOrder(loyal)
|
||||
exec(`UPDATE orders SET refund_amount = 20000 WHERE id = ?`, loyalOrder)
|
||||
_, err = earning.ReverseForOrder(ctx, loyalOrder)
|
||||
require.NoError(t, err)
|
||||
var fromOlder int64
|
||||
require.NoError(t, db.Raw(`SELECT COALESCE(SUM(a.amount), 0) FROM wallet_lot_allocations a
|
||||
JOIN wallet_transactions t ON t.id = a.transaction_id
|
||||
WHERE t.reference_id = ? AND t.type = 'EARN_REVERSAL' AND a.lot_id = ?`, loyalOrder, older.Lots[0].ID).Scan(&fromOlder).Error)
|
||||
assert.Zero(t, fromOlder, "the older lot is untouched")
|
||||
assert.Equal(t, int64(200), reversed(loyalOrder, "POINT"))
|
||||
|
||||
// An order that never earned has nothing to reverse.
|
||||
outcome, err = earning.ReverseForOrder(ctx, uuid.New())
|
||||
assert.ErrorIs(t, err, repository.ErrEarningOrderNotFound)
|
||||
assert.Nil(t, outcome)
|
||||
|
||||
// Every reversal kept balances, ledger and lots in step (§7.5).
|
||||
found, err := repository.NewWalletReconciliationRepository(db).FindDiscrepancies(ctx, 1000)
|
||||
require.NoError(t, err)
|
||||
for _, d := range found {
|
||||
for _, c := range customers {
|
||||
assert.NotEqual(t, c, d.CustomerID, "discrepancy %s for one of our customers", d.Check)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// committed runs fn in a transaction that commits before it returns, for engine calls
|
||||
// made directly from a test.
|
||||
func committed[T any](t *testing.T, txm *repository.TxManager, fn func(ctx context.Context) (T, error)) T {
|
||||
t.Helper()
|
||||
var out T
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
var err error
|
||||
out, err = fn(ctx)
|
||||
return err
|
||||
}))
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
func TestEarningReversalTarget(t *testing.T) {
|
||||
earn := entities.WalletTransaction{Amount: 875, Metadata: entities.Metadata{"basis": 87500.0}}
|
||||
target := func(order repository.EarningOrder) int64 { return earningReversalTarget(&order, earn) }
|
||||
|
||||
assert.Equal(t, int64(0), target(repository.EarningOrder{}), "nothing refunded, nothing to take")
|
||||
assert.Equal(t, int64(875), target(repository.EarningOrder{IsVoid: true}), "void takes everything")
|
||||
assert.Equal(t, int64(200), target(repository.EarningOrder{RefundAmount: 20000}), "floor(875 × 20000 / 87500)")
|
||||
assert.Equal(t, int64(299), target(repository.EarningOrder{RefundAmount: 29999.99}), "floor, not round")
|
||||
assert.Equal(t, int64(875), target(repository.EarningOrder{RefundAmount: 87500}))
|
||||
assert.Equal(t, int64(875), target(repository.EarningOrder{RefundAmount: 96250}), "a refund including tax cannot take more than was earned")
|
||||
|
||||
noBasis := entities.WalletTransaction{Amount: 10, Metadata: entities.Metadata{}}
|
||||
assert.Equal(t, int64(10), earningReversalTarget(&repository.EarningOrder{RefundAmount: 1}, noBasis), "without a basis any refund takes it all")
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// ErrInvalidLoyaltySettings wraps every rejection of a setting that breaks the rules in
|
||||
// docs/prd-point-coin.md F1, F2 and F12.
|
||||
var ErrInvalidLoyaltySettings = errors.New("invalid loyalty settings")
|
||||
|
||||
// LoyaltySettingsProcessor reads and writes the loyalty settings of outlets (F1) and
|
||||
// organizations (F2, F12). Reads always return every setting, with the PRD default for
|
||||
// a key that was never set, so callers never handle a missing value. Every write
|
||||
// records what changed in loyalty_setting_changes.
|
||||
type LoyaltySettingsProcessor struct {
|
||||
repo repository.LoyaltySettingsRepository
|
||||
tx TxRunner
|
||||
}
|
||||
|
||||
func NewLoyaltySettingsProcessor(repo repository.LoyaltySettingsRepository, tx TxRunner) *LoyaltySettingsProcessor {
|
||||
return &LoyaltySettingsProcessor{repo: repo, tx: tx}
|
||||
}
|
||||
|
||||
// Outlet returns an outlet's settings.
|
||||
func (p *LoyaltySettingsProcessor) Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) {
|
||||
raw, err := p.repo.GetOutletValues(ctx, outletID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var s models.OutletLoyaltySettings
|
||||
loadLoyaltyFields(outletLoyaltyFields(&s), raw, "outlet "+outletID.String())
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// OutletForOrganization returns an outlet's settings for the dashboard, and
|
||||
// repository.ErrLoyaltyOutletNotFound unless the outlet belongs to the organization.
|
||||
func (p *LoyaltySettingsProcessor) OutletForOrganization(ctx context.Context, organizationID, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) {
|
||||
owner, err := p.repo.OutletOrganizationID(ctx, outletID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if owner != organizationID {
|
||||
return nil, repository.ErrLoyaltyOutletNotFound
|
||||
}
|
||||
return p.Outlet(ctx, outletID)
|
||||
}
|
||||
|
||||
// Organization returns an organization's settings.
|
||||
func (p *LoyaltySettingsProcessor) Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
|
||||
raw, err := p.repo.GetOrganizationValues(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var s models.OrganizationLoyaltySettings
|
||||
loadLoyaltyFields(organizationLoyaltyFields(&s), raw, "organization "+organizationID.String())
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// PointValue is the rupiah value of one EnakPoint in an organization.
|
||||
func (p *LoyaltySettingsProcessor) PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error) {
|
||||
s, err := p.Organization(ctx, organizationID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.PointValue, nil
|
||||
}
|
||||
|
||||
// UpdateOutlet replaces an outlet's settings and returns what changed. The outlet must
|
||||
// belong to the organization; otherwise repository.ErrLoyaltyOutletNotFound.
|
||||
func (p *LoyaltySettingsProcessor) UpdateOutlet(ctx context.Context, organizationID, outletID, changedBy uuid.UUID, settings models.OutletLoyaltySettings) ([]models.LoyaltySettingChange, error) {
|
||||
if err := validateLoyaltyFields(outletLoyaltyFields(&settings)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var changes []models.LoyaltySettingChange
|
||||
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
owner, err := p.repo.OutletOrganizationID(ctx, outletID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != organizationID {
|
||||
return repository.ErrLoyaltyOutletNotFound
|
||||
}
|
||||
if err := p.repo.LockScope(ctx, outletID); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := p.repo.GetOutletValues(ctx, outletID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var current models.OutletLoyaltySettings
|
||||
loadLoyaltyFields(outletLoyaltyFields(¤t), raw, "outlet "+outletID.String())
|
||||
|
||||
changes, err = p.apply(ctx, organizationID, &outletID, changedBy, raw,
|
||||
outletLoyaltyFields(¤t), outletLoyaltyFields(&settings),
|
||||
func(key string, value *string) error { return p.repo.SetOutletValue(ctx, outletID, key, value) })
|
||||
return err
|
||||
})
|
||||
return changes, err
|
||||
}
|
||||
|
||||
// UpdateOrganization replaces an organization's settings and returns what changed.
|
||||
func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organizationID, changedBy uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, error) {
|
||||
if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var changes []models.LoyaltySettingChange
|
||||
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
if err := p.repo.LockScope(ctx, organizationID); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := p.repo.GetOrganizationValues(ctx, organizationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var current models.OrganizationLoyaltySettings
|
||||
loadLoyaltyFields(organizationLoyaltyFields(¤t), raw, "organization "+organizationID.String())
|
||||
|
||||
changes, err = p.apply(ctx, organizationID, nil, changedBy, raw,
|
||||
organizationLoyaltyFields(¤t), organizationLoyaltyFields(&settings),
|
||||
func(key string, value *string) error {
|
||||
return p.repo.SetOrganizationValue(ctx, organizationID, key, value)
|
||||
})
|
||||
return err
|
||||
})
|
||||
return changes, err
|
||||
}
|
||||
|
||||
// apply stores every field whose effective value changes and records it. A key left
|
||||
// on its default and not changed is not written, so the history only shows real
|
||||
// changes. current and next list the same keys in the same order.
|
||||
func (p *LoyaltySettingsProcessor) apply(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, changedBy uuid.UUID, raw map[string]string, current, next []loyaltyField, store func(key string, value *string) error) ([]models.LoyaltySettingChange, error) {
|
||||
rows := diffLoyaltyFields(organizationID, outletID, changedBy, raw, current, next)
|
||||
for _, row := range rows {
|
||||
if err := store(row.Key, row.NewValue); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := p.repo.InsertChanges(ctx, rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changes := make([]models.LoyaltySettingChange, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
changes = append(changes, loyaltySettingChangeModel(row))
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
// ListChanges returns a page of an organization's settings history, newest first.
|
||||
// A nil outlet lists the whole organization, outlets included.
|
||||
func (p *LoyaltySettingsProcessor) ListChanges(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int) (*models.PaginatedResponse[models.LoyaltySettingChange], error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
rows, total, err := p.repo.ListChanges(ctx, repository.LoyaltySettingChangeFilter{
|
||||
OrganizationID: organizationID, OutletID: outletID, Offset: (page - 1) * limit, Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]models.LoyaltySettingChange, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, loyaltySettingChangeModel(row))
|
||||
}
|
||||
return &models.PaginatedResponse[models.LoyaltySettingChange]{
|
||||
Data: items,
|
||||
Pagination: models.Pagination{
|
||||
Page: page, Limit: limit, Total: total, TotalPages: int((total + int64(limit) - 1) / int64(limit)),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loyaltySettingChangeModel(row entities.LoyaltySettingChange) models.LoyaltySettingChange {
|
||||
return models.LoyaltySettingChange{
|
||||
ID: row.ID,
|
||||
OrganizationID: row.OrganizationID,
|
||||
OutletID: row.OutletID,
|
||||
Key: row.Key,
|
||||
OldValue: row.OldValue,
|
||||
NewValue: row.NewValue,
|
||||
ChangedBy: row.ChangedBy,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// outletLoyaltyFields describes every outlet key (F1), bound to s.
|
||||
func outletLoyaltyFields(s *models.OutletLoyaltySettings) []loyaltyField {
|
||||
return []loyaltyField{
|
||||
boolLoyaltyField(constants.LoyaltyPointEnabledKey, &s.Point.Enabled, false),
|
||||
intLoyaltyField(constants.LoyaltyPointEarnPerAmountKey, &s.Point.EarnPerAmount, constants.LoyaltyPointEarnPerAmountDefault, 1, noLoyaltyMax),
|
||||
intLoyaltyField(constants.LoyaltyPointEarnValueKey, &s.Point.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax),
|
||||
intLoyaltyField(constants.LoyaltyPointMinOrderAmountKey, &s.Point.MinOrderAmount, 0, 0, noLoyaltyMax),
|
||||
optionalIntLoyaltyField(constants.LoyaltyPointMaxPerOrderKey, &s.Point.MaxPerOrder, 0),
|
||||
|
||||
boolLoyaltyField(constants.LoyaltyCoinEnabledKey, &s.Coin.Enabled, false),
|
||||
intLoyaltyField(constants.LoyaltyCoinEarnPerAmountKey, &s.Coin.EarnPerAmount, constants.LoyaltyCoinEarnPerAmountDefault, 1, noLoyaltyMax),
|
||||
intLoyaltyField(constants.LoyaltyCoinEarnValueKey, &s.Coin.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax),
|
||||
intLoyaltyField(constants.LoyaltyCoinMinOrderAmountKey, &s.Coin.MinOrderAmount, 0, 0, noLoyaltyMax),
|
||||
optionalIntLoyaltyField(constants.LoyaltyCoinMaxPerOrderKey, &s.Coin.MaxPerOrder, 0),
|
||||
|
||||
boolLoyaltyField(constants.LoyaltyPointAcceptPaymentKey, &s.PointPayment.AcceptPayment, false),
|
||||
intLoyaltyField(constants.LoyaltyPointMinPaymentPointsKey, &s.PointPayment.MinPaymentPoints, constants.LoyaltyMinPaymentPointsDefault, 1, noLoyaltyMax),
|
||||
intLoyaltyField(constants.LoyaltyPointMaxPaymentPercentKey, &s.PointPayment.MaxPaymentPercent, constants.LoyaltyMaxPaymentPercentDefault, 0, 100),
|
||||
}
|
||||
}
|
||||
|
||||
// organizationLoyaltyFields describes every organization key (F2, F12), bound to s.
|
||||
func organizationLoyaltyFields(s *models.OrganizationLoyaltySettings) []loyaltyField {
|
||||
fields := []loyaltyField{
|
||||
intLoyaltyField(constants.LoyaltyPointValueKey, &s.PointValue, constants.LoyaltyPointValueDefault, 1, noLoyaltyMax),
|
||||
intLoyaltyField(constants.LoyaltyExchangeCoinAmountKey, &s.Exchange.CoinAmount, constants.LoyaltyExchangeAmountDefault, 1, noLoyaltyMax),
|
||||
intLoyaltyField(constants.LoyaltyExchangePointAmountKey, &s.Exchange.PointAmount, constants.LoyaltyExchangeAmountDefault, 1, noLoyaltyMax),
|
||||
boolLoyaltyField(constants.LoyaltyTransferEnabledKey, &s.Transfer.Enabled, constants.LoyaltyTransferEnabledDefault),
|
||||
intLoyaltyField(constants.LoyaltyTransferMinAmountKey, &s.Transfer.MinAmount, constants.LoyaltyTransferMinAmountDefault, 1, noLoyaltyMax),
|
||||
optionalIntLoyaltyField(constants.LoyaltyTransferMaxPerTransactionKey, &s.Transfer.MaxPerTransaction, 1),
|
||||
optionalIntLoyaltyField(constants.LoyaltyTransferDailyLimitKey, &s.Transfer.DailyLimit, 1),
|
||||
}
|
||||
fields = append(fields, expiryLoyaltyFields("loyalty.point.", &s.PointExpiry)...)
|
||||
return append(fields, expiryLoyaltyFields("loyalty.coin.", &s.CoinExpiry)...)
|
||||
}
|
||||
|
||||
func expiryLoyaltyFields(prefix string, s *models.LoyaltyExpirySettings) []loyaltyField {
|
||||
return []loyaltyField{
|
||||
boolLoyaltyField(prefix+constants.LoyaltyExpiryEnabledSuffix, &s.Enabled, false),
|
||||
intLoyaltyField(prefix+constants.LoyaltyExpiryPeriodSuffix, &s.Period, constants.LoyaltyExpiryPeriodDefault, 1, noLoyaltyMax),
|
||||
enumLoyaltyField(prefix+constants.LoyaltyExpiryUnitSuffix, &s.Unit, constants.LoyaltyExpiryUnitDefault,
|
||||
constants.LoyaltyExpiryUnitDay, constants.LoyaltyExpiryUnitMonth),
|
||||
boolLoyaltyField(prefix+constants.LoyaltyExpiryEndOfMonthSuffix, &s.EndOfMonth, false),
|
||||
intLoyaltyField(prefix+constants.LoyaltyExpiryReminderDaysSuffix, &s.ReminderDays, constants.LoyaltyExpiryReminderDaysDefault, 0, noLoyaltyMax),
|
||||
}
|
||||
}
|
||||
|
||||
// loyaltyField is one setting key bound to the struct field that holds it. The same
|
||||
// description drives reading (parse or default), validating, and writing (format).
|
||||
type loyaltyField struct {
|
||||
key string
|
||||
// parse sets the field from a stored value, or reports it unusable.
|
||||
parse func(raw string) bool
|
||||
// reset sets the field to its default.
|
||||
reset func()
|
||||
// validate checks the field's current value.
|
||||
validate func() error
|
||||
// format is the field's current value as stored; nil means no stored value.
|
||||
format func() *string
|
||||
}
|
||||
|
||||
const noLoyaltyMax = int64(-1)
|
||||
|
||||
func intLoyaltyField(key string, v *int64, def, min, max int64) loyaltyField {
|
||||
check := func(n int64) error {
|
||||
if n < min {
|
||||
return fmt.Errorf("%w: %s must be at least %d", ErrInvalidLoyaltySettings, key, min)
|
||||
}
|
||||
if max != noLoyaltyMax && n > max {
|
||||
return fmt.Errorf("%w: %s must be at most %d", ErrInvalidLoyaltySettings, key, max)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return loyaltyField{
|
||||
key: key,
|
||||
parse: func(raw string) bool {
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil || check(n) != nil {
|
||||
return false
|
||||
}
|
||||
*v = n
|
||||
return true
|
||||
},
|
||||
reset: func() { *v = def },
|
||||
validate: func() error { return check(*v) },
|
||||
format: func() *string { s := strconv.FormatInt(*v, 10); return &s },
|
||||
}
|
||||
}
|
||||
|
||||
// optionalIntLoyaltyField is a limit that may be unset, meaning no limit.
|
||||
func optionalIntLoyaltyField(key string, v **int64, min int64) loyaltyField {
|
||||
return loyaltyField{
|
||||
key: key,
|
||||
parse: func(raw string) bool {
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil || n < min {
|
||||
return false
|
||||
}
|
||||
*v = &n
|
||||
return true
|
||||
},
|
||||
reset: func() { *v = nil },
|
||||
validate: func() error {
|
||||
if *v != nil && **v < min {
|
||||
return fmt.Errorf("%w: %s must be at least %d, or empty for no limit", ErrInvalidLoyaltySettings, key, min)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
format: func() *string {
|
||||
if *v == nil {
|
||||
return nil
|
||||
}
|
||||
s := strconv.FormatInt(**v, 10)
|
||||
return &s
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func boolLoyaltyField(key string, v *bool, def bool) loyaltyField {
|
||||
return loyaltyField{
|
||||
key: key,
|
||||
parse: func(raw string) bool {
|
||||
b, err := strconv.ParseBool(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
*v = b
|
||||
return true
|
||||
},
|
||||
reset: func() { *v = def },
|
||||
validate: func() error { return nil },
|
||||
format: func() *string { s := strconv.FormatBool(*v); return &s },
|
||||
}
|
||||
}
|
||||
|
||||
func enumLoyaltyField(key string, v *string, def string, allowed ...string) loyaltyField {
|
||||
valid := func(s string) bool {
|
||||
for _, a := range allowed {
|
||||
if s == a {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return loyaltyField{
|
||||
key: key,
|
||||
parse: func(raw string) bool {
|
||||
s := strings.ToUpper(strings.TrimSpace(raw))
|
||||
if !valid(s) {
|
||||
return false
|
||||
}
|
||||
*v = s
|
||||
return true
|
||||
},
|
||||
reset: func() { *v = def },
|
||||
validate: func() error {
|
||||
if !valid(*v) {
|
||||
return fmt.Errorf("%w: %s must be one of %s", ErrInvalidLoyaltySettings, key, strings.Join(allowed, ", "))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
format: func() *string { s := *v; return &s },
|
||||
}
|
||||
}
|
||||
|
||||
// loadLoyaltyFields fills each field from its stored value, or its default when there
|
||||
// is none. A stored value that cannot be used also falls back to the default, so a bad
|
||||
// row can never reach a calculation, and it is logged so it gets fixed.
|
||||
func loadLoyaltyFields(fields []loyaltyField, raw map[string]string, scope string) {
|
||||
for _, f := range fields {
|
||||
f.reset()
|
||||
value, ok := raw[f.key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !f.parse(value) {
|
||||
f.reset()
|
||||
logger.NonContext.Warnf("Loyalty setting %s of %s has an unusable value %q; using the default", f.key, scope, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateLoyaltyFields(fields []loyaltyField) error {
|
||||
for _, f := range fields {
|
||||
if err := f.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func equalOptionalStrings(a, b *string) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
// diffLoyaltyFields lists the keys whose effective value differs between current and
|
||||
// next, with the stored old value (nil when the key was on its default). current and
|
||||
// next list the same keys in the same order.
|
||||
func diffLoyaltyFields(organizationID uuid.UUID, outletID *uuid.UUID, changedBy uuid.UUID, raw map[string]string, current, next []loyaltyField) []entities.LoyaltySettingChange {
|
||||
var rows []entities.LoyaltySettingChange
|
||||
for i, field := range next {
|
||||
newValue := field.format()
|
||||
if equalOptionalStrings(current[i].format(), newValue) {
|
||||
continue
|
||||
}
|
||||
var oldValue *string
|
||||
if v, ok := raw[field.key]; ok {
|
||||
oldValue = &v
|
||||
}
|
||||
rows = append(rows, entities.LoyaltySettingChange{
|
||||
OrganizationID: organizationID,
|
||||
OutletID: outletID,
|
||||
Key: field.key,
|
||||
OldValue: oldValue,
|
||||
NewValue: newValue,
|
||||
ChangedBy: changedBy,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// PreviewOrganization validates new organization settings and lists what saving them
|
||||
// would change, without writing anything.
|
||||
func (p *LoyaltySettingsProcessor) PreviewOrganization(ctx context.Context, organizationID uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, error) {
|
||||
if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := p.repo.GetOrganizationValues(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var current models.OrganizationLoyaltySettings
|
||||
loadLoyaltyFields(organizationLoyaltyFields(¤t), raw, "organization "+organizationID.String())
|
||||
rows := diffLoyaltyFields(organizationID, nil, uuid.Nil, raw, organizationLoyaltyFields(¤t), organizationLoyaltyFields(&settings))
|
||||
changes := make([]models.LoyaltySettingChange, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
changes = append(changes, loyaltySettingChangeModel(row))
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Some processors log; the server sets the logger up at startup.
|
||||
logger.Setup("fatal", "json")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// loyaltyRepoFake keeps settings in maps. It does not check for a transaction.
|
||||
type loyaltyRepoFake struct {
|
||||
outletOrg map[uuid.UUID]uuid.UUID
|
||||
outletValues map[string]string
|
||||
orgValues map[string]string
|
||||
changes []entities.LoyaltySettingChange
|
||||
locks []uuid.UUID
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) GetOutletValues(context.Context, uuid.UUID) (map[string]string, error) {
|
||||
return copyStringMap(f.outletValues), nil
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) GetOrganizationValues(context.Context, uuid.UUID) (map[string]string, error) {
|
||||
return copyStringMap(f.orgValues), nil
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) OutletOrganizationID(_ context.Context, outletID uuid.UUID) (uuid.UUID, error) {
|
||||
org, ok := f.outletOrg[outletID]
|
||||
if !ok {
|
||||
return uuid.Nil, repository.ErrLoyaltyOutletNotFound
|
||||
}
|
||||
return org, nil
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) LockScope(_ context.Context, id uuid.UUID) error {
|
||||
f.locks = append(f.locks, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) SetOutletValue(_ context.Context, _ uuid.UUID, key string, value *string) error {
|
||||
f.outletValues = setOptional(f.outletValues, key, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) SetOrganizationValue(_ context.Context, _ uuid.UUID, key string, value *string) error {
|
||||
f.orgValues = setOptional(f.orgValues, key, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) InsertChanges(_ context.Context, changes []entities.LoyaltySettingChange) error {
|
||||
f.changes = append(f.changes, changes...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) ListChanges(context.Context, repository.LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) {
|
||||
return f.changes, int64(len(f.changes)), nil
|
||||
}
|
||||
|
||||
func copyStringMap(m map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func setOptional(m map[string]string, key string, value *string) map[string]string {
|
||||
if m == nil {
|
||||
m = map[string]string{}
|
||||
}
|
||||
if value == nil {
|
||||
delete(m, key)
|
||||
} else {
|
||||
m[key] = *value
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// txRunnerFake runs fn in place.
|
||||
type txRunnerFake struct{}
|
||||
|
||||
func (txRunnerFake) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
func TestLoyaltySettings_OutletWithoutSettingsGetsEveryDefault(t *testing.T) {
|
||||
p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{})
|
||||
s, err := p.Outlet(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, models.OutletLoyaltySettings{
|
||||
Point: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 100, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||
Coin: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 25000, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||
PointPayment: models.LoyaltyPointPaymentSettings{AcceptPayment: false, MinPaymentPoints: 1, MaxPaymentPercent: 100},
|
||||
}, *s)
|
||||
}
|
||||
|
||||
func TestLoyaltySettings_OrganizationWithoutSettingsGetsEveryDefault(t *testing.T) {
|
||||
p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{})
|
||||
s, err := p.Organization(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
expiry := models.LoyaltyExpirySettings{Enabled: false, Period: 12, Unit: "MONTH", EndOfMonth: false, ReminderDays: 7}
|
||||
assert.Equal(t, models.OrganizationLoyaltySettings{
|
||||
PointValue: 1,
|
||||
Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1},
|
||||
Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1},
|
||||
PointExpiry: expiry,
|
||||
CoinExpiry: expiry,
|
||||
}, *s)
|
||||
}
|
||||
|
||||
func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
|
||||
repo := &loyaltyRepoFake{
|
||||
outletValues: map[string]string{
|
||||
constants.LoyaltyPointEnabledKey: "true",
|
||||
constants.LoyaltyPointEarnPerAmountKey: " 1000 ",
|
||||
constants.LoyaltyPointMaxPerOrderKey: "500",
|
||||
constants.LoyaltyPointMaxPaymentPercentKey: "50",
|
||||
"loyalty.unknown": "ignored",
|
||||
},
|
||||
orgValues: map[string]string{
|
||||
constants.LoyaltyPointValueKey: "100",
|
||||
constants.LoyaltyTransferDailyLimitKey: "10000",
|
||||
"loyalty.coin.expiry_unit": "day",
|
||||
},
|
||||
}
|
||||
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
|
||||
|
||||
outlet, err := p.Outlet(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, outlet.Point.Enabled)
|
||||
assert.Equal(t, int64(1000), outlet.Point.EarnPerAmount)
|
||||
assert.Equal(t, int64(500), *outlet.Point.MaxPerOrder)
|
||||
assert.Equal(t, int64(50), outlet.PointPayment.MaxPaymentPercent)
|
||||
|
||||
org, err := p.Organization(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(100), org.PointValue)
|
||||
assert.Equal(t, int64(10000), *org.Transfer.DailyLimit)
|
||||
assert.Equal(t, "DAY", org.CoinExpiry.Unit)
|
||||
assert.Equal(t, "MONTH", org.PointExpiry.Unit)
|
||||
}
|
||||
|
||||
// A bad row must never reach a calculation: earn_per_amount 0 would divide by zero.
|
||||
func TestLoyaltySettings_UnusableStoredValuesFallBackToDefault(t *testing.T) {
|
||||
repo := &loyaltyRepoFake{
|
||||
outletValues: map[string]string{
|
||||
constants.LoyaltyPointEnabledKey: "yes please",
|
||||
constants.LoyaltyPointEarnPerAmountKey: "0",
|
||||
constants.LoyaltyCoinEarnValueKey: "-1",
|
||||
constants.LoyaltyPointMaxPerOrderKey: "abc",
|
||||
constants.LoyaltyPointMaxPaymentPercentKey: "150",
|
||||
},
|
||||
orgValues: map[string]string{
|
||||
constants.LoyaltyPointValueKey: "0",
|
||||
"loyalty.point.expiry_unit": "YEAR",
|
||||
"loyalty.point.expiry_period": "",
|
||||
},
|
||||
}
|
||||
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
|
||||
|
||||
outlet, err := p.Outlet(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.False(t, outlet.Point.Enabled)
|
||||
assert.Equal(t, int64(100), outlet.Point.EarnPerAmount)
|
||||
assert.Equal(t, int64(1), outlet.Coin.EarnValue)
|
||||
assert.Nil(t, outlet.Point.MaxPerOrder)
|
||||
assert.Equal(t, int64(100), outlet.PointPayment.MaxPaymentPercent)
|
||||
|
||||
for name, raw := range map[string]string{"not set": "", "garbage": "abc", "zero": "0", "negative": "-5"} {
|
||||
repo.orgValues = map[string]string{}
|
||||
if raw != "" {
|
||||
repo.orgValues[constants.LoyaltyPointValueKey] = raw
|
||||
}
|
||||
value, err := p.PointValue(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.LoyaltyPointValueDefault, value, name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoyaltySettings_UpdateRecordsOnlyRealChanges(t *testing.T) {
|
||||
org, outlet, admin := uuid.New(), uuid.New(), uuid.New()
|
||||
repo := &loyaltyRepoFake{
|
||||
outletOrg: map[uuid.UUID]uuid.UUID{outlet: org},
|
||||
outletValues: map[string]string{constants.LoyaltyPointEarnPerAmountKey: "200"},
|
||||
}
|
||||
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
|
||||
ctx := context.Background()
|
||||
|
||||
settings, err := p.Outlet(ctx, outlet)
|
||||
require.NoError(t, err)
|
||||
settings.Point.Enabled = true // default false -> true
|
||||
settings.Point.EarnPerAmount = 100 // stored 200 -> 100 (the default, but set explicitly)
|
||||
settings.Point.MaxPerOrder = ptr(int64(0)) // unset -> 0
|
||||
// Everything else stays on its default.
|
||||
|
||||
changes, err := p.UpdateOutlet(ctx, org, outlet, admin, *settings)
|
||||
require.NoError(t, err)
|
||||
byKey := map[string]models.LoyaltySettingChange{}
|
||||
for _, c := range changes {
|
||||
byKey[c.Key] = c
|
||||
assert.Equal(t, admin, c.ChangedBy)
|
||||
assert.Equal(t, org, c.OrganizationID)
|
||||
assert.Equal(t, &outlet, c.OutletID)
|
||||
}
|
||||
require.Len(t, byKey, 3, "only the three keys that changed")
|
||||
assert.Nil(t, byKey[constants.LoyaltyPointEnabledKey].OldValue, "was on its default")
|
||||
assert.Equal(t, "true", *byKey[constants.LoyaltyPointEnabledKey].NewValue)
|
||||
assert.Equal(t, "200", *byKey[constants.LoyaltyPointEarnPerAmountKey].OldValue)
|
||||
assert.Equal(t, "100", *byKey[constants.LoyaltyPointEarnPerAmountKey].NewValue)
|
||||
assert.Equal(t, "0", *byKey[constants.LoyaltyPointMaxPerOrderKey].NewValue)
|
||||
assert.Len(t, repo.changes, 3)
|
||||
assert.Equal(t, []uuid.UUID{outlet}, repo.locks)
|
||||
|
||||
after, err := p.Outlet(ctx, outlet)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, *settings, *after)
|
||||
|
||||
// Saving the same settings again changes nothing and records nothing.
|
||||
changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *after)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, changes)
|
||||
assert.Len(t, repo.changes, 3)
|
||||
|
||||
// Clearing a limit removes the stored value, recorded as a change to nothing.
|
||||
after.Point.MaxPerOrder = nil
|
||||
changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *after)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, changes, 1)
|
||||
assert.Equal(t, "0", *changes[0].OldValue)
|
||||
assert.Nil(t, changes[0].NewValue)
|
||||
assert.NotContains(t, repo.outletValues, constants.LoyaltyPointMaxPerOrderKey)
|
||||
}
|
||||
|
||||
func TestLoyaltySettings_UpdateOrganization(t *testing.T) {
|
||||
org, admin := uuid.New(), uuid.New()
|
||||
repo := &loyaltyRepoFake{}
|
||||
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
|
||||
ctx := context.Background()
|
||||
|
||||
settings, err := p.Organization(ctx, org)
|
||||
require.NoError(t, err)
|
||||
settings.PointValue = 100
|
||||
settings.CoinExpiry.Enabled = true
|
||||
settings.CoinExpiry.Unit = constants.LoyaltyExpiryUnitDay
|
||||
|
||||
changes, err := p.UpdateOrganization(ctx, org, admin, *settings)
|
||||
require.NoError(t, err)
|
||||
keys := []string{}
|
||||
for _, c := range changes {
|
||||
keys = append(keys, c.Key)
|
||||
assert.Nil(t, c.OutletID, "organization settings have no outlet")
|
||||
}
|
||||
assert.ElementsMatch(t, []string{constants.LoyaltyPointValueKey, "loyalty.coin.expiry_enabled", "loyalty.coin.expiry_unit"}, keys)
|
||||
|
||||
value, err := p.PointValue(ctx, org)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(100), value)
|
||||
}
|
||||
|
||||
func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
|
||||
org, outlet := uuid.New(), uuid.New()
|
||||
repo := &loyaltyRepoFake{outletOrg: map[uuid.UUID]uuid.UUID{outlet: org}}
|
||||
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
|
||||
ctx := context.Background()
|
||||
|
||||
for name, mutate := range map[string]func(*models.OutletLoyaltySettings){
|
||||
"earn_per_amount 0": func(s *models.OutletLoyaltySettings) { s.Point.EarnPerAmount = 0 },
|
||||
"negative earn_value": func(s *models.OutletLoyaltySettings) { s.Coin.EarnValue = -1 },
|
||||
"negative min_order": func(s *models.OutletLoyaltySettings) { s.Point.MinOrderAmount = -1 },
|
||||
"negative max_per_order": func(s *models.OutletLoyaltySettings) { s.Coin.MaxPerOrder = ptr(int64(-1)) },
|
||||
"payment percent over 100": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = 101 },
|
||||
"negative payment percent": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = -1 },
|
||||
"min payment points 0": func(s *models.OutletLoyaltySettings) { s.PointPayment.MinPaymentPoints = 0 },
|
||||
} {
|
||||
s, err := p.Outlet(ctx, outlet)
|
||||
require.NoError(t, err)
|
||||
mutate(s)
|
||||
_, err = p.UpdateOutlet(ctx, org, outlet, uuid.New(), *s)
|
||||
assert.ErrorIs(t, err, ErrInvalidLoyaltySettings, name)
|
||||
}
|
||||
|
||||
for name, mutate := range map[string]func(*models.OrganizationLoyaltySettings){
|
||||
"point value 0": func(s *models.OrganizationLoyaltySettings) { s.PointValue = 0 },
|
||||
"exchange coin amount 0": func(s *models.OrganizationLoyaltySettings) { s.Exchange.CoinAmount = 0 },
|
||||
"transfer min 0": func(s *models.OrganizationLoyaltySettings) { s.Transfer.MinAmount = 0 },
|
||||
"daily limit 0": func(s *models.OrganizationLoyaltySettings) { s.Transfer.DailyLimit = ptr(int64(0)) },
|
||||
"expiry period 0": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.Period = 0 },
|
||||
"expiry unit YEAR": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.Unit = "YEAR" },
|
||||
"negative reminder days": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.ReminderDays = -1 },
|
||||
} {
|
||||
s, err := p.Organization(ctx, org)
|
||||
require.NoError(t, err)
|
||||
mutate(s)
|
||||
_, err = p.UpdateOrganization(ctx, org, uuid.New(), *s)
|
||||
assert.ErrorIs(t, err, ErrInvalidLoyaltySettings, name)
|
||||
}
|
||||
assert.Empty(t, repo.changes, "nothing is written when a value is invalid")
|
||||
assert.Empty(t, repo.locks, "invalid input is rejected before touching the database")
|
||||
|
||||
// An outlet of another organization.
|
||||
s, err := p.Outlet(ctx, outlet)
|
||||
require.NoError(t, err)
|
||||
_, err = p.UpdateOutlet(ctx, uuid.New(), outlet, uuid.New(), *s)
|
||||
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
|
||||
_, err = p.UpdateOutlet(ctx, org, uuid.New(), uuid.New(), *s)
|
||||
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// These fakes embed the interface they stand for and implement only what the paths
|
||||
// under test call; anything else would panic, which would show a path doing more than
|
||||
// expected.
|
||||
type hookOrderRepo struct {
|
||||
OrderRepository
|
||||
order *entities.Order
|
||||
statusUpdates int
|
||||
hookCallsAtPay int // hook calls seen when the status was written
|
||||
hook *orderPaidHookFake
|
||||
}
|
||||
|
||||
func (r *hookOrderRepo) GetByID(context.Context, uuid.UUID) (*entities.Order, error) {
|
||||
o := *r.order
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func (r *hookOrderRepo) GetWithRelations(context.Context, uuid.UUID) (*entities.Order, error) {
|
||||
o := *r.order
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func (r *hookOrderRepo) UpdateStatusSuccess(_ context.Context, _ uuid.UUID, status entities.OrderStatus, payment entities.PaymentStatus) error {
|
||||
r.statusUpdates++
|
||||
r.hookCallsAtPay = len(r.hook.calls)
|
||||
r.order.Status, r.order.PaymentStatus = status, payment
|
||||
return nil
|
||||
}
|
||||
|
||||
type hookPaymentRepo struct {
|
||||
PaymentRepository
|
||||
created []*entities.Payment
|
||||
}
|
||||
|
||||
func (r *hookPaymentRepo) GetTotalPaidByOrderID(context.Context, uuid.UUID) (float64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (r *hookPaymentRepo) Create(_ context.Context, p *entities.Payment) error {
|
||||
p.ID = uuid.New()
|
||||
r.created = append(r.created, p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *hookPaymentRepo) GetByID(_ context.Context, id uuid.UUID) (*entities.Payment, error) {
|
||||
for _, p := range r.created {
|
||||
if p.ID == id {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *hookPaymentRepo) GetByOrderID(context.Context, uuid.UUID) ([]*entities.Payment, error) {
|
||||
return r.created, nil
|
||||
}
|
||||
|
||||
type hookPaymentMethodRepo struct{}
|
||||
|
||||
func (hookPaymentMethodRepo) GetByID(_ context.Context, id uuid.UUID) (*entities.PaymentMethod, error) {
|
||||
return &entities.PaymentMethod{ID: id}, nil
|
||||
}
|
||||
|
||||
type hookOrderItemRepo struct{ OrderItemRepository }
|
||||
|
||||
func (hookOrderItemRepo) GetByOrderID(context.Context, uuid.UUID) ([]*entities.OrderItem, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// splitFake settles the order on the payment that covers what is left, as the real
|
||||
// split bill processor does.
|
||||
type splitFake struct{ settle bool }
|
||||
|
||||
func (f splitFake) split(order *entities.Order) (*models.SplitBillResponse, error) {
|
||||
if f.settle {
|
||||
order.PaymentStatus = entities.PaymentStatusCompleted
|
||||
} else {
|
||||
order.PaymentStatus = entities.PaymentStatusPartial
|
||||
}
|
||||
return &models.SplitBillResponse{OrderID: order.ID}, nil
|
||||
}
|
||||
|
||||
func (f splitFake) SplitByAmount(_ context.Context, _ *models.SplitBillRequest, order *entities.Order, _ *entities.PaymentMethod, _ *entities.Customer) (*models.SplitBillResponse, error) {
|
||||
return f.split(order)
|
||||
}
|
||||
|
||||
func (f splitFake) SplitByItem(_ context.Context, _ *models.SplitBillRequest, order *entities.Order, _ *entities.PaymentMethod, _ *entities.Customer) (*models.SplitBillResponse, error) {
|
||||
return f.split(order)
|
||||
}
|
||||
|
||||
type orderPaidHookFake struct {
|
||||
calls []uuid.UUID
|
||||
ctxs []context.Context
|
||||
refunded []uuid.UUID
|
||||
earned map[uuid.UUID]OrderEarned
|
||||
}
|
||||
|
||||
func (h *orderPaidHookFake) OnOrderPaid(ctx context.Context, orderID uuid.UUID) {
|
||||
h.calls = append(h.calls, orderID)
|
||||
h.ctxs = append(h.ctxs, ctx)
|
||||
}
|
||||
|
||||
func (h *orderPaidHookFake) OnOrderRefunded(_ context.Context, orderID uuid.UUID) {
|
||||
h.refunded = append(h.refunded, orderID)
|
||||
}
|
||||
|
||||
func (h *orderPaidHookFake) EarnedByOrders(context.Context, []uuid.UUID) (map[uuid.UUID]OrderEarned, error) {
|
||||
return h.earned, nil
|
||||
}
|
||||
|
||||
func newHookedOrderProcessor(split SplitBillProcessor) (*OrderProcessorImpl, *hookOrderRepo, *orderPaidHookFake) {
|
||||
hook := &orderPaidHookFake{}
|
||||
orders := &hookOrderRepo{
|
||||
order: &entities.Order{ID: uuid.New(), OrganizationID: uuid.New(), OutletID: uuid.New(), TotalAmount: 100000, PaymentStatus: entities.PaymentStatusPending},
|
||||
hook: hook,
|
||||
}
|
||||
p := &OrderProcessorImpl{
|
||||
orderRepo: orders,
|
||||
orderItemRepo: hookOrderItemRepo{},
|
||||
paymentRepo: &hookPaymentRepo{},
|
||||
paymentMethodRepo: hookPaymentMethodRepo{},
|
||||
splitBillProcessor: split,
|
||||
txManager: repository.NewTxManager(nil),
|
||||
}
|
||||
p.SetLoyalty(hook)
|
||||
return p, orders, hook
|
||||
}
|
||||
|
||||
func TestOrderPaidHook_CreatePayment(t *testing.T) {
|
||||
p, orders, hook := newHookedOrderProcessor(nil)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
_, err := p.CreatePayment(ctx, &models.CreatePaymentRequest{OrderID: orders.order.ID, PaymentMethodID: uuid.New(), Amount: 100000})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
|
||||
assert.Zero(t, orders.hookCallsAtPay, "the hook runs after the payment, not inside its transaction")
|
||||
|
||||
// The hook's context outlives the request.
|
||||
cancel()
|
||||
assert.NoError(t, hook.ctxs[0].Err())
|
||||
}
|
||||
|
||||
func TestOrderPaidHook_UpdateOrder(t *testing.T) {
|
||||
p, orders, hook := newHookedOrderProcessor(nil)
|
||||
_, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
|
||||
assert.Equal(t, 1, orders.statusUpdates)
|
||||
assert.Zero(t, orders.hookCallsAtPay)
|
||||
}
|
||||
|
||||
func TestOrderPaidHook_SplitBillOnlyOnTheSettlingPayment(t *testing.T) {
|
||||
for _, splitType := range []string{"AMOUNT", "ITEM"} {
|
||||
t.Run(splitType, func(t *testing.T) {
|
||||
req := &models.SplitBillRequest{Type: splitType, PaymentMethodID: uuid.New()}
|
||||
|
||||
p, orders, hook := newHookedOrderProcessor(splitFake{settle: false})
|
||||
req.OrderID = orders.order.ID
|
||||
_, err := p.SplitBill(context.Background(), req)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, hook.calls, "a partial split payment does not make the order paid")
|
||||
|
||||
p, orders, hook = newHookedOrderProcessor(splitFake{settle: true})
|
||||
req.OrderID = orders.order.ID
|
||||
_, err = p.SplitBill(context.Background(), req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderPaidHook_NoHookIsFine(t *testing.T) {
|
||||
p, orders, _ := newHookedOrderProcessor(nil)
|
||||
p.SetLoyalty(nil)
|
||||
_, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func (r *hookOrderRepo) VoidOrderWithStatus(_ context.Context, _ uuid.UUID, status entities.OrderStatus, _ string, _ uuid.UUID) error {
|
||||
r.order.Status, r.order.IsVoid = status, true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *hookOrderRepo) Update(_ context.Context, o *entities.Order) error {
|
||||
r.order.RefundAmount = o.RefundAmount
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *hookOrderRepo) RefundOrder(context.Context, uuid.UUID, string, uuid.UUID) error {
|
||||
r.order.IsRefund = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *hookPaymentRepo) RefundPayment(_ context.Context, id uuid.UUID, amount float64, _ string, _ uuid.UUID) error {
|
||||
for _, p := range r.created {
|
||||
if p.ID == id {
|
||||
p.RefundAmount = amount
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestOrderRefundedHook(t *testing.T) {
|
||||
t.Run("void", func(t *testing.T) {
|
||||
p, orders, hook := newHookedOrderProcessor(nil)
|
||||
require.NoError(t, p.VoidOrder(context.Background(), &models.VoidOrderRequest{OrderID: orders.order.ID, Type: "ALL", Reason: "salah input"}, uuid.New()))
|
||||
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.refunded)
|
||||
})
|
||||
|
||||
t.Run("refund order", func(t *testing.T) {
|
||||
p, orders, hook := newHookedOrderProcessor(nil)
|
||||
orders.order.PaymentStatus = entities.PaymentStatusCompleted
|
||||
amount := 25000.0
|
||||
require.NoError(t, p.RefundOrder(context.Background(), orders.order.ID, &models.RefundOrderRequest{RefundAmount: &amount}, uuid.New()))
|
||||
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.refunded)
|
||||
})
|
||||
|
||||
t.Run("refund payment", func(t *testing.T) {
|
||||
p, orders, hook := newHookedOrderProcessor(nil)
|
||||
payments := p.paymentRepo.(*hookPaymentRepo)
|
||||
payment := &entities.Payment{ID: uuid.New(), OrderID: orders.order.ID, Amount: 50000, Status: entities.PaymentTransactionStatusCompleted}
|
||||
payments.created = append(payments.created, payment)
|
||||
require.NoError(t, p.RefundPayment(context.Background(), payment.ID, 20000, "kembali", uuid.New()))
|
||||
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.refunded)
|
||||
|
||||
// A refund that fails does not run the hook.
|
||||
assert.Error(t, p.RefundPayment(context.Background(), payment.ID, 60000, "terlalu besar", uuid.New()))
|
||||
assert.Len(t, hook.refunded, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *hookOrderRepo) List(context.Context, map[string]interface{}, int, int) ([]*entities.Order, int64, error) {
|
||||
o := *r.order
|
||||
other := entities.Order{ID: uuid.New()}
|
||||
return []*entities.Order{&o, &other}, 2, nil
|
||||
}
|
||||
|
||||
func TestOrderResponsesCarryEarnings(t *testing.T) {
|
||||
p, orders, hook := newHookedOrderProcessor(nil)
|
||||
hook.earned = map[uuid.UUID]OrderEarned{orders.order.ID: {Points: 875, Coins: 3}}
|
||||
|
||||
got, err := p.GetOrderByID(context.Background(), orders.order.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(875), got.PointsEarned)
|
||||
assert.Equal(t, int64(3), got.CoinsEarned)
|
||||
|
||||
list, err := p.ListOrders(context.Background(), &models.ListOrdersRequest{Page: 1, Limit: 10})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list.Orders, 2)
|
||||
assert.Equal(t, int64(875), list.Orders[0].PointsEarned)
|
||||
assert.Equal(t, int64(0), list.Orders[1].PointsEarned, "an order that earned nothing shows 0")
|
||||
|
||||
// Without the loyalty program the fields stay at 0.
|
||||
p.SetLoyalty(nil)
|
||||
got, err = p.GetOrderByID(context.Background(), orders.order.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), got.PointsEarned)
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
@@ -108,6 +110,78 @@ type OrderProcessorImpl struct {
|
||||
ingredientRepo IngredientRepository
|
||||
inventoryMovementService InventoryMovementService
|
||||
productOutletPriceRepo repository.ProductOutletPriceRepository
|
||||
loyalty OrderLoyalty
|
||||
}
|
||||
|
||||
// OrderLoyalty is what the order flow tells and asks the loyalty program
|
||||
// (docs/prd-point-coin.md F3, F10). EarningProcessor is one.
|
||||
type OrderLoyalty interface {
|
||||
// OnOrderPaid runs once an order has just become fully paid and the payment has
|
||||
// committed.
|
||||
OnOrderPaid(ctx context.Context, orderID uuid.UUID)
|
||||
// OnOrderRefunded runs once an order has been voided or (partly) refunded and that
|
||||
// has committed.
|
||||
OnOrderRefunded(ctx context.Context, orderID uuid.UUID)
|
||||
// EarnedByOrders returns what each order earned.
|
||||
EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]OrderEarned, error)
|
||||
}
|
||||
|
||||
// OrderEarned is what one order earned, as written in its EARN rows.
|
||||
type OrderEarned struct {
|
||||
Points int64
|
||||
Coins int64
|
||||
}
|
||||
|
||||
// SetLoyalty connects the order flow to the loyalty program.
|
||||
func (p *OrderProcessorImpl) SetLoyalty(loyalty OrderLoyalty) {
|
||||
p.loyalty = loyalty
|
||||
}
|
||||
|
||||
// onOrderPaid is the single place every path that completes an order's payment goes
|
||||
// through: UpdateOrder, CreatePayment and both kinds of split bill. It must be called
|
||||
// after the payment has committed. The hook runs detached from the caller's
|
||||
// transaction and from the request being cancelled, and anything it does cannot fail
|
||||
// the payment.
|
||||
func (p *OrderProcessorImpl) onOrderPaid(ctx context.Context, orderID uuid.UUID) {
|
||||
if p.loyalty == nil {
|
||||
return
|
||||
}
|
||||
p.loyalty.OnOrderPaid(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID)
|
||||
}
|
||||
|
||||
// onOrderRefunded is called at the end of VoidOrder, RefundOrder and RefundPayment,
|
||||
// after their writes have committed, with the same guarantees as onOrderPaid: it can
|
||||
// never block or fail the void or refund.
|
||||
func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) {
|
||||
if p.loyalty == nil {
|
||||
return
|
||||
}
|
||||
p.loyalty.OnOrderRefunded(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID)
|
||||
}
|
||||
|
||||
// attachEarnings fills points_earned and coins_earned (F3). A failure is logged and
|
||||
// leaves them at zero rather than failing the order read.
|
||||
func (p *OrderProcessorImpl) attachEarnings(ctx context.Context, responses ...*models.OrderResponse) {
|
||||
if p.loyalty == nil || len(responses) == 0 {
|
||||
return
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(responses))
|
||||
for _, r := range responses {
|
||||
if r != nil {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
}
|
||||
earned, err := p.loyalty.EarnedByOrders(repository.DetachTransaction(ctx), ids)
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("OrderProcessorImpl::attachEarnings -> failed to read earnings")
|
||||
return
|
||||
}
|
||||
for _, r := range responses {
|
||||
if r != nil {
|
||||
e := earned[r.ID]
|
||||
r.PointsEarned, r.CoinsEarned = e.Points, e.Coins
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewOrderProcessorImpl(
|
||||
@@ -149,6 +223,33 @@ func NewOrderProcessorImpl(
|
||||
}
|
||||
}
|
||||
|
||||
// resolveLineQuantity reconciles the requested quantity and weight against how the
|
||||
// product is sold, returning the values to store on the order line.
|
||||
//
|
||||
// For weight-based products a line is exactly one weighing, so quantity is pinned to 1
|
||||
// and the weighed amount is what gets priced. This mirrors
|
||||
// chk_order_items_weight_single_line in the database.
|
||||
func resolveLineQuantity(product *entities.Product, quantity int, weight *float64) (int, *float64, error) {
|
||||
if product.SellBy == constants.SellByWeight {
|
||||
if weight == nil {
|
||||
return 0, nil, fmt.Errorf("product %s is sold by weight and requires a weight", product.Name)
|
||||
}
|
||||
rounded := entities.RoundWeight(*weight)
|
||||
if rounded <= 0 {
|
||||
return 0, nil, fmt.Errorf("weight for product %s must be greater than 0", product.Name)
|
||||
}
|
||||
return 1, &rounded, nil
|
||||
}
|
||||
|
||||
if weight != nil {
|
||||
return 0, nil, fmt.Errorf("product %s is not sold by weight and must not carry a weight", product.Name)
|
||||
}
|
||||
if quantity < 1 {
|
||||
return 0, nil, fmt.Errorf("quantity for product %s must be at least 1", product.Name)
|
||||
}
|
||||
return quantity, nil, nil
|
||||
}
|
||||
|
||||
func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) {
|
||||
orderNumber, err := p.orderRepo.GetNextOrderNumber(ctx, organizationID, req.OutletID)
|
||||
if err != nil {
|
||||
@@ -194,25 +295,29 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
|
||||
}
|
||||
}
|
||||
|
||||
itemTotalPrice := float64(itemReq.Quantity) * unitPrice
|
||||
itemTotalCost := float64(itemReq.Quantity) * unitCost
|
||||
|
||||
subtotal += itemTotalPrice
|
||||
totalCost += itemTotalCost
|
||||
quantity, weight, err := resolveLineQuantity(product, itemReq.Quantity, itemReq.Weight)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orderItem := &entities.OrderItem{
|
||||
ProductID: itemReq.ProductID,
|
||||
ProductVariantID: itemReq.ProductVariantID,
|
||||
Quantity: itemReq.Quantity,
|
||||
Quantity: quantity,
|
||||
Weight: weight,
|
||||
UnitID: product.UnitID,
|
||||
UnitPrice: unitPrice,
|
||||
TotalPrice: itemTotalPrice,
|
||||
UnitCost: unitCost,
|
||||
TotalCost: itemTotalCost,
|
||||
Modifiers: entities.Modifiers(itemReq.Modifiers),
|
||||
Notes: itemReq.Notes,
|
||||
Metadata: entities.Metadata(itemReq.Metadata),
|
||||
Status: entities.OrderItemStatusPending,
|
||||
}
|
||||
orderItem.CalculateTotalPrice()
|
||||
orderItem.CalculateTotalCost()
|
||||
|
||||
subtotal += orderItem.TotalPrice
|
||||
totalCost += orderItem.TotalCost
|
||||
|
||||
orderItems = append(orderItems, orderItem)
|
||||
}
|
||||
@@ -265,6 +370,7 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
|
||||
}
|
||||
|
||||
response := mappers.OrderEntityToResponse(orderWithRelations)
|
||||
p.attachEarnings(ctx, response)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -327,26 +433,30 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
|
||||
}
|
||||
}
|
||||
|
||||
itemTotalPrice := float64(itemReq.Quantity) * unitPrice
|
||||
itemTotalCost := float64(itemReq.Quantity) * unitCost
|
||||
|
||||
newSubtotal += itemTotalPrice
|
||||
newTotalCost += itemTotalCost
|
||||
quantity, weight, err := resolveLineQuantity(product, itemReq.Quantity, itemReq.Weight)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orderItem := &entities.OrderItem{
|
||||
OrderID: orderID,
|
||||
ProductID: itemReq.ProductID,
|
||||
ProductVariantID: itemReq.ProductVariantID,
|
||||
Quantity: itemReq.Quantity,
|
||||
Quantity: quantity,
|
||||
Weight: weight,
|
||||
UnitID: product.UnitID,
|
||||
UnitPrice: unitPrice,
|
||||
TotalPrice: itemTotalPrice,
|
||||
UnitCost: unitCost,
|
||||
TotalCost: itemTotalCost,
|
||||
Modifiers: entities.Modifiers(itemReq.Modifiers),
|
||||
Notes: itemReq.Notes,
|
||||
Metadata: entities.Metadata(itemReq.Metadata),
|
||||
Status: entities.OrderItemStatusPending,
|
||||
}
|
||||
orderItem.CalculateTotalPrice()
|
||||
orderItem.CalculateTotalCost()
|
||||
|
||||
newSubtotal += orderItem.TotalPrice
|
||||
newTotalCost += orderItem.TotalCost
|
||||
|
||||
addedOrderItems = append(addedOrderItems, orderItem)
|
||||
}
|
||||
@@ -399,6 +509,7 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
|
||||
}
|
||||
|
||||
updatedOrderResponse := mappers.OrderEntityToResponse(orderWithRelations)
|
||||
p.attachEarnings(ctx, updatedOrderResponse)
|
||||
|
||||
return &models.AddToOrderResponse{
|
||||
OrderID: orderID,
|
||||
@@ -458,6 +569,7 @@ func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req
|
||||
if err := p.orderRepo.UpdateStatusSuccess(ctx, order.ID, order.Status, order.PaymentStatus); err != nil {
|
||||
return nil, fmt.Errorf("failed to update order: %w", err)
|
||||
}
|
||||
p.onOrderPaid(ctx, order.ID)
|
||||
|
||||
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, id)
|
||||
if err != nil {
|
||||
@@ -465,6 +577,7 @@ func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req
|
||||
}
|
||||
|
||||
response := mappers.OrderEntityToResponse(orderWithRelations)
|
||||
p.attachEarnings(ctx, response)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -475,6 +588,7 @@ func (p *OrderProcessorImpl) GetOrderByID(ctx context.Context, id uuid.UUID) (*m
|
||||
}
|
||||
|
||||
response := mappers.OrderEntityToResponse(order)
|
||||
p.attachEarnings(ctx, response)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -536,6 +650,11 @@ func (p *OrderProcessorImpl) ListOrders(ctx context.Context, req *models.ListOrd
|
||||
}
|
||||
}
|
||||
}
|
||||
listed := make([]*models.OrderResponse, len(orderResponses))
|
||||
for i := range orderResponses {
|
||||
listed[i] = &orderResponses[i]
|
||||
}
|
||||
p.attachEarnings(ctx, listed...)
|
||||
|
||||
totalPages := int(total) / req.Limit
|
||||
if int(total)%req.Limit > 0 {
|
||||
@@ -602,8 +721,9 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
|
||||
return fmt.Errorf("void quantity cannot exceed original quantity for item %d", itemVoid.OrderItemID)
|
||||
}
|
||||
|
||||
voidedAmount := float64(itemVoid.Quantity) * orderItem.UnitPrice
|
||||
voidedCost := float64(itemVoid.Quantity) * orderItem.UnitCost
|
||||
billable := orderItem.BillableQuantityFor(itemVoid.Quantity)
|
||||
voidedAmount := entities.RoundMoney(billable * orderItem.UnitPrice)
|
||||
voidedCost := entities.RoundMoney(billable * orderItem.UnitCost)
|
||||
|
||||
totalVoidedAmount += voidedAmount
|
||||
totalVoidedCost += voidedCost
|
||||
@@ -655,6 +775,10 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
|
||||
return fmt.Errorf("invalid void type: must be 'ALL' or 'ITEM'")
|
||||
}
|
||||
|
||||
// VoidOrder refuses fully paid orders today, so there is usually nothing to take
|
||||
// back; this keeps a void correct if that rule changes. The order repository writes
|
||||
// outside the caller's transaction, so the void is already committed here.
|
||||
p.onOrderRefunded(ctx, req.OrderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -720,7 +844,7 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
|
||||
return fmt.Errorf("refund quantity cannot exceed original quantity")
|
||||
}
|
||||
|
||||
refundAmount := float64(refundQuantity) * orderItem.UnitPrice
|
||||
refundAmount := entities.RoundMoney(orderItem.BillableQuantityFor(refundQuantity) * orderItem.UnitPrice)
|
||||
if itemRefund.RefundAmount != nil {
|
||||
refundAmount = *itemRefund.RefundAmount
|
||||
}
|
||||
@@ -750,6 +874,7 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
|
||||
}
|
||||
}
|
||||
|
||||
p.onOrderRefunded(ctx, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -781,6 +906,8 @@ func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.Crea
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Not from updateOrderStatus: that runs inside the payment's transaction.
|
||||
p.onOrderPaid(ctx, req.OrderID)
|
||||
|
||||
paymentWithRelations, err := p.paymentRepo.GetByID(ctx, payment.ID)
|
||||
if err != nil {
|
||||
@@ -805,7 +932,11 @@ func (p *OrderProcessorImpl) RefundPayment(ctx context.Context, paymentID uuid.U
|
||||
return fmt.Errorf("refund amount cannot exceed payment amount")
|
||||
}
|
||||
|
||||
return p.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment)
|
||||
if err := p.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment); err != nil {
|
||||
return err
|
||||
}
|
||||
p.onOrderRefunded(ctx, payment.OrderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *OrderProcessorImpl) CreatePaymentWithInventoryMovement(ctx context.Context, req *models.CreatePaymentRequest, order *entities.Order, totalPaid float64) (*entities.Payment, error) {
|
||||
@@ -1016,7 +1147,7 @@ func (p *OrderProcessorImpl) createInventoryMovement(ctx context.Context, item *
|
||||
PreviousQuantity: float64(updatedInventory.Quantity + item.Quantity), // Add back the quantity that was subtracted
|
||||
NewQuantity: float64(updatedInventory.Quantity),
|
||||
UnitCost: item.UnitCost,
|
||||
TotalCost: float64(item.Quantity) * item.UnitCost,
|
||||
TotalCost: entities.RoundMoney(item.BillableQuantity() * item.UnitCost),
|
||||
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
||||
t := entities.InventoryMovementReferenceTypePayment
|
||||
return &t
|
||||
@@ -1170,6 +1301,10 @@ func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBil
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Both split paths mark the order paid on the payment that settles it.
|
||||
if order.PaymentStatus == entities.PaymentStatusCompleted {
|
||||
p.onOrderPaid(ctx, order.ID)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -1314,7 +1449,7 @@ func (p *OrderProcessorImpl) prepareProductInventoryMovement(item *entities.Orde
|
||||
PreviousQuantity: float64(previousQuantity),
|
||||
NewQuantity: float64(updatedInventory.Quantity),
|
||||
UnitCost: item.UnitCost,
|
||||
TotalCost: float64(item.Quantity) * item.UnitCost,
|
||||
TotalCost: entities.RoundMoney(item.BillableQuantity() * item.UnitCost),
|
||||
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
||||
t := entities.InventoryMovementReferenceTypePayment
|
||||
return &t
|
||||
@@ -1380,7 +1515,7 @@ type ingredientRecipeItem struct {
|
||||
|
||||
// prepareIngredientRecipeItem prepares data for a single ingredient recipe without making database calls
|
||||
func (p *OrderProcessorImpl) prepareIngredientRecipeItem(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment) (*ingredientRecipeItem, error) {
|
||||
totalIngredientQuantity := recipe.Quantity * float64(item.Quantity)
|
||||
totalIngredientQuantity := recipe.Quantity * item.BillableQuantity()
|
||||
|
||||
currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID)
|
||||
if err != nil {
|
||||
@@ -1478,7 +1613,7 @@ func (p *OrderProcessorImpl) prepareRefundProductInventoryMovement(item *entitie
|
||||
PreviousQuantity: float64(previousQuantity),
|
||||
NewQuantity: float64(updatedInventory.Quantity),
|
||||
UnitCost: item.UnitCost,
|
||||
TotalCost: float64(refundedQuantity) * item.UnitCost,
|
||||
TotalCost: entities.RoundMoney(item.BillableQuantityFor(refundedQuantity) * item.UnitCost),
|
||||
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
||||
t := entities.InventoryMovementReferenceTypeRefund
|
||||
return &t
|
||||
@@ -1544,7 +1679,7 @@ func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeData(ctx context.Con
|
||||
// prepareRefundedIngredientRecipeItem prepares data for a single ingredient recipe restoration
|
||||
func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeItem(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment, refundRatio float64, reason string, refundedBy uuid.UUID, refundAmount float64) (*ingredientRecipeItem, error) {
|
||||
// Calculate total ingredient quantity needed based on order item quantity
|
||||
totalIngredientQuantity := recipe.Quantity * float64(item.Quantity)
|
||||
totalIngredientQuantity := recipe.Quantity * item.BillableQuantity()
|
||||
|
||||
// Get current ingredient details
|
||||
currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
func fPtr(v float64) *float64 { return &v }
|
||||
|
||||
func TestResolveLineQuantity(t *testing.T) {
|
||||
weightProduct := &entities.Product{Name: "Ikan Tude", SellBy: constants.SellByWeight}
|
||||
unitProduct := &entities.Product{Name: "Nasi Goreng", SellBy: constants.SellByUnit}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
product *entities.Product
|
||||
quantity int
|
||||
weight *float64
|
||||
wantQuantity int
|
||||
wantWeight *float64
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "weighed line is pinned to quantity 1",
|
||||
// Mirrors chk_order_items_weight_single_line: even if a client sends 3,
|
||||
// one weighing is one line.
|
||||
product: weightProduct, quantity: 3, weight: fPtr(4.2),
|
||||
wantQuantity: 1, wantWeight: fPtr(4.2),
|
||||
},
|
||||
{
|
||||
name: "weight is rounded to stored precision",
|
||||
product: weightProduct, quantity: 1, weight: fPtr(5.6004),
|
||||
wantQuantity: 1, wantWeight: fPtr(5.6),
|
||||
},
|
||||
{
|
||||
name: "weight product without a weight is rejected",
|
||||
product: weightProduct, quantity: 1, weight: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "weight that rounds away to zero is rejected",
|
||||
product: weightProduct, quantity: 1, weight: fPtr(0.0004),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unit line keeps its quantity and carries no weight",
|
||||
product: unitProduct, quantity: 3, weight: nil,
|
||||
wantQuantity: 3, wantWeight: nil,
|
||||
},
|
||||
{
|
||||
name: "weight on a unit product is rejected",
|
||||
// Otherwise the line would silently be priced by weight.
|
||||
product: unitProduct, quantity: 1, weight: fPtr(4.2),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "non-positive quantity on a unit product is rejected",
|
||||
product: unitProduct, quantity: 0, weight: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotQty, gotWeight, err := resolveLineQuantity(tt.product, tt.quantity, tt.weight)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error, got quantity=%v weight=%v", gotQty, gotWeight)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if gotQty != tt.wantQuantity {
|
||||
t.Errorf("quantity = %v, want %v", gotQty, tt.wantQuantity)
|
||||
}
|
||||
switch {
|
||||
case tt.wantWeight == nil && gotWeight != nil:
|
||||
t.Errorf("weight = %v, want nil", *gotWeight)
|
||||
case tt.wantWeight != nil && gotWeight == nil:
|
||||
t.Errorf("weight = nil, want %v", *tt.wantWeight)
|
||||
case tt.wantWeight != nil && *gotWeight != *tt.wantWeight:
|
||||
t.Errorf("weight = %v, want %v", *gotWeight, *tt.wantWeight)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A product created before this feature has sell_by defaulted to 'unit' by the
|
||||
// migration, but a zero-valued struct must behave the same way.
|
||||
func TestResolveLineQuantityTreatsEmptySellByAsUnit(t *testing.T) {
|
||||
legacy := &entities.Product{Name: "Legacy"}
|
||||
|
||||
quantity, weight, err := resolveLineQuantity(legacy, 2, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if quantity != 2 || weight != nil {
|
||||
t.Errorf("got quantity=%v weight=%v, want quantity=2 weight=nil", quantity, weight)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ type OtpProcessor interface {
|
||||
CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error)
|
||||
ResendOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error)
|
||||
SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error
|
||||
// SendWhatsAppMessage sends any message to a customer number, formatted like OTPs.
|
||||
SendWhatsAppMessage(phoneNumber string, message string) error
|
||||
ValidateOtpCode(code string) bool
|
||||
ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error)
|
||||
InvalidateOtpSession(ctx context.Context, token string) error
|
||||
@@ -133,6 +135,10 @@ func (p *otpProcessor) SendOtpViaWhatsApp(phoneNumber string, otpCode string, pu
|
||||
switch purpose {
|
||||
case "login":
|
||||
message = fmt.Sprintf("Kode OTP untuk login kamu adalah %s. Berlaku 5 menit.", otpCode)
|
||||
case "pin_setup":
|
||||
message = fmt.Sprintf("Kode OTP untuk membuat PIN EnakPoint kamu adalah %s. Berlaku 5 menit. Jangan berikan kode ini kepada siapa pun, termasuk kasir.", otpCode)
|
||||
case "pin_reset":
|
||||
message = fmt.Sprintf("Kode OTP untuk reset PIN EnakPoint kamu adalah %s. Berlaku 5 menit. Jangan berikan kode ini kepada siapa pun. Setelah reset, transfer ditahan 24 jam.", otpCode)
|
||||
case "registration":
|
||||
message = fmt.Sprintf("Kode OTP untuk registrasi kamu adalah %s. Berlaku 5 menit.", otpCode)
|
||||
default:
|
||||
@@ -236,3 +242,10 @@ func (p *otpProcessor) formatPhoneNumber(phoneNumber string) string {
|
||||
|
||||
return digits
|
||||
}
|
||||
|
||||
func (p *otpProcessor) SendWhatsAppMessage(phoneNumber string, message string) error {
|
||||
if err := p.fonnteClient.SendWhatsAppMessage(p.formatPhoneNumber(phoneNumber), message); err != nil {
|
||||
return fmt.Errorf("failed to send WhatsApp message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
paymentCodeDigits = 6
|
||||
paymentCodeTTL = 2 * time.Minute
|
||||
paymentCodeAttempts = 5
|
||||
// PaymentCodeQRPrefix marks a scanned QR as an EnakPoint payment code.
|
||||
PaymentCodeQRPrefix = "enakpoint:"
|
||||
)
|
||||
|
||||
// ErrPaymentCodeInvalid means the code was never issued, has expired, has been used,
|
||||
// or belongs to another customer.
|
||||
var ErrPaymentCodeInvalid = errors.New("payment code is invalid or expired")
|
||||
|
||||
type pinVerifier interface {
|
||||
VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error
|
||||
}
|
||||
|
||||
// PaymentCodeProcessor issues and redeems the one-time codes that let a cashier take a
|
||||
// customer's EnakPoint (docs/prd-point-coin.md F9, K8). The customer approves with
|
||||
// their PIN on their own phone and shows the code; the PIN is never typed on the
|
||||
// cashier's device.
|
||||
type PaymentCodeProcessor struct {
|
||||
codes repository.PaymentCodeRepository
|
||||
pins pinVerifier
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewPaymentCodeProcessor(codes repository.PaymentCodeRepository, pins pinVerifier) *PaymentCodeProcessor {
|
||||
return &PaymentCodeProcessor{codes: codes, pins: pins, now: time.Now}
|
||||
}
|
||||
|
||||
// Issue checks the customer's PIN and returns a fresh 6-digit code, valid for two
|
||||
// minutes and bound to the customer. A new code retires the previous one.
|
||||
func (p *PaymentCodeProcessor) Issue(ctx context.Context, customerID uuid.UUID, pin string, info models.CustomerPinRequestInfo) (*models.PaymentCode, error) {
|
||||
if err := p.pins.VerifyPin(ctx, customerID, pin, PinActionPay, info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for attempt := 0; attempt < paymentCodeAttempts; attempt++ {
|
||||
code, err := randomDigits(paymentCodeDigits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = p.codes.Save(ctx, code, customerID, paymentCodeTTL)
|
||||
if errors.Is(err, repository.ErrPaymentCodeTaken) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &models.PaymentCode{
|
||||
Code: code,
|
||||
QRPayload: PaymentCodeQRPrefix + code,
|
||||
ExpiresAt: p.now().Add(paymentCodeTTL),
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("could not draw a free payment code after %d attempts", paymentCodeAttempts)
|
||||
}
|
||||
|
||||
// Redeem uses a code up for a payment by the given customer. It accepts the code as
|
||||
// typed or as scanned from the QR. Every failure is ErrPaymentCodeInvalid.
|
||||
func (p *PaymentCodeProcessor) Redeem(ctx context.Context, code string, customerID uuid.UUID) error {
|
||||
code = strings.TrimPrefix(strings.TrimSpace(code), PaymentCodeQRPrefix)
|
||||
if len(code) != paymentCodeDigits {
|
||||
return ErrPaymentCodeInvalid
|
||||
}
|
||||
err := p.codes.Consume(ctx, code, customerID)
|
||||
if errors.Is(err, repository.ErrPaymentCodeNotFound) || errors.Is(err, repository.ErrPaymentCodeWrongCustomer) {
|
||||
return ErrPaymentCodeInvalid
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// randomDigits draws n decimal digits from a cryptographic source, so codes cannot be
|
||||
// predicted.
|
||||
func randomDigits(n int) (string, error) {
|
||||
var b strings.Builder
|
||||
for i := 0; i < n; i++ {
|
||||
d, err := rand.Int(rand.Reader, big.NewInt(10))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to draw a payment code: %w", err)
|
||||
}
|
||||
b.WriteByte(byte('0' + d.Int64()))
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
type pinVerifierFake struct{ good string }
|
||||
|
||||
func (f pinVerifierFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, action PinAction, _ models.CustomerPinRequestInfo) error {
|
||||
if action != PinActionPay {
|
||||
return &PinError{Code: "UNEXPECTED_ACTION"}
|
||||
}
|
||||
if pin != f.good {
|
||||
return &PinError{Code: PinErrInvalid, RemainingAttempts: 4}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newPaymentCodeTest(t *testing.T) (*PaymentCodeProcessor, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { client.Close() })
|
||||
return NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(client), pinVerifierFake{good: "482913"}), mr
|
||||
}
|
||||
|
||||
func TestPaymentCode_IssueNeedsThePin(t *testing.T) {
|
||||
p, mr := newPaymentCodeTest(t)
|
||||
_, err := p.Issue(context.Background(), uuid.New(), "000000", models.CustomerPinRequestInfo{})
|
||||
var pe *PinError
|
||||
require.ErrorAs(t, err, &pe)
|
||||
assert.Equal(t, PinErrInvalid, pe.Code)
|
||||
assert.Empty(t, mr.Keys(), "nothing is issued without the PIN")
|
||||
}
|
||||
|
||||
func TestPaymentCode_Lifecycle(t *testing.T) {
|
||||
p, mr := newPaymentCodeTest(t)
|
||||
ctx := context.Background()
|
||||
customer, other := uuid.New(), uuid.New()
|
||||
|
||||
code, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, code.Code, 6)
|
||||
assert.Equal(t, "enakpoint:"+code.Code, code.QRPayload)
|
||||
assert.WithinDuration(t, time.Now().Add(2*time.Minute), code.ExpiresAt, 2*time.Second)
|
||||
assert.InDelta(t, 120, mr.TTL("wallet:paycode:"+code.Code).Seconds(), 1, "Redis expires it by itself")
|
||||
|
||||
// A code of another customer is refused, and stays usable by its owner.
|
||||
assert.ErrorIs(t, p.Redeem(ctx, code.Code, other), ErrPaymentCodeInvalid)
|
||||
// Scanned from the QR it works; used once, it is gone.
|
||||
require.NoError(t, p.Redeem(ctx, code.QRPayload, customer))
|
||||
assert.ErrorIs(t, p.Redeem(ctx, code.Code, customer), ErrPaymentCodeInvalid)
|
||||
|
||||
// An expired code is refused.
|
||||
late, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
mr.FastForward(2*time.Minute + time.Second)
|
||||
assert.ErrorIs(t, p.Redeem(ctx, late.Code, customer), ErrPaymentCodeInvalid)
|
||||
|
||||
// A new code retires the previous one.
|
||||
first, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
second, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
if first.Code != second.Code {
|
||||
assert.ErrorIs(t, p.Redeem(ctx, first.Code, customer), ErrPaymentCodeInvalid)
|
||||
}
|
||||
require.NoError(t, p.Redeem(ctx, second.Code, customer))
|
||||
|
||||
// Garbage is refused without touching Redis.
|
||||
for _, bad := range []string{"", "12345", "1234567", "enakpoint:"} {
|
||||
assert.ErrorIs(t, p.Redeem(ctx, bad, customer), ErrPaymentCodeInvalid, bad)
|
||||
}
|
||||
}
|
||||
|
||||
// Two cashiers scanning the same code at once: exactly one gets it.
|
||||
func TestPaymentCode_UsedOnceUnderRace(t *testing.T) {
|
||||
p, _ := newPaymentCodeTest(t)
|
||||
ctx := context.Background()
|
||||
customer := uuid.New()
|
||||
code, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
|
||||
var wins int32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if p.Redeem(ctx, code.Code, customer) == nil {
|
||||
atomic.AddInt32(&wins, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
assert.Equal(t, int32(1), wins)
|
||||
}
|
||||
|
||||
func TestPaymentCode_SaveRefusesALiveCode(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
defer client.Close()
|
||||
repo := repository.NewPaymentCodeRepository(client)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, repo.Save(ctx, "123456", uuid.New(), time.Minute))
|
||||
assert.ErrorIs(t, repo.Save(ctx, "123456", uuid.New(), time.Minute), repository.ErrPaymentCodeTaken,
|
||||
"a live code is never handed to a second customer")
|
||||
}
|
||||
|
||||
func TestRandomDigits(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 200; i++ {
|
||||
d, err := randomDigits(6)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, d, 6)
|
||||
for _, r := range d {
|
||||
require.True(t, r >= '0' && r <= '9')
|
||||
}
|
||||
seen[d] = true
|
||||
}
|
||||
assert.Greater(t, len(seen), 190, "codes do not repeat")
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"apskel-pos-be/internal/mappers"
|
||||
@@ -20,17 +23,26 @@ type PaymentMethodProcessor interface {
|
||||
GetActivePaymentMethodsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]models.PaymentMethodResponse, error)
|
||||
}
|
||||
|
||||
// ErrSystemPaymentMethod means an attempt to create, delete or retype the EnakPoint
|
||||
// method, which the system owns (docs/prd-point-coin.md F9).
|
||||
var ErrSystemPaymentMethod = errors.New("the EnakPoint payment method is managed by the system: it cannot be created, deleted or change type")
|
||||
|
||||
type PaymentMethodProcessorImpl struct {
|
||||
paymentMethodRepo repository.PaymentMethodRepository
|
||||
outletSettings outletSettingsReader
|
||||
}
|
||||
|
||||
func NewPaymentMethodProcessorImpl(paymentMethodRepo repository.PaymentMethodRepository) *PaymentMethodProcessorImpl {
|
||||
func NewPaymentMethodProcessorImpl(paymentMethodRepo repository.PaymentMethodRepository, outletSettings outletSettingsReader) *PaymentMethodProcessorImpl {
|
||||
return &PaymentMethodProcessorImpl{
|
||||
paymentMethodRepo: paymentMethodRepo,
|
||||
outletSettings: outletSettings,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PaymentMethodProcessorImpl) CreatePaymentMethod(ctx context.Context, req *models.CreatePaymentMethodRequest) (*models.PaymentMethodResponse, error) {
|
||||
if req.Type == constants.PaymentMethodTypePoint {
|
||||
return nil, ErrSystemPaymentMethod
|
||||
}
|
||||
exists, err := p.paymentMethodRepo.ExistsByName(ctx, req.OrganizationID, req.Name, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check payment method name uniqueness: %w", err)
|
||||
@@ -77,6 +89,17 @@ func (p *PaymentMethodProcessorImpl) ListPaymentMethods(ctx context.Context, req
|
||||
if req.Search != "" {
|
||||
filters["search"] = req.Search
|
||||
}
|
||||
// At the cashier EnakPoint only shows where the outlet accepts it (F9). Filtered in
|
||||
// the query so paging stays right.
|
||||
if req.OutletID != nil && p.outletSettings != nil {
|
||||
settings, err := p.outletSettings.Outlet(ctx, *req.OutletID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read outlet loyalty settings: %w", err)
|
||||
}
|
||||
if !settings.PointPayment.AcceptPayment {
|
||||
filters["exclude_type"] = string(constants.PaymentMethodTypePoint)
|
||||
}
|
||||
}
|
||||
|
||||
offset := (req.Page - 1) * req.Limit
|
||||
|
||||
@@ -116,6 +139,15 @@ func (p *PaymentMethodProcessorImpl) UpdatePaymentMethod(ctx context.Context, id
|
||||
return nil, fmt.Errorf("payment method not found: %w", err)
|
||||
}
|
||||
|
||||
// The EnakPoint method keeps its type, and no other method can become one.
|
||||
if req.Type != nil {
|
||||
wasPoint := existingPaymentMethod.Type == entities.PaymentMethodTypePoint
|
||||
isPoint := *req.Type == constants.PaymentMethodTypePoint
|
||||
if wasPoint != isPoint {
|
||||
return nil, ErrSystemPaymentMethod
|
||||
}
|
||||
}
|
||||
|
||||
// Check name uniqueness if name is being updated
|
||||
if req.Name != nil && *req.Name != existingPaymentMethod.Name {
|
||||
exists, err := p.paymentMethodRepo.ExistsByName(ctx, existingPaymentMethod.OrganizationID, *req.Name, &id)
|
||||
@@ -147,10 +179,13 @@ func (p *PaymentMethodProcessorImpl) UpdatePaymentMethod(ctx context.Context, id
|
||||
|
||||
func (p *PaymentMethodProcessorImpl) DeletePaymentMethod(ctx context.Context, id uuid.UUID) error {
|
||||
// Check if payment method exists
|
||||
_, err := p.paymentMethodRepo.GetByID(ctx, id)
|
||||
existing, err := p.paymentMethodRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("payment method not found: %w", err)
|
||||
}
|
||||
if existing.Type == entities.PaymentMethodTypePoint {
|
||||
return ErrSystemPaymentMethod
|
||||
}
|
||||
|
||||
// TODO: Check if payment method is being used in any payments
|
||||
// For now, allow deletion
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// Needs TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestPointPaymentMethod_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
|
||||
org, user, accepting, refusing := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
exec := func(q string, args ...any) error { return db.Exec(q, args...).Error }
|
||||
require.NoError(t, exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'pm test', 'basic')`, org))
|
||||
require.NoError(t, exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Terima'), (?, ?, 'Tolak')`, accepting, org, refusing, org))
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id IN ?`, []uuid.UUID{accepting, refusing})
|
||||
db.Exec(`DELETE FROM outlets WHERE id IN ?`, []uuid.UUID{accepting, refusing})
|
||||
db.Exec(`DELETE FROM payment_methods WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
|
||||
// A new organization gets exactly one EnakPoint method from the trigger.
|
||||
var methods []struct {
|
||||
ID string
|
||||
Name string
|
||||
Type string
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT id::text AS id, name, type FROM payment_methods WHERE organization_id = ?`, org).Scan(&methods).Error)
|
||||
require.Len(t, methods, 1)
|
||||
assert.Equal(t, "EnakPoint", methods[0].Name)
|
||||
assert.Equal(t, "point", methods[0].Type)
|
||||
pointID := uuid.MustParse(methods[0].ID)
|
||||
|
||||
// The database refuses a second one.
|
||||
assert.Error(t, exec(`INSERT INTO payment_methods (organization_id, name, type) VALUES (?, 'EnakPoint 2', 'point')`, org))
|
||||
|
||||
txm := repository.NewTxManager(db)
|
||||
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
|
||||
s, err := settings.Outlet(ctx, accepting)
|
||||
require.NoError(t, err)
|
||||
s.PointPayment.AcceptPayment = true
|
||||
_, err = settings.UpdateOutlet(ctx, org, accepting, user, *s)
|
||||
require.NoError(t, err)
|
||||
|
||||
p := NewPaymentMethodProcessorImpl(repository.NewPaymentMethodRepositoryImpl(db), settings)
|
||||
cash, err := p.CreatePaymentMethod(ctx, &models.CreatePaymentMethodRequest{OrganizationID: org, Name: "Tunai", Type: constants.PaymentMethodTypeCash, IsActive: ptr(true)})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The API cannot make, retype or delete an EnakPoint method.
|
||||
_, err = p.CreatePaymentMethod(ctx, &models.CreatePaymentMethodRequest{OrganizationID: org, Name: "Poin Lain", Type: constants.PaymentMethodTypePoint, IsActive: ptr(true)})
|
||||
assert.ErrorIs(t, err, ErrSystemPaymentMethod)
|
||||
toCash := constants.PaymentMethodTypeCash
|
||||
_, err = p.UpdatePaymentMethod(ctx, pointID, &models.UpdatePaymentMethodRequest{Type: &toCash})
|
||||
assert.ErrorIs(t, err, ErrSystemPaymentMethod)
|
||||
toPoint := constants.PaymentMethodTypePoint
|
||||
_, err = p.UpdatePaymentMethod(ctx, cash.ID, &models.UpdatePaymentMethodRequest{Type: &toPoint})
|
||||
assert.ErrorIs(t, err, ErrSystemPaymentMethod)
|
||||
assert.ErrorIs(t, p.DeletePaymentMethod(ctx, pointID), ErrSystemPaymentMethod)
|
||||
|
||||
// Renaming it is fine; its type stays.
|
||||
name := "Bayar pakai EnakPoint"
|
||||
renamed, err := p.UpdatePaymentMethod(ctx, pointID, &models.UpdatePaymentMethodRequest{Name: &name, Type: &toPoint})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, name, renamed.Name)
|
||||
|
||||
// At the cashier it shows only where the outlet accepts EnakPoint.
|
||||
types := func(outlet *uuid.UUID) []constants.PaymentMethodType {
|
||||
t.Helper()
|
||||
list, err := p.ListPaymentMethods(ctx, &models.ListPaymentMethodsRequest{OrganizationID: &org, OutletID: outlet, Page: 1, Limit: 50})
|
||||
require.NoError(t, err)
|
||||
var out []constants.PaymentMethodType
|
||||
for _, m := range list.PaymentMethods {
|
||||
out = append(out, m.Type)
|
||||
}
|
||||
assert.Equal(t, len(out), list.TotalCount, "the count matches what is listed")
|
||||
return out
|
||||
}
|
||||
assert.ElementsMatch(t, []constants.PaymentMethodType{"cash", "point"}, types(&accepting))
|
||||
assert.ElementsMatch(t, []constants.PaymentMethodType{"cash"}, types(&refusing))
|
||||
assert.ElementsMatch(t, []constants.PaymentMethodType{"cash", "point"}, types(nil), "the dashboard, without an outlet, sees it")
|
||||
|
||||
// A payment either records both points_used and point_value, or neither.
|
||||
var orderID uuid.UUID
|
||||
require.NoError(t, exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'K', ?, 'x', 'cashier')`, user, org, user.String()+"@t"))
|
||||
orderID = uuid.New()
|
||||
require.NoError(t, exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, order_number, order_type, subtotal, tax_amount, total_amount)
|
||||
VALUES (?, ?, ?, ?, ?, 'dine_in', 1000, 0, 1000)`, orderID, org, accepting, user, "PM-"+orderID.String()[:8]))
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM payments WHERE order_id = ?`, orderID)
|
||||
db.Exec(`DELETE FROM orders WHERE id = ?`, orderID)
|
||||
db.Exec(`DELETE FROM users WHERE id = ?`, user)
|
||||
})
|
||||
assert.Error(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used) VALUES (?, ?, 1000, 1000)`, orderID, pointID))
|
||||
assert.Error(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used, point_value) VALUES (?, ?, 1000, 0, 1)`, orderID, pointID))
|
||||
assert.NoError(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used, point_value) VALUES (?, ?, 1000, 1000, 1)`, orderID, pointID))
|
||||
assert.NoError(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount) VALUES (?, ?, 1000)`, orderID, cash.ID))
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
@@ -191,6 +192,13 @@ func (p *ProductProcessorImpl) UpdateProduct(ctx context.Context, id uuid.UUID,
|
||||
|
||||
mappers.UpdateProductEntityFromRequest(existingProduct, req)
|
||||
|
||||
// Checked after the merge, not on the request: switching a product to sell_by
|
||||
// "weight" is valid when it already carries a unit, and clearing the unit is
|
||||
// invalid when it is already sold by weight. Only the merged product shows either.
|
||||
if existingProduct.SellBy == constants.SellByWeight && existingProduct.UnitID == nil {
|
||||
return nil, fmt.Errorf("product '%s' is sold by weight and requires a unit_id", existingProduct.Name)
|
||||
}
|
||||
|
||||
if err := p.productRepo.Update(ctx, existingProduct); err != nil {
|
||||
return nil, fmt.Errorf("failed to update product: %w", err)
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ func calculateSplitAmounts(req *models.SplitBillRequest, validation *SplitBillVa
|
||||
|
||||
for _, item := range req.Items {
|
||||
orderItem := validation.OrderItems[item.OrderItemID]
|
||||
itemAmount := float64(item.Quantity) * orderItem.UnitPrice
|
||||
itemAmount := entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice)
|
||||
itemTaxAmount := itemAmount * validation.Outlet.TaxRate
|
||||
totalItemAmount := itemAmount + itemTaxAmount
|
||||
|
||||
@@ -186,7 +186,7 @@ func (p *SplitBillProcessorImpl) createSplitPayment(ctx context.Context, req *mo
|
||||
func (p *SplitBillProcessorImpl) createPaymentOrderItems(ctx context.Context, splitPayment *entities.Payment, req *models.SplitBillRequest, validation *SplitBillValidation) error {
|
||||
for _, item := range req.Items {
|
||||
orderItem := validation.OrderItems[item.OrderItemID]
|
||||
itemAmount := float64(item.Quantity) * orderItem.UnitPrice
|
||||
itemAmount := entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice)
|
||||
itemTaxAmount := itemAmount * validation.Outlet.TaxRate
|
||||
totalItemAmount := itemAmount + itemTaxAmount
|
||||
|
||||
@@ -228,7 +228,7 @@ func (p *SplitBillProcessorImpl) updateOrderAfterSplit(ctx context.Context, orde
|
||||
quantityInfo[item.OrderItemID.String()] = map[string]interface{}{
|
||||
"quantity": item.Quantity,
|
||||
"unit_price": orderItem.UnitPrice,
|
||||
"total_amount": float64(item.Quantity) * orderItem.UnitPrice,
|
||||
"total_amount": entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice),
|
||||
}
|
||||
}
|
||||
order.Metadata[MetadataKeyLastSplitQuantities] = quantityInfo
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// walletDescriptionLimit is the size of wallet_transactions.description.
|
||||
const walletDescriptionLimit = 255
|
||||
|
||||
// WalletAdminProcessor serves the dashboard's view of one customer's wallet and the
|
||||
// manual adjustments an admin makes to it (docs/prd-point-coin.md F7).
|
||||
type WalletAdminProcessor struct {
|
||||
admin repository.WalletAdminRepository
|
||||
query repository.WalletQueryRepository
|
||||
wallet *WalletProcessor
|
||||
tx TxRunner
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewWalletAdminProcessor(admin repository.WalletAdminRepository, query repository.WalletQueryRepository, wallet *WalletProcessor, tx TxRunner) *WalletAdminProcessor {
|
||||
return &WalletAdminProcessor{admin: admin, query: query, wallet: wallet, tx: tx, now: time.Now}
|
||||
}
|
||||
|
||||
// GetCustomerWallet returns the customer's balances, lots and a page of history.
|
||||
// A customer outside the organization is reported as not found.
|
||||
func (p *WalletAdminProcessor) GetCustomerWallet(ctx context.Context, organizationID, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) (*models.AdminCustomerWallet, error) {
|
||||
filter, page, err := walletTransactionFilter(customerID, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
customer, err := p.admin.GetCustomer(ctx, organizationID, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := p.now()
|
||||
|
||||
result := &models.AdminCustomerWallet{
|
||||
Customer: models.AdminWalletCustomer{ID: customer.ID, Name: customer.Name, Phone: customer.Phone},
|
||||
Lots: []models.AdminWalletLot{},
|
||||
}
|
||||
wallet, err := p.admin.GetWallet(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wallet != nil {
|
||||
result.PointBalance = wallet.PointBalance
|
||||
result.CoinBalance = wallet.CoinBalance
|
||||
}
|
||||
spendable, err := p.query.SpendableBalances(ctx, customerID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.SpendablePointBalance = spendable[constants.WalletCurrencyPoint]
|
||||
result.SpendableCoinBalance = spendable[constants.WalletCurrencyCoin]
|
||||
|
||||
lots, err := p.admin.ListLotsWithBalance(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, lot := range lots {
|
||||
result.Lots = append(result.Lots, models.AdminWalletLot{
|
||||
ID: lot.ID,
|
||||
Currency: lot.Currency,
|
||||
OriginalAmount: lot.OriginalAmount,
|
||||
RemainingAmount: lot.RemainingAmount,
|
||||
ExpiresAt: lot.ExpiresAt,
|
||||
Expired: lot.ExpiresAt != nil && !lot.ExpiresAt.After(now),
|
||||
SourceTransactionID: lot.SourceTransactionID,
|
||||
OriginLotID: lot.OriginLotID,
|
||||
CreatedAt: lot.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
rows, total, err := p.query.ListTransactions(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transactions, err := p.adminTransactions(ctx, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Transactions = models.PaginatedResponse[models.AdminWalletTransaction]{
|
||||
Data: transactions,
|
||||
Pagination: models.Pagination{
|
||||
Page: page,
|
||||
Limit: filter.Limit,
|
||||
Total: total,
|
||||
TotalPages: int((total + int64(filter.Limit) - 1) / int64(filter.Limit)),
|
||||
},
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Adjust adds to or takes from a customer's balance by hand. It is recorded as an
|
||||
// ADJUSTMENT pointing at the admin, with the reason. Taking more than the customer
|
||||
// can spend is refused with repository.ErrWalletInsufficientBalance.
|
||||
func (p *WalletAdminProcessor) Adjust(ctx context.Context, organizationID, adminID, customerID uuid.UUID, in models.WalletAdjustment) (*models.AdminWalletAdjustmentResult, error) {
|
||||
reason := strings.TrimSpace(in.Reason)
|
||||
if adminID == uuid.Nil {
|
||||
return nil, fmt.Errorf("%w: the admin making the adjustment is unknown", ErrWalletInvalidEntry)
|
||||
}
|
||||
if in.Amount == 0 {
|
||||
return nil, fmt.Errorf("%w: amount must not be zero", ErrWalletInvalidEntry)
|
||||
}
|
||||
|
||||
entry := WalletEntry{
|
||||
CustomerID: customerID,
|
||||
Currency: in.Currency,
|
||||
Type: constants.WalletTxTypeAdjustment,
|
||||
Amount: abs(in.Amount),
|
||||
ReferenceType: constants.WalletRefTypeUser,
|
||||
ReferenceID: adminID,
|
||||
CreatedByUser: &adminID,
|
||||
Reason: &reason,
|
||||
Description: truncateRunes("Koreksi oleh admin: "+reason, walletDescriptionLimit),
|
||||
}
|
||||
if in.IdempotencyKey != "" {
|
||||
// Scoped to the customer so a key chosen by the dashboard cannot collide with
|
||||
// the keys other flows use.
|
||||
entry.IdempotencyKey = fmt.Sprintf("adjust:%s:%s", customerID, in.IdempotencyKey)
|
||||
}
|
||||
|
||||
var res *WalletResult
|
||||
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
if _, err := p.admin.GetCustomer(ctx, organizationID, customerID); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
if in.Amount > 0 {
|
||||
// Adjustments never expire until expiry is decided (F12, note N4).
|
||||
res, err = p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: entry})
|
||||
} else {
|
||||
res, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: entry})
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transactions, err := p.adminTransactions(ctx, []entities.WalletTransaction{*res.Transaction})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spendable, err := p.query.SpendableBalances(ctx, customerID, p.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &models.AdminWalletAdjustmentResult{
|
||||
Transaction: transactions[0],
|
||||
SpendablePointBalance: spendable[constants.WalletCurrencyPoint],
|
||||
SpendableCoinBalance: spendable[constants.WalletCurrencyCoin],
|
||||
Replayed: res.Replayed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// adminTransactions adds, to what the customer sees, the names behind each row.
|
||||
func (p *WalletAdminProcessor) adminTransactions(ctx context.Context, rows []entities.WalletTransaction) ([]models.AdminWalletTransaction, error) {
|
||||
base, err := buildWalletTransactions(ctx, p.query, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var customerIDs, userIDs, outletIDs []uuid.UUID
|
||||
for _, row := range rows {
|
||||
if row.CounterpartyCustomerID != nil {
|
||||
customerIDs = append(customerIDs, *row.CounterpartyCustomerID)
|
||||
}
|
||||
if row.CreatedByUser != nil {
|
||||
userIDs = append(userIDs, *row.CreatedByUser)
|
||||
}
|
||||
if row.OutletID != nil {
|
||||
outletIDs = append(outletIDs, *row.OutletID)
|
||||
}
|
||||
}
|
||||
customers, err := p.admin.CustomerNames(ctx, customerIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users, err := p.admin.UserNames(ctx, userIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outlets, err := p.admin.OutletNames(ctx, outletIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
named := func(id *uuid.UUID, names map[uuid.UUID]string) *models.AdminWalletNamedRef {
|
||||
if id == nil {
|
||||
return nil
|
||||
}
|
||||
return &models.AdminWalletNamedRef{ID: *id, Name: names[*id]}
|
||||
}
|
||||
|
||||
out := make([]models.AdminWalletTransaction, 0, len(rows))
|
||||
for i, row := range rows {
|
||||
out = append(out, models.AdminWalletTransaction{
|
||||
CustomerWalletTransaction: base[i],
|
||||
Counterparty: named(row.CounterpartyCustomerID, customers),
|
||||
CreatedBy: named(row.CreatedByUser, users),
|
||||
Outlet: named(row.OutletID, outlets),
|
||||
Reason: row.Reason,
|
||||
Metadata: row.Metadata,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func truncateRunes(s string, limit int) string {
|
||||
if utf8.RuneCountInString(s) <= limit {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:limit])
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// TxRunner runs fn inside a database transaction. repository.TxManager is one.
|
||||
type TxRunner interface {
|
||||
WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error
|
||||
}
|
||||
|
||||
// WalletMigrationDiscrepancy is a customer whose legacy balance is now lower than
|
||||
// what was already migrated: the old code spent from it after the migration ran.
|
||||
// The wallet is left alone, because only an admin adjustment can take balance away.
|
||||
type WalletMigrationDiscrepancy struct {
|
||||
CustomerID uuid.UUID
|
||||
Currency string
|
||||
Legacy int64
|
||||
Migrated int64
|
||||
}
|
||||
|
||||
type WalletMigrationReport struct {
|
||||
DryRun bool
|
||||
CustomersScanned int
|
||||
// Ledger rows written (or, on a dry run, that would be written) and their sum.
|
||||
PointCredits int
|
||||
PointsCredited int64
|
||||
CoinCredits int
|
||||
CoinsCredited int64
|
||||
Discrepancies []WalletMigrationDiscrepancy
|
||||
// Taken after the run. On a dry run they show the state before it.
|
||||
Totals *repository.WalletMigrationTotals
|
||||
}
|
||||
|
||||
// Balanced reports whether everything in the legacy tables is now in the wallet.
|
||||
func (r *WalletMigrationReport) Balanced() bool {
|
||||
return len(r.Discrepancies) == 0 && r.Totals != nil &&
|
||||
r.Totals.LegacyPoints == r.Totals.MigratedPoints &&
|
||||
r.Totals.LegacyCoins == r.Totals.MigratedCoins
|
||||
}
|
||||
|
||||
// WalletMigrationProcessor moves the balances in customer_points and customer_tokens
|
||||
// into the wallet (docs/prd-point-coin.md §10, PC-105). Each customer gets a MIGRATION
|
||||
// ledger row and a non-expiring lot per currency, through WalletProcessor like any
|
||||
// other credit, so the wallet reconciles from the first row.
|
||||
//
|
||||
// It credits the difference between the legacy balance and what earlier runs already
|
||||
// migrated, so running it again never doubles a balance, and a run after the old code
|
||||
// kept writing to the legacy tables picks up only what was added since.
|
||||
type WalletMigrationProcessor struct {
|
||||
repo repository.WalletMigrationRepository
|
||||
wallet *WalletProcessor
|
||||
tx TxRunner
|
||||
}
|
||||
|
||||
func NewWalletMigrationProcessor(repo repository.WalletMigrationRepository, wallet *WalletProcessor, tx TxRunner) *WalletMigrationProcessor {
|
||||
return &WalletMigrationProcessor{repo: repo, wallet: wallet, tx: tx}
|
||||
}
|
||||
|
||||
// Run migrates every customer with a legacy balance, one transaction per customer.
|
||||
// With dryRun it only reports what it would credit.
|
||||
func (p *WalletMigrationProcessor) Run(ctx context.Context, dryRun bool, batchSize int) (*WalletMigrationReport, error) {
|
||||
if batchSize <= 0 {
|
||||
batchSize = 500
|
||||
}
|
||||
report := &WalletMigrationReport{DryRun: dryRun}
|
||||
|
||||
after := uuid.Nil
|
||||
for {
|
||||
ids, err := p.repo.ListLegacyCustomers(ctx, after, batchSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
break
|
||||
}
|
||||
for _, id := range ids {
|
||||
if dryRun {
|
||||
err = p.migrateCustomer(ctx, id, true, report)
|
||||
} else {
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
return p.migrateCustomer(ctx, id, false, report)
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("customer %s: %w", id, err)
|
||||
}
|
||||
report.CustomersScanned++
|
||||
}
|
||||
after = ids[len(ids)-1]
|
||||
}
|
||||
|
||||
totals, err := p.repo.Totals(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
report.Totals = totals
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func (p *WalletMigrationProcessor) migrateCustomer(ctx context.Context, customerID uuid.UUID, dryRun bool, report *WalletMigrationReport) error {
|
||||
// Lock before reading what was migrated, so two runs at once cannot both see the
|
||||
// same gap and fill it twice.
|
||||
if !dryRun {
|
||||
if err := p.wallet.LockWallet(ctx, customerID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
legacy, err := p.repo.GetLegacyBalance(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Points come from the single customer_points row. Tokens come from several rows,
|
||||
// one per type, so the ledger row points at the customer and lists the rows.
|
||||
pointsRef := customerID
|
||||
if legacy.PointsRowID != nil {
|
||||
pointsRef = *legacy.PointsRowID
|
||||
}
|
||||
tokens := make([]map[string]any, 0, len(legacy.Tokens))
|
||||
for _, t := range legacy.Tokens {
|
||||
tokens = append(tokens, map[string]any{"id": t.ID, "token_type": string(t.TokenType), "balance": t.Balance})
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
currency, refType string
|
||||
refID uuid.UUID
|
||||
legacy int64
|
||||
metadata entities.Metadata
|
||||
credits *int
|
||||
credited *int64
|
||||
}{
|
||||
{constants.WalletCurrencyPoint, constants.WalletRefTypeLegacyPoints, pointsRef, legacy.Points,
|
||||
entities.Metadata{}, &report.PointCredits, &report.PointsCredited},
|
||||
{constants.WalletCurrencyCoin, constants.WalletRefTypeLegacyTokens, customerID, legacy.Coins(),
|
||||
entities.Metadata{"legacy_tokens": tokens}, &report.CoinCredits, &report.CoinsCredited},
|
||||
} {
|
||||
migrated, err := p.repo.SumMigrated(ctx, customerID, c.currency)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delta := c.legacy - migrated
|
||||
if delta < 0 {
|
||||
report.Discrepancies = append(report.Discrepancies, WalletMigrationDiscrepancy{
|
||||
CustomerID: customerID, Currency: c.currency, Legacy: c.legacy, Migrated: migrated,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if delta == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if !dryRun {
|
||||
c.metadata["legacy_balance"] = c.legacy
|
||||
c.metadata["previously_migrated"] = migrated
|
||||
_, err = p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{
|
||||
CustomerID: customerID,
|
||||
Currency: c.currency,
|
||||
Type: constants.WalletTxTypeMigration,
|
||||
Amount: delta,
|
||||
ReferenceType: c.refType,
|
||||
ReferenceID: c.refID,
|
||||
Description: "Saldo awal dari sistem lama",
|
||||
Metadata: c.metadata,
|
||||
// The legacy total in the key lets a later run top up a balance that
|
||||
// grew, while a retry of the same run is still recognised.
|
||||
IdempotencyKey: fmt.Sprintf("migration:%s:%s:%d", c.currency, customerID, c.legacy),
|
||||
}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
*c.credits++
|
||||
*c.credited += delta
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// Needs TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go. Other packages' tests may use the
|
||||
// same database at the same time, so everything here is scoped to its own customers.
|
||||
func TestWalletMigrationProcessor_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
|
||||
org := uuid.New()
|
||||
full, tokensOnly, pointsOnly, none := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
customers := []uuid.UUID{full, tokensOnly, pointsOnly, none}
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'migration test', 'basic')`, org)
|
||||
for _, c := range customers {
|
||||
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'migration test')`, c, org)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
|
||||
// The example from §10: SPIN 5 + RAFFLE 2 + MINIGAME 1 = 8 EnakCoin.
|
||||
exec(`INSERT INTO customer_points (customer_id, balance) VALUES (?, 100), (?, 0), (?, 40)`, full, tokensOnly, pointsOnly)
|
||||
exec(`INSERT INTO customer_tokens (customer_id, token_type, balance) VALUES
|
||||
(?, 'SPIN', 5), (?, 'RAFFLE', 2), (?, 'MINIGAME', 1), (?, 'SPIN', 3)`, full, full, full, tokensOnly)
|
||||
|
||||
migrator := NewWalletMigrationProcessor(
|
||||
repository.NewWalletMigrationRepository(db),
|
||||
NewWalletProcessor(repository.NewWalletRepository(db)),
|
||||
repository.NewTxManager(db),
|
||||
)
|
||||
|
||||
type balance struct{ Point, Coin int64 }
|
||||
balances := func() map[uuid.UUID]balance {
|
||||
t.Helper()
|
||||
var rows []struct {
|
||||
CustomerID uuid.UUID
|
||||
PointBalance, CoinBalance int64
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT customer_id, point_balance, coin_balance FROM customer_wallets WHERE customer_id IN ?`, customers).Scan(&rows).Error)
|
||||
out := map[uuid.UUID]balance{}
|
||||
for _, r := range rows {
|
||||
out[r.CustomerID] = balance{r.PointBalance, r.CoinBalance}
|
||||
}
|
||||
return out
|
||||
}
|
||||
countRows := func() int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id IN ?`, customers).Scan(&n).Error)
|
||||
return n
|
||||
}
|
||||
|
||||
// A dry run reports and writes nothing, not even the wallets.
|
||||
report, err := migrator.Run(ctx, true, 2)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, report.PointsCredited, int64(140))
|
||||
assert.GreaterOrEqual(t, report.CoinsCredited, int64(11))
|
||||
assert.Empty(t, balances())
|
||||
assert.Zero(t, countRows())
|
||||
|
||||
// The real run. A batch of 2 makes it page through the customers.
|
||||
_, err = migrator.Run(ctx, false, 2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[uuid.UUID]balance{
|
||||
full: {Point: 100, Coin: 8},
|
||||
tokensOnly: {Point: 0, Coin: 3},
|
||||
pointsOnly: {Point: 40, Coin: 0},
|
||||
}, balances(), "a customer without legacy rows gets no wallet")
|
||||
assert.Equal(t, int64(4), countRows(), "one row per customer per currency with a balance")
|
||||
|
||||
var coinRow struct {
|
||||
ReferenceType string
|
||||
ReferenceID uuid.UUID
|
||||
Metadata string
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT reference_type, reference_id, metadata::text AS metadata FROM wallet_transactions
|
||||
WHERE customer_id = ? AND currency = 'COIN'`, full).Scan(&coinRow).Error)
|
||||
assert.Equal(t, constants.WalletRefTypeLegacyTokens, coinRow.ReferenceType)
|
||||
assert.Equal(t, full, coinRow.ReferenceID)
|
||||
for _, part := range []string{`"token_type": "SPIN"`, `"token_type": "RAFFLE"`, `"token_type": "MINIGAME"`, `"legacy_balance": 8`} {
|
||||
assert.Contains(t, coinRow.Metadata, part)
|
||||
}
|
||||
|
||||
var pointRef, pointsRowID string
|
||||
require.NoError(t, db.Raw(`SELECT reference_id::text FROM wallet_transactions WHERE customer_id = ? AND currency = 'POINT'`, full).Scan(&pointRef).Error)
|
||||
require.NoError(t, db.Raw(`SELECT id::text FROM customer_points WHERE customer_id = ?`, full).Scan(&pointsRowID).Error)
|
||||
assert.NotEmpty(t, pointRef)
|
||||
assert.Equal(t, pointsRowID, pointRef, "points row points at the customer_points row")
|
||||
|
||||
var expiring int64
|
||||
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_lots WHERE customer_id IN ? AND expires_at IS NOT NULL`, customers).Scan(&expiring).Error)
|
||||
assert.Zero(t, expiring, "migrated lots never expire")
|
||||
|
||||
// Running again changes nothing.
|
||||
report, err = migrator.Run(ctx, false, 2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(4), countRows())
|
||||
assert.Empty(t, discrepanciesFor(report, customers))
|
||||
|
||||
// The old code kept writing: one balance grew, one shrank. Only the growth is
|
||||
// migrated; the shrink is reported and left alone.
|
||||
exec(`UPDATE customer_tokens SET balance = 9 WHERE customer_id = ? AND token_type = 'SPIN'`, full)
|
||||
exec(`UPDATE customer_points SET balance = 30 WHERE customer_id = ?`, pointsOnly)
|
||||
report, err = migrator.Run(ctx, false, 2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, balance{Point: 100, Coin: 12}, balances()[full])
|
||||
assert.Equal(t, balance{Point: 40, Coin: 0}, balances()[pointsOnly])
|
||||
assert.Equal(t, []WalletMigrationDiscrepancy{{CustomerID: pointsOnly, Currency: constants.WalletCurrencyPoint, Legacy: 30, Migrated: 40}},
|
||||
discrepanciesFor(report, customers))
|
||||
assert.Equal(t, int64(5), countRows())
|
||||
|
||||
// §7.5 for these customers.
|
||||
var broken int64
|
||||
require.NoError(t, db.Raw(`
|
||||
SELECT COUNT(*) FROM customer_wallets w
|
||||
WHERE w.customer_id IN ? AND (
|
||||
w.point_balance <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions t WHERE t.customer_id = w.customer_id AND t.currency = 'POINT')
|
||||
OR w.coin_balance <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions t WHERE t.customer_id = w.customer_id AND t.currency = 'COIN')
|
||||
OR w.point_balance <> (SELECT COALESCE(SUM(remaining_amount), 0) FROM wallet_lots l WHERE l.customer_id = w.customer_id AND l.currency = 'POINT')
|
||||
OR w.coin_balance <> (SELECT COALESCE(SUM(remaining_amount), 0) FROM wallet_lots l WHERE l.customer_id = w.customer_id AND l.currency = 'COIN'))`,
|
||||
customers).Scan(&broken).Error)
|
||||
assert.Zero(t, broken)
|
||||
}
|
||||
|
||||
func discrepanciesFor(report *WalletMigrationReport, customers []uuid.UUID) []WalletMigrationDiscrepancy {
|
||||
mine := map[uuid.UUID]bool{}
|
||||
for _, c := range customers {
|
||||
mine[c] = true
|
||||
}
|
||||
var out []WalletMigrationDiscrepancy
|
||||
for _, d := range report.Discrepancies {
|
||||
if mine[d.CustomerID] {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrWalletInvalidEntry wraps every rejection of an entry that breaks the rules in
|
||||
// docs/prd-point-coin.md §8.1. The database enforces most of them too; checking
|
||||
// here first gives callers a readable error instead of a constraint name.
|
||||
ErrWalletInvalidEntry = errors.New("wallet: invalid entry")
|
||||
// ErrWalletIdempotencyConflict means an idempotency key was reused for a different
|
||||
// operation. Retrying the same operation with the same key is not a conflict.
|
||||
ErrWalletIdempotencyConflict = errors.New("wallet: idempotency key already used for a different operation")
|
||||
)
|
||||
|
||||
// WalletEntry is what every ledger row needs, whichever way it moves the balance.
|
||||
// Which of the optional fields a type requires is listed in §8.1.
|
||||
type WalletEntry struct {
|
||||
// Optional. Set it when another row must reference this one before it exists, as
|
||||
// the two rows of an exchange or a transfer do.
|
||||
TransactionID uuid.UUID
|
||||
|
||||
CustomerID uuid.UUID
|
||||
Currency string
|
||||
Type string
|
||||
// Always positive: Credit adds it, Debit takes it away.
|
||||
Amount int64
|
||||
|
||||
ReferenceType string
|
||||
ReferenceID uuid.UUID
|
||||
|
||||
GroupID *uuid.UUID
|
||||
CounterpartyCustomerID *uuid.UUID
|
||||
ReversesTransactionID *uuid.UUID
|
||||
OutletID *uuid.UUID
|
||||
CreatedByUser *uuid.UUID
|
||||
Reason *string
|
||||
|
||||
Description string
|
||||
Metadata entities.Metadata
|
||||
// Optional. A retry with the same key returns the first result without moving
|
||||
// anything again.
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
// WalletLotInput is one lot a credit creates.
|
||||
type WalletLotInput struct {
|
||||
Amount int64
|
||||
// Nil means the lot never expires.
|
||||
ExpiresAt *time.Time
|
||||
// The lot this one was carried over from, for transfers, exchanges and refunds.
|
||||
OriginLotID *uuid.UUID
|
||||
}
|
||||
|
||||
type WalletCreditInput struct {
|
||||
WalletEntry
|
||||
// How the credit is split into lots. Their amounts must add up to Amount. Leave
|
||||
// empty for a single lot that never expires.
|
||||
Lots []WalletLotInput
|
||||
}
|
||||
|
||||
type WalletDebitInput struct {
|
||||
WalletEntry
|
||||
// Lots to draw from first, in this order, before falling back to the K9 order.
|
||||
// A reversal names the lots its EARN created (F10), and the expiry job names the
|
||||
// lot that expired. These lots are used even if they have already expired.
|
||||
PreferredLotIDs []uuid.UUID
|
||||
}
|
||||
|
||||
// WalletAllocation is how much a debit took from one lot. It carries the lot's
|
||||
// expiry, so a transfer or exchange can give the receiving lot the same expiry (K9).
|
||||
type WalletAllocation struct {
|
||||
LotID uuid.UUID
|
||||
Amount int64
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
type WalletResult struct {
|
||||
// Nil only when DebitUpTo found nothing to take.
|
||||
Transaction *entities.WalletTransaction
|
||||
// The lots a credit created.
|
||||
Lots []entities.WalletLot
|
||||
// The lots a debit drew from, in the order they were used.
|
||||
Allocations []WalletAllocation
|
||||
// What DebitUpTo could not take because the balance ran out.
|
||||
Shortfall int64
|
||||
// True when the idempotency key had already been used and nothing moved.
|
||||
Replayed bool
|
||||
}
|
||||
|
||||
// CarryOver turns a debit's allocations into lots for the receiving side of a
|
||||
// transfer or exchange. Each lot keeps the expiry of the lot it came from and points
|
||||
// back at it, so a balance cannot be kept alive by moving it around (K9).
|
||||
func (r *WalletResult) CarryOver() []WalletLotInput {
|
||||
lots := make([]WalletLotInput, 0, len(r.Allocations))
|
||||
for _, a := range r.Allocations {
|
||||
lotID := a.LotID
|
||||
lots = append(lots, WalletLotInput{Amount: a.Amount, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID})
|
||||
}
|
||||
return lots
|
||||
}
|
||||
|
||||
// WalletProcessor is the only code allowed to change a wallet balance. Every change
|
||||
// writes the balance, the ledger row and the lots or allocations together, which is
|
||||
// what keeps SUM(ledger) = balance = SUM(lot remaining) (§7.5).
|
||||
//
|
||||
// Every method must run inside a transaction from TxManager, and the repository
|
||||
// refuses otherwise. Each method locks the customer's wallet itself, so a single-wallet
|
||||
// caller needs nothing more. A caller touching two wallets, such as a transfer, must
|
||||
// call LockWallets first so the locks are always taken in the same order.
|
||||
type WalletProcessor struct {
|
||||
repo repository.WalletRepository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewWalletProcessor(repo repository.WalletRepository) *WalletProcessor {
|
||||
return &WalletProcessor{repo: repo, now: time.Now}
|
||||
}
|
||||
|
||||
// LockWallet locks one customer's wallet, creating it if needed. Credit and Debit do
|
||||
// this themselves; call it when something must be read under the lock first.
|
||||
func (p *WalletProcessor) LockWallet(ctx context.Context, customerID uuid.UUID) error {
|
||||
_, err := p.repo.LockWallet(ctx, customerID)
|
||||
return err
|
||||
}
|
||||
|
||||
// LockWallets locks two customers' wallets in a fixed order. Call it before touching
|
||||
// both wallets in one transaction.
|
||||
func (p *WalletProcessor) LockWallets(ctx context.Context, a, b uuid.UUID) error {
|
||||
_, _, err := p.repo.LockWallets(ctx, a, b)
|
||||
return err
|
||||
}
|
||||
|
||||
// Credit adds Amount to the wallet and creates its lots.
|
||||
func (p *WalletProcessor) Credit(ctx context.Context, in WalletCreditInput) (*WalletResult, error) {
|
||||
if err := validateWalletEntry(&in.WalletEntry, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lots := in.Lots
|
||||
if len(lots) == 0 {
|
||||
lots = []WalletLotInput{{Amount: in.Amount}}
|
||||
}
|
||||
var total int64
|
||||
for _, lot := range lots {
|
||||
if lot.Amount <= 0 {
|
||||
return nil, fmt.Errorf("%w: lot amount must be positive, got %d", ErrWalletInvalidEntry, lot.Amount)
|
||||
}
|
||||
total += lot.Amount
|
||||
}
|
||||
if total != in.Amount {
|
||||
return nil, fmt.Errorf("%w: lots add up to %d, not %d", ErrWalletInvalidEntry, total, in.Amount)
|
||||
}
|
||||
|
||||
wallet, err := p.repo.LockWallet(ctx, in.CustomerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if replay, err := p.replay(ctx, &in.WalletEntry, true, true); replay != nil || err != nil {
|
||||
return replay, err
|
||||
}
|
||||
|
||||
balance, err := p.repo.AddBalance(ctx, in.CustomerID, in.Currency, in.Amount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
walletTx := newWalletTransaction(wallet, &in.WalletEntry, in.Amount, balance, nil)
|
||||
if err := p.repo.CreateTransaction(ctx, walletTx); err != nil {
|
||||
return nil, fmt.Errorf("failed to create wallet transaction: %w", err)
|
||||
}
|
||||
|
||||
result := &WalletResult{Transaction: walletTx}
|
||||
for _, lotIn := range lots {
|
||||
lot := entities.WalletLot{
|
||||
OrganizationID: wallet.OrganizationID,
|
||||
CustomerID: in.CustomerID,
|
||||
Currency: in.Currency,
|
||||
SourceTransactionID: walletTx.ID,
|
||||
OriginLotID: lotIn.OriginLotID,
|
||||
OriginalAmount: lotIn.Amount,
|
||||
RemainingAmount: lotIn.Amount,
|
||||
ExpiresAt: lotIn.ExpiresAt,
|
||||
}
|
||||
if err := p.repo.CreateLot(ctx, &lot); err != nil {
|
||||
return nil, fmt.Errorf("failed to create wallet lot: %w", err)
|
||||
}
|
||||
result.Lots = append(result.Lots, lot)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Debit takes exactly Amount from the wallet, or nothing at all with
|
||||
// repository.ErrWalletInsufficientBalance if the usable balance is short.
|
||||
func (p *WalletProcessor) Debit(ctx context.Context, in WalletDebitInput) (*WalletResult, error) {
|
||||
return p.debit(ctx, in, false)
|
||||
}
|
||||
|
||||
// DebitUpTo takes as much of Amount as the wallet has and reports the rest as
|
||||
// Shortfall. It is for reversing earnings the customer has already spent (F10, Q3).
|
||||
// When there is nothing to take, no ledger row is written and Transaction is nil;
|
||||
// such a call leaves no trace, so a retry with the same key takes whatever the
|
||||
// balance holds by then.
|
||||
func (p *WalletProcessor) DebitUpTo(ctx context.Context, in WalletDebitInput) (*WalletResult, error) {
|
||||
return p.debit(ctx, in, true)
|
||||
}
|
||||
|
||||
func (p *WalletProcessor) debit(ctx context.Context, in WalletDebitInput, upTo bool) (*WalletResult, error) {
|
||||
if err := validateWalletEntry(&in.WalletEntry, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wallet, err := p.repo.LockWallet(ctx, in.CustomerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// DebitUpTo may have taken less than asked, so the amount cannot be compared.
|
||||
if replay, err := p.replay(ctx, &in.WalletEntry, false, !upTo); replay != nil || err != nil {
|
||||
return replay, err
|
||||
}
|
||||
|
||||
lots, err := p.spendableLots(ctx, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var available int64
|
||||
for _, lot := range lots {
|
||||
available += lot.RemainingAmount
|
||||
}
|
||||
take := in.Amount
|
||||
if available < take {
|
||||
if !upTo {
|
||||
return nil, repository.ErrWalletInsufficientBalance
|
||||
}
|
||||
take = available
|
||||
}
|
||||
result := &WalletResult{Shortfall: in.Amount - take}
|
||||
if take == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var metadata entities.Metadata
|
||||
if upTo {
|
||||
metadata = entities.Metadata{"requested_amount": in.Amount, "shortfall": result.Shortfall}
|
||||
}
|
||||
|
||||
balance, err := p.repo.AddBalance(ctx, in.CustomerID, in.Currency, -take)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
walletTx := newWalletTransaction(wallet, &in.WalletEntry, -take, balance, metadata)
|
||||
if err := p.repo.CreateTransaction(ctx, walletTx); err != nil {
|
||||
return nil, fmt.Errorf("failed to create wallet transaction: %w", err)
|
||||
}
|
||||
result.Transaction = walletTx
|
||||
|
||||
var allocations []entities.WalletLotAllocation
|
||||
remaining := take
|
||||
for _, lot := range lots {
|
||||
if remaining == 0 {
|
||||
break
|
||||
}
|
||||
amount := min(lot.RemainingAmount, remaining)
|
||||
remaining -= amount
|
||||
if err := p.repo.ConsumeLot(ctx, lot.ID, amount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allocations = append(allocations, entities.WalletLotAllocation{TransactionID: walletTx.ID, LotID: lot.ID, Amount: amount})
|
||||
result.Allocations = append(result.Allocations, WalletAllocation{LotID: lot.ID, Amount: amount, ExpiresAt: lot.ExpiresAt})
|
||||
}
|
||||
if err := p.repo.CreateAllocations(ctx, allocations); err != nil {
|
||||
return nil, fmt.Errorf("failed to create wallet lot allocations: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// spendableLots returns the lots a debit may draw from, in the order it draws: the
|
||||
// preferred lots first, then the unexpired lots in K9 order.
|
||||
func (p *WalletProcessor) spendableLots(ctx context.Context, in *WalletDebitInput) ([]entities.WalletLot, error) {
|
||||
var lots []entities.WalletLot
|
||||
preferred := make(map[uuid.UUID]bool, len(in.PreferredLotIDs))
|
||||
|
||||
if len(in.PreferredLotIDs) > 0 {
|
||||
found, err := p.repo.GetLotsByIDs(ctx, in.PreferredLotIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := make(map[uuid.UUID]entities.WalletLot, len(found))
|
||||
for _, lot := range found {
|
||||
byID[lot.ID] = lot
|
||||
}
|
||||
for _, id := range in.PreferredLotIDs {
|
||||
lot, ok := byID[id]
|
||||
if !ok || lot.CustomerID != in.CustomerID || lot.Currency != in.Currency {
|
||||
return nil, fmt.Errorf("%w: lot %s is not a %s lot of this customer", ErrWalletInvalidEntry, id, in.Currency)
|
||||
}
|
||||
if preferred[id] {
|
||||
continue
|
||||
}
|
||||
preferred[id] = true
|
||||
if lot.RemainingAmount > 0 {
|
||||
lots = append(lots, lot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
active, err := p.repo.ListActiveLots(ctx, in.CustomerID, in.Currency, p.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, lot := range active {
|
||||
if !preferred[lot.ID] {
|
||||
lots = append(lots, lot)
|
||||
}
|
||||
}
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
// replay returns the first result for an idempotency key that has already been used,
|
||||
// or nil when the key is new. It runs after the wallet lock, so a concurrent request
|
||||
// with the same key has either committed its row or not started.
|
||||
func (p *WalletProcessor) replay(ctx context.Context, in *WalletEntry, credit, compareAmount bool) (*WalletResult, error) {
|
||||
if in.IdempotencyKey == "" {
|
||||
return nil, nil
|
||||
}
|
||||
walletTx, err := p.repo.GetTransactionByIdempotencyKey(ctx, in.IdempotencyKey)
|
||||
if err != nil || walletTx == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sameDirection := (walletTx.Amount > 0) == credit
|
||||
sameAmount := !compareAmount || abs(walletTx.Amount) == in.Amount
|
||||
if walletTx.CustomerID != in.CustomerID || walletTx.Currency != in.Currency ||
|
||||
walletTx.Type != in.Type || !sameDirection || !sameAmount {
|
||||
return nil, ErrWalletIdempotencyConflict
|
||||
}
|
||||
|
||||
result := &WalletResult{Transaction: walletTx, Replayed: true}
|
||||
if credit {
|
||||
result.Lots, err = p.repo.ListLotsBySourceTransaction(ctx, walletTx.ID)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// JSON numbers come back from JSONB as float64.
|
||||
switch shortfall := walletTx.Metadata["shortfall"].(type) {
|
||||
case float64:
|
||||
result.Shortfall = int64(shortfall)
|
||||
case int64:
|
||||
result.Shortfall = shortfall
|
||||
}
|
||||
allocations, err := p.repo.ListAllocationsByTransaction(ctx, walletTx.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(allocations))
|
||||
for _, a := range allocations {
|
||||
ids = append(ids, a.LotID)
|
||||
}
|
||||
lots, err := p.repo.GetLotsByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expiry := make(map[uuid.UUID]*time.Time, len(lots))
|
||||
for _, lot := range lots {
|
||||
expiry[lot.ID] = lot.ExpiresAt
|
||||
}
|
||||
for _, a := range allocations {
|
||||
result.Allocations = append(result.Allocations, WalletAllocation{LotID: a.LotID, Amount: a.Amount, ExpiresAt: expiry[a.LotID]})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func newWalletTransaction(wallet *entities.CustomerWallet, in *WalletEntry, amount, balance int64, extra entities.Metadata) *entities.WalletTransaction {
|
||||
metadata := entities.Metadata{}
|
||||
for k, v := range in.Metadata {
|
||||
metadata[k] = v
|
||||
}
|
||||
for k, v := range extra {
|
||||
metadata[k] = v
|
||||
}
|
||||
var key *string
|
||||
if in.IdempotencyKey != "" {
|
||||
k := in.IdempotencyKey
|
||||
key = &k
|
||||
}
|
||||
return &entities.WalletTransaction{
|
||||
ID: in.TransactionID,
|
||||
OrganizationID: wallet.OrganizationID,
|
||||
CustomerID: in.CustomerID,
|
||||
Currency: in.Currency,
|
||||
Type: in.Type,
|
||||
Amount: amount,
|
||||
BalanceAfter: balance,
|
||||
GroupID: in.GroupID,
|
||||
ReferenceType: in.ReferenceType,
|
||||
ReferenceID: in.ReferenceID,
|
||||
CounterpartyCustomerID: in.CounterpartyCustomerID,
|
||||
ReversesTransactionID: in.ReversesTransactionID,
|
||||
OutletID: in.OutletID,
|
||||
CreatedByUser: in.CreatedByUser,
|
||||
Reason: in.Reason,
|
||||
Description: in.Description,
|
||||
Metadata: metadata,
|
||||
IdempotencyKey: key,
|
||||
}
|
||||
}
|
||||
|
||||
// walletTypeRule is one row of §8.1.
|
||||
type walletTypeRule struct {
|
||||
credit, debit bool
|
||||
currency string // empty: either currency
|
||||
referenceTypes []string
|
||||
needsOutlet bool
|
||||
needsReverses bool
|
||||
needsGroup bool
|
||||
needsCounter bool
|
||||
needsActor bool
|
||||
}
|
||||
|
||||
var walletTypeRules = map[string]walletTypeRule{
|
||||
constants.WalletTxTypeEarn: {credit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true},
|
||||
constants.WalletTxTypeEarnReversal: {debit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true, needsReverses: true},
|
||||
constants.WalletTxTypePayment: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypePayment}, needsOutlet: true},
|
||||
constants.WalletTxTypePaymentRefund: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypePayment}, needsOutlet: true, needsReverses: true},
|
||||
constants.WalletTxTypeExchangeOut: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true},
|
||||
constants.WalletTxTypeExchangeIn: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true},
|
||||
constants.WalletTxTypeTransferOut: {debit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true},
|
||||
constants.WalletTxTypeTransferIn: {credit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true},
|
||||
constants.WalletTxTypeGameSpend: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeGamePlay}},
|
||||
constants.WalletTxTypeExpire: {debit: true, referenceTypes: []string{constants.WalletRefTypeLot}},
|
||||
constants.WalletTxTypeAdjustment: {credit: true, debit: true, referenceTypes: []string{constants.WalletRefTypeUser}, needsActor: true},
|
||||
constants.WalletTxTypeMigration: {credit: true, referenceTypes: []string{constants.WalletRefTypeLegacyPoints, constants.WalletRefTypeLegacyTokens}},
|
||||
constants.WalletTxTypeRewardRedeem: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeRewardRedemption}},
|
||||
}
|
||||
|
||||
func validateWalletEntry(in *WalletEntry, credit bool) error {
|
||||
invalid := func(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", ErrWalletInvalidEntry, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
rule, ok := walletTypeRules[in.Type]
|
||||
if !ok {
|
||||
return invalid("unknown type %q", in.Type)
|
||||
}
|
||||
if credit && !rule.credit {
|
||||
return invalid("%s cannot add to a balance", in.Type)
|
||||
}
|
||||
if !credit && !rule.debit {
|
||||
return invalid("%s cannot take from a balance", in.Type)
|
||||
}
|
||||
if in.CustomerID == uuid.Nil {
|
||||
return invalid("customer is required")
|
||||
}
|
||||
if !constants.IsValidWalletCurrency(in.Currency) {
|
||||
return invalid("unknown currency %q", in.Currency)
|
||||
}
|
||||
if rule.currency != "" && in.Currency != rule.currency {
|
||||
return invalid("%s must be in %s", in.Type, rule.currency)
|
||||
}
|
||||
if in.Amount <= 0 {
|
||||
return invalid("amount must be positive, got %d", in.Amount)
|
||||
}
|
||||
if !containsString(rule.referenceTypes, in.ReferenceType) {
|
||||
return invalid("%s must reference %s, got %q", in.Type, strings.Join(rule.referenceTypes, " or "), in.ReferenceType)
|
||||
}
|
||||
if in.ReferenceID == uuid.Nil {
|
||||
return invalid("reference id is required")
|
||||
}
|
||||
if strings.TrimSpace(in.Description) == "" {
|
||||
return invalid("description is required")
|
||||
}
|
||||
if rule.needsOutlet && isNilID(in.OutletID) {
|
||||
return invalid("%s requires an outlet", in.Type)
|
||||
}
|
||||
if rule.needsReverses && isNilID(in.ReversesTransactionID) {
|
||||
return invalid("%s requires the transaction it reverses", in.Type)
|
||||
}
|
||||
if rule.needsGroup && isNilID(in.GroupID) {
|
||||
return invalid("%s requires a group id", in.Type)
|
||||
}
|
||||
if rule.needsCounter {
|
||||
if isNilID(in.CounterpartyCustomerID) {
|
||||
return invalid("%s requires a counterparty", in.Type)
|
||||
}
|
||||
if *in.CounterpartyCustomerID == in.CustomerID {
|
||||
return invalid("%s cannot go to the same customer", in.Type)
|
||||
}
|
||||
}
|
||||
if rule.needsActor {
|
||||
if isNilID(in.CreatedByUser) {
|
||||
return invalid("%s requires the admin who made it", in.Type)
|
||||
}
|
||||
if in.Reason == nil || strings.TrimSpace(*in.Reason) == "" {
|
||||
return invalid("%s requires a reason", in.Type)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isNilID(id *uuid.UUID) bool {
|
||||
return id == nil || *id == uuid.Nil
|
||||
}
|
||||
|
||||
func containsString(values []string, v string) bool {
|
||||
for _, value := range values {
|
||||
if value == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func abs(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// Runs the engine against Postgres, to show the rows it writes pass the database
|
||||
// constraints and reconcile the way §7.5 requires. Needs TEST_DATABASE_URL pointing
|
||||
// at a migrated database; see internal/repository/wallet_repository_test.go.
|
||||
func TestWalletProcessor_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
|
||||
org, a, b := uuid.New(), uuid.New(), uuid.New()
|
||||
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet test', 'basic')`, org).Error)
|
||||
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'A'), (?, ?, 'B')`, a, org, b, org).Error)
|
||||
customers := []uuid.UUID{a, b}
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
|
||||
p := NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
txm := repository.NewTxManager(db)
|
||||
now := time.Now()
|
||||
inTx := func(fn func(ctx context.Context) error) {
|
||||
t.Helper()
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), fn))
|
||||
}
|
||||
|
||||
// Without a transaction nothing moves.
|
||||
_, err = p.Credit(context.Background(), earn(a, 10, nil))
|
||||
assert.ErrorIs(t, err, repository.ErrWalletTxRequired)
|
||||
|
||||
var earned *WalletResult
|
||||
inTx(func(ctx context.Context) error {
|
||||
soon := now.Add(time.Hour)
|
||||
earned, err = p.Credit(ctx, earn(a, 100, &soon))
|
||||
require.NoError(t, err)
|
||||
_, err = p.Credit(ctx, earn(a, 50, nil))
|
||||
return err
|
||||
})
|
||||
|
||||
// Transfer 120 from A to B, spanning both of A's lots.
|
||||
inTx(func(ctx context.Context) error {
|
||||
require.NoError(t, p.LockWallets(ctx, a, b))
|
||||
group, outID, inID := uuid.New(), uuid.New(), uuid.New()
|
||||
out, err := p.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
|
||||
TransactionID: outID, CustomerID: a, Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypeTransferOut, Amount: 120,
|
||||
ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: inID,
|
||||
GroupID: &group, CounterpartyCustomerID: &b, Description: "Transfer ke B",
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
_, err = p.Credit(ctx, WalletCreditInput{
|
||||
WalletEntry: WalletEntry{
|
||||
TransactionID: inID, CustomerID: b, Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypeTransferIn, Amount: 120,
|
||||
ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: outID,
|
||||
GroupID: &group, CounterpartyCustomerID: &a, Description: "Transfer dari A",
|
||||
},
|
||||
Lots: out.CarryOver(),
|
||||
})
|
||||
return err
|
||||
})
|
||||
|
||||
// Reversing the 100 earned leaves A 30 short. The retry reads the shortfall back
|
||||
// out of JSONB and takes nothing more.
|
||||
rev := reversal(a, 100, earned)
|
||||
rev.IdempotencyKey = "reverse:" + earned.Transaction.ID.String()
|
||||
var first, second *WalletResult
|
||||
inTx(func(ctx context.Context) error {
|
||||
first, err = p.DebitUpTo(ctx, rev)
|
||||
return err
|
||||
})
|
||||
inTx(func(ctx context.Context) error {
|
||||
second, err = p.DebitUpTo(ctx, rev)
|
||||
return err
|
||||
})
|
||||
assert.Equal(t, int64(-30), first.Transaction.Amount)
|
||||
assert.Equal(t, int64(70), first.Shortfall)
|
||||
assert.True(t, second.Replayed)
|
||||
assert.Equal(t, int64(70), second.Shortfall)
|
||||
assert.Equal(t, first.Transaction.ID, second.Transaction.ID)
|
||||
|
||||
// Overdraw fails and rolls back cleanly.
|
||||
err = txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
_, err := p.Debit(ctx, pay(b, 121))
|
||||
return err
|
||||
})
|
||||
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
|
||||
|
||||
var balances []struct {
|
||||
CustomerID uuid.UUID
|
||||
PointBalance int64
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT customer_id, point_balance FROM customer_wallets WHERE customer_id IN ?`, customers).Scan(&balances).Error)
|
||||
got := map[uuid.UUID]int64{}
|
||||
for _, row := range balances {
|
||||
got[row.CustomerID] = row.PointBalance
|
||||
}
|
||||
assert.Equal(t, map[uuid.UUID]int64{a: 0, b: 120}, got)
|
||||
|
||||
// §7.5, straight from the tables.
|
||||
var broken []string
|
||||
require.NoError(t, db.Raw(`
|
||||
SELECT 'wallet ' || w.customer_id FROM customer_wallets w
|
||||
WHERE w.customer_id IN ? AND (
|
||||
w.point_balance <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions t WHERE t.customer_id = w.customer_id AND t.currency = 'POINT')
|
||||
OR w.point_balance <> (SELECT COALESCE(SUM(remaining_amount), 0) FROM wallet_lots l WHERE l.customer_id = w.customer_id AND l.currency = 'POINT'))
|
||||
UNION ALL
|
||||
SELECT 'lot ' || l.id FROM wallet_lots l
|
||||
WHERE l.customer_id IN ? AND l.original_amount - l.remaining_amount
|
||||
<> (SELECT COALESCE(SUM(amount), 0) FROM wallet_lot_allocations a WHERE a.lot_id = l.id)
|
||||
UNION ALL
|
||||
SELECT 'debit ' || t.id FROM wallet_transactions t
|
||||
WHERE t.customer_id IN ? AND t.amount < 0
|
||||
AND -t.amount <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_lot_allocations a WHERE a.transaction_id = t.id)`,
|
||||
customers, customers, customers).Scan(&broken).Error)
|
||||
assert.Empty(t, broken)
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// walletRepoFake is an in-memory WalletRepository with the same conditional-update
|
||||
// semantics as the real one. Locks are only counted: these tests are single-threaded,
|
||||
// and the locking itself is covered by the repository tests against Postgres.
|
||||
type walletRepoFake struct {
|
||||
customers map[uuid.UUID]uuid.UUID // customer -> organization
|
||||
wallets map[uuid.UUID]*entities.CustomerWallet
|
||||
transactions []*entities.WalletTransaction
|
||||
lots []*entities.WalletLot
|
||||
allocations []entities.WalletLotAllocation
|
||||
locks map[uuid.UUID]int
|
||||
clock time.Time
|
||||
}
|
||||
|
||||
func newWalletRepoFake() *walletRepoFake {
|
||||
return &walletRepoFake{
|
||||
customers: map[uuid.UUID]uuid.UUID{},
|
||||
wallets: map[uuid.UUID]*entities.CustomerWallet{},
|
||||
locks: map[uuid.UUID]int{},
|
||||
clock: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *walletRepoFake) tick() time.Time {
|
||||
f.clock = f.clock.Add(time.Second)
|
||||
return f.clock
|
||||
}
|
||||
|
||||
func (f *walletRepoFake) LockWallet(_ context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) {
|
||||
org, ok := f.customers[customerID]
|
||||
if !ok {
|
||||
return nil, repository.ErrWalletNotFound
|
||||
}
|
||||
if f.wallets[customerID] == nil {
|
||||
f.wallets[customerID] = &entities.CustomerWallet{CustomerID: customerID, OrganizationID: org}
|
||||
}
|
||||
f.locks[customerID]++
|
||||
w := *f.wallets[customerID]
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
func (f *walletRepoFake) LockWallets(ctx context.Context, a, b uuid.UUID) (*entities.CustomerWallet, *entities.CustomerWallet, error) {
|
||||
wa, err := f.LockWallet(ctx, a)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
wb, err := f.LockWallet(ctx, b)
|
||||
return wa, wb, err
|
||||
}
|
||||
|
||||
func (f *walletRepoFake) AddBalance(_ context.Context, customerID uuid.UUID, currency string, delta int64) (int64, error) {
|
||||
w := f.wallets[customerID]
|
||||
if w == nil {
|
||||
return 0, repository.ErrWalletNotFound
|
||||
}
|
||||
balance := &w.PointBalance
|
||||
if currency == constants.WalletCurrencyCoin {
|
||||
balance = &w.CoinBalance
|
||||
}
|
||||
if *balance+delta < 0 {
|
||||
return 0, repository.ErrWalletInsufficientBalance
|
||||
}
|
||||
*balance += delta
|
||||
return *balance, nil
|
||||
}
|
||||
|
||||
func (f *walletRepoFake) GetWallet(_ context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) {
|
||||
w := f.wallets[customerID]
|
||||
if w == nil {
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
c := *w
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (f *walletRepoFake) CreateTransaction(_ context.Context, tx *entities.WalletTransaction) error {
|
||||
if tx.IdempotencyKey != nil {
|
||||
for _, t := range f.transactions {
|
||||
if t.IdempotencyKey != nil && *t.IdempotencyKey == *tx.IdempotencyKey {
|
||||
return errors.New("duplicate idempotency key")
|
||||
}
|
||||
}
|
||||
}
|
||||
if tx.ID == uuid.Nil {
|
||||
tx.ID = uuid.New()
|
||||
}
|
||||
tx.CreatedAt = f.tick()
|
||||
c := *tx
|
||||
f.transactions = append(f.transactions, &c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *walletRepoFake) GetTransactionByIdempotencyKey(_ context.Context, key string) (*entities.WalletTransaction, error) {
|
||||
for _, t := range f.transactions {
|
||||
if t.IdempotencyKey != nil && *t.IdempotencyKey == key {
|
||||
c := *t
|
||||
return &c, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *walletRepoFake) CreateLot(_ context.Context, lot *entities.WalletLot) error {
|
||||
if lot.ID == uuid.Nil {
|
||||
lot.ID = uuid.New()
|
||||
}
|
||||
lot.CreatedAt = f.tick()
|
||||
c := *lot
|
||||
f.lots = append(f.lots, &c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *walletRepoFake) GetLotsByIDs(_ context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) {
|
||||
var out []entities.WalletLot
|
||||
for _, lot := range f.lots {
|
||||
for _, id := range ids {
|
||||
if lot.ID == id {
|
||||
out = append(out, *lot)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *walletRepoFake) ListLotsBySourceTransaction(_ context.Context, txID uuid.UUID) ([]entities.WalletLot, error) {
|
||||
var out []entities.WalletLot
|
||||
for _, lot := range f.lots {
|
||||
if lot.SourceTransactionID == txID {
|
||||
out = append(out, *lot)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *walletRepoFake) ListActiveLots(_ context.Context, customerID uuid.UUID, currency string, asOf time.Time) ([]entities.WalletLot, error) {
|
||||
var out []entities.WalletLot
|
||||
for _, lot := range f.lots {
|
||||
if lot.CustomerID == customerID && lot.Currency == currency && lot.RemainingAmount > 0 &&
|
||||
(lot.ExpiresAt == nil || lot.ExpiresAt.After(asOf)) {
|
||||
out = append(out, *lot)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
a, b := out[i], out[j]
|
||||
switch {
|
||||
case a.ExpiresAt == nil && b.ExpiresAt != nil:
|
||||
return false
|
||||
case a.ExpiresAt != nil && b.ExpiresAt == nil:
|
||||
return true
|
||||
case a.ExpiresAt != nil && !a.ExpiresAt.Equal(*b.ExpiresAt):
|
||||
return a.ExpiresAt.Before(*b.ExpiresAt)
|
||||
}
|
||||
return a.CreatedAt.Before(b.CreatedAt)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *walletRepoFake) ConsumeLot(_ context.Context, lotID uuid.UUID, amount int64) error {
|
||||
for _, lot := range f.lots {
|
||||
if lot.ID == lotID {
|
||||
if lot.RemainingAmount < amount {
|
||||
return repository.ErrWalletLotInsufficient
|
||||
}
|
||||
lot.RemainingAmount -= amount
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return repository.ErrWalletLotInsufficient
|
||||
}
|
||||
|
||||
func (f *walletRepoFake) CreateAllocations(_ context.Context, allocations []entities.WalletLotAllocation) error {
|
||||
f.allocations = append(f.allocations, allocations...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *walletRepoFake) ListAllocationsByTransaction(_ context.Context, txID uuid.UUID) ([]entities.WalletLotAllocation, error) {
|
||||
var out []entities.WalletLotAllocation
|
||||
for _, a := range f.allocations {
|
||||
if a.TransactionID == txID {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// assertInvariants checks the reconciliation rules of §7.5 over everything the fake
|
||||
// holds.
|
||||
func (f *walletRepoFake) assertInvariants(t *testing.T) {
|
||||
t.Helper()
|
||||
allocatedFromLot := map[uuid.UUID]int64{}
|
||||
allocatedByTx := map[uuid.UUID]int64{}
|
||||
for _, a := range f.allocations {
|
||||
allocatedFromLot[a.LotID] += a.Amount
|
||||
allocatedByTx[a.TransactionID] += a.Amount
|
||||
}
|
||||
createdByTx := map[uuid.UUID]int64{}
|
||||
for _, lot := range f.lots {
|
||||
createdByTx[lot.SourceTransactionID] += lot.OriginalAmount
|
||||
assert.Equal(t, lot.OriginalAmount-allocatedFromLot[lot.ID], lot.RemainingAmount, "lot %s: original - allocations = remaining", lot.ID)
|
||||
}
|
||||
for _, tx := range f.transactions {
|
||||
if tx.Amount > 0 {
|
||||
assert.Equal(t, tx.Amount, createdByTx[tx.ID], "credit %s: lots add up to the amount", tx.Type)
|
||||
assert.Zero(t, allocatedByTx[tx.ID], "credit %s has no allocations", tx.Type)
|
||||
} else {
|
||||
assert.Equal(t, -tx.Amount, allocatedByTx[tx.ID], "debit %s: allocations add up to the amount", tx.Type)
|
||||
assert.Zero(t, createdByTx[tx.ID], "debit %s creates no lots", tx.Type)
|
||||
}
|
||||
}
|
||||
for customerID, w := range f.wallets {
|
||||
for currency, balance := range map[string]int64{
|
||||
constants.WalletCurrencyPoint: w.PointBalance,
|
||||
constants.WalletCurrencyCoin: w.CoinBalance,
|
||||
} {
|
||||
var ledger, lots, last int64
|
||||
for _, tx := range f.transactions {
|
||||
if tx.CustomerID == customerID && tx.Currency == currency {
|
||||
ledger += tx.Amount
|
||||
last = tx.BalanceAfter
|
||||
}
|
||||
}
|
||||
for _, lot := range f.lots {
|
||||
if lot.CustomerID == customerID && lot.Currency == currency {
|
||||
lots += lot.RemainingAmount
|
||||
}
|
||||
}
|
||||
assert.Equal(t, balance, ledger, "%s balance = SUM(ledger)", currency)
|
||||
assert.Equal(t, balance, lots, "%s balance = SUM(lot remaining)", currency)
|
||||
assert.Equal(t, balance, last, "%s balance = last balance_after", currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type walletTestEnv struct {
|
||||
repo *walletRepoFake
|
||||
p *WalletProcessor
|
||||
now time.Time
|
||||
org uuid.UUID
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func newWalletTestEnv(t *testing.T) *walletTestEnv {
|
||||
repo := newWalletRepoFake()
|
||||
env := &walletTestEnv{
|
||||
repo: repo,
|
||||
p: NewWalletProcessor(repo),
|
||||
now: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC),
|
||||
org: uuid.New(),
|
||||
ctx: context.Background(),
|
||||
}
|
||||
env.p.now = func() time.Time { return env.now }
|
||||
t.Cleanup(func() { repo.assertInvariants(t) })
|
||||
return env
|
||||
}
|
||||
|
||||
func (e *walletTestEnv) customer() uuid.UUID {
|
||||
id := uuid.New()
|
||||
e.repo.customers[id] = e.org
|
||||
return id
|
||||
}
|
||||
|
||||
func (e *walletTestEnv) at(d time.Duration) *time.Time {
|
||||
v := e.now.Add(d)
|
||||
return &v
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func earn(customerID uuid.UUID, amount int64, expiresAt *time.Time) WalletCreditInput {
|
||||
return WalletCreditInput{
|
||||
WalletEntry: WalletEntry{
|
||||
CustomerID: customerID,
|
||||
Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypeEarn,
|
||||
Amount: amount,
|
||||
ReferenceType: constants.WalletRefTypeOrder,
|
||||
ReferenceID: uuid.New(),
|
||||
OutletID: ptr(uuid.New()),
|
||||
Description: "Belanja",
|
||||
},
|
||||
Lots: []WalletLotInput{{Amount: amount, ExpiresAt: expiresAt}},
|
||||
}
|
||||
}
|
||||
|
||||
func pay(customerID uuid.UUID, amount int64) WalletDebitInput {
|
||||
return WalletDebitInput{WalletEntry: WalletEntry{
|
||||
CustomerID: customerID,
|
||||
Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypePayment,
|
||||
Amount: amount,
|
||||
ReferenceType: constants.WalletRefTypePayment,
|
||||
ReferenceID: uuid.New(),
|
||||
OutletID: ptr(uuid.New()),
|
||||
Description: "Bayar",
|
||||
}}
|
||||
}
|
||||
|
||||
func (e *walletTestEnv) credit(t *testing.T, in WalletCreditInput) *WalletResult {
|
||||
t.Helper()
|
||||
res, err := e.p.Credit(e.ctx, in)
|
||||
require.NoError(t, err)
|
||||
return res
|
||||
}
|
||||
|
||||
func (e *walletTestEnv) balance(t *testing.T, customerID uuid.UUID) int64 {
|
||||
t.Helper()
|
||||
w, err := e.repo.GetWallet(e.ctx, customerID)
|
||||
require.NoError(t, err)
|
||||
return w.PointBalance
|
||||
}
|
||||
|
||||
func allocationsOf(res *WalletResult) map[uuid.UUID]int64 {
|
||||
out := map[uuid.UUID]int64{}
|
||||
for _, a := range res.Allocations {
|
||||
out[a.LotID] = a.Amount
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestWalletProcessor_CreditCreatesLedgerRowAndLot(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
c := e.customer()
|
||||
|
||||
res := e.credit(t, earn(c, 100, e.at(24*time.Hour)))
|
||||
|
||||
assert.Equal(t, int64(100), res.Transaction.Amount)
|
||||
assert.Equal(t, int64(100), res.Transaction.BalanceAfter)
|
||||
assert.Equal(t, e.org, res.Transaction.OrganizationID, "organization comes from the wallet")
|
||||
require.Len(t, res.Lots, 1)
|
||||
assert.Equal(t, res.Transaction.ID, res.Lots[0].SourceTransactionID)
|
||||
assert.Equal(t, e.at(24*time.Hour), res.Lots[0].ExpiresAt)
|
||||
assert.Equal(t, int64(100), e.balance(t, c))
|
||||
assert.Equal(t, 1, e.repo.locks[c], "credit locks the wallet itself")
|
||||
}
|
||||
|
||||
func TestWalletProcessor_CreditWithoutLotsMakesOneNonExpiringLot(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
c := e.customer()
|
||||
in := earn(c, 40, nil)
|
||||
in.Lots = nil
|
||||
|
||||
res := e.credit(t, in)
|
||||
require.Len(t, res.Lots, 1)
|
||||
assert.Equal(t, int64(40), res.Lots[0].OriginalAmount)
|
||||
assert.Nil(t, res.Lots[0].ExpiresAt)
|
||||
}
|
||||
|
||||
func TestWalletProcessor_CreditRejectsLotsThatDoNotAddUp(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
c := e.customer()
|
||||
in := earn(c, 100, nil)
|
||||
in.Lots = []WalletLotInput{{Amount: 60}, {Amount: 30}}
|
||||
|
||||
_, err := e.p.Credit(e.ctx, in)
|
||||
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
|
||||
|
||||
in.Lots = []WalletLotInput{{Amount: 100}, {Amount: 0}}
|
||||
_, err = e.p.Credit(e.ctx, in)
|
||||
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
|
||||
assert.Empty(t, e.repo.transactions)
|
||||
}
|
||||
|
||||
func TestWalletProcessor_DebitAcrossSeveralLots(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
c := e.customer()
|
||||
first := e.credit(t, earn(c, 30, e.at(1*time.Hour))).Lots[0]
|
||||
second := e.credit(t, earn(c, 50, e.at(2*time.Hour))).Lots[0]
|
||||
third := e.credit(t, earn(c, 40, e.at(3*time.Hour))).Lots[0]
|
||||
|
||||
res, err := e.p.Debit(e.ctx, pay(c, 70))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(-70), res.Transaction.Amount)
|
||||
assert.Equal(t, int64(50), res.Transaction.BalanceAfter)
|
||||
assert.Equal(t, map[uuid.UUID]int64{first.ID: 30, second.ID: 40}, allocationsOf(res))
|
||||
assert.Equal(t, first.ID, res.Allocations[0].LotID, "allocations are reported in the order used")
|
||||
assert.Equal(t, e.at(1*time.Hour), res.Allocations[0].ExpiresAt)
|
||||
|
||||
lots, _ := e.repo.GetLotsByIDs(e.ctx, []uuid.UUID{first.ID, second.ID, third.ID})
|
||||
remaining := map[uuid.UUID]int64{}
|
||||
for _, l := range lots {
|
||||
remaining[l.ID] = l.RemainingAmount
|
||||
}
|
||||
assert.Equal(t, map[uuid.UUID]int64{first.ID: 0, second.ID: 10, third.ID: 40}, remaining)
|
||||
}
|
||||
|
||||
// K9: soonest expiry first, lots without an expiry last and oldest first among them,
|
||||
// expired lots never.
|
||||
func TestWalletProcessor_DebitFollowsLotOrder(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
c := e.customer()
|
||||
neverOld := e.credit(t, earn(c, 10, nil)).Lots[0]
|
||||
late := e.credit(t, earn(c, 10, e.at(48*time.Hour))).Lots[0]
|
||||
soon := e.credit(t, earn(c, 10, e.at(1*time.Hour))).Lots[0]
|
||||
neverNew := e.credit(t, earn(c, 10, nil)).Lots[0]
|
||||
e.credit(t, earn(c, 10, e.at(-1*time.Hour))) // already expired
|
||||
|
||||
var order []uuid.UUID
|
||||
for i := 0; i < 4; i++ {
|
||||
res, err := e.p.Debit(e.ctx, pay(c, 10))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Allocations, 1)
|
||||
order = append(order, res.Allocations[0].LotID)
|
||||
}
|
||||
assert.Equal(t, []uuid.UUID{soon.ID, late.ID, neverOld.ID, neverNew.ID}, order)
|
||||
|
||||
// The expired lot still counts in the balance until the expiry job removes it,
|
||||
// but it cannot be spent (§7.3).
|
||||
assert.Equal(t, int64(10), e.balance(t, c))
|
||||
_, err := e.p.Debit(e.ctx, pay(c, 10))
|
||||
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
|
||||
}
|
||||
|
||||
func TestWalletProcessor_DebitOverBalanceChangesNothing(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
c := e.customer()
|
||||
e.credit(t, earn(c, 50, nil))
|
||||
|
||||
_, err := e.p.Debit(e.ctx, pay(c, 51))
|
||||
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
|
||||
assert.Equal(t, int64(50), e.balance(t, c))
|
||||
assert.Len(t, e.repo.transactions, 1)
|
||||
assert.Empty(t, e.repo.allocations)
|
||||
|
||||
// A customer who never had a wallet has nothing to spend.
|
||||
_, err = e.p.Debit(e.ctx, pay(e.customer(), 1))
|
||||
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
|
||||
}
|
||||
|
||||
func reversal(customerID uuid.UUID, amount int64, earnRes *WalletResult) WalletDebitInput {
|
||||
in := WalletDebitInput{WalletEntry: WalletEntry{
|
||||
CustomerID: customerID,
|
||||
Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypeEarnReversal,
|
||||
Amount: amount,
|
||||
ReferenceType: constants.WalletRefTypeOrder,
|
||||
ReferenceID: earnRes.Transaction.ReferenceID,
|
||||
ReversesTransactionID: &earnRes.Transaction.ID,
|
||||
OutletID: earnRes.Transaction.OutletID,
|
||||
Description: "Batal",
|
||||
}}
|
||||
for _, lot := range earnRes.Lots {
|
||||
in.PreferredLotIDs = append(in.PreferredLotIDs, lot.ID)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func TestWalletProcessor_DebitUpToWithShortfall(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
c := e.customer()
|
||||
earned := e.credit(t, earn(c, 100, nil))
|
||||
_, err := e.p.Debit(e.ctx, pay(c, 70))
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := e.p.DebitUpTo(e.ctx, reversal(c, 100, earned))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(-30), res.Transaction.Amount)
|
||||
assert.Equal(t, int64(70), res.Shortfall)
|
||||
assert.Equal(t, int64(100), res.Transaction.Metadata["requested_amount"])
|
||||
assert.Equal(t, int64(70), res.Transaction.Metadata["shortfall"])
|
||||
assert.Equal(t, int64(0), e.balance(t, c))
|
||||
|
||||
// Nothing left: no ledger row, the whole amount is shortfall.
|
||||
in := reversal(c, 5, earned)
|
||||
res, err = e.p.DebitUpTo(e.ctx, in)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, res.Transaction)
|
||||
assert.Equal(t, int64(5), res.Shortfall)
|
||||
assert.Len(t, e.repo.transactions, 3)
|
||||
}
|
||||
|
||||
// A reversal draws from the lots its EARN created first (F10), even when an older lot
|
||||
// would come first in K9 order, and even when that lot has expired.
|
||||
func TestWalletProcessor_DebitDrawsPreferredLotsFirst(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
c := e.customer()
|
||||
older := e.credit(t, earn(c, 50, e.at(1*time.Hour))).Lots[0]
|
||||
earned := e.credit(t, earn(c, 20, e.at(-1*time.Hour)))
|
||||
|
||||
res, err := e.p.Debit(e.ctx, reversal(c, 30, earned))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Allocations, 2)
|
||||
assert.Equal(t, earned.Lots[0].ID, res.Allocations[0].LotID)
|
||||
assert.Equal(t, int64(20), res.Allocations[0].Amount)
|
||||
assert.Equal(t, older.ID, res.Allocations[1].LotID)
|
||||
assert.Equal(t, int64(10), res.Allocations[1].Amount)
|
||||
}
|
||||
|
||||
func TestWalletProcessor_DebitRejectsSomeoneElsesLot(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
a, b := e.customer(), e.customer()
|
||||
e.credit(t, earn(a, 10, nil))
|
||||
other := e.credit(t, earn(b, 10, nil))
|
||||
|
||||
in := pay(a, 5)
|
||||
in.PreferredLotIDs = []uuid.UUID{other.Lots[0].ID}
|
||||
_, err := e.p.Debit(e.ctx, in)
|
||||
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
|
||||
|
||||
in.PreferredLotIDs = []uuid.UUID{uuid.New()}
|
||||
_, err = e.p.Debit(e.ctx, in)
|
||||
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
|
||||
}
|
||||
|
||||
func TestWalletProcessor_ExpireDrawsTheExpiredLot(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
c := e.customer()
|
||||
e.credit(t, earn(c, 10, nil))
|
||||
expired := e.credit(t, earn(c, 25, e.at(-1*time.Hour))).Lots[0]
|
||||
|
||||
res, err := e.p.Debit(e.ctx, WalletDebitInput{
|
||||
WalletEntry: WalletEntry{
|
||||
CustomerID: c,
|
||||
Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypeExpire,
|
||||
Amount: expired.RemainingAmount,
|
||||
ReferenceType: constants.WalletRefTypeLot,
|
||||
ReferenceID: expired.ID,
|
||||
Description: "Kedaluwarsa",
|
||||
IdempotencyKey: "expire:" + expired.ID.String(),
|
||||
},
|
||||
PreferredLotIDs: []uuid.UUID{expired.ID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[uuid.UUID]int64{expired.ID: 25}, allocationsOf(res))
|
||||
assert.Equal(t, int64(10), e.balance(t, c))
|
||||
}
|
||||
|
||||
func TestWalletProcessor_IdempotentCredit(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
c := e.customer()
|
||||
in := earn(c, 100, nil)
|
||||
in.IdempotencyKey = "earn:order-1"
|
||||
|
||||
first := e.credit(t, in)
|
||||
in.ReferenceID = first.Transaction.ReferenceID
|
||||
second := e.credit(t, in)
|
||||
|
||||
assert.True(t, second.Replayed)
|
||||
assert.False(t, first.Replayed)
|
||||
assert.Equal(t, first.Transaction.ID, second.Transaction.ID)
|
||||
assert.Equal(t, first.Lots[0].ID, second.Lots[0].ID)
|
||||
assert.Equal(t, int64(100), e.balance(t, c))
|
||||
assert.Len(t, e.repo.transactions, 1)
|
||||
}
|
||||
|
||||
func TestWalletProcessor_IdempotentDebit(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
c := e.customer()
|
||||
e.credit(t, earn(c, 30, e.at(time.Hour)))
|
||||
e.credit(t, earn(c, 30, nil))
|
||||
in := pay(c, 40)
|
||||
in.IdempotencyKey = "pay:1"
|
||||
|
||||
first, err := e.p.Debit(e.ctx, in)
|
||||
require.NoError(t, err)
|
||||
second, err := e.p.Debit(e.ctx, in)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, second.Replayed)
|
||||
assert.Equal(t, first.Transaction.ID, second.Transaction.ID)
|
||||
assert.ElementsMatch(t, first.Allocations, second.Allocations)
|
||||
assert.Equal(t, int64(20), e.balance(t, c))
|
||||
}
|
||||
|
||||
func TestWalletProcessor_IdempotentDebitUpToKeepsShortfall(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
c := e.customer()
|
||||
earned := e.credit(t, earn(c, 100, nil))
|
||||
_, err := e.p.Debit(e.ctx, pay(c, 60))
|
||||
require.NoError(t, err)
|
||||
in := reversal(c, 100, earned)
|
||||
in.IdempotencyKey = "reverse:order-1"
|
||||
|
||||
first, err := e.p.DebitUpTo(e.ctx, in)
|
||||
require.NoError(t, err)
|
||||
e.credit(t, earn(c, 500, nil)) // new balance must not be taken by the retry
|
||||
second, err := e.p.DebitUpTo(e.ctx, in)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, second.Replayed)
|
||||
assert.Equal(t, first.Transaction.ID, second.Transaction.ID)
|
||||
assert.Equal(t, int64(60), second.Shortfall)
|
||||
assert.Equal(t, int64(500), e.balance(t, c))
|
||||
}
|
||||
|
||||
func TestWalletProcessor_IdempotencyKeyReusedForAnotherOperation(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
c := e.customer()
|
||||
in := earn(c, 100, nil)
|
||||
in.IdempotencyKey = "k"
|
||||
e.credit(t, in)
|
||||
|
||||
other := earn(c, 99, nil)
|
||||
other.IdempotencyKey = "k"
|
||||
_, err := e.p.Credit(e.ctx, other)
|
||||
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
|
||||
|
||||
debit := pay(c, 100)
|
||||
debit.IdempotencyKey = "k"
|
||||
_, err = e.p.Debit(e.ctx, debit)
|
||||
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
|
||||
|
||||
otherCustomer := earn(e.customer(), 100, nil)
|
||||
otherCustomer.IdempotencyKey = "k"
|
||||
_, err = e.p.Credit(e.ctx, otherCustomer)
|
||||
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
|
||||
}
|
||||
|
||||
// A transfer debits the sender and credits the receiver with lots that keep the
|
||||
// sender's expiry (K9), following the example in §8.
|
||||
func TestWalletProcessor_TransferCarriesExpiry(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
a, b := e.customer(), e.customer()
|
||||
dec := e.credit(t, earn(a, 100, e.at(30*24*time.Hour))).Lots[0]
|
||||
jan := e.credit(t, earn(a, 50, e.at(60*24*time.Hour))).Lots[0]
|
||||
|
||||
require.NoError(t, e.p.LockWallets(e.ctx, a, b))
|
||||
group, outID, inID := uuid.New(), uuid.New(), uuid.New()
|
||||
out, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: WalletEntry{
|
||||
TransactionID: outID, CustomerID: a, Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypeTransferOut, Amount: 120,
|
||||
ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: inID,
|
||||
GroupID: &group, CounterpartyCustomerID: &b, Description: "Transfer ke B",
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
received, err := e.p.Credit(e.ctx, WalletCreditInput{
|
||||
WalletEntry: WalletEntry{
|
||||
TransactionID: inID, CustomerID: b, Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypeTransferIn, Amount: 120,
|
||||
ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: outID,
|
||||
GroupID: &group, CounterpartyCustomerID: &a, Description: "Transfer dari A",
|
||||
},
|
||||
Lots: out.CarryOver(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, outID, out.Transaction.ID)
|
||||
assert.Equal(t, inID, received.Transaction.ID)
|
||||
require.Len(t, received.Lots, 2)
|
||||
assert.Equal(t, int64(100), received.Lots[0].OriginalAmount)
|
||||
assert.Equal(t, dec.ExpiresAt, received.Lots[0].ExpiresAt)
|
||||
assert.Equal(t, &dec.ID, received.Lots[0].OriginLotID)
|
||||
assert.Equal(t, int64(20), received.Lots[1].OriginalAmount)
|
||||
assert.Equal(t, jan.ExpiresAt, received.Lots[1].ExpiresAt)
|
||||
assert.Equal(t, &jan.ID, received.Lots[1].OriginLotID)
|
||||
assert.Equal(t, int64(30), e.balance(t, a))
|
||||
assert.Equal(t, int64(120), e.balance(t, b))
|
||||
}
|
||||
|
||||
func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) {
|
||||
c := uuid.New()
|
||||
outlet := ptr(uuid.New())
|
||||
|
||||
credits := map[string]func(*WalletCreditInput){
|
||||
"unknown type": func(in *WalletCreditInput) { in.Type = "BONUS" },
|
||||
"debit-only type as credit": func(in *WalletCreditInput) {
|
||||
in.Type = constants.WalletTxTypePayment
|
||||
in.ReferenceType = constants.WalletRefTypePayment
|
||||
},
|
||||
"unknown currency": func(in *WalletCreditInput) { in.Currency = "GOLD" },
|
||||
"zero amount": func(in *WalletCreditInput) { in.Amount = 0; in.Lots = nil },
|
||||
"negative amount": func(in *WalletCreditInput) { in.Amount = -5; in.Lots = nil },
|
||||
"wrong reference type": func(in *WalletCreditInput) { in.ReferenceType = constants.WalletRefTypeUser },
|
||||
"missing reference id": func(in *WalletCreditInput) { in.ReferenceID = uuid.Nil },
|
||||
"missing description": func(in *WalletCreditInput) { in.Description = " " },
|
||||
"EARN without outlet": func(in *WalletCreditInput) { in.OutletID = nil },
|
||||
"missing customer": func(in *WalletCreditInput) { in.CustomerID = uuid.Nil },
|
||||
"EXCHANGE_IN in COIN": func(in *WalletCreditInput) {
|
||||
in.Type = constants.WalletTxTypeExchangeIn
|
||||
in.Currency = constants.WalletCurrencyCoin
|
||||
in.ReferenceType = constants.WalletRefTypeWalletTx
|
||||
in.GroupID = ptr(uuid.New())
|
||||
},
|
||||
"TRANSFER_IN to self": func(in *WalletCreditInput) {
|
||||
in.Type = constants.WalletTxTypeTransferIn
|
||||
in.ReferenceType = constants.WalletRefTypeWalletTx
|
||||
in.GroupID = ptr(uuid.New())
|
||||
in.CounterpartyCustomerID = &in.CustomerID
|
||||
},
|
||||
"TRANSFER_IN without group": func(in *WalletCreditInput) {
|
||||
in.Type = constants.WalletTxTypeTransferIn
|
||||
in.ReferenceType = constants.WalletRefTypeWalletTx
|
||||
in.CounterpartyCustomerID = ptr(uuid.New())
|
||||
},
|
||||
"PAYMENT_REFUND without source": func(in *WalletCreditInput) {
|
||||
in.Type = constants.WalletTxTypePaymentRefund
|
||||
in.ReferenceType = constants.WalletRefTypePayment
|
||||
},
|
||||
"ADJUSTMENT without reason": func(in *WalletCreditInput) {
|
||||
in.Type = constants.WalletTxTypeAdjustment
|
||||
in.ReferenceType = constants.WalletRefTypeUser
|
||||
in.CreatedByUser = ptr(uuid.New())
|
||||
},
|
||||
"ADJUSTMENT blank reason": func(in *WalletCreditInput) {
|
||||
in.Type = constants.WalletTxTypeAdjustment
|
||||
in.ReferenceType = constants.WalletRefTypeUser
|
||||
in.CreatedByUser = ptr(uuid.New())
|
||||
in.Reason = ptr(" ")
|
||||
},
|
||||
"ADJUSTMENT without admin": func(in *WalletCreditInput) {
|
||||
in.Type = constants.WalletTxTypeAdjustment
|
||||
in.ReferenceType = constants.WalletRefTypeUser
|
||||
in.Reason = ptr("komplain")
|
||||
},
|
||||
"MIGRATION wrong reference": func(in *WalletCreditInput) {
|
||||
in.Type = constants.WalletTxTypeMigration
|
||||
in.ReferenceType = constants.WalletRefTypeOrder
|
||||
},
|
||||
}
|
||||
for name, mutate := range credits {
|
||||
t.Run("credit/"+name, func(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
e.repo.customers[c] = e.org
|
||||
in := earn(c, 10, nil)
|
||||
in.OutletID = outlet
|
||||
mutate(&in)
|
||||
_, err := e.p.Credit(e.ctx, in)
|
||||
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
|
||||
assert.Empty(t, e.repo.transactions)
|
||||
})
|
||||
}
|
||||
|
||||
debits := map[string]func(*WalletDebitInput){
|
||||
"credit-only type as debit": func(in *WalletDebitInput) {
|
||||
in.Type = constants.WalletTxTypeMigration
|
||||
in.ReferenceType = constants.WalletRefTypeLegacyPoints
|
||||
},
|
||||
"PAYMENT in COIN": func(in *WalletDebitInput) { in.Currency = constants.WalletCurrencyCoin },
|
||||
"GAME_SPEND in POINT": func(in *WalletDebitInput) {
|
||||
in.Type = constants.WalletTxTypeGameSpend
|
||||
in.ReferenceType = constants.WalletRefTypeGamePlay
|
||||
},
|
||||
"EXPIRE not pointing at a lot": func(in *WalletDebitInput) {
|
||||
in.Type = constants.WalletTxTypeExpire
|
||||
in.ReferenceType = constants.WalletRefTypeOrder
|
||||
},
|
||||
"EARN_REVERSAL without source": func(in *WalletDebitInput) {
|
||||
in.Type = constants.WalletTxTypeEarnReversal
|
||||
in.ReferenceType = constants.WalletRefTypeOrder
|
||||
},
|
||||
"TRANSFER_OUT without counterparty": func(in *WalletDebitInput) {
|
||||
in.Type = constants.WalletTxTypeTransferOut
|
||||
in.ReferenceType = constants.WalletRefTypeWalletTx
|
||||
in.GroupID = ptr(uuid.New())
|
||||
},
|
||||
"REWARD_REDEEM wrong reference": func(in *WalletDebitInput) {
|
||||
in.Type = constants.WalletTxTypeRewardRedeem
|
||||
in.ReferenceType = constants.WalletRefTypeOrder
|
||||
},
|
||||
}
|
||||
for name, mutate := range debits {
|
||||
t.Run("debit/"+name, func(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
e.repo.customers[c] = e.org
|
||||
e.credit(t, earn(c, 100, nil))
|
||||
in := pay(c, 10)
|
||||
mutate(&in)
|
||||
_, err := e.p.Debit(e.ctx, in)
|
||||
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
|
||||
assert.Len(t, e.repo.transactions, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletProcessor_UnknownCustomer(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
_, err := e.p.Credit(e.ctx, earn(uuid.New(), 10, nil))
|
||||
assert.ErrorIs(t, err, repository.ErrWalletNotFound)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// ErrInvalidWalletQuery wraps every rejection of a malformed history query, so the
|
||||
// handler can answer with a validation error rather than a server error.
|
||||
var ErrInvalidWalletQuery = errors.New("invalid wallet query")
|
||||
|
||||
const (
|
||||
walletRecentTransactions = 5
|
||||
walletLegacyHistorySize = 10
|
||||
walletTransactionsPageLimit = 20
|
||||
walletTransactionsMaxLimit = 100
|
||||
)
|
||||
|
||||
// walletDisplayLocation is where the customer's calendar days start and end, for
|
||||
// date filters. Same zone as the database connection and the expiry grouping.
|
||||
var walletDisplayLocation = func() *time.Location {
|
||||
loc, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err != nil {
|
||||
return time.FixedZone("WIB", 7*60*60)
|
||||
}
|
||||
return loc
|
||||
}()
|
||||
|
||||
// WalletQueryProcessor answers what the customer app shows about a wallet: balances,
|
||||
// history and upcoming expiry (docs/prd-point-coin.md F6). It only reads.
|
||||
type WalletQueryProcessor struct {
|
||||
repo repository.WalletQueryRepository
|
||||
settings pointValueReader
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// pointValueReader gives the rupiah value of one EnakPoint in an organization.
|
||||
// LoyaltySettingsProcessor is one.
|
||||
type pointValueReader interface {
|
||||
PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error)
|
||||
}
|
||||
|
||||
func NewWalletQueryProcessor(repo repository.WalletQueryRepository, settings pointValueReader) *WalletQueryProcessor {
|
||||
return &WalletQueryProcessor{repo: repo, settings: settings, now: time.Now}
|
||||
}
|
||||
|
||||
// Summary is GET /customer/wallet. A customer who has never had a wallet gets zeros.
|
||||
func (p *WalletQueryProcessor) Summary(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerWalletResponseData, error) {
|
||||
organizationID, err := p.repo.GetCustomerOrganizationID(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := p.now()
|
||||
|
||||
balances, err := p.repo.SpendableBalances(ctx, customerID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pointValue, err := p.settings.PointValue(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expiring, err := p.repo.NearestExpiring(ctx, customerID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recent, err := p.recent(ctx, customerID, "", walletRecentTransactions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
points, err := p.legacyPoints(ctx, customerID, balances)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens, err := p.legacyTokens(ctx, customerID, balances)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := &models.GetCustomerWalletResponseData{
|
||||
PointBalance: balances[constants.WalletCurrencyPoint],
|
||||
CoinBalance: balances[constants.WalletCurrencyCoin],
|
||||
PointValue: pointValue,
|
||||
PointDiscountValue: balances[constants.WalletCurrencyPoint] * pointValue,
|
||||
RecentTransactions: recent,
|
||||
TotalPoints: points.TotalPoints,
|
||||
TotalTokens: tokens.TotalTokens,
|
||||
PointsHistory: points.PointsHistory,
|
||||
TokensHistory: tokens.TokensHistory,
|
||||
LastUpdated: points.LastUpdated,
|
||||
}
|
||||
if tokens.LastUpdated.After(data.LastUpdated) {
|
||||
data.LastUpdated = tokens.LastUpdated
|
||||
}
|
||||
for _, e := range expiring {
|
||||
entry := &models.CustomerWalletExpiring{Amount: e.Amount, Date: e.Date}
|
||||
switch e.Currency {
|
||||
case constants.WalletCurrencyPoint:
|
||||
data.NearestExpiring.Point = entry
|
||||
case constants.WalletCurrencyCoin:
|
||||
data.NearestExpiring.Coin = entry
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Transactions is GET /customer/wallet/transactions.
|
||||
func (p *WalletQueryProcessor) Transactions(ctx context.Context, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) {
|
||||
filter, page, err := walletTransactionFilter(customerID, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, total, err := p.repo.ListTransactions(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := p.toTransactions(ctx, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalPages := int((total + int64(filter.Limit) - 1) / int64(filter.Limit))
|
||||
return &models.PaginatedResponse[models.CustomerWalletTransaction]{
|
||||
Data: items,
|
||||
Pagination: models.Pagination{Page: page, Limit: filter.Limit, Total: total, TotalPages: totalPages},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LegacyPoints is the pre-wallet GET /customer/points, now read from the wallet.
|
||||
func (p *WalletQueryProcessor) LegacyPoints(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerPointsResponseData, error) {
|
||||
balances, err := p.repo.SpendableBalances(ctx, customerID, p.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.legacyPoints(ctx, customerID, balances)
|
||||
}
|
||||
|
||||
func (p *WalletQueryProcessor) legacyPoints(ctx context.Context, customerID uuid.UUID, balances map[string]int64) (*models.GetCustomerPointsResponseData, error) {
|
||||
recent, err := p.recent(ctx, customerID, constants.WalletCurrencyPoint, walletLegacyHistorySize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := &models.GetCustomerPointsResponseData{TotalPoints: balances[constants.WalletCurrencyPoint]}
|
||||
for _, tx := range recent {
|
||||
data.PointsHistory = append(data.PointsHistory, models.PointsHistoryItem{
|
||||
ID: tx.ID.String(), Points: tx.Amount, Type: tx.Type, Description: tx.Description, CreatedAt: tx.CreatedAt,
|
||||
})
|
||||
}
|
||||
if len(recent) > 0 {
|
||||
data.LastUpdated = recent[0].CreatedAt
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// LegacyTokens is the pre-wallet GET /customer/tokens. Tokens are EnakCoin now.
|
||||
func (p *WalletQueryProcessor) LegacyTokens(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerTokensResponseData, error) {
|
||||
balances, err := p.repo.SpendableBalances(ctx, customerID, p.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.legacyTokens(ctx, customerID, balances)
|
||||
}
|
||||
|
||||
func (p *WalletQueryProcessor) legacyTokens(ctx context.Context, customerID uuid.UUID, balances map[string]int64) (*models.GetCustomerTokensResponseData, error) {
|
||||
recent, err := p.recent(ctx, customerID, constants.WalletCurrencyCoin, walletLegacyHistorySize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := &models.GetCustomerTokensResponseData{TotalTokens: balances[constants.WalletCurrencyCoin]}
|
||||
for _, tx := range recent {
|
||||
data.TokensHistory = append(data.TokensHistory, models.TokensHistoryItem{
|
||||
ID: tx.ID.String(), Tokens: tx.Amount, Type: tx.Type, Description: tx.Description, CreatedAt: tx.CreatedAt,
|
||||
})
|
||||
}
|
||||
if len(recent) > 0 {
|
||||
data.LastUpdated = recent[0].CreatedAt
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *WalletQueryProcessor) recent(ctx context.Context, customerID uuid.UUID, currency string, limit int) ([]models.CustomerWalletTransaction, error) {
|
||||
rows, _, err := p.repo.ListTransactions(ctx, repository.WalletTransactionFilter{
|
||||
CustomerID: customerID, Currency: currency, Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.toTransactions(ctx, rows)
|
||||
}
|
||||
|
||||
func (p *WalletQueryProcessor) toTransactions(ctx context.Context, rows []entities.WalletTransaction) ([]models.CustomerWalletTransaction, error) {
|
||||
return buildWalletTransactions(ctx, p.repo, rows)
|
||||
}
|
||||
|
||||
// buildWalletTransactions turns ledger rows into what the apps show, in the same
|
||||
// order: where each row came from or went to, and the lots each credit created.
|
||||
func buildWalletTransactions(ctx context.Context, repo repository.WalletQueryRepository, rows []entities.WalletTransaction) ([]models.CustomerWalletTransaction, error) {
|
||||
var credits []uuid.UUID
|
||||
for _, row := range rows {
|
||||
if row.Amount > 0 {
|
||||
credits = append(credits, row.ID)
|
||||
}
|
||||
}
|
||||
lots, err := repo.ListLotsBySourceTransactions(ctx, credits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lotsByTx := make(map[uuid.UUID][]entities.WalletLot, len(credits))
|
||||
for _, lot := range lots {
|
||||
lotsByTx[lot.SourceTransactionID] = append(lotsByTx[lot.SourceTransactionID], lot)
|
||||
}
|
||||
|
||||
items := make([]models.CustomerWalletTransaction, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ref := &models.CustomerWalletTransactionRef{Type: row.ReferenceType, ID: row.ReferenceID}
|
||||
item := models.CustomerWalletTransaction{
|
||||
ID: row.ID,
|
||||
Currency: row.Currency,
|
||||
Type: row.Type,
|
||||
Amount: row.Amount,
|
||||
BalanceAfter: row.BalanceAfter,
|
||||
Description: row.Description,
|
||||
OutletID: row.OutletID,
|
||||
ReversesTransactionID: row.ReversesTransactionID,
|
||||
GroupID: row.GroupID,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
// The counterparty's id, the admin and the metadata are left out on purpose:
|
||||
// the customer sees the masked name in the description (§8.1).
|
||||
if row.Amount > 0 {
|
||||
item.Source = ref
|
||||
for _, lot := range lotsByTx[row.ID] {
|
||||
item.Lots = append(item.Lots, models.CustomerWalletLot{
|
||||
Amount: lot.OriginalAmount, Remaining: lot.RemainingAmount, ExpiresAt: lot.ExpiresAt,
|
||||
})
|
||||
if lot.ExpiresAt != nil && (item.ExpiresAt == nil || lot.ExpiresAt.Before(*item.ExpiresAt)) {
|
||||
item.ExpiresAt = lot.ExpiresAt
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item.Destination = ref
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func walletTransactionFilter(customerID uuid.UUID, q models.ListCustomerWalletTransactionsQuery) (repository.WalletTransactionFilter, int, error) {
|
||||
invalid := func(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidWalletQuery, fmt.Sprintf(format, args...))
|
||||
}
|
||||
filter := repository.WalletTransactionFilter{CustomerID: customerID}
|
||||
|
||||
page := q.Page
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
if page < 1 {
|
||||
return filter, 0, invalid("page must be at least 1")
|
||||
}
|
||||
filter.Limit = q.Limit
|
||||
if filter.Limit == 0 {
|
||||
filter.Limit = walletTransactionsPageLimit
|
||||
}
|
||||
if filter.Limit < 1 || filter.Limit > walletTransactionsMaxLimit {
|
||||
return filter, 0, invalid("limit must be between 1 and %d", walletTransactionsMaxLimit)
|
||||
}
|
||||
filter.Offset = (page - 1) * filter.Limit
|
||||
|
||||
if q.Currency != "" {
|
||||
currency := strings.ToUpper(q.Currency)
|
||||
if !constants.IsValidWalletCurrency(currency) {
|
||||
return filter, 0, invalid("currency must be POINT or COIN")
|
||||
}
|
||||
filter.Currency = currency
|
||||
}
|
||||
for _, t := range strings.Split(q.Type, ",") {
|
||||
t = strings.ToUpper(strings.TrimSpace(t))
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := walletTypeRules[t]; !ok {
|
||||
return filter, 0, invalid("unknown type %q", t)
|
||||
}
|
||||
filter.Types = append(filter.Types, t)
|
||||
}
|
||||
|
||||
if q.From != "" {
|
||||
from, err := time.ParseInLocation("2006-01-02", q.From, walletDisplayLocation)
|
||||
if err != nil {
|
||||
return filter, 0, invalid("from must be a date like 2026-01-31")
|
||||
}
|
||||
filter.From = &from
|
||||
}
|
||||
if q.To != "" {
|
||||
to, err := time.ParseInLocation("2006-01-02", q.To, walletDisplayLocation)
|
||||
if err != nil {
|
||||
return filter, 0, invalid("to must be a date like 2026-01-31")
|
||||
}
|
||||
// To is inclusive for the customer, exclusive in the query.
|
||||
end := to.AddDate(0, 0, 1)
|
||||
filter.To = &end
|
||||
}
|
||||
if filter.From != nil && filter.To != nil && !filter.From.Before(*filter.To) {
|
||||
return filter, 0, invalid("from must not be after to")
|
||||
}
|
||||
return filter, page, nil
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
type walletQueryRepoFake struct {
|
||||
org uuid.UUID
|
||||
spendable map[string]int64
|
||||
expiring []repository.WalletExpiringAmount
|
||||
transactions []entities.WalletTransaction
|
||||
total int64
|
||||
lots []entities.WalletLot
|
||||
filters []repository.WalletTransactionFilter
|
||||
}
|
||||
|
||||
func (f *walletQueryRepoFake) GetCustomerOrganizationID(context.Context, uuid.UUID) (uuid.UUID, error) {
|
||||
if f.org == uuid.Nil {
|
||||
return uuid.Nil, repository.ErrWalletNotFound
|
||||
}
|
||||
return f.org, nil
|
||||
}
|
||||
|
||||
func (f *walletQueryRepoFake) SpendableBalances(context.Context, uuid.UUID, time.Time) (map[string]int64, error) {
|
||||
return f.spendable, nil
|
||||
}
|
||||
|
||||
func (f *walletQueryRepoFake) NearestExpiring(context.Context, uuid.UUID, time.Time) ([]repository.WalletExpiringAmount, error) {
|
||||
return f.expiring, nil
|
||||
}
|
||||
|
||||
func (f *walletQueryRepoFake) ListTransactions(_ context.Context, filter repository.WalletTransactionFilter) ([]entities.WalletTransaction, int64, error) {
|
||||
f.filters = append(f.filters, filter)
|
||||
var out []entities.WalletTransaction
|
||||
for _, tx := range f.transactions {
|
||||
if filter.Currency == "" || tx.Currency == filter.Currency {
|
||||
out = append(out, tx)
|
||||
}
|
||||
}
|
||||
if len(out) > filter.Limit {
|
||||
out = out[:filter.Limit]
|
||||
}
|
||||
return out, f.total, nil
|
||||
}
|
||||
|
||||
func (f *walletQueryRepoFake) ListLotsBySourceTransactions(_ context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) {
|
||||
var out []entities.WalletLot
|
||||
for _, lot := range f.lots {
|
||||
for _, id := range ids {
|
||||
if lot.SourceTransactionID == id {
|
||||
out = append(out, lot)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type orgSettingsFake map[string]string
|
||||
|
||||
func newWalletQueryTest(repo *walletQueryRepoFake, settings orgSettingsFake) *WalletQueryProcessor {
|
||||
p := NewWalletQueryProcessor(repo, NewLoyaltySettingsProcessor(&loyaltyRepoFake{orgValues: settings}, txRunnerFake{}))
|
||||
p.now = func() time.Time { return time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) }
|
||||
return p
|
||||
}
|
||||
|
||||
func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing.T) {
|
||||
order, payment := uuid.New(), uuid.New()
|
||||
earnID, payID := uuid.New(), uuid.New()
|
||||
soon := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
later := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
|
||||
created := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
repo := &walletQueryRepoFake{
|
||||
org: uuid.New(),
|
||||
spendable: map[string]int64{constants.WalletCurrencyPoint: 250, constants.WalletCurrencyCoin: 8},
|
||||
expiring: []repository.WalletExpiringAmount{
|
||||
{Currency: constants.WalletCurrencyPoint, Date: "2026-07-01", Amount: 100},
|
||||
},
|
||||
transactions: []entities.WalletTransaction{
|
||||
{ID: payID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, Amount: -50, BalanceAfter: 250,
|
||||
ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, Description: "Bayar #ORD-1", CreatedAt: created.Add(time.Hour),
|
||||
CounterpartyCustomerID: ptr(uuid.New()), Metadata: entities.Metadata{"point_value": 100}},
|
||||
{ID: earnID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn, Amount: 300, BalanceAfter: 300,
|
||||
ReferenceType: constants.WalletRefTypeOrder, ReferenceID: order, Description: "Belanja #ORD-1", CreatedAt: created},
|
||||
},
|
||||
lots: []entities.WalletLot{
|
||||
{SourceTransactionID: earnID, OriginalAmount: 200, RemainingAmount: 150, ExpiresAt: &later},
|
||||
{SourceTransactionID: earnID, OriginalAmount: 100, RemainingAmount: 100, ExpiresAt: &soon},
|
||||
},
|
||||
}
|
||||
p := newWalletQueryTest(repo, orgSettingsFake{constants.LoyaltyPointValueKey: "100"})
|
||||
|
||||
data, err := p.Summary(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(250), data.PointBalance)
|
||||
assert.Equal(t, int64(8), data.CoinBalance)
|
||||
assert.Equal(t, int64(100), data.PointValue)
|
||||
assert.Equal(t, int64(25000), data.PointDiscountValue)
|
||||
assert.Equal(t, &models.CustomerWalletExpiring{Amount: 100, Date: "2026-07-01"}, data.NearestExpiring.Point)
|
||||
assert.Nil(t, data.NearestExpiring.Coin)
|
||||
|
||||
require.Len(t, data.RecentTransactions, 2)
|
||||
pay, earn := data.RecentTransactions[0], data.RecentTransactions[1]
|
||||
assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypePayment, ID: payment}, pay.Destination)
|
||||
assert.Nil(t, pay.Source)
|
||||
assert.Empty(t, pay.Lots)
|
||||
assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypeOrder, ID: order}, earn.Source)
|
||||
assert.Nil(t, earn.Destination)
|
||||
assert.Equal(t, &soon, earn.ExpiresAt, "earliest expiry among the lots the credit created")
|
||||
assert.Len(t, earn.Lots, 2)
|
||||
|
||||
// The pre-wallet fields are still filled, from the wallet.
|
||||
assert.Equal(t, int64(250), data.TotalPoints)
|
||||
assert.Equal(t, int64(8), data.TotalTokens)
|
||||
require.Len(t, data.PointsHistory, 2)
|
||||
assert.Equal(t, int64(-50), data.PointsHistory[0].Points)
|
||||
assert.Equal(t, constants.WalletTxTypePayment, data.PointsHistory[0].Type)
|
||||
assert.Equal(t, created.Add(time.Hour), data.LastUpdated)
|
||||
}
|
||||
|
||||
func TestWalletQueryProcessor_PointValueFallsBackToDefault(t *testing.T) {
|
||||
for name, settings := range map[string]orgSettingsFake{
|
||||
"not set": {},
|
||||
"garbage": {constants.LoyaltyPointValueKey: "abc"},
|
||||
"zero": {constants.LoyaltyPointValueKey: "0"},
|
||||
"negative": {constants.LoyaltyPointValueKey: "-5"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
repo := &walletQueryRepoFake{org: uuid.New(), spendable: map[string]int64{constants.WalletCurrencyPoint: 40}}
|
||||
data, err := newWalletQueryTest(repo, settings).Summary(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.LoyaltyPointValueDefault, data.PointValue)
|
||||
assert.Equal(t, int64(40), data.PointDiscountValue)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletQueryProcessor_SummaryForCustomerWithoutWallet(t *testing.T) {
|
||||
repo := &walletQueryRepoFake{org: uuid.New(), spendable: map[string]int64{}}
|
||||
data, err := newWalletQueryTest(repo, orgSettingsFake{}).Summary(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, data.PointBalance)
|
||||
assert.Zero(t, data.CoinBalance)
|
||||
assert.NotNil(t, data.RecentTransactions, "an empty list, not null, for the app")
|
||||
|
||||
_, err = newWalletQueryTest(&walletQueryRepoFake{}, orgSettingsFake{}).Summary(context.Background(), uuid.New())
|
||||
assert.ErrorIs(t, err, repository.ErrWalletNotFound)
|
||||
}
|
||||
|
||||
func TestWalletQueryProcessor_LegacyEndpointsSplitByCurrency(t *testing.T) {
|
||||
repo := &walletQueryRepoFake{
|
||||
org: uuid.New(),
|
||||
spendable: map[string]int64{constants.WalletCurrencyPoint: 100, constants.WalletCurrencyCoin: 8},
|
||||
transactions: []entities.WalletTransaction{
|
||||
{ID: uuid.New(), Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration, Amount: 8},
|
||||
{ID: uuid.New(), Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, Amount: 100},
|
||||
},
|
||||
}
|
||||
p := newWalletQueryTest(repo, orgSettingsFake{})
|
||||
|
||||
points, err := p.LegacyPoints(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(100), points.TotalPoints)
|
||||
require.Len(t, points.PointsHistory, 1)
|
||||
assert.Equal(t, int64(100), points.PointsHistory[0].Points)
|
||||
|
||||
tokens, err := p.LegacyTokens(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(8), tokens.TotalTokens)
|
||||
require.Len(t, tokens.TokensHistory, 1)
|
||||
assert.Equal(t, int64(8), tokens.TokensHistory[0].Tokens)
|
||||
}
|
||||
|
||||
func TestWalletQueryProcessor_TransactionsQuery(t *testing.T) {
|
||||
customer := uuid.New()
|
||||
jakarta := walletDisplayLocation
|
||||
|
||||
repo := &walletQueryRepoFake{org: uuid.New(), total: 45}
|
||||
page, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), customer, models.ListCustomerWalletTransactionsQuery{
|
||||
Page: 3, Limit: 10, Currency: "point", Type: "earn, PAYMENT", From: "2026-05-01", To: "2026-05-31",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, models.Pagination{Page: 3, Limit: 10, Total: 45, TotalPages: 5}, page.Pagination)
|
||||
assert.NotNil(t, page.Data)
|
||||
|
||||
f := repo.filters[0]
|
||||
assert.Equal(t, customer, f.CustomerID)
|
||||
assert.Equal(t, 20, f.Offset)
|
||||
assert.Equal(t, 10, f.Limit)
|
||||
assert.Equal(t, constants.WalletCurrencyPoint, f.Currency)
|
||||
assert.Equal(t, []string{constants.WalletTxTypeEarn, constants.WalletTxTypePayment}, f.Types)
|
||||
assert.True(t, f.From.Equal(time.Date(2026, 5, 1, 0, 0, 0, 0, jakarta)))
|
||||
assert.True(t, f.To.Equal(time.Date(2026, 6, 1, 0, 0, 0, 0, jakarta)), "to covers the whole last day")
|
||||
|
||||
// Defaults.
|
||||
repo = &walletQueryRepoFake{org: uuid.New()}
|
||||
page, err = newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), customer, models.ListCustomerWalletTransactionsQuery{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, page.Pagination.Page)
|
||||
assert.Equal(t, walletTransactionsPageLimit, repo.filters[0].Limit)
|
||||
assert.Zero(t, repo.filters[0].Offset)
|
||||
assert.Nil(t, repo.filters[0].From)
|
||||
assert.Empty(t, repo.filters[0].Types)
|
||||
}
|
||||
|
||||
func TestWalletQueryProcessor_RejectsBadQueries(t *testing.T) {
|
||||
for name, q := range map[string]models.ListCustomerWalletTransactionsQuery{
|
||||
"negative page": {Page: -1},
|
||||
"limit too big": {Limit: walletTransactionsMaxLimit + 1},
|
||||
"negative limit": {Limit: -1},
|
||||
"unknown currency": {Currency: "GOLD"},
|
||||
"unknown type": {Type: "EARN,BONUS"},
|
||||
"bad from": {From: "01-05-2026"},
|
||||
"bad to": {To: "2026-13-01"},
|
||||
"from after to": {From: "2026-06-02", To: "2026-06-01"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
repo := &walletQueryRepoFake{org: uuid.New()}
|
||||
_, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), uuid.New(), q)
|
||||
assert.ErrorIs(t, err, ErrInvalidWalletQuery)
|
||||
assert.Empty(t, repo.filters, "nothing is queried")
|
||||
})
|
||||
}
|
||||
|
||||
// A single day is a valid range.
|
||||
repo := &walletQueryRepoFake{org: uuid.New()}
|
||||
_, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), uuid.New(),
|
||||
models.ListCustomerWalletTransactionsQuery{From: "2026-06-01", To: "2026-06-01"})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func (f *walletQueryRepoFake) OrganizationOutstanding(context.Context, uuid.UUID) (int64, int64, error) {
|
||||
return 0, 0, nil
|
||||
}
|
||||
@@ -29,6 +29,20 @@ type AnalyticsRepository interface {
|
||||
GetOutletName(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID) (string, error)
|
||||
}
|
||||
|
||||
// A weight-based line is a single weighing: order_items.quantity is pinned to 1 and
|
||||
// the weighed amount lives in order_items.weight, while unit_price and unit_cost are
|
||||
// per unit of weight. Anything that multiplies or divides by a per-unit rate must use
|
||||
// the weighed amount, not the row count, or a 4.2 ons fish is costed as a single ons.
|
||||
// These mirror entities.OrderItem.BillableQuantity for aggregate SQL.
|
||||
const (
|
||||
// billableQty is the multiplier for a per-unit price or cost, before refunds.
|
||||
billableQty = `COALESCE(oi.weight, oi.quantity)`
|
||||
|
||||
// billableQtyNet is billableQty after refunds. A weighed line is always refunded
|
||||
// whole, so with quantity pinned to 1 the factor is the full weight or zero.
|
||||
billableQtyNet = `COALESCE(oi.weight, 1) * (oi.quantity - COALESCE(oi.refund_quantity, 0))`
|
||||
)
|
||||
|
||||
type AnalyticsRepositoryImpl struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
@@ -461,9 +475,10 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
|
||||
c.name as category_name,
|
||||
c.order as category_order,
|
||||
COALESCE(SUM(oi.quantity), 0) as quantity_sold,
|
||||
COALESCE(SUM(oi.weight), 0) as weight_sold,
|
||||
COALESCE(SUM(oi.total_price), 0) as revenue,
|
||||
CASE
|
||||
WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(oi.quantity)
|
||||
WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(` + billableQty + `)
|
||||
ELSE 0
|
||||
END as average_price,
|
||||
COUNT(DISTINCT oi.order_id) as order_count,
|
||||
@@ -478,14 +493,14 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
|
||||
FROM product_recipes pr
|
||||
JOIN ingredients i ON pr.ingredient_id = i.id
|
||||
WHERE pr.product_id = p.id
|
||||
), p.cost, 0) * COALESCE(SUM(oi.quantity), 0) as standard_hpp_total,
|
||||
), p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as standard_hpp_total,
|
||||
CASE
|
||||
WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(oi.quantity)
|
||||
WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(` + billableQty + `)
|
||||
ELSE 0
|
||||
END as fifo_hpp_per_unit,
|
||||
COALESCE(SUM(oi.total_cost), 0) as fifo_hpp_total,
|
||||
COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit,
|
||||
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(oi.quantity), 0) as moving_average_hpp_total
|
||||
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as moving_average_hpp_total
|
||||
`).
|
||||
Joins("JOIN products p ON oi.product_id = p.id").
|
||||
Joins("JOIN categories c ON p.category_id = c.id").
|
||||
@@ -536,9 +551,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity,
|
||||
COUNT(DISTINCT p.id) as product_count,
|
||||
COUNT(DISTINCT oi.order_id) as order_count,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp
|
||||
`).
|
||||
Joins("JOIN products p ON oi.product_id = p.id").
|
||||
Joins("JOIN categories c ON p.category_id = c.id").
|
||||
@@ -589,9 +604,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte
|
||||
COUNT(DISTINCT c.id) as category_count,
|
||||
COUNT(DISTINCT p.id) as product_count,
|
||||
COUNT(DISTINCT oi.order_id) as order_count,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp
|
||||
`).
|
||||
Joins("JOIN products p ON oi.product_id = p.id").
|
||||
Joins("JOIN categories c ON p.category_id = c.id").
|
||||
@@ -706,9 +721,9 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
|
||||
COUNT(DISTINCT c.id) as category_count,
|
||||
COUNT(DISTINCT p.id) as product_count,
|
||||
COUNT(DISTINCT oi.order_id) as order_count,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp
|
||||
`).
|
||||
Scan(summary).Error
|
||||
if err != nil {
|
||||
@@ -727,9 +742,9 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity,
|
||||
COUNT(DISTINCT p.id) as product_count,
|
||||
COUNT(DISTINCT oi.order_id) as order_count,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp
|
||||
`).
|
||||
Group("c.id, c.name, c.order").
|
||||
Order("c.order ASC, c.name ASC").
|
||||
@@ -759,18 +774,18 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as revenue,
|
||||
COALESCE(
|
||||
SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END)
|
||||
/ NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0),
|
||||
/ NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0),
|
||||
0) as average_price,
|
||||
COUNT(DISTINCT oi.order_id) as order_count,
|
||||
COALESCE(shpp.hpp_per_unit, p.cost, 0) as standard_hpp_per_unit,
|
||||
COALESCE(shpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as standard_hpp_total,
|
||||
COALESCE(shpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0) as standard_hpp_total,
|
||||
COALESCE(
|
||||
SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END)
|
||||
/ NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0),
|
||||
/ NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0),
|
||||
0) as fifo_hpp_per_unit,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as fifo_hpp_total,
|
||||
COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit,
|
||||
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as moving_average_hpp_total
|
||||
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0) as moving_average_hpp_total
|
||||
`).
|
||||
Group("p.id, p.name, p.sku, p.price, p.cost, pop.price, c.id, c.name, c.order, shpp.hpp_per_unit, mahpp.hpp_per_unit").
|
||||
Order("revenue DESC").
|
||||
@@ -1167,9 +1182,9 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialTotal(ctx context.C
|
||||
}
|
||||
|
||||
type purchasingTotals struct {
|
||||
Total float64
|
||||
RawMaterial float64
|
||||
Expense float64
|
||||
Total float64
|
||||
RawMaterial float64
|
||||
Expense float64
|
||||
}
|
||||
|
||||
func (r *AnalyticsRepositoryImpl) getPurchaseOrderTotals(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) (purchasingTotals, error) {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ErrPinCustomerNotFound means the customer does not exist.
|
||||
var ErrPinCustomerNotFound = errors.New("pin: customer not found")
|
||||
|
||||
// CustomerPinState is a customer's PIN and what guards it. It lives in the customers
|
||||
// table but is read and written only here, never through the Customer entity, so the
|
||||
// hash cannot end up in a customer response.
|
||||
type CustomerPinState struct {
|
||||
CustomerID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
PhoneNumber *string
|
||||
BirthDate *time.Time
|
||||
PinHash *string
|
||||
PinSetAt *time.Time
|
||||
FailedAttempts int
|
||||
LockedUntil *time.Time
|
||||
TransferBlockedUntil *time.Time
|
||||
}
|
||||
|
||||
// CustomerSecurityEvent is one row of the PIN security log.
|
||||
type CustomerSecurityEvent struct {
|
||||
ID uuid.UUID
|
||||
CustomerID uuid.UUID
|
||||
Event string
|
||||
ActorUser *uuid.UUID
|
||||
Reason *string
|
||||
IPAddress *string
|
||||
UserAgent *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CustomerPinRepository stores customer PINs and their security log
|
||||
// (docs/prd-point-coin.md F11).
|
||||
type CustomerPinRepository interface {
|
||||
GetState(ctx context.Context, customerID uuid.UUID) (*CustomerPinState, error)
|
||||
// SetPin stores a new PIN hash, clears the failure counter and any lock, and sets
|
||||
// or clears the transfer hold.
|
||||
SetPin(ctx context.Context, customerID uuid.UUID, hash string, transferBlockedUntil *time.Time) error
|
||||
// RemovePin deletes the PIN, so the customer has to create a new one through OTP.
|
||||
RemovePin(ctx context.Context, customerID uuid.UUID) error
|
||||
// RecordFailure adds one wrong attempt in a single statement, so wrong attempts
|
||||
// made at the same time all count. A lock that has already run out starts the
|
||||
// count again. When the count reaches maxAttempts the PIN is locked until
|
||||
// lockUntil. It returns the count and lock after the update.
|
||||
RecordFailure(ctx context.Context, customerID uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error)
|
||||
ClearFailures(ctx context.Context, customerID uuid.UUID) error
|
||||
|
||||
InsertEvent(ctx context.Context, event CustomerSecurityEvent) error
|
||||
// ListEvents returns a page of the customer's log, newest first, and the total.
|
||||
ListEvents(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerSecurityEvent, int64, error)
|
||||
}
|
||||
|
||||
type customerPinRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCustomerPinRepository(db *gorm.DB) CustomerPinRepository {
|
||||
return &customerPinRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *customerPinRepository) GetState(ctx context.Context, customerID uuid.UUID) (*CustomerPinState, error) {
|
||||
var rows []struct {
|
||||
CustomerID string
|
||||
OrganizationID string
|
||||
PhoneNumber *string
|
||||
BirthDate *time.Time
|
||||
PinHash *string
|
||||
PinSetAt *time.Time
|
||||
PinFailedAttempts int
|
||||
PinLockedUntil *time.Time
|
||||
TransferBlockedUntil *time.Time
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT id::text AS customer_id, organization_id::text AS organization_id,
|
||||
COALESCE(phone_number, phone) AS phone_number, birth_date,
|
||||
pin_hash, pin_set_at, pin_failed_attempts, pin_locked_until, transfer_blocked_until
|
||||
FROM customers WHERE id = ? LIMIT 1`, customerID).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read customer PIN: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, ErrPinCustomerNotFound
|
||||
}
|
||||
row := rows[0]
|
||||
state := &CustomerPinState{
|
||||
PhoneNumber: row.PhoneNumber,
|
||||
BirthDate: row.BirthDate,
|
||||
PinHash: row.PinHash,
|
||||
PinSetAt: row.PinSetAt,
|
||||
FailedAttempts: row.PinFailedAttempts,
|
||||
LockedUntil: row.PinLockedUntil,
|
||||
TransferBlockedUntil: row.TransferBlockedUntil,
|
||||
}
|
||||
state.CustomerID, _ = uuid.Parse(row.CustomerID)
|
||||
state.OrganizationID, _ = uuid.Parse(row.OrganizationID)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (r *customerPinRepository) SetPin(ctx context.Context, customerID uuid.UUID, hash string, transferBlockedUntil *time.Time) error {
|
||||
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||||
UPDATE customers SET pin_hash = ?, pin_set_at = NOW(), pin_failed_attempts = 0,
|
||||
pin_locked_until = NULL, transfer_blocked_until = ?, updated_at = NOW()
|
||||
WHERE id = ?`, hash, transferBlockedUntil, customerID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to store customer PIN: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrPinCustomerNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *customerPinRepository) RemovePin(ctx context.Context, customerID uuid.UUID) error {
|
||||
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||||
UPDATE customers SET pin_hash = NULL, pin_set_at = NULL, pin_failed_attempts = 0,
|
||||
pin_locked_until = NULL, updated_at = NOW()
|
||||
WHERE id = ?`, customerID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to remove customer PIN: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrPinCustomerNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *customerPinRepository) RecordFailure(ctx context.Context, customerID uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error) {
|
||||
var rows []struct {
|
||||
PinFailedAttempts int
|
||||
PinLockedUntil *time.Time
|
||||
}
|
||||
// When an earlier lock has run out, this attempt is the first of a new series.
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
UPDATE customers SET
|
||||
pin_failed_attempts = CASE
|
||||
WHEN pin_locked_until IS NOT NULL AND pin_locked_until <= @now THEN 1
|
||||
ELSE pin_failed_attempts + 1 END,
|
||||
pin_locked_until = CASE
|
||||
WHEN pin_locked_until IS NOT NULL AND pin_locked_until <= @now THEN NULL
|
||||
WHEN pin_failed_attempts + 1 >= @max THEN @lock
|
||||
ELSE pin_locked_until END
|
||||
WHERE id = @id
|
||||
RETURNING pin_failed_attempts, pin_locked_until`,
|
||||
map[string]interface{}{"now": now, "max": maxAttempts, "lock": lockUntil, "id": customerID}).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("failed to record a wrong PIN: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return 0, nil, ErrPinCustomerNotFound
|
||||
}
|
||||
return rows[0].PinFailedAttempts, rows[0].PinLockedUntil, nil
|
||||
}
|
||||
|
||||
func (r *customerPinRepository) ClearFailures(ctx context.Context, customerID uuid.UUID) error {
|
||||
return DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||||
UPDATE customers SET pin_failed_attempts = 0, pin_locked_until = NULL
|
||||
WHERE id = ? AND (pin_failed_attempts <> 0 OR pin_locked_until IS NOT NULL)`, customerID).Error
|
||||
}
|
||||
|
||||
func (r *customerPinRepository) InsertEvent(ctx context.Context, event CustomerSecurityEvent) error {
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||||
INSERT INTO customer_security_events (customer_id, event, actor_user, reason, ip_address, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
event.CustomerID, event.Event, event.ActorUser, event.Reason, event.IPAddress, event.UserAgent).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record security event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *customerPinRepository) ListEvents(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerSecurityEvent, int64, error) {
|
||||
db := DBFromContext(ctx, r.db).WithContext(ctx)
|
||||
var total int64
|
||||
if err := db.Table("customer_security_events").Where("customer_id = ?", customerID).Count(&total).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to count security events: %w", err)
|
||||
}
|
||||
var rows []struct {
|
||||
ID string
|
||||
CustomerID string
|
||||
Event string
|
||||
ActorUser *string
|
||||
Reason *string
|
||||
IPAddress *string
|
||||
UserAgent *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
err := db.Raw(`
|
||||
SELECT id::text AS id, customer_id::text AS customer_id, event, actor_user::text AS actor_user,
|
||||
reason, ip_address, user_agent, created_at
|
||||
FROM customer_security_events WHERE customer_id = ?
|
||||
ORDER BY created_at DESC, id DESC OFFSET ? LIMIT ?`, customerID, offset, limit).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list security events: %w", err)
|
||||
}
|
||||
events := make([]CustomerSecurityEvent, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
e := CustomerSecurityEvent{Event: row.Event, Reason: row.Reason, IPAddress: row.IPAddress, UserAgent: row.UserAgent, CreatedAt: row.CreatedAt}
|
||||
e.ID, _ = uuid.Parse(row.ID)
|
||||
e.CustomerID, _ = uuid.Parse(row.CustomerID)
|
||||
if row.ActorUser != nil {
|
||||
if id, err := uuid.Parse(*row.ActorUser); err == nil {
|
||||
e.ActorUser = &id
|
||||
}
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, total, nil
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
// ErrEarningOrderNotFound means the order does not exist.
|
||||
var ErrEarningOrderNotFound = errors.New("earning: order not found")
|
||||
|
||||
// EarningOrder is what earning needs to know about an order.
|
||||
type EarningOrder struct {
|
||||
ID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
OutletID uuid.UUID
|
||||
OrderNumber string
|
||||
OutletName string
|
||||
CustomerID *uuid.UUID
|
||||
Subtotal float64
|
||||
DiscountAmount float64
|
||||
PaymentStatus string
|
||||
IsVoid bool
|
||||
// Cumulative rupiah refunded so far.
|
||||
RefundAmount float64
|
||||
// Nil when the order has no customer, or the customer row is gone.
|
||||
CustomerIsDefault *bool
|
||||
CustomerIsActive *bool
|
||||
}
|
||||
|
||||
// EarningCursor pages through orders by (updated_at, id).
|
||||
type EarningCursor struct {
|
||||
UpdatedAt time.Time
|
||||
ID uuid.UUID
|
||||
}
|
||||
|
||||
// EarningRepository reads orders for loyalty earning (docs/prd-point-coin.md F3).
|
||||
type EarningRepository interface {
|
||||
GetOrderForEarning(ctx context.Context, orderID uuid.UUID) (*EarningOrder, error)
|
||||
// PointPaidAmount is the rupiah part of the order paid with EnakPoint, which earns
|
||||
// nothing (Q10). Zero until EnakPoint payment exists (phase 3).
|
||||
PointPaidAmount(ctx context.Context, orderID uuid.UUID) (float64, error)
|
||||
// ListPaidOrdersWithoutEarning pages, oldest first, through orders updated since
|
||||
// the given time that are paid, not void, have an eligible customer, belong to an
|
||||
// outlet that earns something, and have no EARN row yet. Pass the previous page's
|
||||
// last cursor to continue; nil starts at the beginning.
|
||||
ListPaidOrdersWithoutEarning(ctx context.Context, since time.Time, after *EarningCursor, limit int) ([]EarningCursor, error)
|
||||
// ListEarnTransactions returns the EARN rows written for an order.
|
||||
ListEarnTransactions(ctx context.Context, orderID uuid.UUID) ([]entities.WalletTransaction, error)
|
||||
// ReversalRequested is how much has been asked back from an EARN row so far: what
|
||||
// was taken plus any shortfall.
|
||||
ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error)
|
||||
// LotIDsCreatedBy returns the lots a credit created.
|
||||
LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error)
|
||||
// EarnedByOrders sums the EARN rows of each order per currency, as
|
||||
// map[order]map[currency]amount. Orders that earned nothing are absent.
|
||||
EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]map[string]int64, error)
|
||||
}
|
||||
|
||||
type earningRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewEarningRepository(db *gorm.DB) EarningRepository {
|
||||
return &earningRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid.UUID) (*EarningOrder, error) {
|
||||
var rows []struct {
|
||||
ID string
|
||||
OrganizationID string
|
||||
OutletID string
|
||||
OrderNumber string
|
||||
OutletName string
|
||||
CustomerID *string
|
||||
Subtotal float64
|
||||
DiscountAmount float64
|
||||
PaymentStatus string
|
||||
IsVoid bool
|
||||
RefundAmount float64
|
||||
CustomerIsDefault *bool
|
||||
CustomerIsActive *bool
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id,
|
||||
o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id,
|
||||
o.subtotal, COALESCE(o.discount_amount, 0) AS discount_amount, o.payment_status,
|
||||
COALESCE(o.is_void, false) AS is_void, COALESCE(o.refund_amount, 0) AS refund_amount,
|
||||
c.is_default AS customer_is_default, c.is_active AS customer_is_active
|
||||
FROM orders o
|
||||
LEFT JOIN outlets ou ON ou.id = o.outlet_id
|
||||
LEFT JOIN customers c ON c.id = o.customer_id
|
||||
WHERE o.id = ?
|
||||
LIMIT 1`, orderID).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get order for earning: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, ErrEarningOrderNotFound
|
||||
}
|
||||
row := rows[0]
|
||||
order := &EarningOrder{
|
||||
OrderNumber: row.OrderNumber,
|
||||
OutletName: row.OutletName,
|
||||
Subtotal: row.Subtotal,
|
||||
DiscountAmount: row.DiscountAmount,
|
||||
PaymentStatus: row.PaymentStatus,
|
||||
IsVoid: row.IsVoid,
|
||||
RefundAmount: row.RefundAmount,
|
||||
CustomerIsDefault: row.CustomerIsDefault,
|
||||
CustomerIsActive: row.CustomerIsActive,
|
||||
}
|
||||
order.ID, _ = uuid.Parse(row.ID)
|
||||
order.OrganizationID, _ = uuid.Parse(row.OrganizationID)
|
||||
order.OutletID, _ = uuid.Parse(row.OutletID)
|
||||
if row.CustomerID != nil {
|
||||
if id, err := uuid.Parse(*row.CustomerID); err == nil {
|
||||
order.CustomerID = &id
|
||||
}
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (r *earningRepository) PointPaidAmount(ctx context.Context, orderID uuid.UUID) (float64, error) {
|
||||
var total float64
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT COALESCE(SUM(p.amount), 0)
|
||||
FROM payments p
|
||||
JOIN payment_methods pm ON pm.id = p.payment_method_id
|
||||
WHERE p.order_id = ? AND pm.type = ? AND p.status = ?`,
|
||||
orderID, constants.PaymentMethodTypePoint, entities.PaymentTransactionStatusCompleted).
|
||||
Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sum EnakPoint payments: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *earningRepository) ListPaidOrdersWithoutEarning(ctx context.Context, since time.Time, after *EarningCursor, limit int) ([]EarningCursor, error) {
|
||||
cursorAt, cursorID := since, uuid.Nil
|
||||
if after != nil {
|
||||
cursorAt, cursorID = after.UpdatedAt, after.ID
|
||||
}
|
||||
var rows []struct {
|
||||
ID string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
// An outlet that has neither currency switched on can never earn, so its orders are
|
||||
// not candidates; otherwise every order of such an outlet would be rescanned on
|
||||
// every run.
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT o.id::text AS id, o.updated_at
|
||||
FROM orders o
|
||||
JOIN customers c ON c.id = o.customer_id
|
||||
WHERE o.payment_status = ?
|
||||
AND COALESCE(o.is_void, false) = false
|
||||
AND c.is_default = false AND c.is_active = true
|
||||
AND o.updated_at >= ?
|
||||
AND (o.updated_at, o.id) > (?, ?)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM outlet_settings s
|
||||
WHERE s.outlet_id = o.outlet_id
|
||||
AND s.key IN (?, ?)
|
||||
AND lower(trim(s.value)) IN ('true', 't', '1')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM wallet_transactions t
|
||||
WHERE t.reference_type = ? AND t.reference_id = o.id AND t.type = ?
|
||||
)
|
||||
ORDER BY o.updated_at, o.id
|
||||
LIMIT ?`,
|
||||
entities.PaymentStatusCompleted, since, cursorAt, cursorID,
|
||||
constants.LoyaltyPointEnabledKey, constants.LoyaltyCoinEnabledKey,
|
||||
constants.WalletRefTypeOrder, constants.WalletTxTypeEarn, limit).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list paid orders without earning: %w", err)
|
||||
}
|
||||
out := make([]EarningCursor, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if id, err := uuid.Parse(row.ID); err == nil {
|
||||
out = append(out, EarningCursor{UpdatedAt: row.UpdatedAt, ID: id})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *earningRepository) ListEarnTransactions(ctx context.Context, orderID uuid.UUID) ([]entities.WalletTransaction, error) {
|
||||
var rows []entities.WalletTransaction
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("reference_type = ? AND reference_id = ? AND type = ?", constants.WalletRefTypeOrder, orderID, constants.WalletTxTypeEarn).
|
||||
Order("currency").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list EARN rows: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *earningRepository) ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error) {
|
||||
var total int64
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT COALESCE(SUM(COALESCE((metadata->>'requested_amount')::bigint, -amount)), 0)
|
||||
FROM wallet_transactions
|
||||
WHERE reverses_transaction_id = ? AND type = ?`,
|
||||
earnTransactionID, constants.WalletTxTypeEarnReversal).Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sum earning reversals: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *earningRepository) LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error) {
|
||||
var ids []string
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("wallet_lots").
|
||||
Where("source_transaction_id = ?", transactionID).
|
||||
Order("expires_at NULLS LAST, created_at, id").
|
||||
Pluck("id::text", &ids).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list lots of a credit: %w", err)
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(ids))
|
||||
for _, s := range ids {
|
||||
if id, err := uuid.Parse(s); err == nil {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *earningRepository) EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]map[string]int64, error) {
|
||||
earned := map[uuid.UUID]map[string]int64{}
|
||||
if len(orderIDs) == 0 {
|
||||
return earned, nil
|
||||
}
|
||||
var rows []struct {
|
||||
OrderID string
|
||||
Currency string
|
||||
Amount int64
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT reference_id::text AS order_id, currency, SUM(amount) AS amount
|
||||
FROM wallet_transactions
|
||||
WHERE reference_type = ? AND type = ? AND reference_id IN ?
|
||||
GROUP BY reference_id, currency`,
|
||||
constants.WalletRefTypeOrder, constants.WalletTxTypeEarn, orderIDs).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sum earnings of orders: %w", err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
id, err := uuid.Parse(row.OrderID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if earned[id] == nil {
|
||||
earned[id] = map[string]int64{}
|
||||
}
|
||||
earned[id][row.Currency] = row.Amount
|
||||
}
|
||||
return earned, nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
// ErrLoyaltyOutletNotFound means the outlet does not exist or belongs to another
|
||||
// organization.
|
||||
var ErrLoyaltyOutletNotFound = errors.New("loyalty settings: outlet not found")
|
||||
|
||||
// LoyaltySettingChangeFilter narrows the loyalty settings history.
|
||||
type LoyaltySettingChangeFilter struct {
|
||||
OrganizationID uuid.UUID
|
||||
// Nil for the whole organization, outlets included.
|
||||
OutletID *uuid.UUID
|
||||
Offset int
|
||||
Limit int
|
||||
}
|
||||
|
||||
// LoyaltySettingsRepository stores the raw loyalty settings (the loyalty.* keys of
|
||||
// outlet_settings and organization_settings) and their history
|
||||
// (docs/prd-point-coin.md F1, F2). The loyalty settings processor turns them into
|
||||
// typed values and is the only writer.
|
||||
type LoyaltySettingsRepository interface {
|
||||
// GetOutletValues returns the stored loyalty keys of an outlet. A key with no row,
|
||||
// or a NULL value, is absent.
|
||||
GetOutletValues(ctx context.Context, outletID uuid.UUID) (map[string]string, error)
|
||||
GetOrganizationValues(ctx context.Context, organizationID uuid.UUID) (map[string]string, error)
|
||||
// OutletOrganizationID returns ErrLoyaltyOutletNotFound for an unknown outlet.
|
||||
OutletOrganizationID(ctx context.Context, outletID uuid.UUID) (uuid.UUID, error)
|
||||
|
||||
// LockScope serializes writers of one outlet's or one organization's settings
|
||||
// until the transaction ends, so two saves at once cannot both compute their
|
||||
// changes from the same old values.
|
||||
LockScope(ctx context.Context, scopeID uuid.UUID) error
|
||||
// SetOutletValue stores a value, or removes the key when value is nil so it falls
|
||||
// back to its default.
|
||||
SetOutletValue(ctx context.Context, outletID uuid.UUID, key string, value *string) error
|
||||
SetOrganizationValue(ctx context.Context, organizationID uuid.UUID, key string, value *string) error
|
||||
InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error
|
||||
// ListChanges returns a page of history, newest first, and the total count.
|
||||
ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error)
|
||||
}
|
||||
|
||||
type loyaltySettingsRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewLoyaltySettingsRepository(db *gorm.DB) LoyaltySettingsRepository {
|
||||
return &loyaltySettingsRepository{db: db}
|
||||
}
|
||||
|
||||
// requireTx returns the caller's transaction. Writes must run in one, together with
|
||||
// the history rows they produce.
|
||||
func (r *loyaltySettingsRepository) requireTx(ctx context.Context) (*gorm.DB, error) {
|
||||
if tx, ok := ctx.Value(txKey).(*gorm.DB); ok && tx != nil {
|
||||
return tx.WithContext(ctx), nil
|
||||
}
|
||||
return nil, errors.New("loyalty settings: write must run inside a transaction")
|
||||
}
|
||||
|
||||
func (r *loyaltySettingsRepository) values(ctx context.Context, table, scopeColumn string, scopeID uuid.UUID) (map[string]string, error) {
|
||||
var rows []struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table(table).
|
||||
Select("key, value").
|
||||
Where(scopeColumn+" = ? AND key LIKE 'loyalty.%' AND value IS NOT NULL", scopeID).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read %s: %w", table, err)
|
||||
}
|
||||
values := make(map[string]string, len(rows))
|
||||
for _, row := range rows {
|
||||
values[row.Key] = row.Value
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (r *loyaltySettingsRepository) GetOutletValues(ctx context.Context, outletID uuid.UUID) (map[string]string, error) {
|
||||
return r.values(ctx, "outlet_settings", "outlet_id", outletID)
|
||||
}
|
||||
|
||||
func (r *loyaltySettingsRepository) GetOrganizationValues(ctx context.Context, organizationID uuid.UUID) (map[string]string, error) {
|
||||
return r.values(ctx, "organization_settings", "organization_id", organizationID)
|
||||
}
|
||||
|
||||
func (r *loyaltySettingsRepository) OutletOrganizationID(ctx context.Context, outletID uuid.UUID) (uuid.UUID, error) {
|
||||
var ids []string
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("outlets").
|
||||
Where("id = ?", outletID).
|
||||
Limit(1).
|
||||
Pluck("organization_id::text", &ids).Error
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("failed to get outlet organization: %w", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return uuid.Nil, ErrLoyaltyOutletNotFound
|
||||
}
|
||||
return uuid.Parse(ids[0])
|
||||
}
|
||||
|
||||
func (r *loyaltySettingsRepository) LockScope(ctx context.Context, scopeID uuid.UUID) error {
|
||||
db, err := r.requireTx(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Exec(`SELECT pg_advisory_xact_lock(hashtextextended(?, 0))`, "loyalty-settings:"+scopeID.String()).Error
|
||||
}
|
||||
|
||||
func (r *loyaltySettingsRepository) SetOutletValue(ctx context.Context, outletID uuid.UUID, key string, value *string) error {
|
||||
return r.setValue(ctx, "outlet_settings", "outlet_id", outletID, key, value)
|
||||
}
|
||||
|
||||
func (r *loyaltySettingsRepository) SetOrganizationValue(ctx context.Context, organizationID uuid.UUID, key string, value *string) error {
|
||||
return r.setValue(ctx, "organization_settings", "organization_id", organizationID, key, value)
|
||||
}
|
||||
|
||||
// setValue upserts or deletes one key. table and scopeColumn are never user input.
|
||||
func (r *loyaltySettingsRepository) setValue(ctx context.Context, table, scopeColumn string, scopeID uuid.UUID, key string, value *string) error {
|
||||
db, err := r.requireTx(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value == nil {
|
||||
err = db.Exec(`DELETE FROM `+table+` WHERE `+scopeColumn+` = ? AND key = ?`, scopeID, key).Error
|
||||
} else {
|
||||
err = db.Exec(`INSERT INTO `+table+` (`+scopeColumn+`, key, value) VALUES (?, ?, ?)
|
||||
ON CONFLICT (`+scopeColumn+`, key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
|
||||
scopeID, key, *value).Error
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store %s in %s: %w", key, table, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *loyaltySettingsRepository) InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error {
|
||||
if len(changes) == 0 {
|
||||
return nil
|
||||
}
|
||||
db, err := r.requireTx(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Create(&changes).Error
|
||||
}
|
||||
|
||||
func (r *loyaltySettingsRepository) ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) {
|
||||
query := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Model(&entities.LoyaltySettingChange{}).
|
||||
Where("organization_id = ?", filter.OrganizationID)
|
||||
if filter.OutletID != nil {
|
||||
query = query.Where("outlet_id = ?", *filter.OutletID)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to count loyalty setting changes: %w", err)
|
||||
}
|
||||
var changes []entities.LoyaltySettingChange
|
||||
err := query.Order("created_at DESC, id").Offset(filter.Offset).Limit(filter.Limit).Find(&changes).Error
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list loyalty setting changes: %w", err)
|
||||
}
|
||||
return changes, total, nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package repository_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
applogger "apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// Reads and writes loyalty settings through the processor against Postgres. Needs
|
||||
// TEST_DATABASE_URL pointing at a migrated database; see wallet_repository_test.go.
|
||||
func TestLoyaltySettings_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
applogger.Setup("fatal", "json")
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
|
||||
org, otherOrg, outlet, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'loyalty test', 'basic'), (?, 'other', 'basic')`, org, otherOrg)
|
||||
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org)
|
||||
// A setting that is not a loyalty setting must be left alone.
|
||||
exec(`INSERT INTO outlet_settings (outlet_id, key, value) VALUES (?, 'receipt.footer', 'Terima kasih')`, outlet)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg})
|
||||
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet)
|
||||
db.Exec(`DELETE FROM organization_settings WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg})
|
||||
db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet)
|
||||
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg})
|
||||
})
|
||||
|
||||
repo := repository.NewLoyaltySettingsRepository(db)
|
||||
p := processor.NewLoyaltySettingsProcessor(repo, repository.NewTxManager(db))
|
||||
|
||||
// No settings: every PRD default.
|
||||
settings, err := p.Outlet(ctx, outlet)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, settings.Point.Enabled)
|
||||
assert.Equal(t, int64(100), settings.Point.EarnPerAmount)
|
||||
assert.Equal(t, int64(25000), settings.Coin.EarnPerAmount)
|
||||
assert.Nil(t, settings.Point.MaxPerOrder)
|
||||
assert.Equal(t, int64(100), settings.PointPayment.MaxPaymentPercent)
|
||||
|
||||
// Change three keys.
|
||||
settings.Point.Enabled = true
|
||||
settings.Point.EarnPerAmount = 1000
|
||||
settings.Coin.MaxPerOrder = ptrInt64(5)
|
||||
changes, err := p.UpdateOutlet(ctx, org, outlet, admin, *settings)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, changes, 3)
|
||||
|
||||
var stored []struct{ Key, Value string }
|
||||
require.NoError(t, db.Raw(`SELECT key, value FROM outlet_settings WHERE outlet_id = ? ORDER BY key`, outlet).Scan(&stored).Error)
|
||||
assert.Equal(t, []struct{ Key, Value string }{
|
||||
{constants.LoyaltyCoinMaxPerOrderKey, "5"},
|
||||
{constants.LoyaltyPointEarnPerAmountKey, "1000"},
|
||||
{constants.LoyaltyPointEnabledKey, "true"},
|
||||
{"receipt.footer", "Terima kasih"},
|
||||
}, stored, "only the changed keys are stored, next to the untouched non-loyalty key")
|
||||
|
||||
history, err := p.ListChanges(ctx, org, &outlet, 1, 20)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, history.Data, 3)
|
||||
for _, c := range history.Data {
|
||||
assert.Equal(t, admin, c.ChangedBy)
|
||||
assert.Nil(t, c.OldValue, "%s was on its default", c.Key)
|
||||
assert.NotNil(t, c.NewValue)
|
||||
}
|
||||
|
||||
// A second change records the old value.
|
||||
settings.Point.EarnPerAmount = 500
|
||||
changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *settings)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, changes, 1)
|
||||
assert.Equal(t, "1000", *changes[0].OldValue)
|
||||
assert.Equal(t, "500", *changes[0].NewValue)
|
||||
|
||||
// Another organization cannot change this outlet.
|
||||
_, err = p.UpdateOutlet(ctx, otherOrg, outlet, admin, *settings)
|
||||
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
|
||||
|
||||
// Writing outside a transaction is refused.
|
||||
assert.Error(t, repo.SetOutletValue(ctx, outlet, constants.LoyaltyPointEnabledKey, nil))
|
||||
|
||||
// A save must wait while another save of the same organization holds the lock, so
|
||||
// two admins saving at once cannot both compute their change from the same old
|
||||
// value. Hold the lock in another transaction and check the save blocks until it
|
||||
// is released.
|
||||
txm := repository.NewTxManager(db)
|
||||
holding, release, holderDone := make(chan struct{}), make(chan struct{}), make(chan error, 1)
|
||||
go func() {
|
||||
holderDone <- txm.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
if err := repo.LockScope(ctx, org); err != nil {
|
||||
return err
|
||||
}
|
||||
close(holding)
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
<-holding
|
||||
|
||||
orgSettings, err := p.Organization(ctx, org)
|
||||
require.NoError(t, err)
|
||||
orgSettings.PointValue = 200
|
||||
saved := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := p.UpdateOrganization(ctx, org, admin, *orgSettings)
|
||||
saved <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-saved:
|
||||
t.Fatalf("the save finished while another transaction held the lock (err: %v)", err)
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
require.NoError(t, <-holderDone)
|
||||
require.NoError(t, <-saved)
|
||||
|
||||
value, err := p.PointValue(ctx, org)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(200), value)
|
||||
|
||||
// The whole organization's history includes the outlet's changes.
|
||||
all, err := p.ListChanges(ctx, org, nil, 1, 100)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 5, all.Pagination.Total, "four outlet changes and one organization change")
|
||||
}
|
||||
|
||||
func ptrInt64(v int64) *int64 { return &v }
|
||||
@@ -50,6 +50,7 @@ func (r *OrderItemRepositoryImpl) GetByOrderID(ctx context.Context, orderID uuid
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Product").
|
||||
Preload("ProductVariant").
|
||||
Preload("Unit").
|
||||
Where("order_id = ?", orderID).
|
||||
Find(&orderItems).Error
|
||||
return orderItems, err
|
||||
@@ -109,8 +110,12 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI
|
||||
return err
|
||||
}
|
||||
|
||||
if voidQuantity >= orderItem.Quantity {
|
||||
voidedAmount := float64(voidQuantity) * orderItem.UnitPrice
|
||||
// A weighed line is one weighing, so it is voided whole — never split into a
|
||||
// remainder row. chk_order_items_weight_single_line pins its quantity to 1, which
|
||||
// already sends it down this branch; the explicit check keeps that guarantee
|
||||
// visible and independent of the comparison below.
|
||||
if orderItem.IsWeighed() || voidQuantity >= orderItem.Quantity {
|
||||
voidedAmount := entities.RoundMoney(orderItem.BillableQuantityFor(voidQuantity) * orderItem.UnitPrice)
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"refund_quantity": voidQuantity,
|
||||
@@ -128,16 +133,18 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
// Only unit-priced lines reach here: weighed lines were voided whole above.
|
||||
voidedOrderItem := entities.OrderItem{
|
||||
OrderID: orderItem.OrderID,
|
||||
ProductID: orderItem.ProductID,
|
||||
ProductVariantID: orderItem.ProductVariantID,
|
||||
Quantity: voidQuantity,
|
||||
UnitID: orderItem.UnitID,
|
||||
UnitPrice: orderItem.UnitPrice,
|
||||
TotalPrice: float64(voidQuantity) * orderItem.UnitPrice,
|
||||
TotalPrice: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitPrice),
|
||||
UnitCost: orderItem.UnitCost,
|
||||
TotalCost: float64(voidQuantity) * orderItem.UnitCost,
|
||||
RefundAmount: float64(voidQuantity) * orderItem.UnitPrice,
|
||||
TotalCost: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitCost),
|
||||
RefundAmount: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitPrice),
|
||||
RefundQuantity: voidQuantity,
|
||||
IsPartiallyRefunded: false,
|
||||
IsFullyRefunded: true,
|
||||
@@ -155,8 +162,8 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI
|
||||
}
|
||||
|
||||
remainingQuantity := orderItem.Quantity - voidQuantity
|
||||
remainingTotalPrice := float64(remainingQuantity) * orderItem.UnitPrice
|
||||
remainingTotalCost := float64(remainingQuantity) * orderItem.UnitCost
|
||||
remainingTotalPrice := entities.RoundMoney(float64(remainingQuantity) * orderItem.UnitPrice)
|
||||
remainingTotalCost := entities.RoundMoney(float64(remainingQuantity) * orderItem.UnitCost)
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"quantity": remainingQuantity,
|
||||
|
||||
@@ -63,6 +63,7 @@ func (r *OrderRepositoryImpl) GetWithRelations(ctx context.Context, id uuid.UUID
|
||||
Preload("OrderItems.Product.Category").
|
||||
Preload("OrderItems.Product.ProductOutletPrices").
|
||||
Preload("OrderItems.ProductVariant").
|
||||
Preload("OrderItems.Unit").
|
||||
Preload("Payments").
|
||||
Preload("Payments.PaymentMethod").
|
||||
Preload("Payments.PaymentOrderItems").
|
||||
@@ -144,6 +145,7 @@ func (r *OrderRepositoryImpl) List(ctx context.Context, filters map[string]inter
|
||||
Preload("OrderItems.Product.Category").
|
||||
Preload("OrderItems.Product.ProductOutletPrices").
|
||||
Preload("OrderItems.ProductVariant").
|
||||
Preload("OrderItems.Unit").
|
||||
Preload("Payments").
|
||||
Preload("Payments.PaymentMethod").
|
||||
Preload("Payments.PaymentOrderItems").
|
||||
@@ -162,6 +164,7 @@ func (r *OrderRepositoryImpl) ListBySessionID(ctx context.Context, sessionID str
|
||||
Preload("OrderItems.Product.Category").
|
||||
Preload("OrderItems.Product.ProductOutletPrices").
|
||||
Preload("OrderItems.ProductVariant").
|
||||
Preload("OrderItems.Unit").
|
||||
Preload("Payments").
|
||||
Preload("Payments.PaymentMethod").
|
||||
Preload("Payments.PaymentOrderItems").
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrPaymentCodeTaken means the code is already live for someone; draw another.
|
||||
ErrPaymentCodeTaken = errors.New("payment code already in use")
|
||||
// ErrPaymentCodeNotFound means the code does not exist: never issued, expired, or
|
||||
// already used.
|
||||
ErrPaymentCodeNotFound = errors.New("payment code not found")
|
||||
// ErrPaymentCodeWrongCustomer means the code belongs to another customer.
|
||||
ErrPaymentCodeWrongCustomer = errors.New("payment code belongs to another customer")
|
||||
)
|
||||
|
||||
// PaymentCodeRepository keeps one-time EnakPoint payment codes in Redis
|
||||
// (docs/prd-point-coin.md F9). A code expires by TTL and is removed when used.
|
||||
type PaymentCodeRepository interface {
|
||||
// Save stores a code for a customer for ttl, and retires the customer's previous
|
||||
// code so only the newest one works. ErrPaymentCodeTaken if the code is live.
|
||||
Save(ctx context.Context, code string, customerID uuid.UUID, ttl time.Duration) error
|
||||
// Consume uses a code up if it belongs to the customer. A code of another customer
|
||||
// is left in place, so a cashier scanning it against the wrong order does not burn
|
||||
// it for its owner.
|
||||
Consume(ctx context.Context, code string, customerID uuid.UUID) error
|
||||
}
|
||||
|
||||
type paymentCodeRepository struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
func NewPaymentCodeRepository(client *redis.Client) PaymentCodeRepository {
|
||||
return &paymentCodeRepository{client: client}
|
||||
}
|
||||
|
||||
func paymentCodeKey(code string) string { return "wallet:paycode:" + code }
|
||||
|
||||
func paymentCodeCustomerKey(customerID uuid.UUID) string {
|
||||
return "wallet:paycode:customer:" + customerID.String()
|
||||
}
|
||||
|
||||
func (r *paymentCodeRepository) Save(ctx context.Context, code string, customerID uuid.UUID, ttl time.Duration) error {
|
||||
ok, err := r.client.SetNX(ctx, paymentCodeKey(code), customerID.String(), ttl).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store payment code: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return ErrPaymentCodeTaken
|
||||
}
|
||||
previous, err := r.client.GetSet(ctx, paymentCodeCustomerKey(customerID), code).Result()
|
||||
if err != nil && !errors.Is(err, redis.Nil) {
|
||||
return fmt.Errorf("failed to track payment code: %w", err)
|
||||
}
|
||||
r.client.Expire(ctx, paymentCodeCustomerKey(customerID), ttl)
|
||||
if previous != "" && previous != code {
|
||||
// Only if it is still that customer's: the number may have been reissued.
|
||||
if err := r.compareAndDelete(ctx, previous, customerID); err != nil && !errors.Is(err, ErrPaymentCodeNotFound) && !errors.Is(err, ErrPaymentCodeWrongCustomer) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// consumeScript deletes a code only if it belongs to the given customer, in one step.
|
||||
// Returns 1 when used up, 0 when missing, -1 when it belongs to someone else.
|
||||
var consumeScript = redis.NewScript(`
|
||||
local owner = redis.call('GET', KEYS[1])
|
||||
if not owner then return 0 end
|
||||
if owner ~= ARGV[1] then return -1 end
|
||||
redis.call('DEL', KEYS[1])
|
||||
return 1
|
||||
`)
|
||||
|
||||
func (r *paymentCodeRepository) Consume(ctx context.Context, code string, customerID uuid.UUID) error {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return ErrPaymentCodeNotFound
|
||||
}
|
||||
return r.compareAndDelete(ctx, code, customerID)
|
||||
}
|
||||
|
||||
func (r *paymentCodeRepository) compareAndDelete(ctx context.Context, code string, customerID uuid.UUID) error {
|
||||
result, err := consumeScript.Run(ctx, r.client, []string{paymentCodeKey(code)}, customerID.String()).Int()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to use payment code: %w", err)
|
||||
}
|
||||
switch result {
|
||||
case 1:
|
||||
return nil
|
||||
case -1:
|
||||
return ErrPaymentCodeWrongCustomer
|
||||
default:
|
||||
return ErrPaymentCodeNotFound
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,8 @@ func (r *PaymentMethodRepositoryImpl) List(ctx context.Context, filters map[stri
|
||||
case "search":
|
||||
searchValue := "%" + value.(string) + "%"
|
||||
query = query.Where("name ILIKE ? OR processor ILIKE ?", searchValue, searchValue)
|
||||
case "exclude_type":
|
||||
query = query.Where("type <> ?", value)
|
||||
default:
|
||||
query = query.Where(key+" = ?", value)
|
||||
}
|
||||
|
||||
@@ -50,3 +50,10 @@ func (m *TxManager) WithTransactionOptions(ctx context.Context, opts *sql.TxOpti
|
||||
return fn(ctxTx)
|
||||
}, opts)
|
||||
}
|
||||
|
||||
// DetachTransaction returns ctx without the caller's transaction, so work started from
|
||||
// it (such as loyalty earning after a payment) reads committed data and commits on its
|
||||
// own, whatever happens to the caller's transaction.
|
||||
func DetachTransaction(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, txKey, (*gorm.DB)(nil))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
// WalletCustomer is the customer a dashboard wallet screen is about.
|
||||
type WalletCustomer struct {
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
Phone *string
|
||||
}
|
||||
|
||||
// WalletAdminRepository serves what the dashboard adds on top of the customer's own
|
||||
// wallet view (docs/prd-point-coin.md F7): the raw balances, every lot with balance,
|
||||
// and the real names behind a ledger row. Nothing here changes a balance.
|
||||
type WalletAdminRepository interface {
|
||||
// GetCustomer returns ErrWalletNotFound unless the customer exists and belongs to
|
||||
// the organization, so one organization can never read another's customers.
|
||||
GetCustomer(ctx context.Context, organizationID, customerID uuid.UUID) (*WalletCustomer, error)
|
||||
// GetWallet returns nil, nil for a customer who has never had a wallet.
|
||||
GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error)
|
||||
// ListLotsWithBalance returns every lot that still holds something, expired or
|
||||
// not, in K9 spending order.
|
||||
ListLotsWithBalance(ctx context.Context, customerID uuid.UUID) ([]entities.WalletLot, error)
|
||||
CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
|
||||
UserNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
|
||||
OutletNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
|
||||
}
|
||||
|
||||
type walletAdminRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewWalletAdminRepository(db *gorm.DB) WalletAdminRepository {
|
||||
return &walletAdminRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *walletAdminRepository) GetCustomer(ctx context.Context, organizationID, customerID uuid.UUID) (*WalletCustomer, error) {
|
||||
var customers []WalletCustomer
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("customers").
|
||||
Select("id, name, COALESCE(phone_number, phone) AS phone").
|
||||
Where("id = ? AND organization_id = ?", customerID, organizationID).
|
||||
Limit(1).
|
||||
Scan(&customers).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer: %w", err)
|
||||
}
|
||||
if len(customers) == 0 {
|
||||
return nil, ErrWalletNotFound
|
||||
}
|
||||
return &customers[0], nil
|
||||
}
|
||||
|
||||
func (r *walletAdminRepository) GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) {
|
||||
var wallets []entities.CustomerWallet
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("customer_id = ?", customerID).
|
||||
Limit(1).
|
||||
Find(&wallets).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer wallet: %w", err)
|
||||
}
|
||||
if len(wallets) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &wallets[0], nil
|
||||
}
|
||||
|
||||
func (r *walletAdminRepository) ListLotsWithBalance(ctx context.Context, customerID uuid.UUID) ([]entities.WalletLot, error) {
|
||||
var lots []entities.WalletLot
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("customer_id = ? AND remaining_amount > 0", customerID).
|
||||
Order("currency, expires_at NULLS LAST, created_at, id").
|
||||
Find(&lots).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list wallet lots: %w", err)
|
||||
}
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
func (r *walletAdminRepository) CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
|
||||
return r.names(ctx, "customers", ids)
|
||||
}
|
||||
|
||||
func (r *walletAdminRepository) UserNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
|
||||
return r.names(ctx, "users", ids)
|
||||
}
|
||||
|
||||
func (r *walletAdminRepository) OutletNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
|
||||
return r.names(ctx, "outlets", ids)
|
||||
}
|
||||
|
||||
// names looks up the name column of a table by id. The table name is never taken
|
||||
// from user input.
|
||||
func (r *walletAdminRepository) names(ctx context.Context, table string, ids []uuid.UUID) (map[uuid.UUID]string, error) {
|
||||
names := make(map[uuid.UUID]string, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return names, nil
|
||||
}
|
||||
var rows []struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table(table).
|
||||
Select("id::text AS id, name").
|
||||
Where("id IN ?", ids).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to look up %s names: %w", table, err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if id, err := uuid.Parse(row.ID); err == nil {
|
||||
names[id] = row.Name
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
// LegacyBalance is what one customer holds in customer_points and customer_tokens,
|
||||
// the tables the wallet replaces (docs/prd-point-coin.md §10).
|
||||
type LegacyBalance struct {
|
||||
CustomerID uuid.UUID
|
||||
// Nil when the customer has no customer_points row.
|
||||
PointsRowID *uuid.UUID
|
||||
Points int64
|
||||
Tokens []entities.CustomerTokens
|
||||
}
|
||||
|
||||
// Coins is the sum of every token type: all of them become EnakCoin (Q6).
|
||||
func (b LegacyBalance) Coins() int64 {
|
||||
var total int64
|
||||
for _, t := range b.Tokens {
|
||||
total += t.Balance
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// WalletMigrationTotals compares the legacy tables with what has been migrated.
|
||||
type WalletMigrationTotals struct {
|
||||
LegacyPoints int64
|
||||
LegacyCoins int64
|
||||
MigratedPoints int64
|
||||
MigratedCoins int64
|
||||
WalletPoints int64
|
||||
WalletCoins int64
|
||||
}
|
||||
|
||||
// WalletMigrationRepository reads the legacy balances for the one-time move into the
|
||||
// wallet. The writes go through the wallet processor like any other credit.
|
||||
type WalletMigrationRepository interface {
|
||||
// ListLegacyCustomers returns, in id order, up to limit customers after the given
|
||||
// id that have a row in customer_points or customer_tokens.
|
||||
ListLegacyCustomers(ctx context.Context, after uuid.UUID, limit int) ([]uuid.UUID, error)
|
||||
GetLegacyBalance(ctx context.Context, customerID uuid.UUID) (*LegacyBalance, error)
|
||||
// SumMigrated returns how much has already been credited to the customer by
|
||||
// MIGRATION ledger rows in the currency.
|
||||
SumMigrated(ctx context.Context, customerID uuid.UUID, currency string) (int64, error)
|
||||
Totals(ctx context.Context) (*WalletMigrationTotals, error)
|
||||
}
|
||||
|
||||
type walletMigrationRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewWalletMigrationRepository(db *gorm.DB) WalletMigrationRepository {
|
||||
return &walletMigrationRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *walletMigrationRepository) ListLegacyCustomers(ctx context.Context, after uuid.UUID, limit int) ([]uuid.UUID, error) {
|
||||
var ids []uuid.UUID
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT customer_id FROM (
|
||||
SELECT customer_id FROM customer_points
|
||||
UNION
|
||||
SELECT customer_id FROM customer_tokens
|
||||
) legacy
|
||||
WHERE customer_id > ?
|
||||
ORDER BY customer_id
|
||||
LIMIT ?`, after, limit).
|
||||
Scan(&ids).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list legacy customers: %w", err)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *walletMigrationRepository) GetLegacyBalance(ctx context.Context, customerID uuid.UUID) (*LegacyBalance, error) {
|
||||
db := DBFromContext(ctx, r.db).WithContext(ctx)
|
||||
balance := &LegacyBalance{CustomerID: customerID}
|
||||
|
||||
// Find rather than First: many customers have tokens but no points row, and First
|
||||
// would log each of them as a "record not found" error.
|
||||
var points []entities.CustomerPoints
|
||||
if err := db.Where("customer_id = ?", customerID).Limit(1).Find(&points).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to get legacy points: %w", err)
|
||||
}
|
||||
if len(points) > 0 {
|
||||
balance.PointsRowID = &points[0].ID
|
||||
balance.Points = points[0].Balance
|
||||
}
|
||||
|
||||
err := db.Where("customer_id = ?", customerID).Order("token_type").Find(&balance.Tokens).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get legacy tokens: %w", err)
|
||||
}
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
func (r *walletMigrationRepository) SumMigrated(ctx context.Context, customerID uuid.UUID, currency string) (int64, error) {
|
||||
var total int64
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Model(&entities.WalletTransaction{}).
|
||||
Where("customer_id = ? AND currency = ? AND type = ?", customerID, currency, constants.WalletTxTypeMigration).
|
||||
Select("COALESCE(SUM(amount), 0)").
|
||||
Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sum migrated balance: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *walletMigrationRepository) Totals(ctx context.Context) (*WalletMigrationTotals, error) {
|
||||
var totals WalletMigrationTotals
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT
|
||||
(SELECT COALESCE(SUM(balance), 0) FROM customer_points) AS legacy_points,
|
||||
(SELECT COALESCE(SUM(balance), 0) FROM customer_tokens) AS legacy_coins,
|
||||
(SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE type = ? AND currency = ?) AS migrated_points,
|
||||
(SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE type = ? AND currency = ?) AS migrated_coins,
|
||||
(SELECT COALESCE(SUM(point_balance), 0) FROM customer_wallets) AS wallet_points,
|
||||
(SELECT COALESCE(SUM(coin_balance), 0) FROM customer_wallets) AS wallet_coins`,
|
||||
constants.WalletTxTypeMigration, constants.WalletCurrencyPoint,
|
||||
constants.WalletTxTypeMigration, constants.WalletCurrencyCoin).
|
||||
Scan(&totals).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compute wallet migration totals: %w", err)
|
||||
}
|
||||
return &totals, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user