Compare commits

...
6 Commits
Author SHA1 Message Date
efrilmandClaude Opus 5.5 3cd88a55c8 docs(loyalty): EnakPoint & EnakCoin integration guide
Adds docs/integration-enakpoint.md for the customer app, POS and dashboard
teams (PC-602), in the style of the weight-based products guide.

It covers the response envelope and error codes, balances and history with
every ledger type, the expiring list and FCM push types with their data,
the PIN flows and the four PIN error codes, paying with EnakPoint at the
cashier (payment code, preview, POST /payments) and in the app, void and
refund rules, exchange and transfer with Idempotency-Key, games on
EnakCoin, the deprecated endpoints and fields with their replacements, the
dashboard's outlet and organization settings including both expiry models,
the customer wallet, adjustments, trace and PIN removal, and a checklist
per team.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:46:06 +07:00
efrilmandClaude Opus 5.5 d1e543a79f refactor(loyalty): remove the dead customer points and tokens code
First part of PC-601 (docs/prd-point-coin.md §10.7): the code that has
had no way in since balances moved to the wallet.

- The /marketing/customer-points and /marketing/customer-tokens routes
  were commented out; their 16 handler methods, the GamificationService
  methods behind them, and the validators, transformers, mappers and
  contract/model types only they used are gone.
- CustomerPointsProcessor loses its "not implemented" stubs; it keeps the
  customer app's balance, wallet and games endpoints.
- CustomerTokensProcessor and the customer points and tokens repositories,
  wired but no longer called by anything, are gone.

What stays until its preconditions are met: the customer_points and
customer_tokens tables and their entities, which cmd/wallet-migrate still
reads, and the /customer/points, /customer/tokens aliases and the
token_used / tokens_remaining fields, until the apps no longer use them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:43:15 +07:00
efrilmandClaude Opus 5.5 550122f29c feat(loyalty): remind customers before balances expire
Adds what the customer sees of expiry (docs/prd-point-coin.md F6, F12,
PC-504).

GET /customer/wallet/expiring lists everything that will expire, per
currency and day, soonest first. GET /customer/wallet already had the
nearest expiry per currency.

The expiry job now also sends reminders, with the settings of note N4 as
decided: once, reminder_days before (7 by default, 0 for none), per
currency. A customer gets one FCM push per currency and expiry day,
however many lots make it up: "150 EnakPoint akan kedaluwarsa pada 31 Okt
2026. Pakai sebelum hangus.", with type WALLET_EXPIRING, the currency,
amount and expiry_date in its data. Reminders cover whatever falls within
the window, so a run that was missed catches up rather than skipping a day.

Migration 000097 adds wallet_expiry_reminders, one row per customer,
currency and expiry day. The row is written before the push is sent, so
several instances of the job or a restart never remind twice; a push that
then fails is logged and not retried. Lots that expire later on the same
day as an earlier reminder are not reminded of again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:35:12 +07:00
efrilmandClaude Opus 5.5 d293786cde feat(loyalty): expire balances whose time is up
Adds the expiry job (docs/prd-point-coin.md F12, PC-503).

Every 15 minutes it lists the lots whose expiry has passed and that still
hold something, the longest overdue first, 500 at a time, and expires each
in its own transaction through WalletProcessor.ExpireLot: lock the wallet,
read the lot again, and take what is left with an EXPIRE row pointing at
the lot, keyed expire:{lot_id}. The description is frozen as
"Kedaluwarsa: 130 EnakPoint dari Belanja #ORD-0098", using the amount read
under the lock. Lots expire at the end of their day, so none stays past it
for more than about a quarter of an hour.

It is safe on several instances and across restarts, keeping no state in
memory as OmsetMilestoneScheduler does. Selecting the lots FOR UPDATE SKIP
LOCKED, as PC-503 suggested, would lock a lot before its wallet and
deadlock against payments, which lock the wallet first; instead the
listing takes no lock, and the wallet lock plus the idempotency key make a
second instance find the lot empty or the key used and take nothing.

A lot that fails is logged and retried on the next run without stopping
the others. Each customer gets one FCM push per currency with the total
that expired ("180 EnakPoint kamu sudah kedaluwarsa.", type
WALLET_EXPIRED).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:31:38 +07:00
efrilmandClaude Opus 5.5 4d63673a25 feat(loyalty): give new balances their expiry
Every lot now gets its expiry when it is created (docs/prd-point-coin.md
F12, PC-502), where it used to never expire until note N4 was settled:

- EARN and an ADJUSTMENT that adds: ComputeExpiry of the organization's
  settings for that currency, from the moment received.
- EXCHANGE_IN: the sooner of the EnakCoin lot's expiry and when EnakPoint
  received now expire (F4).
- PAYMENT_REFUND: the expiry of the lot the EnakPoint came from, but at
  least seven days from the refund (N4, decided). A lot that never expired
  stays so.
- TRANSFER_IN: unchanged, exactly the sender's expiry.

Turning expiry on for a currency for the first time dates every lot of the
organization that still holds something and has no expiry, MIGRATION lots
included, in the same transaction as the setting: a full period from now
when ROLLING, the second fixed date on or after today when FIXED_DATE, so
no customer loses a balance soon after the rule is announced (N4,
decided). Turning it off leaves dated lots as they are. PUT
/marketing/loyalty-settings reports these as expiry_activations (currency,
lots, amount, expires_at); a dry run counts them without dating anything.

The earning processor now also reads the organization settings, and the
wallet admin processor takes the settings reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:29:25 +07:00
efrilmandClaude Opus 5.5 9ce55e6002 feat(loyalty): expiry settings for both expiry models
Settles note N4 of docs/prd-point-coin.md: both expiry models are
supported, chosen per currency by the owner, defaulting to one fixed date a
year (PC-501, F12).

New organization keys, per currency (loyalty.point.* / loyalty.coin.*):
- expiry_mode: FIXED_DATE (default) or ROLLING.
- expiry_fixed_dates: the days of the year balances expire on, as sorted
  MM-DD values ("12-31" by default, "06-30,12-31" for twice a year). 29 Feb
  is refused.
- expiry_grace_months: 0 to 24, default 3. A balance lasts at least this
  long before a fixed date takes it.
The existing period, unit and end_of_month keys now belong to ROLLING, and
reminder_days to both.

ComputeExpiry gives the expiry of a balance received at a time: the first
fixed date on or after the day received plus the grace months, or the day
received plus the period (to the end of that month when asked). Days are
the customer's (WIB), a shorter month keeps to its last day, and a lot
lasts to 23:59:59 of its day so the apps group it under that day. Nil when
expiry is off. ActivationExpiry, RefundExpiry and EarlierExpiry hold the
other decided rules and are used by PC-502.

GET and PUT /marketing/loyalty-settings return expiry_preview: when a
balance received now would expire, for the dashboard's "received today
expires on ..." hint, also on a dry run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:23:40 +07:00
50 changed files with 2161 additions and 1804 deletions
+635
View File
@@ -0,0 +1,635 @@
# Integrasi EnakPoint & EnakCoin — Customer App, POS & Dashboard
**Migrasi:** `000090`–`000097` · **Base URL:** `/api/v1` · **Kompatibilitas:** endpoint
lama tetap jalan sebagai alias (lihat §8)
Panduan untuk memakai saldo loyalitas dari sisi klien. Alasan di balik setiap aturan
ada di [`prd-point-coin.md`](./prd-point-coin.md).
---
## 1. Konsep inti
| | EnakPoint (`POINT`) | EnakCoin (`COIN`) |
|---|---|---|
| Didapat dari | Order lunas (per outlet), adjustment admin, exchange | Order lunas (per outlet), adjustment admin |
| Dipakai untuk | **Membayar order** | **Main game**, ditukar ke EnakPoint |
| Bisa ditransfer | Ya | Ya |
| Bisa kedaluwarsa | Ya, bila diaktifkan owner | Ya, bila diaktifkan owner |
Aturan yang berlaku di seluruh dokumen ini:
1. **Semua jumlah bilangan bulat.** Tidak ada "setengah EnakPoint".
2. **Saldo tidak pernah jadi uang.** Tidak ada pencairan, tidak ada kembalian, dan
bagian order yang dibayar EnakPoint hanya bisa kembali sebagai EnakPoint. Tampilkan
nilai rupiahnya sebagai **"setara potongan Rp …"**, bukan "saldo Rp …".
3. **Semua aksi customer yang memindahkan saldo butuh PIN 6 digit** (§3): bayar,
buat kode bayar, exchange, transfer. Main game tidak butuh PIN.
4. **Wallet milik customer di satu organisasi.** Saldo berlaku di semua outlet
organisasi itu. Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan
kedaluwarsa diatur per organisasi; earning dan penerimaan pembayaran per outlet.
5. **Setiap mutasi tercatat** di riwayat beserta asal atau tujuannya, dan tidak pernah
dihapus. Koreksi muncul sebagai baris baru.
### Format response
Semua endpoint memakai amplop yang sama:
```json
{ "success": true, "data": { … }, "errors": null }
```
```json
{
"success": false,
"data": null,
"errors": [{ "code": "304", "entity": "wallet_service", "cause": "wallet move refused: not enough EnakCoin" }]
}
```
| `code` | HTTP | Arti |
|---|---|---|
| `303`, `310` | 400 | Body atau parameter tidak lengkap / salah format |
| `304` | 400 | Permintaan ditolak aturan bisnis; `cause` menjelaskan alasannya |
| `404` | 404 | Tidak ditemukan (juga dipakai untuk data milik customer/organisasi lain) |
| `429` | 429 | Terlalu cepat meminta ulang (OTP) |
| `PIN_NOT_SET` | 403 | Customer belum membuat PIN |
| `PIN_INVALID` | 400 | PIN salah |
| `PIN_LOCKED` | 423 | PIN terkunci |
| `TRANSFER_BLOCKED` | 403 | Transfer ditahan setelah reset PIN |
| `900` | 500 | Kesalahan server |
---
## 2. Customer app — saldo & riwayat
Semua endpoint customer memakai header `Authorization: Bearer <token customer>`.
### 2.1 Saldo
`GET /api/v1/customer/wallet`
```json
{
"point_balance": 12500,
"coin_balance": 8,
"point_value": 1,
"point_discount_value": 12500,
"nearest_expiring": {
"point": { "amount": 150, "date": "2026-12-31" },
"coin": null
},
"recent_transactions": [ … ]
}
```
- `point_balance` dan `coin_balance` adalah saldo yang **bisa dipakai sekarang**.
- `point_discount_value` = `point_balance × point_value`. Tampilkan sebagai
"setara potongan Rp 12.500".
- `nearest_expiring` bernilai `null` per currency bila tidak ada yang akan kedaluwarsa.
- `recent_transactions` berisi 5 mutasi terakhir dengan bentuk yang sama seperti §2.2.
### 2.2 Riwayat
`GET /api/v1/customer/wallet/transactions?page=1&limit=20&currency=POINT&type=EARN,PAYMENT&from=2026-09-01&to=2026-09-30`
Semua query opsional. `limit` 1–100 (default 20). `type` boleh beberapa, dipisah koma.
`from` / `to` tanggal WIB, inklusif.
```json
{
"data": [
{
"id": "…",
"currency": "POINT",
"type": "EARN",
"amount": 875,
"balance_after": 12500,
"description": "Belanja #ORD-0123 di Outlet Kemang",
"source": { "type": "ORDER", "id": "…" },
"outlet_id": "…",
"expires_at": "2026-12-31T23:59:59+07:00",
"lots": [{ "amount": 875, "remaining": 875, "expires_at": "2026-12-31T23:59:59+07:00" }],
"created_at": "2026-09-30T12:01:00Z"
}
],
"pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 }
}
```
- `amount` bertanda: positif menambah saldo, negatif mengurangi.
- Penambahan punya `source`, pengurangan punya `destination`. Keduanya berbentuk
`{ type, id }` dan menunjuk hal yang bisa dibuka di detail (order, pembayaran, game
play, dst.).
- `description` sudah siap tampil dan tidak berubah walau nama outlet atau customer
berubah belakangan. Nama lawan transfer sudah disamarkan.
- Dua baris exchange atau transfer berbagi `group_id` yang sama.
| `type` | Arah | Arti | `source` / `destination` |
|---|---|---|---|
| `EARN` | + | Didapat dari order lunas | `ORDER` |
| `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` |
| `PAYMENT` | − | Membayar order | `PAYMENT` |
| `PAYMENT_REFUND` | + | Kembali karena pembayaran di-void/refund | `PAYMENT` |
| `EXCHANGE_OUT` / `EXCHANGE_IN` | − / + | Tukar EnakCoin ke EnakPoint | `WALLET_TX` (baris pasangannya) |
| `TRANSFER_OUT` / `TRANSFER_IN` | − / + | Transfer antar customer | `WALLET_TX` (baris pasangannya) |
| `GAME_SPEND` | − | Main game | `GAME_PLAY` |
| `EXPIRE` | − | Hangus karena kedaluwarsa | `LOT` |
| `ADJUSTMENT` | + / − | Koreksi oleh admin | `USER` |
| `MIGRATION` | + | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` |
### 2.3 Yang akan kedaluwarsa
`GET /api/v1/customer/wallet/expiring`
```json
{
"point": [
{ "amount": 150, "date": "2026-10-31" },
{ "amount": 200, "date": "2026-12-31" }
],
"coin": []
}
```
Dikelompokkan per tanggal (WIB), paling dekat lebih dulu. Saldo bisa dipakai sampai
akhir hari tanggal itu. Daftar kosong berarti tidak ada yang akan kedaluwarsa.
### 2.4 Notifikasi push (FCM)
Aplikasi mendaftarkan token FCM-nya **setelah login dan setiap kali FCM memberi token
baru**:
`PUT /api/v1/customer/devices`
```json
{ "device_id": "a1b2c3", "fcm_token": "…", "platform": "android", "app_version": "2.4.0" }
```
`platform`: `android`, `ios`, atau `web` (opsional). Saat logout, panggil
`DELETE /api/v1/customer/devices/:device_id` supaya HP itu tidak lagi menerima
notifikasi customer tersebut. Satu token hanya milik satu customer: bila customer lain
login di HP yang sama dan mendaftarkan token yang sama, customer sebelumnya otomatis
tidak menerima notifikasi di HP itu lagi.
Push yang dikirim, dibedakan lewat `data.type`:
| `data.type` | Kapan | Isi `data` lainnya |
|---|---|---|
| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` |
| `WALLET_EXPIRING` | `reminder_days` hari sebelum saldo kedaluwarsa, sekali per tanggal | `currency`, `amount`, `expiry_date` |
| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` |
| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339, UTC) |
Semua nilai di `data` berupa string, sesuai aturan FCM.
---
## 3. Customer app — PIN
PIN 6 digit, terpisah dari password login, dikirim sebagai **string** supaya angka nol
di depan tidak hilang. PIN tidak pernah dikembalikan di response.
### 3.1 Cek status
`GET /api/v1/customer/pin/status`
```json
{ "has_pin": true, "locked_until": null, "transfer_blocked_until": null }
```
Minta customer membuat PIN saat pertama kali ia melakukan aksi yang butuh PIN
(`has_pin: false`), bukan saat registrasi.
### 3.2 Membuat PIN pertama kali
1. `POST /api/v1/customer/pin/otp` dengan `{ "purpose": "pin_setup" }`. OTP dikirim ke
nomor customer lewat WhatsApp. Response: `{ "purpose", "otp_token", "expires_at" }`.
2. `POST /api/v1/customer/pin` dengan
`{ "otp_token": "…", "otp_code": "123456", "pin": "482913", "confirm_pin": "482913" }`.
PIN ditolak (`304`) bila bukan 6 digit, konfirmasinya beda, semua digit sama
(`111111`), berurutan (`123456`, `654321`), atau sama dengan tanggal lahir
(`DDMMYY` / `YYMMDD`). Tampilkan `cause` apa adanya. Meminta OTP terlalu cepat
menghasilkan `429`.
### 3.3 Mengganti dan mereset PIN
- **Ganti:** `PUT /api/v1/customer/pin` dengan `{ "old_pin", "pin", "confirm_pin" }`.
- **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu
`POST /api/v1/customer/pin/reset` dengan body yang sama seperti §3.2. Reset juga
membuka PIN yang terkunci. Setelah reset, **transfer keluar ditahan 24 jam**;
pembayaran dan exchange tetap bisa.
### 3.4 Menangani error PIN
Setiap endpoint yang menerima `pin` bisa mengembalikan error PIN. Pada error ini `data`
**tidak** `null`:
```json
{
"success": false,
"data": { "code": "PIN_INVALID", "remaining_attempts": 3 },
"errors": [{ "code": "PIN_INVALID", "entity": "customer_pin_service", "cause": "wrong PIN, 3 attempts left" }]
}
```
| `data.code` | Field tambahan | Yang ditampilkan aplikasi |
|---|---|---|
| `PIN_NOT_SET` | – | Arahkan ke pembuatan PIN (§3.2) |
| `PIN_INVALID` | `remaining_attempts` | "PIN salah, sisa 3 percobaan" |
| `PIN_LOCKED` | `locked_until` | "PIN terkunci sampai 14:30", tawarkan reset PIN |
| `TRANSFER_BLOCKED` | `transfer_blocked_until` | "Transfer bisa dilakukan lagi pada …" |
Lima kali salah berturut-turut mengunci PIN selama 30 menit. Selama terkunci, PIN yang
benar pun ditolak. Penghitung disimpan di server, jadi tidak bisa diakali dengan
reinstall atau ganti HP.
---
## 4. Membayar dengan EnakPoint
Ada dua jalur. Keduanya memakai logika perhitungan yang sama.
### 4.1 Batas pembayaran
EnakPoint maksimal yang bisa dipakai untuk satu order:
```
batas_rupiah = min(sisa_tagihan, total_order × max_payment_percent / 100 − yang_sudah_dibayar_EnakPoint)
maks_point = min(saldo_customer, floor(batas_rupiah / point_value))
```
Ditambah minimal `min_payment_points` per pembayaran. Nominal rupiah pembayaran selalu
`points × point_value` dan **tidak pernah melebihi sisa tagihan**, jadi tidak ada
kembalian. Sisa tagihan dibayar dengan method lain seperti biasa (split).
### 4.2 POS — kode bayar dari aplikasi customer
PIN **tidak pernah** diketik di perangkat kasir. Customer menyetujui di HP-nya sendiri:
1. **Customer app:** `POST /api/v1/customer/wallet/payment-code` dengan `{ "pin": "482913" }`.
```json
{ "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" }
```
Tampilkan `code` sebagai angka dan `qr_payload` sebagai QR. Kode berlaku **2 menit**,
sekali pakai, dan hanya untuk customer itu. Membuat kode baru membatalkan kode lama.
2. **POS:** tampilkan batas untuk tombol "pakai maksimal":
`GET /api/v1/orders/:id/point-payment/preview`
```json
{
"order_id": "…",
"customer_id": "…",
"eligible": true,
"point_balance": 12500,
"point_value": 1,
"remaining_amount": 87500,
"min_payment_points": 1,
"max_payment_percent": 100,
"max_points": 12500,
"max_amount": 12500
}
```
Bila `eligible: false`, `reason` menjelaskan kenapa (order walk-in, outlet tidak
menerima EnakPoint, saldo di bawah minimal, dst.).
3. **POS:** bayar lewat endpoint pembayaran yang sudah ada, dengan payment method
bertipe `point`:
`POST /api/v1/payments` (header `X-Idempotency-Key` wajib seperti pembayaran lain)
```json
{
"order_id": "…",
"payment_method_id": "<id method EnakPoint>",
"points": 12500,
"payment_code": "482913"
}
```
`amount` tidak perlu dikirim; backend menghitungnya. `payment_code` boleh berupa
angka yang diketik kasir atau hasil scan QR apa adanya (`enakpoint:482913`).
Response pembayaran membawa `points_used` dan `point_value` untuk struk, misalnya
"EnakPoint: 12.500 (Rp 12.500)". Jika pembayaran ini melunasi order, order menjadi
`completed`; jika belum, sisanya dibayar dengan method lain.
Pembayaran ditolak (`304`, `cause` menjelaskan) bila: order tanpa customer atau
customer walk-in, customer nonaktif, outlet tidak menerima EnakPoint, `points` di luar
batas §4.1, kode salah/kedaluwarsa/sudah dipakai/milik customer lain, atau method
EnakPoint dipakai sebagai split (bayar bagian EnakPoint sebagai pembayaran tersendiri,
lalu split sisanya seperti biasa). Kode bayar dipakai habis begitu diterima, sebelum
batas dicek ulang; bila pembayaran lalu ditolak (misalnya saldo berubah), minta
customer membuat kode baru.
**Method EnakPoint** dibuat otomatis untuk setiap organisasi dan tidak bisa dihapus
atau diubah tipenya (namanya boleh diganti). Daftar payment method yang dikirim
`?outlet_id=` tidak menampilkannya bila outlet itu tidak menerima EnakPoint.
### 4.3 Customer app / self-order — bayar order sendiri
`POST /api/v1/customer/orders/:id/pay-with-points`
```json
{ "points": 12500, "pin": "482913" }
```
Hanya untuk order milik customer yang login; order lain dijawab `404`. Response sama
dengan response pembayaran di §4.2.
### 4.4 Void dan refund
- **Void order:** semua EnakPoint yang dipakai kembali ke customer sebagai EnakPoint.
- **Refund pembayaran EnakPoint** (`POST /api/v1/payments/:id/refund` pada pembayaran
EnakPoint): yang kembali `floor(rupiah_direfund / point_value_saat_bayar)`. Perubahan
nilai EnakPoint setelah pembayaran tidak mengubah jumlah yang kembali; sisa di bawah
1 EnakPoint hangus.
- **Refund order ke tunai / method lain** hanya boleh sebesar bagian yang dibayar
dengan method lain. Bagian EnakPoint harus direfund lewat pembayaran EnakPoint-nya
sendiri; mencoba lewat tunai dijawab `304`.
- EnakPoint yang kembali mengikuti tanggal kedaluwarsa asalnya, tapi minimal 7 hari
sejak refund.
- EnakPoint dan EnakCoin yang didapat dari order ikut ditarik saat void/refund. Bila
saldo customer sudah terpakai, yang ditarik sebanyak yang ada; refund tidak pernah
diblokir karena ini.
### 4.5 Earning di layar order dan struk
Response order membawa `points_earned` dan `coins_earned` (0 bila order tidak
menghasilkan apa-apa). Earning dihitung dari `subtotal − discount − bagian yang
dibayar EnakPoint`, sebelum pajak, dan diberikan saat order lunas.
---
## 5. Exchange EnakCoin → EnakPoint
Kurs per organisasi: `coin_amount` EnakCoin = `point_amount` EnakPoint (default 1 : 1).
1. **Preview** sebelum minta PIN:
`GET /api/v1/customer/wallet/exchange/preview?coins=30`
```json
{ "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true }
```
Bila `valid: false`, tampilkan `reason` (misalnya harus kelipatan `coin_amount`,
atau EnakCoin tidak cukup).
2. **Tukar:**
`POST /api/v1/customer/wallet/exchange` dengan header **`Idempotency-Key`** (wajib,
maks. 50 karakter, satu key per percobaan tukar)
```json
{ "coins": 30, "pin": "482913" }
```
```json
{
"group_id": "…",
"coins": 30,
"points": 9,
"coin_amount": 10,
"point_amount": 3,
"lots": [{ "amount": 9, "expires_at": "2026-12-31T23:59:59+07:00" }],
"coin_balance": 5,
"point_balance": 9,
"replayed": false
}
```
- Jumlah EnakCoin harus kelipatan `coin_amount`. Kesalahan jumlah ditolak **sebelum**
PIN dicek, jadi tidak memakan jatah percobaan PIN.
- Exchange tidak bisa dibatalkan; tampilkan konfirmasi.
- Kirim ulang dengan `Idempotency-Key` yang sama bila koneksi putus: hasil pertama
dikembalikan dengan `replayed: true` tanpa menukar lagi, dengan kurs saat itu.
`Idempotency-Key` yang sama untuk jumlah berbeda ditolak.
- EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin asalnya (`lots`
menunjukkan tanggalnya).
---
## 6. Transfer ke customer lain
1. **Cek penerima** sebelum konfirmasi:
`GET /api/v1/customer/wallet/transfer/recipient?phone=081234561234`
```json
{ "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }
```
Nomor yang tidak terdaftar di organisasi yang sama dijawab `404`. Diri sendiri,
customer walk-in, atau customer nonaktif dijawab `304`.
2. **Kirim:**
`POST /api/v1/customer/wallet/transfer` dengan header **`Idempotency-Key`** (wajib)
```json
{ "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" }
```
```json
{
"group_id": "…",
"currency": "POINT",
"amount": 120,
"recipient": { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" },
"lots": [
{ "amount": 100, "expires_at": "2026-12-31T23:59:59+07:00" },
{ "amount": 20, "expires_at": null }
],
"balance": 30,
"replayed": false
}
```
- `currency`: `POINT` atau `COIN`, satu jenis per transfer.
- Batas dari organisasi: transfer bisa dimatikan, ada minimal, maksimal per
transaksi, dan batas harian per currency (reset tengah malam WIB). Pelanggaran batas
ditolak `304` sebelum PIN dicek.
- Transfer final dan tidak bisa dibatalkan customer.
- Saldo yang dikirim membawa tanggal kedaluwarsa aslinya ke penerima (`lots`).
Tampilkan ini ke pengirim.
- Penerima mendapat push `WALLET_TRANSFER_IN` (§2.4).
- Retry dengan `Idempotency-Key` yang sama mengembalikan hasil pertama
(`replayed: true`) dan tidak dihitung dua kali terhadap batas harian.
---
## 7. Game
`POST /api/v1/customer/spin` dengan `{ "spin_id": "<id game>" }`. Tanpa PIN.
Setiap game memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1).
Response:
```json
{
"game_play": { "id": "…", "game_id": "…", "coins_used": 1, "token_used": 1, "created_at": "…" },
"prize_won": { "id": "…", "name": "Voucher 10rb", … },
"coins_remaining": 7,
"tokens_remaining": 7
}
```
EnakCoin kurang, game nonaktif, atau hadiah baru saja habis dijawab `304`; tidak ada
EnakCoin yang terpotong. Baca `coins_used` dan `coins_remaining`; `token_used` dan
`tokens_remaining` hanya salinan untuk versi aplikasi lama.
Di dashboard, `metadata.coin_cost` diisi per game dengan bilangan bulat ≥ 1.
---
## 8. Endpoint lama (deprecated)
Masih jalan dan membaca saldo wallet, tapi akan dihapus setelah semua versi aplikasi
pindah. Aplikasi baru jangan memakainya.
| Lama | Ganti dengan |
|---|---|
| `GET /customer/points` | `GET /customer/wallet` (`point_balance`) |
| `GET /customer/tokens` | `GET /customer/wallet` (`coin_balance`) |
| `total_points`, `total_tokens`, `points_history`, `tokens_history`, `last_updated` di `/customer/wallet` | `point_balance`, `coin_balance`, `recent_transactions` |
| `token_used`, `tokens_remaining` di respons game | `coins_used`, `coins_remaining` |
| `sort_by=token_used` di daftar game play | `sort_by=coins_used` |
Beri tahu tim backend setelah aplikasi yang beredar tidak lagi memakai kolom kiri,
supaya alias dan tabel lama (`customer_points`, `customer_tokens`) bisa dihapus.
---
## 9. Dashboard
Semua endpoint di bagian ini butuh login user dengan role Admin atau Manager.
### 9.1 Pengaturan per outlet
`GET` / `PUT /api/v1/outlets/:outlet_id/loyalty-settings`
```json
{
"point": { "enabled": true, "earn_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
"coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
}
```
Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan
`point_value` organisasi dan `point_cashback_percent`
(`earn_value × point_value / earn_per_amount × 100`). **Tampilkan persentase ini di
samping setting** supaya owner tidak salah membaca skala: default di atas setara
cashback 1%.
### 9.2 Pengaturan organisasi
`GET` / `PUT /api/v1/marketing/loyalty-settings` (tambah `?dry_run=true` untuk preview
tanpa menyimpan)
```json
{
"point_value": 1,
"exchange": { "coin_amount": 1, "point_amount": 1 },
"transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null },
"point_expiry": {
"enabled": false,
"mode": "FIXED_DATE",
"fixed_dates": ["12-31"],
"grace_months": 3,
"period": 12,
"unit": "MONTH",
"end_of_month": false,
"reminder_days": 7
},
"coin_expiry": { … sama … }
}
```
Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan:
- `impact`: total saldo beredar dan nilai rupiahnya **sebelum dan sesudah** perubahan
`point_value` atau kurs. Tampilkan sebagai peringatan sebelum owner menyimpan.
- `expiry_preview`: `{ "point": …, "coin": … }`, kapan saldo yang didapat hari ini
akan kedaluwarsa (`null` bila tidak kedaluwarsa). Tampilkan sebagai "EnakPoint yang
didapat hari ini kedaluwarsa pada 31 Des 2026".
- `expiry_activations`: bila perubahan ini **menyalakan** kedaluwarsa untuk pertama
kali, berapa saldo lama yang ikut diberi tanggal (`lots`, `amount`) dan tanggalnya
(`expires_at`). Selalu minta konfirmasi dengan `dry_run=true` dulu.
- `changes`: key yang berubah.
**Kedaluwarsa** diatur per currency dengan salah satu model:
| `mode` | Cara kerja | Field yang dipakai |
|---|---|---|
| `FIXED_DATE` (default) | Semua saldo hangus di tanggal tetap setiap tahun. Saldo yang didapat kurang dari `grace_months` sebelum tanggal itu ikut ke tanggal berikutnya | `fixed_dates` (format `MM-DD`, boleh lebih dari satu, `02-29` ditolak), `grace_months` (0–24) |
| `ROLLING` | Tiap saldo berlaku sekian lama sejak didapat | `period`, `unit` (`DAY` / `MONTH`), `end_of_month` |
- `reminder_days` berlaku untuk keduanya: customer diingatkan sekian hari sebelum
hangus (0 = tanpa pengingat).
- Mengubah pengaturan hanya berlaku untuk saldo yang masuk setelahnya.
- Menyalakan kedaluwarsa pertama kali memberi saldo lama masa berlaku penuh: tanggal
hangus kedua berikutnya (`FIXED_DATE`) atau satu periode penuh (`ROLLING`).
- Mematikan kedaluwarsa tidak membatalkan tanggal yang sudah terjadwal.
Riwayat perubahan: `GET /api/v1/marketing/loyalty-settings/history?page=1&limit=20`
(tambah `outlet_id=` untuk setting outlet).
### 9.3 Wallet customer
- `GET /api/v1/marketing/customers/:id/wallet` — saldo buku dan saldo yang bisa
dipakai, semua lot yang masih berisi, dan riwayat dengan nama asli (lawan transfer,
admin, kasir, outlet). Query riwayat sama seperti §2.2.
- `POST /api/v1/marketing/customers/:id/wallet/adjust`
```json
{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-45" }
```
`amount` bertanda. `reason` wajib. Pengurangan yang melebihi saldo ditolak.
Adjustment tidak disertai pembayaran uang, jadi jangan pakai alasan "pencairan".
- `GET /api/v1/marketing/wallet-transactions/:id/trace` — telusuri satu mutasi per
butir: lot mana yang dipakai atau dibuat, lalu rantai asalnya lewat transfer,
exchange, atau refund sampai ke earning/adjustment/migrasi pertama. Contoh: dari
pembayaran B bisa terlihat bahwa EnakPoint-nya berasal dari order #ORD-1 milik A
yang mentransfer ke B.
### 9.4 PIN customer
- `DELETE /api/v1/marketing/customers/:id/pin` dengan `{ "reason": "…" }` — hapus PIN
bila customer kehilangan akses. Customer lalu membuat PIN baru lewat OTP. Admin
**tidak bisa** membuat, mengganti, atau melihat PIN.
- `GET /api/v1/marketing/customers/:id/security-events?page=1&limit=20` — log keamanan:
`PIN_SET`, `PIN_CHANGED`, `PIN_RESET`, `PIN_FAILED`, `PIN_LOCKED`,
`PIN_REMOVED_BY_ADMIN`, beserta waktu, IP, dan perangkat.
---
## 10. Checklist integrasi
**Customer app**
- [ ] Daftarkan token FCM setelah login dan saat token berganti; hapus saat logout.
- [ ] Tangani empat kode error PIN (§3.4) di semua layar yang meminta PIN.
- [ ] Kirim `Idempotency-Key` baru untuk setiap exchange dan transfer, dan pakai ulang
key yang sama saat retry.
- [ ] Tampilkan nilai rupiah sebagai "setara potongan", bukan saldo uang.
- [ ] Baca `coins_used` / `coins_remaining` dan `/customer/wallet`, bukan field lama.
**POS**
- [ ] Scan QR atau ketik kode bayar, jangan pernah meminta PIN customer di layar kasir.
- [ ] Pakai `point-payment/preview` untuk tombol "pakai maksimal".
- [ ] Cetak `points_used`, `points_earned`, dan `coins_earned` di struk.
- [ ] Refund bagian EnakPoint lewat pembayaran EnakPoint-nya, bukan tunai.
**Dashboard**
- [ ] Tampilkan `point_cashback_percent`, `impact`, `expiry_preview`, dan
`expiry_activations` sebelum owner menyimpan setting.
- [ ] Isi `metadata.coin_cost` untuk setiap game.
+12 -8
View File
@@ -33,6 +33,7 @@ type App struct {
omsetScheduler *service.OmsetMilestoneScheduler
walletRecon *service.WalletReconciliationJob
earningRetry *service.EarningBackfillJob
walletExpiry *service.WalletExpiryJob
}
func NewApp(db *gorm.DB, redisClient *redis.Client) *App {
@@ -63,6 +64,9 @@ func (a *App) Initialize(cfg *config.Config) error {
)
// Earns for paid orders whose earning failed at payment time (docs/prd-point-coin.md F3)
a.earningRetry = service.NewEarningBackfillJob(processors.earningProcessor)
// Expires balances whose time is up and reminds customers before (docs/prd-point-coin.md F12)
a.walletExpiry = service.NewWalletExpiryJob(processor.NewWalletExpiryProcessor(
repository.NewWalletExpiryRepository(a.db), processors.loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager, processors.customerDeviceProcessor))
services := a.initServices(processors, repos, cfg)
validators := a.initValidators()
@@ -178,6 +182,9 @@ func (a *App) Start(port string) error {
if a.earningRetry != nil {
a.earningRetry.Start(30 * time.Minute)
}
if a.walletExpiry != nil {
a.walletExpiry.Start(15 * time.Minute)
}
engine := a.router.Init()
@@ -223,6 +230,9 @@ func (a *App) Shutdown() {
if a.earningRetry != nil {
a.earningRetry.Stop()
}
if a.walletExpiry != nil {
a.walletExpiry.Stop()
}
close(a.shutdown)
}
@@ -257,7 +267,6 @@ type repositories struct {
chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl
accountRepo *repository.AccountRepositoryImpl
orderIngredientTransactionRepo *repository.OrderIngredientTransactionRepositoryImpl
customerTokensRepo *repository.CustomerTokensRepository
tierRepo *repository.TierRepository
gameRepo *repository.GameRepository
gamePrizeRepo *repository.GamePrizeRepository
@@ -267,7 +276,6 @@ type repositories struct {
campaignRepo repository.CampaignRepository
campaignRuleRepo repository.CampaignRuleRepository
customerAuthRepo repository.CustomerAuthRepository
customerPointsRepo repository.CustomerPointsRepository
otpRepo repository.OtpRepository
sessionRepo repository.SessionRepository
txManager *repository.TxManager
@@ -315,7 +323,6 @@ func (a *App) initRepositories() *repositories {
chartOfAccountRepo: repository.NewChartOfAccountRepositoryImpl(a.db),
accountRepo: repository.NewAccountRepositoryImpl(a.db),
orderIngredientTransactionRepo: repository.NewOrderIngredientTransactionRepositoryImpl(a.db).(*repository.OrderIngredientTransactionRepositoryImpl),
customerTokensRepo: repository.NewCustomerTokensRepository(a.db),
tierRepo: repository.NewTierRepository(a.db),
gameRepo: repository.NewGameRepository(a.db),
gamePrizeRepo: repository.NewGamePrizeRepository(a.db),
@@ -325,7 +332,6 @@ func (a *App) initRepositories() *repositories {
campaignRepo: repository.NewCampaignRepository(a.db),
campaignRuleRepo: repository.NewCampaignRuleRepository(a.db),
customerAuthRepo: repository.NewCustomerAuthRepository(a.db),
customerPointsRepo: repository.NewCustomerPointsRepository(a.db),
otpRepo: repository.NewOtpRepository(a.db),
sessionRepo: repository.NewSessionRepository(a.redisClient),
txManager: repository.NewTxManager(a.db),
@@ -368,7 +374,6 @@ type processors struct {
chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl
accountProcessor *processor.AccountProcessorImpl
orderIngredientTransactionProcessor *processor.OrderIngredientTransactionProcessorImpl
customerTokensProcessor *processor.CustomerTokensProcessor
tierProcessor *processor.TierProcessor
gameProcessor *processor.GameProcessor
gamePrizeProcessor *processor.GamePrizeProcessor
@@ -451,7 +456,6 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
chartOfAccountProcessor: processor.NewChartOfAccountProcessorImpl(repos.chartOfAccountRepo, repos.chartOfAccountTypeRepo),
accountProcessor: processor.NewAccountProcessorImpl(repos.accountRepo, repos.chartOfAccountRepo),
orderIngredientTransactionProcessor: processor.NewOrderIngredientTransactionProcessorImpl(repos.orderIngredientTransactionRepo, repos.productRecipeRepo, repos.ingredientRepo, repos.unitRepo).(*processor.OrderIngredientTransactionProcessorImpl),
customerTokensProcessor: processor.NewCustomerTokensProcessor(repos.customerTokensRepo),
tierProcessor: processor.NewTierProcessor(repos.tierRepo),
gameProcessor: processor.NewGameProcessor(repos.gameRepo),
gamePrizeProcessor: processor.NewGamePrizeProcessor(repos.gamePrizeRepo),
@@ -480,7 +484,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
walletTransferProcessor: walletTransferProcessor,
walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)),
customerDeviceProcessor: customerDeviceProcessor,
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager),
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), loyaltySettingsProcessor, repos.txManager),
}
}
@@ -560,7 +564,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
chartOfAccountService := service.NewChartOfAccountService(processors.chartOfAccountProcessor)
accountService := service.NewAccountService(processors.accountProcessor)
orderIngredientTransactionService := service.NewOrderIngredientTransactionService(processors.orderIngredientTransactionProcessor, repos.txManager)
gamificationService := service.NewGamificationService(processors.customerPointsProcessor, processors.customerTokensProcessor, processors.tierProcessor, processors.gameProcessor, processors.gamePrizeProcessor, processors.gamePlayProcessor, processors.omsetTrackerProcessor)
gamificationService := service.NewGamificationService(processors.tierProcessor, processors.gameProcessor, processors.gamePrizeProcessor, processors.gamePlayProcessor, processors.omsetTrackerProcessor)
rewardService := service.NewRewardService(processors.rewardProcessor)
campaignService := service.NewCampaignService(processors.campaignProcessor, processors.campaignRuleProcessor)
customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor)
+15
View File
@@ -42,6 +42,9 @@ const (
LoyaltyExpiryUnitSuffix = "expiry_unit"
LoyaltyExpiryEndOfMonthSuffix = "expiry_end_of_month"
LoyaltyExpiryReminderDaysSuffix = "expiry_reminder_days"
LoyaltyExpiryModeSuffix = "expiry_mode"
LoyaltyExpiryFixedDatesSuffix = "expiry_fixed_dates"
LoyaltyExpiryGraceMonthsSuffix = "expiry_grace_months"
)
// Units of loyalty.{point,coin}.expiry_unit.
@@ -50,6 +53,14 @@ const (
LoyaltyExpiryUnitMonth = "MONTH"
)
// Modes of loyalty.{point,coin}.expiry_mode (note N4, decided: both are supported).
const (
// Every balance expires on the same fixed dates of the year, such as 31 Dec.
LoyaltyExpiryModeFixedDate = "FIXED_DATE"
// Every balance lasts a period from when it was received.
LoyaltyExpiryModeRolling = "ROLLING"
)
// Defaults from the PRD.
const (
LoyaltyPointEarnPerAmountDefault = int64(100)
@@ -68,4 +79,8 @@ const (
LoyaltyExpiryPeriodDefault = int64(12)
LoyaltyExpiryUnitDefault = LoyaltyExpiryUnitMonth
LoyaltyExpiryReminderDaysDefault = int64(7)
LoyaltyExpiryModeDefault = LoyaltyExpiryModeFixedDate
// Once a year on 31 December, as MM-DD.
LoyaltyExpiryFixedDatesDefault = "12-31"
LoyaltyExpiryGraceMonthsDefault = int64(3)
)
@@ -2,66 +2,8 @@ package contract
import (
"time"
"github.com/google/uuid"
)
// Existing gamification contracts
type CreateCustomerPointsRequest struct {
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
Balance int64 `json:"balance" validate:"min=0"`
}
type UpdateCustomerPointsRequest struct {
Balance int64 `json:"balance" validate:"min=0"`
}
type AddCustomerPointsRequest struct {
Points int64 `json:"points" validate:"required,min=1"`
}
type DeductCustomerPointsRequest struct {
Points int64 `json:"points" validate:"required,min=1"`
}
type CustomerPointsResponse struct {
ID uuid.UUID `json:"id"`
CustomerID uuid.UUID `json:"customer_id"`
Balance int64 `json:"balance"`
Customer *CustomerResponse `json:"customer,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListCustomerPointsRequest struct {
Page int `json:"page" validate:"min=1"`
Limit int `json:"limit" validate:"min=1,max=100"`
Search string `json:"search"`
SortBy string `json:"sort_by" validate:"omitempty,oneof=balance created_at updated_at"`
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
}
type PaginatedCustomerPointsResponse struct {
Data []CustomerPointsResponse `json:"data"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
// New customer API contracts
type GetCustomerPointsRequest struct {
// No additional fields needed - customer ID comes from JWT token
}
type GetCustomerTokensRequest struct {
// No additional fields needed - customer ID comes from JWT token
}
type GetCustomerWalletRequest struct {
// No additional fields needed - customer ID comes from JWT token
}
// Response Contracts
type GetCustomerPointsResponse struct {
Status string `json:"status"`
@@ -1,52 +0,0 @@
package contract
import (
"time"
"github.com/google/uuid"
)
type CreateCustomerTokensRequest struct {
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
TokenType string `json:"token_type" validate:"required,oneof=SPIN RAFFLE MINIGAME"`
Balance int64 `json:"balance" validate:"min=0"`
}
type UpdateCustomerTokensRequest struct {
Balance int64 `json:"balance" validate:"min=0"`
}
type AddCustomerTokensRequest struct {
Tokens int64 `json:"tokens" validate:"required,min=1"`
}
type DeductCustomerTokensRequest struct {
Tokens int64 `json:"tokens" validate:"required,min=1"`
}
type CustomerTokensResponse struct {
ID uuid.UUID `json:"id"`
CustomerID uuid.UUID `json:"customer_id"`
TokenType string `json:"token_type"`
Balance int64 `json:"balance"`
Customer *CustomerResponse `json:"customer,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListCustomerTokensRequest struct {
Page int `json:"page" validate:"min=1"`
Limit int `json:"limit" validate:"min=1,max=100"`
Search string `json:"search"`
TokenType string `json:"token_type" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"`
SortBy string `json:"sort_by" validate:"omitempty,oneof=balance token_type created_at updated_at"`
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
}
type PaginatedCustomerTokensResponse struct {
Data []CustomerTokensResponse `json:"data"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
@@ -204,3 +204,26 @@ func walletErrorCode(err error) string {
return constants.InternalServerErrorCode
}
}
// GetCustomerWalletExpiring is GET /customer/wallet/expiring: what will expire, per
// currency and day (docs/prd-point-coin.md F6).
func (h *CustomerPointsHandler) GetCustomerWalletExpiring(c *gin.Context) {
ctx := c.Request.Context()
customerID, ok := c.Get("customer_id")
customerIDStr, isString := customerID.(string)
if !ok || !isString {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"),
}), "CustomerPointsHandler::GetCustomerWalletExpiring")
return
}
response, err := h.customerPointsService.GetCustomerWalletExpiring(ctx, customerIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWalletExpiring -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
}), "CustomerPointsHandler::GetCustomerWalletExpiring")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWalletExpiring")
}
-471
View File
@@ -9,7 +9,6 @@ import (
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type GamificationHandler struct {
@@ -27,239 +26,6 @@ func NewGamificationHandler(
}
}
// Customer Points Handlers
func (h *GamificationHandler) CreateCustomerPoints(c *gin.Context) {
ctx := c.Request.Context()
var req contract.CreateCustomerPointsRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerPoints -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerPoints")
return
}
validationError, validationErrorCode := h.gamificationValidator.ValidateCreateCustomerPointsRequest(&req)
if validationError != nil {
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateCustomerPoints -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerPoints")
return
}
response, err := h.gamificationService.CreateCustomerPoints(ctx, &req)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerPoints -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::CreateCustomerPoints")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateCustomerPoints")
}
func (h *GamificationHandler) GetCustomerPoints(c *gin.Context) {
ctx := c.Request.Context()
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPoints -> invalid ID")
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid ID format")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerPoints")
return
}
response, err := h.gamificationService.GetCustomerPoints(ctx, id)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPoints -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::GetCustomerPoints")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerPoints")
}
func (h *GamificationHandler) GetCustomerPointsByCustomerID(c *gin.Context) {
ctx := c.Request.Context()
customerIDStr := c.Param("customer_id")
customerID, err := uuid.Parse(customerIDStr)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPointsByCustomerID -> invalid customer ID")
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid customer ID format")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerPointsByCustomerID")
return
}
response, err := h.gamificationService.GetCustomerPointsByCustomerID(ctx, customerID)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPointsByCustomerID -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::GetCustomerPointsByCustomerID")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerPointsByCustomerID")
}
func (h *GamificationHandler) ListCustomerPoints(c *gin.Context) {
ctx := c.Request.Context()
var req contract.ListCustomerPointsRequest
if err := c.ShouldBindQuery(&req); err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerPoints -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerPoints")
return
}
validationError, validationErrorCode := h.gamificationValidator.ValidateListCustomerPointsRequest(&req)
if validationError != nil {
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListCustomerPoints -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerPoints")
return
}
response, err := h.gamificationService.ListCustomerPoints(ctx, &req)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerPoints -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::ListCustomerPoints")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListCustomerPoints")
}
func (h *GamificationHandler) UpdateCustomerPoints(c *gin.Context) {
ctx := c.Request.Context()
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerPoints -> invalid ID")
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid ID format")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerPoints")
return
}
var req contract.UpdateCustomerPointsRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerPoints -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerPoints")
return
}
validationError, validationErrorCode := h.gamificationValidator.ValidateUpdateCustomerPointsRequest(&req)
if validationError != nil {
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::UpdateCustomerPoints -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerPoints")
return
}
response, err := h.gamificationService.UpdateCustomerPoints(ctx, id, &req)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerPoints -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::UpdateCustomerPoints")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::UpdateCustomerPoints")
}
func (h *GamificationHandler) DeleteCustomerPoints(c *gin.Context) {
ctx := c.Request.Context()
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteCustomerPoints -> invalid ID")
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid ID format")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeleteCustomerPoints")
return
}
err = h.gamificationService.DeleteCustomerPoints(ctx, id)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteCustomerPoints -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::DeleteCustomerPoints")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(nil), "GamificationHandler::DeleteCustomerPoints")
}
func (h *GamificationHandler) AddCustomerPoints(c *gin.Context) {
ctx := c.Request.Context()
customerIDStr := c.Param("customer_id")
customerID, err := uuid.Parse(customerIDStr)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerPoints -> invalid customer ID")
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid customer ID format")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerPoints")
return
}
var req contract.AddCustomerPointsRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerPoints -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerPoints")
return
}
validationError, validationErrorCode := h.gamificationValidator.ValidateAddCustomerPointsRequest(&req)
if validationError != nil {
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::AddCustomerPoints -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerPoints")
return
}
response, err := h.gamificationService.AddCustomerPoints(ctx, customerID, &req)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerPoints -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::AddCustomerPoints")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::AddCustomerPoints")
}
func (h *GamificationHandler) DeductCustomerPoints(c *gin.Context) {
ctx := c.Request.Context()
customerIDStr := c.Param("customer_id")
customerID, err := uuid.Parse(customerIDStr)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerPoints -> invalid customer ID")
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid customer ID format")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerPoints")
return
}
var req contract.DeductCustomerPointsRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerPoints -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerPoints")
return
}
validationError, validationErrorCode := h.gamificationValidator.ValidateDeductCustomerPointsRequest(&req)
if validationError != nil {
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::DeductCustomerPoints -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerPoints")
return
}
response, err := h.gamificationService.DeductCustomerPoints(ctx, customerID, &req)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerPoints -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::DeductCustomerPoints")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::DeductCustomerPoints")
}
// Play Game Handler
func (h *GamificationHandler) PlayGame(c *gin.Context) {
ctx := c.Request.Context()
@@ -288,240 +54,3 @@ func (h *GamificationHandler) PlayGame(c *gin.Context) {
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::PlayGame")
}
// Additional handler methods for other gamification features
func (h *GamificationHandler) CreateCustomerTokens(c *gin.Context) {
ctx := c.Request.Context()
var req contract.CreateCustomerTokensRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerTokens -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerTokens")
return
}
validationError, validationErrorCode := h.gamificationValidator.ValidateCreateCustomerTokensRequest(&req)
if validationError != nil {
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateCustomerTokens -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerTokens")
return
}
response, err := h.gamificationService.CreateCustomerTokens(ctx, &req)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerTokens -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::CreateCustomerTokens")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateCustomerTokens")
}
func (h *GamificationHandler) GetCustomerTokens(c *gin.Context) {
ctx := c.Request.Context()
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokens -> invalid ID")
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid ID format")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerTokens")
return
}
response, err := h.gamificationService.GetCustomerTokens(ctx, id)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokens -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::GetCustomerTokens")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerTokens")
}
func (h *GamificationHandler) GetCustomerTokensByCustomerIDAndType(c *gin.Context) {
ctx := c.Request.Context()
customerIDStr := c.Param("customer_id")
customerID, err := uuid.Parse(customerIDStr)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokensByCustomerIDAndType -> invalid customer ID")
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid customer ID format")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerTokensByCustomerIDAndType")
return
}
tokenType := c.Param("token_type")
response, err := h.gamificationService.GetCustomerTokensByCustomerIDAndType(ctx, customerID, tokenType)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokensByCustomerIDAndType -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::GetCustomerTokensByCustomerIDAndType")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerTokensByCustomerIDAndType")
}
func (h *GamificationHandler) ListCustomerTokens(c *gin.Context) {
ctx := c.Request.Context()
var req contract.ListCustomerTokensRequest
if err := c.ShouldBindQuery(&req); err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerTokens -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerTokens")
return
}
validationError, validationErrorCode := h.gamificationValidator.ValidateListCustomerTokensRequest(&req)
if validationError != nil {
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListCustomerTokens -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerTokens")
return
}
response, err := h.gamificationService.ListCustomerTokens(ctx, &req)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerTokens -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::ListCustomerTokens")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListCustomerTokens")
}
func (h *GamificationHandler) UpdateCustomerTokens(c *gin.Context) {
ctx := c.Request.Context()
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerTokens -> invalid ID")
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid ID format")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerTokens")
return
}
var req contract.UpdateCustomerTokensRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerTokens -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerTokens")
return
}
validationError, validationErrorCode := h.gamificationValidator.ValidateUpdateCustomerTokensRequest(&req)
if validationError != nil {
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::UpdateCustomerTokens -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerTokens")
return
}
response, err := h.gamificationService.UpdateCustomerTokens(ctx, id, &req)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerTokens -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::UpdateCustomerTokens")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::UpdateCustomerTokens")
}
func (h *GamificationHandler) DeleteCustomerTokens(c *gin.Context) {
ctx := c.Request.Context()
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteCustomerTokens -> invalid ID")
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid ID format")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeleteCustomerTokens")
return
}
err = h.gamificationService.DeleteCustomerTokens(ctx, id)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteCustomerTokens -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::DeleteCustomerTokens")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(nil), "GamificationHandler::DeleteCustomerTokens")
}
func (h *GamificationHandler) AddCustomerTokens(c *gin.Context) {
ctx := c.Request.Context()
customerIDStr := c.Param("customer_id")
customerID, err := uuid.Parse(customerIDStr)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerTokens -> invalid customer ID")
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid customer ID format")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerTokens")
return
}
tokenType := c.Param("token_type")
var req contract.AddCustomerTokensRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerTokens -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerTokens")
return
}
validationError, validationErrorCode := h.gamificationValidator.ValidateAddCustomerTokensRequest(&req)
if validationError != nil {
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::AddCustomerTokens -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerTokens")
return
}
response, err := h.gamificationService.AddCustomerTokens(ctx, customerID, tokenType, &req)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerTokens -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::AddCustomerTokens")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::AddCustomerTokens")
}
func (h *GamificationHandler) DeductCustomerTokens(c *gin.Context) {
ctx := c.Request.Context()
customerIDStr := c.Param("customer_id")
customerID, err := uuid.Parse(customerIDStr)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerTokens -> invalid customer ID")
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid customer ID format")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerTokens")
return
}
tokenType := c.Param("token_type")
var req contract.DeductCustomerTokensRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerTokens -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerTokens")
return
}
validationError, validationErrorCode := h.gamificationValidator.ValidateDeductCustomerTokensRequest(&req)
if validationError != nil {
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::DeductCustomerTokens -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerTokens")
return
}
response, err := h.gamificationService.DeductCustomerTokens(ctx, customerID, tokenType, &req)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerTokens -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::DeductCustomerTokens")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::DeductCustomerTokens")
}
+1 -1
View File
@@ -113,7 +113,7 @@ func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
orgSettings, err := settings.Organization(context.Background(), org)
require.NoError(t, err)
orgSettings.PointValue = 5
_, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings)
_, _, 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)
+1 -1
View File
@@ -74,7 +74,7 @@ func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) {
}))
h := NewWalletAdminHandler(
service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, txm), processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(db))),
service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm), txm), processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(db))),
validator.NewWalletValidator(),
)
gin.SetMode(gin.TestMode)
@@ -1,46 +0,0 @@
package mappers
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
// ToCustomerPointsResponse converts a customer points entity to a customer points response
func ToCustomerPointsResponse(customerPoints *entities.CustomerPoints) *models.CustomerPointsResponse {
if customerPoints == nil {
return nil
}
return &models.CustomerPointsResponse{
ID: customerPoints.ID,
CustomerID: customerPoints.CustomerID,
Balance: customerPoints.Balance,
Customer: ToCustomerResponse(&customerPoints.Customer),
CreatedAt: customerPoints.CreatedAt,
UpdatedAt: customerPoints.UpdatedAt,
}
}
// ToCustomerPointsResponses converts a slice of customer points entities to customer points responses
func ToCustomerPointsResponses(customerPoints []entities.CustomerPoints) []models.CustomerPointsResponse {
responses := make([]models.CustomerPointsResponse, len(customerPoints))
for i, cp := range customerPoints {
responses[i] = *ToCustomerPointsResponse(&cp)
}
return responses
}
// ToCustomerPointsEntity converts a create customer points request to a customer points entity
func ToCustomerPointsEntity(req *models.CreateCustomerPointsRequest) *entities.CustomerPoints {
return &entities.CustomerPoints{
CustomerID: req.CustomerID,
Balance: req.Balance,
}
}
// UpdateCustomerPointsEntity updates a customer points entity with update request data
func UpdateCustomerPointsEntity(customerPoints *entities.CustomerPoints, req *models.UpdateCustomerPointsRequest) {
if req.Balance >= 0 {
customerPoints.Balance = req.Balance
}
}
@@ -1,48 +0,0 @@
package mappers
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
// ToCustomerTokensResponse converts a customer tokens entity to a customer tokens response
func ToCustomerTokensResponse(customerTokens *entities.CustomerTokens) *models.CustomerTokensResponse {
if customerTokens == nil {
return nil
}
return &models.CustomerTokensResponse{
ID: customerTokens.ID,
CustomerID: customerTokens.CustomerID,
TokenType: string(customerTokens.TokenType),
Balance: customerTokens.Balance,
Customer: ToCustomerResponse(&customerTokens.Customer),
CreatedAt: customerTokens.CreatedAt,
UpdatedAt: customerTokens.UpdatedAt,
}
}
// ToCustomerTokensResponses converts a slice of customer tokens entities to customer tokens responses
func ToCustomerTokensResponses(customerTokens []entities.CustomerTokens) []models.CustomerTokensResponse {
responses := make([]models.CustomerTokensResponse, len(customerTokens))
for i, ct := range customerTokens {
responses[i] = *ToCustomerTokensResponse(&ct)
}
return responses
}
// ToCustomerTokensEntity converts a create customer tokens request to a customer tokens entity
func ToCustomerTokensEntity(req *models.CreateCustomerTokensRequest) *entities.CustomerTokens {
return &entities.CustomerTokens{
CustomerID: req.CustomerID,
TokenType: entities.TokenType(req.TokenType),
Balance: req.Balance,
}
}
// UpdateCustomerTokensEntity updates a customer tokens entity with update request data
func UpdateCustomerTokensEntity(customerTokens *entities.CustomerTokens, req *models.UpdateCustomerTokensRequest) {
if req.Balance >= 0 {
customerTokens.Balance = req.Balance
}
}
-58
View File
@@ -2,66 +2,8 @@ package models
import (
"time"
"github.com/google/uuid"
)
// Existing gamification models
type CreateCustomerPointsRequest struct {
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
Balance int64 `json:"balance" validate:"min=0"`
}
type UpdateCustomerPointsRequest struct {
Balance int64 `json:"balance" validate:"min=0"`
}
type AddCustomerPointsRequest struct {
Balance int64 `json:"balance" validate:"required,min=1"`
}
type DeductCustomerPointsRequest struct {
Balance int64 `json:"balance" validate:"required,min=1"`
}
type CustomerPointsResponse struct {
ID uuid.UUID `json:"id"`
CustomerID uuid.UUID `json:"customer_id"`
Balance int64 `json:"balance"`
Customer *CustomerResponse `json:"customer,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListCustomerPointsQuery struct {
Page int `json:"page" validate:"min=1"`
Limit int `json:"limit" validate:"min=1,max=100"`
Search string `json:"search"`
SortBy string `json:"sort_by" validate:"omitempty,oneof=balance created_at updated_at"`
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
}
type PaginatedCustomerPointsResponse struct {
Data []CustomerPointsResponse `json:"data"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
// New customer API models
type GetCustomerPointsRequest struct {
// No additional fields needed - customer ID comes from JWT token
}
type GetCustomerTokensRequest struct {
// No additional fields needed - customer ID comes from JWT token
}
type GetCustomerWalletRequest struct {
// No additional fields needed - customer ID comes from JWT token
}
// Response Models
type GetCustomerPointsResponse struct {
Status string `json:"status"`
-44
View File
@@ -1,44 +0,0 @@
package models
import (
"time"
"github.com/google/uuid"
)
type CreateCustomerTokensRequest struct {
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
TokenType string `json:"token_type" validate:"required,oneof=SPIN RAFFLE MINIGAME"`
Balance int64 `json:"balance" validate:"min=0"`
}
type UpdateCustomerTokensRequest struct {
Balance int64 `json:"balance" validate:"min=0"`
}
type AddCustomerTokensRequest struct {
Tokens int64 `json:"tokens" validate:"required,min=1"`
}
type DeductCustomerTokensRequest struct {
Tokens int64 `json:"tokens" validate:"required,min=1"`
}
type CustomerTokensResponse struct {
ID uuid.UUID `json:"id"`
CustomerID uuid.UUID `json:"customer_id"`
TokenType string `json:"token_type"`
Balance int64 `json:"balance"`
Customer *CustomerResponse `json:"customer,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListCustomerTokensQuery struct {
Page int `query:"page" validate:"min=1"`
Limit int `query:"limit" validate:"min=1,max=100"`
Search string `query:"search"`
TokenType string `query:"token_type" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=balance token_type created_at updated_at"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
}
+42 -8
View File
@@ -57,15 +57,29 @@ type LoyaltyTransferSettings struct {
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.
// LoyaltyExpirySettings is when one currency expires once received (F12). Both
// models of note N4 are supported, and the owner picks one:
//
// - FIXED_DATE: everything expires on the next of FixedDates falling on or after
// the day received + GraceMonths, so a balance received just before a date moves
// on to the one after.
// - ROLLING: everything lasts Period Units from the day received, to the end of
// that month when EndOfMonth is set.
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"`
Enabled bool `json:"enabled"`
// FIXED_DATE or ROLLING.
Mode string `json:"mode"`
// FIXED_DATE: the days of the year balances expire on, as MM-DD, sorted.
FixedDates []string `json:"fixed_dates"`
// FIXED_DATE: how many months a balance lasts at least before a fixed date takes it.
GraceMonths int64 `json:"grace_months"`
// ROLLING: how long a balance lasts.
Period int64 `json:"period"`
// ROLLING: DAY or MONTH.
Unit string `json:"unit"`
EndOfMonth bool `json:"end_of_month"`
// Days before expiry the customer is reminded; 0 for no reminder.
ReminderDays int64 `json:"reminder_days"`
}
// LoyaltySettingChange is one row of the loyalty settings history.
@@ -109,12 +123,32 @@ type OrganizationLoyaltySettingsView struct {
OrganizationLoyaltySettings
// What the balances in circulation are worth, before and after the change.
Impact LoyaltySettingsImpact `json:"impact"`
// When a balance received now would expire under these settings (F12).
ExpiryPreview LoyaltyExpiryPreview `json:"expiry_preview"`
// The currencies this change turns expiry on for, and the balances affected.
ExpiryActivations []LoyaltyExpiryActivation `json:"expiry_activations"`
// 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"`
}
// LoyaltyExpiryActivation is expiry being turned on for a currency: the balances that
// had no expiry and the expiry they get (F12). On a dry run nothing is dated yet.
type LoyaltyExpiryActivation struct {
Currency string `json:"currency"`
Lots int64 `json:"lots"`
Amount int64 `json:"amount"`
ExpiresAt time.Time `json:"expires_at"`
}
// LoyaltyExpiryPreview is what the dashboard shows next to the expiry settings: "the
// EnakPoint received today expire on …". Nil means they never expire.
type LoyaltyExpiryPreview struct {
Point *time.Time `json:"point"`
Coin *time.Time `json:"coin"`
}
// 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.
+7
View File
@@ -157,3 +157,10 @@ type PointPaymentPreview struct {
// Rupiah covered by MaxPoints.
MaxAmount int64 `json:"max_amount"`
}
// CustomerWalletExpiringList is GET /customer/wallet/expiring (docs/prd-point-coin.md
// F6): everything that will expire, per currency and day, soonest first.
type CustomerWalletExpiringList struct {
Point []CustomerWalletExpiring `json:"point"`
Coin []CustomerWalletExpiring `json:"coin"`
}
@@ -22,53 +22,6 @@ func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor, gameRepo *rep
}
}
// Existing gamification methods - placeholder implementations
func (p *CustomerPointsProcessor) CreateCustomerPoints(ctx context.Context, req *models.CreateCustomerPointsRequest) (*models.CustomerPointsResponse, error) {
// TODO: Implement this method
return nil, fmt.Errorf("not implemented")
}
func (p *CustomerPointsProcessor) GetCustomerPoints(ctx context.Context, id uuid.UUID) (*models.CustomerPointsResponse, error) {
// TODO: Implement this method
return nil, fmt.Errorf("not implemented")
}
func (p *CustomerPointsProcessor) GetCustomerPointsByCustomerID(ctx context.Context, customerID uuid.UUID) (*models.CustomerPointsResponse, error) {
// TODO: Implement this method
return nil, fmt.Errorf("not implemented")
}
func (p *CustomerPointsProcessor) ListCustomerPoints(ctx context.Context, query *models.ListCustomerPointsQuery) (*models.PaginatedCustomerPointsResponse, error) {
// Return empty paginated response for now
return &models.PaginatedCustomerPointsResponse{
Data: []models.CustomerPointsResponse{},
TotalCount: 0,
Page: 1,
Limit: 10,
TotalPages: 0,
}, nil
}
func (p *CustomerPointsProcessor) UpdateCustomerPoints(ctx context.Context, id uuid.UUID, req *models.UpdateCustomerPointsRequest) (*models.CustomerPointsResponse, error) {
// TODO: Implement this method
return nil, fmt.Errorf("not implemented")
}
func (p *CustomerPointsProcessor) DeleteCustomerPoints(ctx context.Context, id uuid.UUID) error {
// TODO: Implement this method
return fmt.Errorf("not implemented")
}
func (p *CustomerPointsProcessor) AddPoints(ctx context.Context, customerID uuid.UUID, points int64) (*models.CustomerPointsResponse, error) {
// TODO: Implement this method
return nil, fmt.Errorf("not implemented")
}
func (p *CustomerPointsProcessor) DeductPoints(ctx context.Context, customerID uuid.UUID, points int64) (*models.CustomerPointsResponse, error) {
// TODO: Implement this method
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).
@@ -224,3 +177,11 @@ func (p *CustomerPointsProcessor) GetFerrisWheelGameAPI(ctx context.Context) (*m
},
}, nil
}
func (p *CustomerPointsProcessor) GetCustomerWalletExpiringAPI(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) {
id, err := parseWalletCustomerID(customerID)
if err != nil {
return nil, err
}
return p.walletQuery.Expiring(ctx, id)
}
@@ -1,198 +0,0 @@
package processor
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
"context"
"errors"
"fmt"
"github.com/google/uuid"
)
type CustomerTokensProcessor struct {
customerTokensRepo *repository.CustomerTokensRepository
}
func NewCustomerTokensProcessor(customerTokensRepo *repository.CustomerTokensRepository) *CustomerTokensProcessor {
return &CustomerTokensProcessor{
customerTokensRepo: customerTokensRepo,
}
}
// CreateCustomerTokens creates a new customer tokens record
func (p *CustomerTokensProcessor) CreateCustomerTokens(ctx context.Context, req *models.CreateCustomerTokensRequest) (*models.CustomerTokensResponse, error) {
// Convert request to entity
customerTokens := mappers.ToCustomerTokensEntity(req)
// Create customer tokens
err := p.customerTokensRepo.Create(ctx, customerTokens)
if err != nil {
return nil, fmt.Errorf("failed to create customer tokens: %w", err)
}
return mappers.ToCustomerTokensResponse(customerTokens), nil
}
// GetCustomerTokens retrieves customer tokens by ID
func (p *CustomerTokensProcessor) GetCustomerTokens(ctx context.Context, id uuid.UUID) (*models.CustomerTokensResponse, error) {
customerTokens, err := p.customerTokensRepo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("customer tokens not found: %w", err)
}
return mappers.ToCustomerTokensResponse(customerTokens), nil
}
// GetCustomerTokensByCustomerIDAndType retrieves customer tokens by customer ID and token type
func (p *CustomerTokensProcessor) GetCustomerTokensByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType string) (*models.CustomerTokensResponse, error) {
customerTokens, err := p.customerTokensRepo.EnsureCustomerTokens(ctx, customerID, entities.TokenType(tokenType))
if err != nil {
return nil, fmt.Errorf("failed to get customer tokens: %w", err)
}
return mappers.ToCustomerTokensResponse(customerTokens), nil
}
// ListCustomerTokens retrieves customer tokens with pagination and filtering
func (p *CustomerTokensProcessor) ListCustomerTokens(ctx context.Context, query *models.ListCustomerTokensQuery) (*models.PaginatedResponse[models.CustomerTokensResponse], error) {
// Set default values
if query.Page <= 0 {
query.Page = 1
}
if query.Limit <= 0 {
query.Limit = 10
}
if query.Limit > 100 {
query.Limit = 100
}
offset := (query.Page - 1) * query.Limit
// Get customer tokens from repository
customerTokens, total, err := p.customerTokensRepo.List(
ctx,
offset,
query.Limit,
query.Search,
query.TokenType,
query.SortBy,
query.SortOrder,
)
if err != nil {
return nil, fmt.Errorf("failed to list customer tokens: %w", err)
}
// Convert to responses
responses := mappers.ToCustomerTokensResponses(customerTokens)
// Calculate pagination info
totalPages := int((total + int64(query.Limit) - 1) / int64(query.Limit))
return &models.PaginatedResponse[models.CustomerTokensResponse]{
Data: responses,
Pagination: models.Pagination{
Page: query.Page,
Limit: query.Limit,
Total: total,
TotalPages: totalPages,
},
}, nil
}
// UpdateCustomerTokens updates an existing customer tokens record
func (p *CustomerTokensProcessor) UpdateCustomerTokens(ctx context.Context, id uuid.UUID, req *models.UpdateCustomerTokensRequest) (*models.CustomerTokensResponse, error) {
// Get existing customer tokens
customerTokens, err := p.customerTokensRepo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("customer tokens not found: %w", err)
}
// Update customer tokens fields
mappers.UpdateCustomerTokensEntity(customerTokens, req)
// Save updated customer tokens
err = p.customerTokensRepo.Update(ctx, customerTokens)
if err != nil {
return nil, fmt.Errorf("failed to update customer tokens: %w", err)
}
return mappers.ToCustomerTokensResponse(customerTokens), nil
}
// DeleteCustomerTokens deletes a customer tokens record
func (p *CustomerTokensProcessor) DeleteCustomerTokens(ctx context.Context, id uuid.UUID) error {
// Get existing customer tokens
_, err := p.customerTokensRepo.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("customer tokens not found: %w", err)
}
// Delete customer tokens
err = p.customerTokensRepo.Delete(ctx, id)
if err != nil {
return fmt.Errorf("failed to delete customer tokens: %w", err)
}
return nil
}
// AddTokens adds tokens to a customer's balance
func (p *CustomerTokensProcessor) AddTokens(ctx context.Context, customerID uuid.UUID, tokenType string, tokens int64) (*models.CustomerTokensResponse, error) {
if tokens <= 0 {
return nil, errors.New("tokens must be greater than 0")
}
// Ensure customer tokens record exists
_, err := p.customerTokensRepo.EnsureCustomerTokens(ctx, customerID, entities.TokenType(tokenType))
if err != nil {
return nil, fmt.Errorf("failed to ensure customer tokens: %w", err)
}
// Add tokens
err = p.customerTokensRepo.AddTokens(ctx, customerID, entities.TokenType(tokenType), tokens)
if err != nil {
return nil, fmt.Errorf("failed to add tokens: %w", err)
}
// Get updated customer tokens
customerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, customerID, entities.TokenType(tokenType))
if err != nil {
return nil, fmt.Errorf("failed to get updated customer tokens: %w", err)
}
return mappers.ToCustomerTokensResponse(customerTokens), nil
}
// DeductTokens deducts tokens from a customer's balance
func (p *CustomerTokensProcessor) DeductTokens(ctx context.Context, customerID uuid.UUID, tokenType string, tokens int64) (*models.CustomerTokensResponse, error) {
if tokens <= 0 {
return nil, errors.New("tokens must be greater than 0")
}
// Get current customer tokens
customerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, customerID, entities.TokenType(tokenType))
if err != nil {
return nil, fmt.Errorf("customer tokens not found: %w", err)
}
if customerTokens.Balance < tokens {
return nil, errors.New("insufficient tokens balance")
}
// Deduct tokens
err = p.customerTokensRepo.DeductTokens(ctx, customerID, entities.TokenType(tokenType), tokens)
if err != nil {
return nil, fmt.Errorf("failed to deduct tokens: %w", err)
}
// Get updated customer tokens
updatedCustomerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, customerID, entities.TokenType(tokenType))
if err != nil {
return nil, fmt.Errorf("failed to get updated customer tokens: %w", err)
}
return mappers.ToCustomerTokensResponse(updatedCustomerTokens), nil
}
+35 -16
View File
@@ -36,17 +36,25 @@ type outletSettingsReader interface {
Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error)
}
// earningSettingsReader gives the outlet's earning settings and the organization's
// expiry. LoyaltySettingsProcessor is one.
type earningSettingsReader interface {
outletSettingsReader
organizationSettingsReader
}
// EarningProcessor credits EnakPoint and EnakCoin for paid orders
// (docs/prd-point-coin.md F3).
type EarningProcessor struct {
orders repository.EarningRepository
settings outletSettingsReader
settings earningSettingsReader
now func() time.Time
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}
func NewEarningProcessor(orders repository.EarningRepository, settings earningSettingsReader, wallet *WalletProcessor, tx TxRunner) *EarningProcessor {
return &EarningProcessor{orders: orders, settings: settings, wallet: wallet, tx: tx, now: time.Now}
}
// OnOrderPaid is called once an order has become fully paid and the payment has
@@ -87,6 +95,12 @@ func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID)
return &EarningOutcome{Skipped: EarningSkipNothingToEarn}, nil
}
orgSettings, err := p.settings.Organization(ctx, order.OrganizationID)
if err != nil {
return nil, err
}
receivedAt := p.now()
outcome := &EarningOutcome{}
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
for _, c := range []struct {
@@ -101,19 +115,24 @@ func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID)
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).
}})
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: []WalletLotInput{{
Amount: c.line.Amount,
ExpiresAt: ComputeExpiry(ExpirySettings(orgSettings, c.currency), receivedAt),
}},
})
if err != nil {
return fmt.Errorf("crediting %s: %w", c.currency, err)
}
@@ -22,7 +22,7 @@ import (
// 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
real earningSettingsReader
mu sync.Mutex
fail bool
}
@@ -227,3 +227,7 @@ func TestEarningProcessor_AgainstPostgres(t *testing.T) {
assert.Equal(t, int64(875+250+600+400), balance.PointBalance)
assert.Equal(t, int64(3+1+2+1), balance.CoinBalance)
}
func (f *failingSettings) Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
return f.real.Organization(ctx, organizationID)
}
+170
View File
@@ -0,0 +1,170 @@
package processor
import (
"errors"
"fmt"
"sort"
"strings"
"time"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/models"
)
// Expiry rules of docs/prd-point-coin.md F12, with the model of note N4 decided: both
// FIXED_DATE and ROLLING are supported and the owner picks one per currency.
//
// A lot expires at the last second of its expiry day in the customer's time zone, so
// "expires on 31 Dec" means it can still be spent all of 31 Dec, and the day the apps
// group it under is that day.
const (
// A longer grace would push a balance past a whole cycle of fixed dates.
loyaltyExpiryMaxGraceMonths = 24
// A refund gives the EnakPoint back at least this long, even when the lot they
// came from has expired or is about to (note N4, decided).
refundMinimumValidity = 7 * 24 * time.Hour
)
// ExpirySettings picks the settings of one currency.
func ExpirySettings(s *models.OrganizationLoyaltySettings, currency string) models.LoyaltyExpirySettings {
if currency == constants.WalletCurrencyCoin {
return s.CoinExpiry
}
return s.PointExpiry
}
// ComputeExpiry is when a balance received at receivedAt expires, or nil when it never
// does because expiry is off. It is the expiry of every lot that is not carried over
// from another one: EARN, ADJUSTMENT and, capped by its origin, EXCHANGE_IN.
func ComputeExpiry(s models.LoyaltyExpirySettings, receivedAt time.Time) *time.Time {
if !s.Enabled {
return nil
}
day := walletDay(receivedAt)
if s.Mode == constants.LoyaltyExpiryModeRolling {
if s.Unit == constants.LoyaltyExpiryUnitDay {
day = day.AddDate(0, 0, int(s.Period))
} else {
day = addMonthsClamped(day, int(s.Period))
}
if s.EndOfMonth {
day = lastDayOfMonth(day)
}
return endOfWalletDay(day)
}
return endOfWalletDay(fixedDateOnOrAfter(s.FixedDates, addMonthsClamped(day, int(s.GraceMonths))))
}
// ActivationExpiry is the expiry given to the balances that already exist, without an
// expiry, when expiry is turned on (note N4, decided): a full period from activation
// when ROLLING, the second fixed date after activation when FIXED_DATE. Either way no
// customer loses a balance soon after the rule is announced.
func ActivationExpiry(s models.LoyaltyExpirySettings, at time.Time) *time.Time {
if s.Mode == constants.LoyaltyExpiryModeRolling {
s.Enabled = true
return ComputeExpiry(s, at)
}
first := fixedDateOnOrAfter(s.FixedDates, walletDay(at))
return endOfWalletDay(fixedDateOnOrAfter(s.FixedDates, first.AddDate(0, 0, 1)))
}
// RefundExpiry is the expiry of EnakPoint given back by a refund: that of the lot they
// came from, but at least seven days from now (note N4, decided). A lot that never
// expires stays so.
func RefundExpiry(origin *time.Time, now time.Time) *time.Time {
if origin == nil {
return nil
}
minimum := endOfWalletDay(walletDay(now.Add(refundMinimumValidity)))
if origin.Before(*minimum) {
return minimum
}
return origin
}
// EarlierExpiry is the sooner of two expiries, where nil means never.
func EarlierExpiry(a, b *time.Time) *time.Time {
switch {
case a == nil:
return b
case b == nil:
return a
case b.Before(*a):
return b
}
return a
}
// normalizeDaysOfYear checks days written as MM-DD and returns them sorted without
// duplicates. 29 February is refused, as it does not come every year.
func normalizeDaysOfYear(days []string) ([]string, error) {
seen := map[string]bool{}
var out []string
for _, d := range days {
d = strings.TrimSpace(d)
if d == "" {
continue
}
parsed, err := time.Parse("01-02", d)
if err != nil || parsed.Format("01-02") != d {
return nil, fmt.Errorf("has %q, which is not a day of the year written as MM-DD", d)
}
if d == "02-29" {
return nil, errors.New("cannot use 02-29, which is not in every year")
}
if !seen[d] {
seen[d] = true
out = append(out, d)
}
}
if len(out) == 0 {
return nil, errors.New("must list at least one day of the year, as MM-DD")
}
sort.Strings(out)
return out, nil
}
// fixedDateOnOrAfter is the first of the days of the year that falls on or after day.
func fixedDateOnOrAfter(days []string, day time.Time) time.Time {
days, err := normalizeDaysOfYear(days)
if err != nil {
days = []string{constants.LoyaltyExpiryFixedDatesDefault}
}
for year := day.Year(); ; year++ {
for _, d := range days {
parsed, _ := time.Parse("01-02", d)
candidate := time.Date(year, parsed.Month(), parsed.Day(), 0, 0, 0, 0, walletDisplayLocation)
if !candidate.Before(day) {
return candidate
}
}
}
}
// walletDay is the calendar day of t in the customer's time zone, at midnight.
func walletDay(t time.Time) time.Time {
local := t.In(walletDisplayLocation)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, walletDisplayLocation)
}
func endOfWalletDay(day time.Time) *time.Time {
end := time.Date(day.Year(), day.Month(), day.Day(), 23, 59, 59, 0, walletDisplayLocation)
return &end
}
// addMonthsClamped adds months, keeping to the last day of a shorter month: 31 Jan
// plus one month is 28 or 29 Feb, not 3 Mar.
func addMonthsClamped(day time.Time, months int) time.Time {
first := time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, day.Location()).AddDate(0, months, 0)
last := lastDayOfMonth(first).Day()
d := day.Day()
if d > last {
d = last
}
return time.Date(first.Year(), first.Month(), d, 0, 0, 0, 0, day.Location())
}
func lastDayOfMonth(day time.Time) time.Time {
return time.Date(day.Year(), day.Month()+1, 0, 0, 0, 0, 0, day.Location())
}
+115
View File
@@ -0,0 +1,115 @@
package processor
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/models"
)
// wib is a moment in the customer's time zone.
func wib(year int, month time.Month, day, hour, minute int) time.Time {
return time.Date(year, month, day, hour, minute, 0, 0, walletDisplayLocation)
}
// expiryDay is the day a lot expires on, as the apps show it.
func expiryDay(t *testing.T, at *time.Time) string {
t.Helper()
require.NotNil(t, at)
local := at.In(walletDisplayLocation)
assert.Equal(t, "23:59:59", local.Format("15:04:05"), "a lot lasts to the end of its day")
return local.Format("2006-01-02")
}
func fixedDate(dates ...string) models.LoyaltyExpirySettings {
return models.LoyaltyExpirySettings{Enabled: true, Mode: "FIXED_DATE", FixedDates: dates, GraceMonths: 3}
}
func rolling(period int64, unit string, endOfMonth bool) models.LoyaltyExpirySettings {
return models.LoyaltyExpirySettings{Enabled: true, Mode: "ROLLING", Period: period, Unit: unit, EndOfMonth: endOfMonth}
}
func TestComputeExpiry_OffNeverExpires(t *testing.T) {
s := fixedDate("12-31")
s.Enabled = false
assert.Nil(t, ComputeExpiry(s, wib(2026, 5, 1, 10, 0)))
}
func TestComputeExpiry_FixedDateWithGrace(t *testing.T) {
yearly := fixedDate("12-31")
for received, want := range map[time.Time]string{
wib(2026, 1, 15, 9, 0): "2026-12-31",
wib(2026, 9, 30, 23, 0): "2026-12-31", // 30 Dec + nothing: still this year
wib(2026, 10, 1, 0, 30): "2027-12-31", // within 3 months of 31 Dec: next year
wib(2026, 12, 31, 8, 0): "2027-12-31",
} {
assert.Equal(t, want, expiryDay(t, ComputeExpiry(yearly, received)), received.String())
}
twice := fixedDate("12-31", "06-30")
assert.Equal(t, "2026-06-30", expiryDay(t, ComputeExpiry(twice, wib(2026, 3, 1, 12, 0))))
assert.Equal(t, "2026-12-31", expiryDay(t, ComputeExpiry(twice, wib(2026, 4, 1, 12, 0))))
assert.Equal(t, "2027-06-30", expiryDay(t, ComputeExpiry(twice, wib(2026, 10, 1, 12, 0))))
noGrace := fixedDate("12-31")
noGrace.GraceMonths = 0
assert.Equal(t, "2026-12-31", expiryDay(t, ComputeExpiry(noGrace, wib(2026, 12, 31, 22, 0))))
}
// The day received is the customer's day: 20:00 UTC on 31 Mar is already 1 Apr in WIB.
func TestComputeExpiry_UsesTheCustomersDay(t *testing.T) {
received := time.Date(2026, 3, 31, 20, 0, 0, 0, time.UTC)
assert.Equal(t, "2026-04-11", expiryDay(t, ComputeExpiry(rolling(10, "DAY", false), received)))
}
func TestComputeExpiry_Rolling(t *testing.T) {
assert.Equal(t, "2027-03-14", expiryDay(t, ComputeExpiry(rolling(12, "MONTH", false), wib(2026, 3, 14, 10, 0))))
assert.Equal(t, "2027-03-31", expiryDay(t, ComputeExpiry(rolling(12, "MONTH", true), wib(2026, 3, 14, 10, 0))))
assert.Equal(t, "2026-03-24", expiryDay(t, ComputeExpiry(rolling(10, "DAY", false), wib(2026, 3, 14, 10, 0))))
// A month that is shorter keeps to its last day.
assert.Equal(t, "2026-02-28", expiryDay(t, ComputeExpiry(rolling(1, "MONTH", false), wib(2026, 1, 31, 10, 0))))
}
func TestActivationExpiry(t *testing.T) {
// FIXED_DATE: the second fixed date after activation, so nobody loses a balance
// weeks after the rule is announced.
assert.Equal(t, "2027-12-31", expiryDay(t, ActivationExpiry(fixedDate("12-31"), wib(2026, 11, 20, 10, 0))))
assert.Equal(t, "2027-12-31", expiryDay(t, ActivationExpiry(fixedDate("12-31"), wib(2026, 12, 31, 10, 0))))
assert.Equal(t, "2027-06-30", expiryDay(t, ActivationExpiry(fixedDate("06-30", "12-31"), wib(2026, 11, 20, 10, 0))))
// ROLLING: a full period from activation.
assert.Equal(t, "2027-11-20", expiryDay(t, ActivationExpiry(rolling(12, "MONTH", false), wib(2026, 11, 20, 10, 0))))
}
func TestRefundExpiry(t *testing.T) {
now := wib(2026, 6, 1, 10, 0)
later := wib(2026, 12, 31, 23, 59)
soon := wib(2026, 6, 3, 23, 59)
past := wib(2026, 5, 1, 23, 59)
assert.Nil(t, RefundExpiry(nil, now))
assert.Equal(t, &later, RefundExpiry(&later, now))
assert.Equal(t, "2026-06-08", expiryDay(t, RefundExpiry(&soon, now)))
assert.Equal(t, "2026-06-08", expiryDay(t, RefundExpiry(&past, now)))
}
func TestEarlierExpiry(t *testing.T) {
a, b := wib(2026, 1, 1, 0, 0), wib(2026, 2, 1, 0, 0)
assert.Equal(t, &a, EarlierExpiry(&a, &b))
assert.Equal(t, &a, EarlierExpiry(&b, &a))
assert.Equal(t, &a, EarlierExpiry(nil, &a))
assert.Equal(t, &a, EarlierExpiry(&a, nil))
assert.Nil(t, EarlierExpiry(nil, nil))
}
func TestNormalizeDaysOfYear(t *testing.T) {
days, err := normalizeDaysOfYear([]string{" 12-31", "06-30", "12-31", ""})
require.NoError(t, err)
assert.Equal(t, []string{"06-30", "12-31"}, days)
for _, bad := range [][]string{{}, {"13-01"}, {"12-32"}, {"1-31"}, {"02-29"}, {"31-12"}} {
_, err := normalizeDaysOfYear(bad)
assert.Error(t, err, bad)
}
}
@@ -6,6 +6,7 @@ import (
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
@@ -27,10 +28,11 @@ var ErrInvalidLoyaltySettings = errors.New("invalid loyalty settings")
type LoyaltySettingsProcessor struct {
repo repository.LoyaltySettingsRepository
tx TxRunner
now func() time.Time
}
func NewLoyaltySettingsProcessor(repo repository.LoyaltySettingsRepository, tx TxRunner) *LoyaltySettingsProcessor {
return &LoyaltySettingsProcessor{repo: repo, tx: tx}
return &LoyaltySettingsProcessor{repo: repo, tx: tx, now: time.Now}
}
// Outlet returns an outlet's settings.
@@ -111,11 +113,15 @@ func (p *LoyaltySettingsProcessor) UpdateOutlet(ctx context.Context, organizatio
}
// 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) {
//
// Turning expiry on for a currency for the first time also gives every existing lot
// without an expiry the activation expiry (F12, note N4), and the result lists them.
func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organizationID, changedBy uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, []models.LoyaltyExpiryActivation, error) {
if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil {
return nil, err
return nil, nil, err
}
var changes []models.LoyaltySettingChange
var activations []models.LoyaltyExpiryActivation
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
if err := p.repo.LockScope(ctx, organizationID); err != nil {
return err
@@ -132,9 +138,42 @@ func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organ
func(key string, value *string) error {
return p.repo.SetOrganizationValue(ctx, organizationID, key, value)
})
if err != nil {
return err
}
activations, err = p.expiryActivations(ctx, organizationID, current, settings, true)
return err
})
return changes, err
if err != nil {
return nil, nil, err
}
return changes, activations, nil
}
// expiryActivations lists the currencies whose expiry these settings turn on, with the
// balances that have no expiry yet and the expiry they get: a full period from now
// (note N4, decided). With apply, it dates those lots.
func (p *LoyaltySettingsProcessor) expiryActivations(ctx context.Context, organizationID uuid.UUID, before, after models.OrganizationLoyaltySettings, apply bool) ([]models.LoyaltyExpiryActivation, error) {
activations := []models.LoyaltyExpiryActivation{}
for _, currency := range []string{constants.WalletCurrencyPoint, constants.WalletCurrencyCoin} {
was, will := ExpirySettings(&before, currency), ExpirySettings(&after, currency)
if was.Enabled || !will.Enabled {
continue
}
expiresAt := *ActivationExpiry(will, p.now())
var lots, amount int64
var err error
if apply {
lots, amount, err = p.repo.DateUndatedLots(ctx, organizationID, currency, expiresAt)
} else {
lots, amount, err = p.repo.UndatedBalance(ctx, organizationID, currency)
}
if err != nil {
return nil, err
}
activations = append(activations, models.LoyaltyExpiryActivation{Currency: currency, Lots: lots, Amount: amount, ExpiresAt: expiresAt})
}
return activations, nil
}
// apply stores every field whose effective value changes and records it. A key left
@@ -236,6 +275,10 @@ func organizationLoyaltyFields(s *models.OrganizationLoyaltySettings) []loyaltyF
func expiryLoyaltyFields(prefix string, s *models.LoyaltyExpirySettings) []loyaltyField {
return []loyaltyField{
boolLoyaltyField(prefix+constants.LoyaltyExpiryEnabledSuffix, &s.Enabled, false),
enumLoyaltyField(prefix+constants.LoyaltyExpiryModeSuffix, &s.Mode, constants.LoyaltyExpiryModeDefault,
constants.LoyaltyExpiryModeFixedDate, constants.LoyaltyExpiryModeRolling),
dayOfYearListLoyaltyField(prefix+constants.LoyaltyExpiryFixedDatesSuffix, &s.FixedDates, constants.LoyaltyExpiryFixedDatesDefault),
intLoyaltyField(prefix+constants.LoyaltyExpiryGraceMonthsSuffix, &s.GraceMonths, constants.LoyaltyExpiryGraceMonthsDefault, 0, loyaltyExpiryMaxGraceMonths),
intLoyaltyField(prefix+constants.LoyaltyExpiryPeriodSuffix, &s.Period, constants.LoyaltyExpiryPeriodDefault, 1, noLoyaltyMax),
enumLoyaltyField(prefix+constants.LoyaltyExpiryUnitSuffix, &s.Unit, constants.LoyaltyExpiryUnitDefault,
constants.LoyaltyExpiryUnitDay, constants.LoyaltyExpiryUnitMonth),
@@ -362,6 +405,46 @@ func enumLoyaltyField(key string, v *string, def string, allowed ...string) loya
}
}
// dayOfYearListLoyaltyField is a non-empty set of days of the year, stored as sorted
// MM-DD values joined by commas, such as "06-30,12-31".
func dayOfYearListLoyaltyField(key string, v *[]string, def string) loyaltyField {
normalize := func(days []string) ([]string, error) {
out, err := normalizeDaysOfYear(days)
if err != nil {
return nil, fmt.Errorf("%w: %s %v", ErrInvalidLoyaltySettings, key, err)
}
return out, nil
}
return loyaltyField{
key: key,
parse: func(raw string) bool {
days, err := normalize(strings.Split(raw, ","))
if err != nil {
return false
}
*v = days
return true
},
reset: func() { *v, _ = normalizeDaysOfYear(strings.Split(def, ",")) },
validate: func() error {
days, err := normalize(*v)
if err != nil {
return err
}
*v = days
return nil
},
format: func() *string {
days, err := normalizeDaysOfYear(*v)
if err != nil {
days = *v
}
s := strings.Join(days, ",")
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.
@@ -422,14 +505,15 @@ func diffLoyaltyFields(organizationID uuid.UUID, outletID *uuid.UUID, changedBy
}
// 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) {
// would change, and which balances turning expiry on would date, without writing
// anything.
func (p *LoyaltySettingsProcessor) PreviewOrganization(ctx context.Context, organizationID uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, []models.LoyaltyExpiryActivation, error) {
if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil {
return nil, err
return nil, nil, err
}
raw, err := p.repo.GetOrganizationValues(ctx, organizationID)
if err != nil {
return nil, err
return nil, nil, err
}
var current models.OrganizationLoyaltySettings
loadLoyaltyFields(organizationLoyaltyFields(&current), raw, "organization "+organizationID.String())
@@ -438,5 +522,9 @@ func (p *LoyaltySettingsProcessor) PreviewOrganization(ctx context.Context, orga
for _, row := range rows {
changes = append(changes, loyaltySettingChangeModel(row))
}
return changes, nil
activations, err := p.expiryActivations(ctx, organizationID, current, settings, false)
if err != nil {
return nil, nil, err
}
return changes, activations, nil
}
@@ -4,6 +4,7 @@ import (
"context"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
@@ -29,6 +30,9 @@ type loyaltyRepoFake struct {
orgValues map[string]string
changes []entities.LoyaltySettingChange
locks []uuid.UUID
// Lots without an expiry per currency: how many and what they hold.
undated map[string][2]int64
dated map[string]time.Time
}
func (f *loyaltyRepoFake) GetOutletValues(context.Context, uuid.UUID) (map[string]string, error) {
@@ -113,7 +117,7 @@ func TestLoyaltySettings_OrganizationWithoutSettingsGetsEveryDefault(t *testing.
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}
expiry := models.LoyaltyExpirySettings{Enabled: false, Mode: "FIXED_DATE", FixedDates: []string{"12-31"}, GraceMonths: 3, Period: 12, Unit: "MONTH", EndOfMonth: false, ReminderDays: 7}
assert.Equal(t, models.OrganizationLoyaltySettings{
PointValue: 1,
Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1},
@@ -136,6 +140,8 @@ func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
constants.LoyaltyPointValueKey: "100",
constants.LoyaltyTransferDailyLimitKey: "10000",
"loyalty.coin.expiry_unit": "day",
"loyalty.point.expiry_fixed_dates": "12-31, 06-30,12-31",
"loyalty.coin.expiry_mode": "rolling",
},
}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
@@ -153,6 +159,9 @@ func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
assert.Equal(t, int64(10000), *org.Transfer.DailyLimit)
assert.Equal(t, "DAY", org.CoinExpiry.Unit)
assert.Equal(t, "MONTH", org.PointExpiry.Unit)
assert.Equal(t, []string{"06-30", "12-31"}, org.PointExpiry.FixedDates)
assert.Equal(t, "ROLLING", org.CoinExpiry.Mode)
assert.Equal(t, "FIXED_DATE", org.PointExpiry.Mode)
}
// A bad row must never reach a calculation: earn_per_amount 0 would divide by zero.
@@ -258,7 +267,7 @@ func TestLoyaltySettings_UpdateOrganization(t *testing.T) {
settings.CoinExpiry.Enabled = true
settings.CoinExpiry.Unit = constants.LoyaltyExpiryUnitDay
changes, err := p.UpdateOrganization(ctx, org, admin, *settings)
changes, _, err := p.UpdateOrganization(ctx, org, admin, *settings)
require.NoError(t, err)
keys := []string{}
for _, c := range changes {
@@ -302,11 +311,16 @@ func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
"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 },
"expiry mode YEARLY": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.Mode = "YEARLY" },
"no fixed dates": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.FixedDates = nil },
"fixed date 13-01": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.FixedDates = []string{"13-01"} },
"fixed date 02-29": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.FixedDates = []string{"02-29"} },
"negative grace": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.GraceMonths = -1 },
} {
s, err := p.Organization(ctx, org)
require.NoError(t, err)
mutate(s)
_, err = p.UpdateOrganization(ctx, org, uuid.New(), *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")
@@ -320,3 +334,52 @@ func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
_, err = p.UpdateOutlet(ctx, org, uuid.New(), uuid.New(), *s)
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
}
func (f *loyaltyRepoFake) UndatedBalance(_ context.Context, _ uuid.UUID, currency string) (int64, int64, error) {
u := f.undated[currency]
return u[0], u[1], nil
}
func (f *loyaltyRepoFake) DateUndatedLots(_ context.Context, _ uuid.UUID, currency string, expiresAt time.Time) (int64, int64, error) {
u := f.undated[currency]
if f.dated == nil {
f.dated = map[string]time.Time{}
}
f.dated[currency] = expiresAt
delete(f.undated, currency)
return u[0], u[1], nil
}
// Turning expiry on dates the balances that had none, a full cycle away; a dry run
// only counts them, and saving again dates nothing more.
func TestLoyaltySettings_TurningExpiryOnDatesExistingBalances(t *testing.T) {
org, admin := uuid.New(), uuid.New()
repo := &loyaltyRepoFake{undated: map[string][2]int64{"POINT": {3, 1500}, "COIN": {2, 40}}}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
p.now = func() time.Time { return time.Date(2026, 11, 20, 3, 0, 0, 0, time.UTC) }
ctx := context.Background()
settings, err := p.Organization(ctx, org)
require.NoError(t, err)
settings.PointExpiry.Enabled = true
_, preview, err := p.PreviewOrganization(ctx, org, *settings)
require.NoError(t, err)
require.Len(t, preview, 1)
assert.Equal(t, "POINT", preview[0].Currency)
assert.Equal(t, int64(1500), preview[0].Amount)
assert.Empty(t, repo.dated, "a dry run dates nothing")
_, activations, err := p.UpdateOrganization(ctx, org, admin, *settings)
require.NoError(t, err)
require.Len(t, activations, 1)
assert.Equal(t, int64(3), activations[0].Lots)
// Default FIXED_DATE on 31 Dec: the second one after 20 Nov 2026.
assert.Equal(t, "2027-12-31", activations[0].ExpiresAt.In(walletDisplayLocation).Format("2006-01-02"))
assert.Equal(t, activations[0].ExpiresAt, repo.dated["POINT"])
assert.NotContains(t, repo.dated, "COIN", "EnakCoin expiry stays off")
_, again, err := p.UpdateOrganization(ctx, org, admin, *settings)
require.NoError(t, err)
assert.Empty(t, again, "already on: nothing is activated twice")
}
+3 -3
View File
@@ -21,8 +21,8 @@ import (
//
// Never more than the payment used, and only what has not come back yet, so it can be
// called again safely. Returned EnakPoint go back into lots with the expiry of the lots
// they were taken from (the 7-day extension waits on note N4). It returns how many came
// back in total.
// they were taken from, but at least seven days from the refund (note N4, decided). It
// returns how many came back in total.
func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid.UUID) (int64, error) {
order, err := p.repo.GetOrder(ctx, orderID, false)
if err != nil {
@@ -78,7 +78,7 @@ func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid
take := min(room, left)
left -= take
lotID := a.LotID
lots = append(lots, WalletLotInput{Amount: take, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID})
lots = append(lots, WalletLotInput{Amount: take, ExpiresAt: RefundExpiry(a.ExpiresAt, p.now()), OriginLotID: &lotID})
}
toReturn -= left
+1 -1
View File
@@ -92,7 +92,7 @@ func TestPointRefund_UsesTheFrozenValue(t *testing.T) {
s, err := settings.Organization(context.Background(), e.org)
require.NoError(t, err)
s.PointValue = v
_, err = settings.UpdateOrganization(context.Background(), e.org, e.cashier, *s)
_, _, err = settings.UpdateOrganization(context.Background(), e.org, e.cashier, *s)
require.NoError(t, err)
}
setValue(100)
+20 -12
View File
@@ -21,15 +21,16 @@ 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
admin repository.WalletAdminRepository
query repository.WalletQueryRepository
wallet *WalletProcessor
settings organizationSettingsReader
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}
func NewWalletAdminProcessor(admin repository.WalletAdminRepository, query repository.WalletQueryRepository, wallet *WalletProcessor, settings organizationSettingsReader, tx TxRunner) *WalletAdminProcessor {
return &WalletAdminProcessor{admin: admin, query: query, wallet: wallet, settings: settings, tx: tx, now: time.Now}
}
// GetCustomerWallet returns the customer's balances, lots and a page of history.
@@ -126,13 +127,20 @@ func (p *WalletAdminProcessor) Adjust(ctx context.Context, organizationID, admin
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 {
if in.Amount < 0 {
var err error
res, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: entry})
return err
}
// An adjustment that adds is a new balance, and expires like one (F7, F12).
settings, err := p.settings.Organization(ctx, organizationID)
if err != nil {
return err
}
res, err = p.wallet.Credit(ctx, WalletCreditInput{
WalletEntry: entry,
Lots: []WalletLotInput{{Amount: entry.Amount, ExpiresAt: ComputeExpiry(ExpirySettings(settings, in.Currency), p.now())}},
})
return err
})
if err != nil {
@@ -169,7 +169,7 @@ func (p *WalletExchangeProcessor) Exchange(ctx context.Context, customerID uuid.
Metadata: metadata,
IdempotencyKey: inKey,
},
Lots: exchangeLots(out.Allocations, rate),
Lots: exchangeLots(out.Allocations, rate, ComputeExpiry(settings.PointExpiry, p.now())),
})
if err != nil {
return err
@@ -225,10 +225,9 @@ func exchangePoints(coins int64, rate models.LoyaltyExchangeSettings) int64 {
// and after it, which adds up exactly because the total is a multiple of
// coin_amount. A lot too small to earn a whole EnakPoint on its own gives none.
//
// F4 caps the expiry at now + the EnakPoint validity as well. EnakPoint have no
// validity until the expiry model is decided (F12, note N4), so for now the EnakCoin
// lot's expiry is the only bound.
func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSettings) []WalletLotInput {
// Each part expires at the sooner of its EnakCoin lot's expiry and pointExpiry, when an
// EnakPoint received now would expire (F4); nil means never.
func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSettings, pointExpiry *time.Time) []WalletLotInput {
var lots []WalletLotInput
var coinsSoFar int64
for _, a := range allocations {
@@ -239,7 +238,7 @@ func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSet
continue
}
lotID := a.LotID
lots = append(lots, WalletLotInput{Amount: points, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID})
lots = append(lots, WalletLotInput{Amount: points, ExpiresAt: EarlierExpiry(a.ExpiresAt, pointExpiry), OriginLotID: &lotID})
}
return lots
}
@@ -332,3 +332,23 @@ func TestWalletExchange_RequiresAnIdempotencyKey(t *testing.T) {
assert.ErrorIs(t, err, ErrWalletMoveRejected)
assert.Empty(t, e.pins.actions)
}
// F4: EnakPoint from an exchange expire at the sooner of the EnakCoin lot's expiry and
// when EnakPoint received now would expire.
func TestWalletExchange_CappedByThePointExpiry(t *testing.T) {
e := newWalletMoveEnv(t)
e.now = wib(2026, 6, 1, 10, 0)
e.settings.PointExpiry = rolling(30, "DAY", false)
c := e.member("Budi", "081234561234")
soon, later := e.at(24*time.Hour), e.at(90*24*time.Hour)
e.earnCoins(t, c, 10, soon)
e.earnCoins(t, c, 10, later)
e.earnCoins(t, c, 10, nil)
res, err := e.exchanges().Exchange(e.ctx, c, 30, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
require.Len(t, res.Lots, 3)
assert.Equal(t, soon, res.Lots[0].ExpiresAt, "the EnakCoin lot expires first")
assert.Equal(t, "2026-07-01", expiryDay(t, res.Lots[1].ExpiresAt), "30 days of EnakPoint come first")
assert.Equal(t, "2026-07-01", expiryDay(t, res.Lots[2].ExpiresAt), "a lot that never expired gets the EnakPoint expiry")
}
@@ -0,0 +1,196 @@
package processor
import (
"context"
"fmt"
"strconv"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/repository"
)
const (
// Lots expired per query; a run keeps going until nothing is due.
walletExpiryBatchSize = 500
// Batches per run at most, so one run cannot run away.
walletExpiryMaxBatches = 40
)
// NotificationTypeWalletExpired is the data type of the push a customer gets when
// part of their balance expires.
const NotificationTypeWalletExpired = "WALLET_EXPIRED"
// NotificationTypeWalletExpiring is the data type of the reminder a customer gets
// before part of their balance expires.
const NotificationTypeWalletExpiring = "WALLET_EXPIRING"
// WalletExpiryProcessor takes what is left in lots whose expiry has passed
// (docs/prd-point-coin.md F12, PC-503). It is safe to run on several instances at
// once: every lot is expired under its wallet's lock with the key expire:{lot_id}.
type WalletExpiryProcessor struct {
repo repository.WalletExpiryRepository
settings organizationSettingsReader
wallet *WalletProcessor
tx TxRunner
notifier customerNotifier
now func() time.Time
}
func NewWalletExpiryProcessor(repo repository.WalletExpiryRepository, settings organizationSettingsReader, wallet *WalletProcessor, tx TxRunner, notifier customerNotifier) *WalletExpiryProcessor {
return &WalletExpiryProcessor{repo: repo, settings: settings, wallet: wallet, tx: tx, notifier: notifier, now: time.Now}
}
type walletExpiredKey struct {
customerID uuid.UUID
currency string
}
// ExpireDue expires every lot due now and tells each customer how much of each
// currency they lost, in one push per currency. It returns how many lots it expired.
// A lot that fails is logged and left for the next run; it does not stop the others.
func (p *WalletExpiryProcessor) ExpireDue(ctx context.Context) (int, error) {
asOf := p.now()
expired := map[walletExpiredKey]int64{}
count := 0
for batch := 0; batch < walletExpiryMaxBatches; batch++ {
due, err := p.repo.ListDueLots(ctx, asOf, walletExpiryBatchSize)
if err != nil {
p.notify(ctx, expired)
return count, err
}
progressed := false
for _, lot := range due {
var res *WalletResult
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
var err error
res, err = p.wallet.ExpireLot(ctx, lot.ID, func(amount int64) string {
return expiryDescription(amount, lot.Currency, lot.SourceDescription)
}, asOf)
return err
})
if err != nil {
logger.NonContext.Error(fmt.Sprintf("Could not expire wallet lot %s; it will be retried", lot.ID), err)
continue
}
if res == nil || res.Transaction == nil || res.Replayed {
// Another run got there first, or a payment used it up.
continue
}
progressed = true
count++
expired[walletExpiredKey{lot.CustomerID, lot.Currency}] += -res.Transaction.Amount
}
// A short batch was the last; a batch that moved nothing would only come back
// the same, whether failing or taken by another instance.
if len(due) < walletExpiryBatchSize || !progressed {
break
}
}
p.notify(ctx, expired)
return count, nil
}
// notify is best effort: the balance has already expired.
func (p *WalletExpiryProcessor) notify(ctx context.Context, expired map[walletExpiredKey]int64) {
if p.notifier == nil {
return
}
for key, amount := range expired {
name := walletCurrencyName(key.currency)
data := map[string]string{
"type": NotificationTypeWalletExpired,
"currency": key.currency,
"amount": strconv.FormatInt(amount, 10),
}
body := fmt.Sprintf("%d %s kamu sudah kedaluwarsa.", amount, name)
if err := p.notifier.Notify(ctx, key.customerID, name+" kedaluwarsa", body, data); err != nil {
logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s about expired %s", key.customerID, name), err)
}
}
}
// expiryDescription is the EXPIRE row's frozen description (§8.1):
// "Kedaluwarsa: 150 EnakPoint dari Belanja #ORD-0098".
func expiryDescription(amount int64, currency, sourceDescription string) string {
description := fmt.Sprintf("Kedaluwarsa: %d %s", amount, walletCurrencyName(currency))
if sourceDescription != "" {
description += " dari " + sourceDescription
}
return truncateRunes(description, walletDescriptionLimit)
}
// SendReminders tells customers, reminder_days before, how much of their balance
// expires on a day (F12): one push per customer, currency and expiry day, however many
// lots make it up. A reminder is recorded before it is sent, so another instance or a
// later run never sends it again; a push that then fails is logged and not retried.
// It returns how many reminders it sent.
func (p *WalletExpiryProcessor) SendReminders(ctx context.Context) (int, error) {
now := p.now()
organizations, err := p.repo.OrganizationsWithUpcomingExpiry(ctx, now)
if err != nil {
return 0, err
}
sent := 0
for _, organizationID := range organizations {
settings, err := p.settings.Organization(ctx, organizationID)
if err != nil {
logger.NonContext.Error(fmt.Sprintf("Could not read the expiry settings of organization %s; its reminders wait for the next run", organizationID), err)
continue
}
for _, currency := range []string{constants.WalletCurrencyPoint, constants.WalletCurrencyCoin} {
days := ExpirySettings(settings, currency).ReminderDays
if days <= 0 {
continue
}
until := endOfWalletDay(walletDay(now).AddDate(0, 0, int(days)))
upcoming, err := p.repo.UpcomingUnreminded(ctx, organizationID, currency, now, *until)
if err != nil {
return sent, err
}
for _, u := range upcoming {
first, err := p.repo.MarkReminded(ctx, u, currency)
if err != nil {
return sent, err
}
if !first {
continue
}
sent++
p.remind(ctx, u, currency)
}
}
}
return sent, nil
}
func (p *WalletExpiryProcessor) remind(ctx context.Context, u repository.UpcomingExpiry, currency string) {
if p.notifier == nil {
return
}
name := walletCurrencyName(currency)
body := fmt.Sprintf("%d %s akan kedaluwarsa pada %s. Pakai sebelum hangus.", u.Amount, name, formatWalletDate(u.Date))
data := map[string]string{
"type": NotificationTypeWalletExpiring,
"currency": currency,
"amount": strconv.FormatInt(u.Amount, 10),
"expiry_date": u.Date,
}
if err := p.notifier.Notify(ctx, u.CustomerID, name+" akan kedaluwarsa", body, data); err != nil {
logger.NonContext.Error(fmt.Sprintf("Could not remind customer %s of expiring %s", u.CustomerID, name), err)
}
}
var walletMonthNames = [...]string{"Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des"}
// formatWalletDate writes a YYYY-MM-DD date the way the apps do: "31 Okt 2026".
func formatWalletDate(date string) string {
d, err := time.Parse("2006-01-02", date)
if err != nil {
return date
}
return fmt.Sprintf("%d %s %d", d.Day(), walletMonthNames[d.Month()-1], d.Year())
}
@@ -0,0 +1,244 @@
package processor
import (
"context"
"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"
)
// walletExpiryRepoFake lists the fake wallet's due lots the way the query does, plus
// any extra lots a test wants listed.
type walletExpiryRepoFake struct {
wallet *walletRepoFake
extra []repository.DueLot
// customer/currency/date of the reminders recorded.
reminded map[string]bool
}
func (f *walletExpiryRepoFake) ListDueLots(_ context.Context, asOf time.Time, limit int) ([]repository.DueLot, error) {
descriptions := map[uuid.UUID]string{}
for _, tx := range f.wallet.transactions {
descriptions[tx.ID] = tx.Description
}
due := append([]repository.DueLot(nil), f.extra...)
for _, lot := range f.wallet.lots {
if lot.RemainingAmount > 0 && lot.ExpiresAt != nil && !lot.ExpiresAt.After(asOf) {
due = append(due, repository.DueLot{
ID: lot.ID, CustomerID: lot.CustomerID, Currency: lot.Currency, Remaining: lot.RemainingAmount,
ExpiresAt: *lot.ExpiresAt, SourceDescription: descriptions[lot.SourceTransactionID],
})
}
}
sort.SliceStable(due, func(i, j int) bool { return due[i].ExpiresAt.Before(due[j].ExpiresAt) })
if len(due) > limit {
due = due[:limit]
}
return due, nil
}
func (e *walletMoveEnv) expiry(notifier customerNotifier) (*WalletExpiryProcessor, *walletExpiryRepoFake) {
repo := &walletExpiryRepoFake{wallet: e.repo}
p := NewWalletExpiryProcessor(repo, e, e.p, txRunnerFake{}, notifier)
p.now = func() time.Time { return e.now }
return p, repo
}
func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
ord := earn(a, 150, e.at(-time.Hour))
ord.Description = "Belanja #ORD-0098"
due := e.credit(t, ord)
e.credit(t, earn(a, 50, e.at(-2*time.Hour)))
e.credit(t, earn(a, 70, e.at(time.Hour))) // not yet
e.credit(t, earn(a, 30, nil)) // never
e.earnCoins(t, a, 4, e.at(-time.Minute))
// A payment already used part of the first lot; only the rest expires.
_, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: pay(a, 20).WalletEntry, PreferredLotIDs: []uuid.UUID{due.Lots[0].ID}})
require.NoError(t, err)
notifier := &notifierFake{}
p, _ := e.expiry(notifier)
count, err := p.ExpireDue(e.ctx)
require.NoError(t, err)
assert.Equal(t, 3, count)
assert.Equal(t, int64(100), e.balance(t, a), "70 not due yet + 30 that never expires")
assert.Equal(t, int64(0), e.coinBalance(t, a))
var expire *entities.WalletTransaction
for _, tx := range e.repo.transactions {
if tx.Type == constants.WalletTxTypeExpire && tx.ReferenceID == due.Lots[0].ID {
expire = tx
}
}
require.NotNil(t, expire)
assert.Equal(t, int64(-130), expire.Amount)
assert.Equal(t, "Kedaluwarsa: 130 EnakPoint dari Belanja #ORD-0098", expire.Description)
assert.Equal(t, constants.WalletRefTypeLot, expire.ReferenceType)
assert.Equal(t, "expire:"+due.Lots[0].ID.String(), *expire.IdempotencyKey)
// One push per currency, with the total.
pushes := notifier.pushes[a]
require.Len(t, pushes, 2)
byCurrency := map[string]pushFake{}
for _, p := range pushes {
byCurrency[p.data["currency"]] = p
}
assert.Equal(t, "EnakPoint kedaluwarsa", byCurrency["POINT"].title)
assert.Equal(t, "180 EnakPoint kamu sudah kedaluwarsa.", byCurrency["POINT"].body)
assert.Equal(t, NotificationTypeWalletExpired, byCurrency["POINT"].data["type"])
assert.Equal(t, "4", byCurrency["COIN"].data["amount"])
}
func TestWalletExpiry_RunningAgainExpiresNothingMore(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
e.credit(t, earn(a, 150, e.at(-time.Hour)))
notifier := &notifierFake{}
// Two instances, one after the other.
first, _ := e.expiry(notifier)
second, _ := e.expiry(notifier)
n1, err := first.ExpireDue(e.ctx)
require.NoError(t, err)
n2, err := second.ExpireDue(e.ctx)
require.NoError(t, err)
assert.Equal(t, 1, n1)
assert.Equal(t, 0, n2)
assert.Len(t, notifier.pushes[a], 1)
var expires int
for _, tx := range e.repo.transactions {
if tx.Type == constants.WalletTxTypeExpire {
expires++
}
}
assert.Equal(t, 1, expires)
}
func TestWalletExpiry_OneFailingLotDoesNotStopTheOthers(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
e.credit(t, earn(a, 150, e.at(-time.Hour)))
p, repo := e.expiry(nil)
// A lot listed that ExpireLot cannot find.
repo.extra = []repository.DueLot{{ID: uuid.New(), CustomerID: a, Currency: "POINT", Remaining: 5, ExpiresAt: e.now.Add(-3 * time.Hour)}}
count, err := p.ExpireDue(e.ctx)
require.NoError(t, err)
assert.Equal(t, 1, count)
assert.Equal(t, int64(0), e.balance(t, a))
}
func TestWalletExpiry_NothingDue(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
e.credit(t, earn(a, 150, e.at(time.Hour)))
notifier := &notifierFake{}
p, _ := e.expiry(notifier)
count, err := p.ExpireDue(e.ctx)
require.NoError(t, err)
assert.Zero(t, count)
assert.Empty(t, notifier.pushes)
}
func (f *walletExpiryRepoFake) OrganizationsWithUpcomingExpiry(_ context.Context, asOf time.Time) ([]uuid.UUID, error) {
seen := map[uuid.UUID]bool{}
var out []uuid.UUID
for _, lot := range f.wallet.lots {
if lot.RemainingAmount > 0 && lot.ExpiresAt != nil && lot.ExpiresAt.After(asOf) && !seen[lot.OrganizationID] {
seen[lot.OrganizationID] = true
out = append(out, lot.OrganizationID)
}
}
return out, nil
}
func (f *walletExpiryRepoFake) UpcomingUnreminded(_ context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]repository.UpcomingExpiry, error) {
sums := map[[2]string]int64{}
var order [][2]string
for _, lot := range f.wallet.lots {
if lot.OrganizationID != organizationID || lot.Currency != currency || lot.RemainingAmount == 0 ||
lot.ExpiresAt == nil || !lot.ExpiresAt.After(asOf) || lot.ExpiresAt.After(until) {
continue
}
key := [2]string{lot.CustomerID.String(), lot.ExpiresAt.In(walletDisplayLocation).Format("2006-01-02")}
if f.reminded[key[0]+"/"+currency+"/"+key[1]] {
continue
}
if _, ok := sums[key]; !ok {
order = append(order, key)
}
sums[key] += lot.RemainingAmount
}
var out []repository.UpcomingExpiry
for _, key := range order {
out = append(out, repository.UpcomingExpiry{CustomerID: uuid.MustParse(key[0]), Date: key[1], Amount: sums[key]})
}
return out, nil
}
func (f *walletExpiryRepoFake) MarkReminded(_ context.Context, u repository.UpcomingExpiry, currency string) (bool, error) {
if f.reminded == nil {
f.reminded = map[string]bool{}
}
key := u.CustomerID.String() + "/" + currency + "/" + u.Date
if f.reminded[key] {
return false, nil
}
f.reminded[key] = true
return true, nil
}
func TestWalletExpiry_RemindsOncePerDayBeforeExpiry(t *testing.T) {
e := newWalletMoveEnv(t)
e.now = wib(2026, 10, 25, 9, 0)
e.settings.PointExpiry.ReminderDays = 7
e.settings.CoinExpiry.ReminderDays = 0 // no reminders for EnakCoin
a := e.member("Anita", "081200005678")
oct31 := wib(2026, 10, 31, 23, 59)
nov30 := wib(2026, 11, 30, 23, 59)
e.credit(t, earn(a, 100, &oct31))
e.credit(t, earn(a, 50, &oct31))
e.credit(t, earn(a, 70, &nov30)) // too far off yet
e.earnCoins(t, a, 5, &oct31)
notifier := &notifierFake{}
p, _ := e.expiry(notifier)
sent, err := p.SendReminders(e.ctx)
require.NoError(t, err)
assert.Equal(t, 1, sent)
require.Len(t, notifier.pushes[a], 1)
push := notifier.pushes[a][0]
assert.Equal(t, "EnakPoint akan kedaluwarsa", push.title)
assert.Equal(t, "150 EnakPoint akan kedaluwarsa pada 31 Okt 2026. Pakai sebelum hangus.", push.body)
assert.Equal(t, map[string]string{"type": NotificationTypeWalletExpiring, "currency": "POINT", "amount": "150", "expiry_date": "2026-10-31"}, push.data)
// The next run, on this instance or another, sends nothing again.
again, err := p.SendReminders(e.ctx)
require.NoError(t, err)
assert.Zero(t, again)
// Once 30 Nov comes within seven days, it gets its own reminder.
e.now = wib(2026, 11, 23, 9, 0)
sent, err = p.SendReminders(e.ctx)
require.NoError(t, err)
assert.Equal(t, 1, sent)
assert.Equal(t, "70", notifier.pushes[a][1].data["amount"])
}
func TestFormatWalletDate(t *testing.T) {
assert.Equal(t, "31 Okt 2026", formatWalletDate("2026-10-31"))
assert.Equal(t, "1 Mei 2027", formatWalletDate("2027-05-01"))
}
+39
View File
@@ -196,3 +196,42 @@ func TestWalletTrace_AgainstPostgres(t *testing.T) {
_, err = NewWalletTraceProcessor(repository.NewWalletTraceRepository(db)).Trace(context.Background(), uuid.New(), payment.Transaction.ID)
assert.ErrorIs(t, err, repository.ErrWalletTransactionNotFound)
}
// Two instances of the expiry job at once expire each lot exactly once (PC-503).
func TestWalletExpiry_TwoInstancesAgainstPostgres(t *testing.T) {
db, _, a, b := walletMoveDB(t)
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
txm := repository.NewTxManager(db)
past := time.Now().Add(-time.Hour)
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
for i := 0; i < 5; i++ {
for _, c := range []uuid.UUID{a, b} {
if _, err := wallet.Credit(ctx, earn(c, 10, &past)); err != nil {
return err
}
}
}
return nil
}))
var wg sync.WaitGroup
counts := make([]int, 2)
for i := range counts {
wg.Add(1)
go func(i int) {
defer wg.Done()
p := NewWalletExpiryProcessor(repository.NewWalletExpiryRepository(db), fixedOrganizationSettings{}, wallet, txm, nil)
n, err := p.ExpireDue(context.Background())
assert.NoError(t, err)
counts[i] = n
}(i)
}
wg.Wait()
assert.Equal(t, 10, counts[0]+counts[1], "every lot once, between them")
var expires, left int64
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id IN ? AND type = 'EXPIRE'`, []uuid.UUID{a, b}).Scan(&expires).Error)
require.NoError(t, db.Raw(`SELECT COALESCE(SUM(point_balance), 0) FROM customer_wallets WHERE customer_id IN ?`, []uuid.UUID{a, b}).Scan(&left).Error)
assert.Equal(t, int64(10), expires)
assert.Zero(t, left)
}
+49
View File
@@ -149,6 +149,55 @@ func (p *WalletProcessor) FindTransaction(ctx context.Context, idempotencyKey st
return p.repo.GetTransactionByIdempotencyKey(ctx, idempotencyKey)
}
// ExpireLot takes what is left in a lot whose expiry has passed at asOf, as an EXPIRE
// row pointing at the lot (F12). It locks the wallet before reading the lot again, so
// it never races a payment for the same balance, and the key expire:{lot_id} makes a
// second run, on this instance or another, take nothing more. describe gives the
// row's description for the amount taken. It returns nil when
// there is nothing to take: the lot is empty, not due, or already expired.
func (p *WalletProcessor) ExpireLot(ctx context.Context, lotID uuid.UUID, describe func(amount int64) string, asOf time.Time) (*WalletResult, error) {
lot, err := p.getLot(ctx, lotID)
if err != nil {
return nil, err
}
if _, err := p.repo.LockWallet(ctx, lot.CustomerID); err != nil {
return nil, err
}
// Read again under the lock: a payment may have used it up meanwhile.
if lot, err = p.getLot(ctx, lotID); err != nil {
return nil, err
}
if lot.RemainingAmount == 0 || lot.ExpiresAt == nil || lot.ExpiresAt.After(asOf) {
return nil, nil
}
return p.Debit(ctx, WalletDebitInput{
WalletEntry: WalletEntry{
CustomerID: lot.CustomerID,
Currency: lot.Currency,
Type: constants.WalletTxTypeExpire,
Amount: lot.RemainingAmount,
ReferenceType: constants.WalletRefTypeLot,
ReferenceID: lot.ID,
Description: describe(lot.RemainingAmount),
Metadata: entities.Metadata{"expires_at": lot.ExpiresAt.UTC().Format(time.RFC3339)},
IdempotencyKey: "expire:" + lot.ID.String(),
},
// Exactly what the lot holds, from the lot itself, even though it has expired.
PreferredLotIDs: []uuid.UUID{lot.ID},
})
}
func (p *WalletProcessor) getLot(ctx context.Context, lotID uuid.UUID) (*entities.WalletLot, error) {
lots, err := p.repo.GetLotsByIDs(ctx, []uuid.UUID{lotID})
if err != nil {
return nil, err
}
if len(lots) == 0 {
return nil, fmt.Errorf("%w: lot %s does not exist", ErrWalletInvalidEntry, lotID)
}
return &lots[0], nil
}
// 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 {
@@ -315,3 +315,22 @@ func walletTransactionFilter(customerID uuid.UUID, q models.ListCustomerWalletTr
}
return filter, page, nil
}
// Expiring is GET /customer/wallet/expiring: what will expire, grouped by day (F6).
func (p *WalletQueryProcessor) Expiring(ctx context.Context, customerID uuid.UUID) (*models.CustomerWalletExpiringList, error) {
rows, err := p.repo.ExpiringByDay(ctx, customerID, p.now())
if err != nil {
return nil, err
}
list := &models.CustomerWalletExpiringList{Point: []models.CustomerWalletExpiring{}, Coin: []models.CustomerWalletExpiring{}}
for _, row := range rows {
item := models.CustomerWalletExpiring{Amount: row.Amount, Date: row.Date}
switch row.Currency {
case constants.WalletCurrencyPoint:
list.Point = append(list.Point, item)
case constants.WalletCurrencyCoin:
list.Coin = append(list.Coin, item)
}
}
return list, nil
}
@@ -243,3 +243,24 @@ func TestWalletQueryProcessor_RejectsBadQueries(t *testing.T) {
func (f *walletQueryRepoFake) OrganizationOutstanding(context.Context, uuid.UUID) (int64, int64, error) {
return 0, 0, nil
}
func (f *walletQueryRepoFake) ExpiringByDay(context.Context, uuid.UUID, time.Time) ([]repository.WalletExpiringAmount, error) {
return f.expiring, nil
}
func TestWalletQueryProcessor_ExpiringGroupsByCurrencyAndDay(t *testing.T) {
repo := &walletQueryRepoFake{org: uuid.New(), expiring: []repository.WalletExpiringAmount{
{Currency: "POINT", Date: "2026-10-31", Amount: 150},
{Currency: "COIN", Date: "2026-10-31", Amount: 4},
{Currency: "POINT", Date: "2026-12-31", Amount: 200},
}}
got, err := newWalletQueryTest(repo, nil).Expiring(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, []models.CustomerWalletExpiring{{Amount: 150, Date: "2026-10-31"}, {Amount: 200, Date: "2026-12-31"}}, got.Point)
assert.Equal(t, []models.CustomerWalletExpiring{{Amount: 4, Date: "2026-10-31"}}, got.Coin)
empty, err := newWalletQueryTest(&walletQueryRepoFake{org: uuid.New()}, nil).Expiring(context.Background(), uuid.New())
require.NoError(t, err)
assert.NotNil(t, empty.Point, "an empty list, not null")
assert.NotNil(t, empty.Coin)
}
@@ -1,97 +0,0 @@
package repository
import (
"context"
"fmt"
"apskel-pos-be/internal/entities"
"gorm.io/gorm"
)
type CustomerPointsRepository interface {
GetCustomerTotalPoints(ctx context.Context, customerID string) (int64, error)
GetCustomerTotalTokens(ctx context.Context, customerID string) (int64, error)
GetCustomerPointsHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerPoints, error)
GetCustomerTokensHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerTokens, error)
}
type customerPointsRepository struct {
db *gorm.DB
}
func NewCustomerPointsRepository(db *gorm.DB) CustomerPointsRepository {
return &customerPointsRepository{
db: db,
}
}
func (r *customerPointsRepository) GetCustomerTotalPoints(ctx context.Context, customerID string) (int64, error) {
var totalPoints int64
err := r.db.WithContext(ctx).
Model(&entities.CustomerPoints{}).
Where("customer_id = ?", customerID).
Select("COALESCE(SUM(balance), 0)").
Scan(&totalPoints).Error
if err != nil {
return 0, fmt.Errorf("failed to get customer total points: %w", err)
}
return totalPoints, nil
}
func (r *customerPointsRepository) GetCustomerTotalTokens(ctx context.Context, customerID string) (int64, error) {
var totalTokens int64
err := r.db.WithContext(ctx).
Model(&entities.CustomerTokens{}).
Where("customer_id = ?", customerID).
Select("COALESCE(SUM(balance), 0)").
Scan(&totalTokens).Error
if err != nil {
return 0, fmt.Errorf("failed to get customer total tokens: %w", err)
}
return totalTokens, nil
}
func (r *customerPointsRepository) GetCustomerPointsHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerPoints, error) {
var pointsHistory []entities.CustomerPoints
query := r.db.WithContext(ctx).
Where("customer_id = ?", customerID).
Order("created_at DESC")
if limit > 0 {
query = query.Limit(limit)
}
err := query.Find(&pointsHistory).Error
if err != nil {
return nil, fmt.Errorf("failed to get customer points history: %w", err)
}
return pointsHistory, nil
}
func (r *customerPointsRepository) GetCustomerTokensHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerTokens, error) {
var tokensHistory []entities.CustomerTokens
query := r.db.WithContext(ctx).
Where("customer_id = ?", customerID).
Order("created_at DESC")
if limit > 0 {
query = query.Limit(limit)
}
err := query.Find(&tokensHistory).Error
if err != nil {
return nil, fmt.Errorf("failed to get customer tokens history: %w", err)
}
return tokensHistory, nil
}
@@ -1,131 +0,0 @@
package repository
import (
"apskel-pos-be/internal/entities"
"context"
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
)
type CustomerTokensRepository struct {
db *gorm.DB
}
func NewCustomerTokensRepository(db *gorm.DB) *CustomerTokensRepository {
return &CustomerTokensRepository{db: db}
}
func (r *CustomerTokensRepository) Create(ctx context.Context, customerTokens *entities.CustomerTokens) error {
return r.db.WithContext(ctx).Create(customerTokens).Error
}
func (r *CustomerTokensRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.CustomerTokens, error) {
var customerTokens entities.CustomerTokens
err := r.db.WithContext(ctx).Preload("Customer").Where("id = ?", id).First(&customerTokens).Error
if err != nil {
return nil, err
}
return &customerTokens, nil
}
func (r *CustomerTokensRepository) GetByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType) (*entities.CustomerTokens, error) {
var customerTokens entities.CustomerTokens
err := r.db.WithContext(ctx).Preload("Customer").Where("customer_id = ? AND token_type = ?", customerID, tokenType).First(&customerTokens).Error
if err != nil {
return nil, err
}
return &customerTokens, nil
}
func (r *CustomerTokensRepository) GetByCustomerID(ctx context.Context, customerID uuid.UUID) ([]entities.CustomerTokens, error) {
var customerTokens []entities.CustomerTokens
err := r.db.WithContext(ctx).Preload("Customer").Where("customer_id = ?", customerID).Find(&customerTokens).Error
if err != nil {
return nil, err
}
return customerTokens, nil
}
func (r *CustomerTokensRepository) List(ctx context.Context, offset, limit int, search, tokenType string, sortBy, sortOrder string) ([]entities.CustomerTokens, int64, error) {
var customerTokens []entities.CustomerTokens
var total int64
query := r.db.WithContext(ctx).Preload("Customer")
if search != "" {
searchTerm := "%" + search + "%"
query = query.Joins("JOIN customers ON customer_tokens.customer_id = customers.id").
Where("customers.name ILIKE ? OR customers.email ILIKE ?", searchTerm, searchTerm)
}
if tokenType != "" {
query = query.Where("token_type = ?", tokenType)
}
if err := query.Model(&entities.CustomerTokens{}).Count(&total).Error; err != nil {
return nil, 0, err
}
if sortBy != "" {
if sortOrder == "" {
sortOrder = "asc"
}
query = query.Order(fmt.Sprintf("customer_tokens.%s %s", sortBy, sortOrder))
} else {
query = query.Order("customer_tokens.created_at DESC")
}
err := query.Offset(offset).Limit(limit).Find(&customerTokens).Error
if err != nil {
return nil, 0, err
}
return customerTokens, total, nil
}
func (r *CustomerTokensRepository) Update(ctx context.Context, customerTokens *entities.CustomerTokens) error {
return r.db.WithContext(ctx).Save(customerTokens).Error
}
func (r *CustomerTokensRepository) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&entities.CustomerTokens{}, id).Error
}
func (r *CustomerTokensRepository) AddTokens(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType, tokens int64) error {
return r.db.WithContext(ctx).Model(&entities.CustomerTokens{}).
Where("customer_id = ? AND token_type = ?", customerID, tokenType).
Update("balance", gorm.Expr("balance + ?", tokens)).Error
}
func (r *CustomerTokensRepository) DeductTokens(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType, tokens int64) error {
return r.db.WithContext(ctx).Model(&entities.CustomerTokens{}).
Where("customer_id = ? AND token_type = ? AND balance >= ?", customerID, tokenType, tokens).
Update("balance", gorm.Expr("balance - ?", tokens)).Error
}
func (r *CustomerTokensRepository) EnsureCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType) (*entities.CustomerTokens, error) {
customerTokens, err := r.GetByCustomerIDAndType(ctx, customerID, tokenType)
if err == nil {
return customerTokens, nil
}
if err != gorm.ErrRecordNotFound {
return nil, err
}
// Create new customer tokens record
newCustomerTokens := &entities.CustomerTokens{
CustomerID: customerID,
TokenType: tokenType,
Balance: 0,
}
err = r.Create(ctx, newCustomerTokens)
if err != nil {
return nil, err
}
return newCustomerTokens, nil
}
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
@@ -47,6 +48,12 @@ type LoyaltySettingsRepository interface {
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)
// UndatedBalance counts an organization's lots of one currency that still hold
// something and have no expiry, and what they hold.
UndatedBalance(ctx context.Context, organizationID uuid.UUID, currency string) (lots, amount int64, err error)
// DateUndatedLots gives those lots an expiry, when expiry is turned on (F12).
DateUndatedLots(ctx context.Context, organizationID uuid.UUID, currency string, expiresAt time.Time) (lots, amount int64, err error)
}
type loyaltySettingsRepository struct {
@@ -174,3 +181,39 @@ func (r *loyaltySettingsRepository) ListChanges(ctx context.Context, filter Loya
}
return changes, total, nil
}
func (r *loyaltySettingsRepository) UndatedBalance(ctx context.Context, organizationID uuid.UUID, currency string) (int64, int64, error) {
var row struct{ Lots, Amount int64 }
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT COUNT(*) AS lots, COALESCE(SUM(remaining_amount), 0) AS amount
FROM wallet_lots
WHERE organization_id = ? AND currency = ? AND expires_at IS NULL AND remaining_amount > 0`,
organizationID, currency).Scan(&row).Error
if err != nil {
return 0, 0, fmt.Errorf("failed to sum undated wallet lots: %w", err)
}
return row.Lots, row.Amount, nil
}
func (r *loyaltySettingsRepository) DateUndatedLots(ctx context.Context, organizationID uuid.UUID, currency string, expiresAt time.Time) (int64, int64, error) {
db, err := r.requireTx(ctx)
if err != nil {
return 0, 0, err
}
var rows []struct{ RemainingAmount int64 }
// Only lots that still hold something: an empty lot never expires anything, and
// leaving it undated keeps the history as it was.
err = db.Raw(`
UPDATE wallet_lots SET expires_at = ?
WHERE organization_id = ? AND currency = ? AND expires_at IS NULL AND remaining_amount > 0
RETURNING remaining_amount`,
expiresAt, organizationID, currency).Scan(&rows).Error
if err != nil {
return 0, 0, fmt.Errorf("failed to date wallet lots: %w", err)
}
var amount int64
for _, row := range rows {
amount += row.RemainingAmount
}
return int64(len(rows)), amount, nil
}
@@ -124,7 +124,7 @@ func TestLoyaltySettings_AgainstPostgres(t *testing.T) {
orgSettings.PointValue = 200
saved := make(chan error, 1)
go func() {
_, err := p.UpdateOrganization(ctx, org, admin, *orgSettings)
_, _, err := p.UpdateOrganization(ctx, org, admin, *orgSettings)
saved <- err
}()
select {
@@ -0,0 +1,118 @@
package repository
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// DueLot is a lot whose expiry has passed and that still holds something.
type DueLot struct {
ID uuid.UUID
CustomerID uuid.UUID
Currency string
Remaining int64
ExpiresAt time.Time
// The description of the row that created the lot, for the EXPIRE row's.
SourceDescription string
}
// UpcomingExpiry is how much of a customer's balance expires on one day.
type UpcomingExpiry struct {
CustomerID uuid.UUID
// A calendar date in walletDisplayTimeZone, formatted YYYY-MM-DD.
Date string
Amount int64
}
// WalletExpiryRepository finds what the expiry job has to do (docs/prd-point-coin.md
// F12). Balances only change through WalletProcessor.
type WalletExpiryRepository interface {
// ListDueLots returns up to limit lots due at asOf, the longest overdue first. It
// takes no lock: locking a lot before its wallet would deadlock against payments,
// which lock the wallet first. WalletProcessor.ExpireLot locks and reads again.
ListDueLots(ctx context.Context, asOf time.Time, limit int) ([]DueLot, error)
// OrganizationsWithUpcomingExpiry lists the organizations that have balance
// expiring after asOf.
OrganizationsWithUpcomingExpiry(ctx context.Context, asOf time.Time) ([]uuid.UUID, error)
// UpcomingUnreminded sums, per customer and expiry day, the balance of one currency
// of an organization expiring after asOf and up to until, leaving out the days the
// customer has already been reminded of.
UpcomingUnreminded(ctx context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]UpcomingExpiry, error)
// MarkReminded records a reminder, and reports false when it was already recorded,
// by this run or another.
MarkReminded(ctx context.Context, reminder UpcomingExpiry, currency string) (bool, error)
}
type walletExpiryRepository struct {
db *gorm.DB
}
func NewWalletExpiryRepository(db *gorm.DB) WalletExpiryRepository {
return &walletExpiryRepository{db: db}
}
func (r *walletExpiryRepository) ListDueLots(ctx context.Context, asOf time.Time, limit int) ([]DueLot, error) {
var lots []DueLot
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT l.id, l.customer_id, l.currency, l.remaining_amount AS remaining, l.expires_at,
t.description AS source_description
FROM wallet_lots l
JOIN wallet_transactions t ON t.id = l.source_transaction_id
WHERE l.remaining_amount > 0 AND l.expires_at <= ?
ORDER BY l.expires_at, l.id
LIMIT ?`, asOf, limit).Scan(&lots).Error
if err != nil {
return nil, fmt.Errorf("failed to list due wallet lots: %w", err)
}
return lots, nil
}
func (r *walletExpiryRepository) OrganizationsWithUpcomingExpiry(ctx context.Context, asOf time.Time) ([]uuid.UUID, error) {
var ids []uuid.UUID
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT DISTINCT organization_id FROM wallet_lots
WHERE remaining_amount > 0 AND expires_at > ?`, asOf).Scan(&ids).Error
if err != nil {
return nil, fmt.Errorf("failed to list organizations with expiring balances: %w", err)
}
return ids, nil
}
func (r *walletExpiryRepository) UpcomingUnreminded(ctx context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]UpcomingExpiry, error) {
var rows []UpcomingExpiry
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
WITH by_day AS (
SELECT customer_id, (expires_at AT TIME ZONE ?)::date AS day, SUM(remaining_amount) AS amount
FROM wallet_lots
WHERE organization_id = ? AND currency = ? AND remaining_amount > 0
AND expires_at > ? AND expires_at <= ?
GROUP BY customer_id, day
)
SELECT d.customer_id, to_char(d.day, 'YYYY-MM-DD') AS date, d.amount
FROM by_day d
LEFT JOIN wallet_expiry_reminders w
ON w.customer_id = d.customer_id AND w.currency = ? AND w.expiry_date = d.day
WHERE w.customer_id IS NULL
ORDER BY d.day, d.customer_id`,
walletDisplayTimeZone, organizationID, currency, asOf, until, currency).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to list upcoming expiry: %w", err)
}
return rows, nil
}
func (r *walletExpiryRepository) MarkReminded(ctx context.Context, reminder UpcomingExpiry, currency string) (bool, error) {
res := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
INSERT INTO wallet_expiry_reminders (customer_id, currency, expiry_date, amount)
VALUES (?, ?, ?::date, ?)
ON CONFLICT DO NOTHING`, reminder.CustomerID, currency, reminder.Date, reminder.Amount)
if res.Error != nil {
return false, fmt.Errorf("failed to record expiry reminder: %w", res.Error)
}
return res.RowsAffected == 1, nil
}
@@ -47,6 +47,9 @@ type WalletQueryRepository interface {
// NearestExpiring returns, per currency, the earliest day after asOf on which some
// balance expires, and how much expires that day.
NearestExpiring(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error)
// ExpiringByDay returns, per currency and day, everything that expires after asOf,
// soonest first.
ExpiringByDay(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error)
// ListTransactions returns a page of the ledger, newest first, and the total count.
ListTransactions(ctx context.Context, filter WalletTransactionFilter) ([]entities.WalletTransaction, int64, error)
// OrganizationOutstanding sums every wallet balance of an organization.
@@ -181,3 +184,19 @@ func (r *walletQueryRepository) OrganizationOutstanding(ctx context.Context, org
}
return totals.Points, totals.Coins, nil
}
func (r *walletQueryRepository) ExpiringByDay(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) {
var rows []WalletExpiringAmount
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT currency, to_char((expires_at AT TIME ZONE ?)::date, 'YYYY-MM-DD') AS date,
SUM(remaining_amount) AS amount
FROM wallet_lots
WHERE customer_id = ? AND remaining_amount > 0 AND expires_at > ?
GROUP BY currency, date
ORDER BY date, currency`, walletDisplayTimeZone, customerID, asOf).
Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to list expiring wallet balance: %w", err)
}
return rows, nil
}
+1 -25
View File
@@ -172,6 +172,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens)
customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet)
customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions)
customer.GET("/wallet/expiring", r.customerPointsHandler.GetCustomerWalletExpiring)
customer.POST("/wallet/payment-code", r.customerPinHandler.IssuePaymentCode)
customer.GET("/wallet/exchange/preview", r.customerWalletHandler.PreviewExchange)
customer.POST("/wallet/exchange", r.customerWalletHandler.Exchange)
@@ -541,31 +542,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
gamification := protected.Group("/marketing")
gamification.Use(r.authMiddleware.RequireAdminOrManager())
{
//customerPoints := gamification.Group("/customer-points")
//{
// customerPoints.POST("", r.gamificationHandler.CreateCustomerPoints)
// customerPoints.GET("", r.gamificationHandler.ListCustomerPoints)
// customerPoints.GET("/:id", r.gamificationHandler.GetCustomerPoints)
// customerPoints.PUT("/:id", r.gamificationHandler.UpdateCustomerPoints)
// customerPoints.DELETE("/:id", r.gamificationHandler.DeleteCustomerPoints)
// customerPoints.GET("/customer/:customer_id", r.gamificationHandler.GetCustomerPointsByCustomerID)
// customerPoints.POST("/customer/:customer_id/add", r.gamificationHandler.AddCustomerPoints)
// customerPoints.POST("/customer/:customer_id/deduct", r.gamificationHandler.DeductCustomerPoints)
//}
// Customer Tokens
//customerTokens := gamification.Group("/customer-tokens")
//{
// customerTokens.POST("", r.gamificationHandler.CreateCustomerTokens)
// customerTokens.GET("", r.gamificationHandler.ListCustomerTokens)
// customerTokens.GET("/:id", r.gamificationHandler.GetCustomerTokens)
// customerTokens.PUT("/:id", r.gamificationHandler.UpdateCustomerTokens)
// customerTokens.DELETE("/:id", r.gamificationHandler.DeleteCustomerTokens)
// customerTokens.GET("/customer/:customer_id/type/:token_type", r.gamificationHandler.GetCustomerTokensByCustomerIDAndType)
// customerTokens.POST("/customer/:customer_id/type/:token_type/add", r.gamificationHandler.AddCustomerTokens)
// customerTokens.POST("/customer/:customer_id/type/:token_type/deduct", r.gamificationHandler.DeductCustomerTokens)
//}
// Tiers
tiers := gamification.Group("/tiers")
{
+1
View File
@@ -28,6 +28,7 @@ func TestAllRoutesRegister(t *testing.T) {
for _, want := range []string{
"GET /api/v1/customer/wallet",
"GET /api/v1/customer/wallet/transactions",
"GET /api/v1/customer/wallet/expiring",
"GET /api/v1/marketing/customers/:id/wallet",
"POST /api/v1/marketing/customers/:id/wallet/adjust",
"GET /api/v1/marketing/wallet-transactions/:id/trace",
@@ -13,6 +13,7 @@ type CustomerPointsService interface {
GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error)
GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error)
GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error)
GetCustomerWalletExpiring(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error)
GetCustomerGames(ctx context.Context) (*models.GetCustomerGamesResponse, error)
GetFerrisWheelGame(ctx context.Context) (*models.GetFerrisWheelGameResponse, error)
}
@@ -90,3 +91,10 @@ func (s *customerPointsService) GetCustomerWalletTransactions(ctx context.Contex
}
return s.customerPointsProcessor.GetCustomerWalletTransactionsAPI(ctx, customerID, query)
}
func (s *customerPointsService) GetCustomerWalletExpiring(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) {
if customerID == "" {
return nil, fmt.Errorf("customer ID is required")
}
return s.customerPointsProcessor.GetCustomerWalletExpiringAPI(ctx, customerID)
}
+10 -164
View File
@@ -11,26 +11,6 @@ import (
)
type GamificationService interface {
// Customer Points
CreateCustomerPoints(ctx context.Context, req *contract.CreateCustomerPointsRequest) (*contract.CustomerPointsResponse, error)
GetCustomerPoints(ctx context.Context, id uuid.UUID) (*contract.CustomerPointsResponse, error)
GetCustomerPointsByCustomerID(ctx context.Context, customerID uuid.UUID) (*contract.CustomerPointsResponse, error)
ListCustomerPoints(ctx context.Context, query *contract.ListCustomerPointsRequest) (*contract.PaginatedCustomerPointsResponse, error)
UpdateCustomerPoints(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerPointsRequest) (*contract.CustomerPointsResponse, error)
DeleteCustomerPoints(ctx context.Context, id uuid.UUID) error
AddCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.AddCustomerPointsRequest) (*contract.CustomerPointsResponse, error)
DeductCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.DeductCustomerPointsRequest) (*contract.CustomerPointsResponse, error)
// Customer Tokens
CreateCustomerTokens(ctx context.Context, req *contract.CreateCustomerTokensRequest) (*contract.CustomerTokensResponse, error)
GetCustomerTokens(ctx context.Context, id uuid.UUID) (*contract.CustomerTokensResponse, error)
GetCustomerTokensByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType string) (*contract.CustomerTokensResponse, error)
ListCustomerTokens(ctx context.Context, query *contract.ListCustomerTokensRequest) (*contract.PaginatedCustomerTokensResponse, error)
UpdateCustomerTokens(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerTokensRequest) (*contract.CustomerTokensResponse, error)
DeleteCustomerTokens(ctx context.Context, id uuid.UUID) error
AddCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.AddCustomerTokensRequest) (*contract.CustomerTokensResponse, error)
DeductCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.DeductCustomerTokensRequest) (*contract.CustomerTokensResponse, error)
// Tiers
CreateTier(ctx context.Context, req *contract.CreateTierRequest) (*contract.TierResponse, error)
GetTier(ctx context.Context, id uuid.UUID) (*contract.TierResponse, error)
@@ -72,18 +52,14 @@ type GamificationService interface {
}
type GamificationServiceImpl struct {
customerPointsProcessor *processor.CustomerPointsProcessor
customerTokensProcessor *processor.CustomerTokensProcessor
tierProcessor *processor.TierProcessor
gameProcessor *processor.GameProcessor
gamePrizeProcessor *processor.GamePrizeProcessor
gamePlayProcessor *processor.GamePlayProcessor
omsetTrackerProcessor *processor.OmsetTrackerProcessor
tierProcessor *processor.TierProcessor
gameProcessor *processor.GameProcessor
gamePrizeProcessor *processor.GamePrizeProcessor
gamePlayProcessor *processor.GamePlayProcessor
omsetTrackerProcessor *processor.OmsetTrackerProcessor
}
func NewGamificationService(
customerPointsProcessor *processor.CustomerPointsProcessor,
customerTokensProcessor *processor.CustomerTokensProcessor,
tierProcessor *processor.TierProcessor,
gameProcessor *processor.GameProcessor,
gamePrizeProcessor *processor.GamePrizeProcessor,
@@ -91,144 +67,14 @@ func NewGamificationService(
omsetTrackerProcessor *processor.OmsetTrackerProcessor,
) *GamificationServiceImpl {
return &GamificationServiceImpl{
customerPointsProcessor: customerPointsProcessor,
customerTokensProcessor: customerTokensProcessor,
tierProcessor: tierProcessor,
gameProcessor: gameProcessor,
gamePrizeProcessor: gamePrizeProcessor,
gamePlayProcessor: gamePlayProcessor,
omsetTrackerProcessor: omsetTrackerProcessor,
tierProcessor: tierProcessor,
gameProcessor: gameProcessor,
gamePrizeProcessor: gamePrizeProcessor,
gamePlayProcessor: gamePlayProcessor,
omsetTrackerProcessor: omsetTrackerProcessor,
}
}
// Customer Points Service Methods
func (s *GamificationServiceImpl) CreateCustomerPoints(ctx context.Context, req *contract.CreateCustomerPointsRequest) (*contract.CustomerPointsResponse, error) {
modelReq := transformer.CreateCustomerPointsRequestToModel(req)
response, err := s.customerPointsProcessor.CreateCustomerPoints(ctx, modelReq)
if err != nil {
return nil, err
}
return transformer.CustomerPointsModelToResponse(response), nil
}
func (s *GamificationServiceImpl) GetCustomerPoints(ctx context.Context, id uuid.UUID) (*contract.CustomerPointsResponse, error) {
response, err := s.customerPointsProcessor.GetCustomerPoints(ctx, id)
if err != nil {
return nil, err
}
return transformer.CustomerPointsModelToResponse(response), nil
}
func (s *GamificationServiceImpl) GetCustomerPointsByCustomerID(ctx context.Context, customerID uuid.UUID) (*contract.CustomerPointsResponse, error) {
response, err := s.customerPointsProcessor.GetCustomerPointsByCustomerID(ctx, customerID)
if err != nil {
return nil, err
}
return transformer.CustomerPointsModelToResponse(response), nil
}
func (s *GamificationServiceImpl) ListCustomerPoints(ctx context.Context, query *contract.ListCustomerPointsRequest) (*contract.PaginatedCustomerPointsResponse, error) {
modelQuery := transformer.ListCustomerPointsRequestToModel(query)
response, err := s.customerPointsProcessor.ListCustomerPoints(ctx, modelQuery)
if err != nil {
return nil, err
}
return transformer.PaginatedCustomerPointsResponseToContract(response), nil
}
func (s *GamificationServiceImpl) UpdateCustomerPoints(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerPointsRequest) (*contract.CustomerPointsResponse, error) {
modelReq := transformer.UpdateCustomerPointsRequestToModel(req)
response, err := s.customerPointsProcessor.UpdateCustomerPoints(ctx, id, modelReq)
if err != nil {
return nil, err
}
return transformer.CustomerPointsModelToResponse(response), nil
}
func (s *GamificationServiceImpl) DeleteCustomerPoints(ctx context.Context, id uuid.UUID) error {
return s.customerPointsProcessor.DeleteCustomerPoints(ctx, id)
}
func (s *GamificationServiceImpl) AddCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.AddCustomerPointsRequest) (*contract.CustomerPointsResponse, error) {
response, err := s.customerPointsProcessor.AddPoints(ctx, customerID, req.Points)
if err != nil {
return nil, err
}
return transformer.CustomerPointsModelToResponse(response), nil
}
func (s *GamificationServiceImpl) DeductCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.DeductCustomerPointsRequest) (*contract.CustomerPointsResponse, error) {
response, err := s.customerPointsProcessor.DeductPoints(ctx, customerID, req.Points)
if err != nil {
return nil, err
}
return transformer.CustomerPointsModelToResponse(response), nil
}
// Customer Tokens Service Methods
func (s *GamificationServiceImpl) CreateCustomerTokens(ctx context.Context, req *contract.CreateCustomerTokensRequest) (*contract.CustomerTokensResponse, error) {
modelReq := transformer.CreateCustomerTokensRequestToModel(req)
response, err := s.customerTokensProcessor.CreateCustomerTokens(ctx, modelReq)
if err != nil {
return nil, err
}
return transformer.CustomerTokensModelToResponse(response), nil
}
func (s *GamificationServiceImpl) GetCustomerTokens(ctx context.Context, id uuid.UUID) (*contract.CustomerTokensResponse, error) {
response, err := s.customerTokensProcessor.GetCustomerTokens(ctx, id)
if err != nil {
return nil, err
}
return transformer.CustomerTokensModelToResponse(response), nil
}
func (s *GamificationServiceImpl) GetCustomerTokensByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType string) (*contract.CustomerTokensResponse, error) {
response, err := s.customerTokensProcessor.GetCustomerTokensByCustomerIDAndType(ctx, customerID, tokenType)
if err != nil {
return nil, err
}
return transformer.CustomerTokensModelToResponse(response), nil
}
func (s *GamificationServiceImpl) ListCustomerTokens(ctx context.Context, query *contract.ListCustomerTokensRequest) (*contract.PaginatedCustomerTokensResponse, error) {
modelQuery := transformer.ListCustomerTokensRequestToModel(query)
response, err := s.customerTokensProcessor.ListCustomerTokens(ctx, modelQuery)
if err != nil {
return nil, err
}
return transformer.PaginatedCustomerTokensResponseToContract(response), nil
}
func (s *GamificationServiceImpl) UpdateCustomerTokens(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerTokensRequest) (*contract.CustomerTokensResponse, error) {
modelReq := transformer.UpdateCustomerTokensRequestToModel(req)
response, err := s.customerTokensProcessor.UpdateCustomerTokens(ctx, id, modelReq)
if err != nil {
return nil, err
}
return transformer.CustomerTokensModelToResponse(response), nil
}
func (s *GamificationServiceImpl) DeleteCustomerTokens(ctx context.Context, id uuid.UUID) error {
return s.customerTokensProcessor.DeleteCustomerTokens(ctx, id)
}
func (s *GamificationServiceImpl) AddCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.AddCustomerTokensRequest) (*contract.CustomerTokensResponse, error) {
response, err := s.customerTokensProcessor.AddTokens(ctx, customerID, tokenType, req.Tokens)
if err != nil {
return nil, err
}
return transformer.CustomerTokensModelToResponse(response), nil
}
func (s *GamificationServiceImpl) DeductCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.DeductCustomerTokensRequest) (*contract.CustomerTokensResponse, error) {
response, err := s.customerTokensProcessor.DeductTokens(ctx, customerID, tokenType, req.Tokens)
if err != nil {
return nil, err
}
return transformer.CustomerTokensModelToResponse(response), nil
}
// Tier Service Methods
func (s *GamificationServiceImpl) CreateTier(ctx context.Context, req *contract.CreateTierRequest) (*contract.TierResponse, error) {
modelReq := transformer.CreateTierRequestToModel(req)
+22 -10
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
@@ -133,7 +134,7 @@ func (s *LoyaltySettingsServiceImpl) GetOrganizationSettings(ctx context.Context
if err != nil {
return loyaltyErrorResponse(err)
}
view, err := s.organizationView(ctx, apctx.OrganizationID, *current, *current, nil, false)
view, err := s.organizationView(ctx, apctx.OrganizationID, *current, *current, nil, nil, false)
if err != nil {
return loyaltyErrorResponse(err)
}
@@ -144,9 +145,11 @@ func (s *LoyaltySettingsServiceImpl) GetOrganizationSettings(ctx context.Context
// of the GET response, which are ignored.
type organizationSettingsInput struct {
*models.OrganizationLoyaltySettings
Impact json.RawMessage `json:"impact"`
Changes json.RawMessage `json:"changes"`
DryRun json.RawMessage `json:"dry_run"`
Impact json.RawMessage `json:"impact"`
ExpiryPreview json.RawMessage `json:"expiry_preview"`
ExpiryActivations json.RawMessage `json:"expiry_activations"`
Changes json.RawMessage `json:"changes"`
DryRun json.RawMessage `json:"dry_run"`
}
func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo, body []byte, dryRun bool) *contract.Response {
@@ -164,10 +167,11 @@ func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Cont
}
var changes []models.LoyaltySettingChange
var activations []models.LoyaltyExpiryActivation
if dryRun {
changes, err = s.settings.PreviewOrganization(ctx, apctx.OrganizationID, next)
changes, activations, err = s.settings.PreviewOrganization(ctx, apctx.OrganizationID, next)
} else {
changes, err = s.settings.UpdateOrganization(ctx, apctx.OrganizationID, apctx.UserID, next)
changes, activations, err = s.settings.UpdateOrganization(ctx, apctx.OrganizationID, apctx.UserID, next)
}
if err != nil {
return loyaltyErrorResponse(err)
@@ -180,7 +184,7 @@ func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Cont
}
after = *saved
}
view, err := s.organizationView(ctx, apctx.OrganizationID, *current, after, changes, dryRun)
view, err := s.organizationView(ctx, apctx.OrganizationID, *current, after, changes, activations, dryRun)
if err != nil {
return loyaltyErrorResponse(err)
}
@@ -195,18 +199,26 @@ func (s *LoyaltySettingsServiceImpl) ListHistory(ctx context.Context, apctx *app
return contract.BuildSuccessResponse(history)
}
func (s *LoyaltySettingsServiceImpl) organizationView(ctx context.Context, organizationID uuid.UUID, before, after models.OrganizationLoyaltySettings, changes []models.LoyaltySettingChange, dryRun bool) (*models.OrganizationLoyaltySettingsView, error) {
func (s *LoyaltySettingsServiceImpl) organizationView(ctx context.Context, organizationID uuid.UUID, before, after models.OrganizationLoyaltySettings, changes []models.LoyaltySettingChange, activations []models.LoyaltyExpiryActivation, dryRun bool) (*models.OrganizationLoyaltySettingsView, error) {
now := time.Now()
points, coins, err := s.outstanding.OrganizationOutstanding(ctx, organizationID)
if err != nil {
return nil, err
}
if activations == nil {
activations = []models.LoyaltyExpiryActivation{}
}
if changes == nil {
changes = []models.LoyaltySettingChange{}
}
return &models.OrganizationLoyaltySettingsView{
OrganizationLoyaltySettings: after,
Impact: models.NewLoyaltySettingsImpact(points, coins, before, after),
Changes: changes,
DryRun: dryRun,
ExpiryPreview: models.LoyaltyExpiryPreview{
Point: processor.ComputeExpiry(after.PointExpiry, now),
Coin: processor.ComputeExpiry(after.CoinExpiry, now),
},
Changes: changes,
DryRun: dryRun,
}, nil
}
+77
View File
@@ -0,0 +1,77 @@
package service
import (
"context"
"sync"
"time"
"apskel-pos-be/internal/logger"
)
// Lots expire at the end of their day, so running every quarter of an hour keeps any
// lot from staying past its expiry for more than about that long (PC-503).
const defaultWalletExpiryInterval = 15 * time.Minute
type walletExpiryWork interface {
ExpireDue(ctx context.Context) (int, error)
SendReminders(ctx context.Context) (int, error)
}
// WalletExpiryJob expires the balances whose time is up and reminds customers of what
// is about to (docs/prd-point-coin.md F12, PC-503, PC-504).
// Unlike OmsetMilestoneScheduler it keeps no state in memory: several instances can
// run it at once, and a restart repeats nothing, because every lot is expired under
// its wallet's lock with an idempotency key.
type WalletExpiryJob struct {
expirer walletExpiryWork
stopCh chan struct{}
stopOnce sync.Once
}
func NewWalletExpiryJob(expirer walletExpiryWork) *WalletExpiryJob {
return &WalletExpiryJob{expirer: expirer, stopCh: make(chan struct{})}
}
func (j *WalletExpiryJob) Start(interval time.Duration) {
if interval <= 0 {
interval = defaultWalletExpiryInterval
}
go func() {
j.RunOnce(context.Background())
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
j.RunOnce(context.Background())
case <-j.stopCh:
return
}
}
}()
logger.NonContext.Infof("Wallet expiry job started (interval: %s)", interval)
}
func (j *WalletExpiryJob) Stop() {
j.stopOnce.Do(func() { close(j.stopCh) })
}
// RunOnce expires what is due, sends the reminders that are due, and returns how many
// lots it expired.
func (j *WalletExpiryJob) RunOnce(ctx context.Context) int {
expired, err := j.expirer.ExpireDue(ctx)
if err != nil {
logger.NonContext.Error("Wallet expiry failed to run", err)
}
if expired > 0 {
logger.NonContext.Infof("Wallet expiry expired %d lots", expired)
}
reminded, err := j.expirer.SendReminders(ctx)
if err != nil {
logger.NonContext.Error("Wallet expiry reminders failed to run", err)
}
if reminded > 0 {
logger.NonContext.Infof("Wallet expiry sent %d reminders", reminded)
}
return expired
}
@@ -5,131 +5,6 @@ import (
"apskel-pos-be/internal/models"
)
// Customer Points Transformers
func CreateCustomerPointsRequestToModel(req *contract.CreateCustomerPointsRequest) *models.CreateCustomerPointsRequest {
return &models.CreateCustomerPointsRequest{
CustomerID: req.CustomerID,
Balance: req.Balance,
}
}
func UpdateCustomerPointsRequestToModel(req *contract.UpdateCustomerPointsRequest) *models.UpdateCustomerPointsRequest {
return &models.UpdateCustomerPointsRequest{
Balance: req.Balance,
}
}
func ListCustomerPointsRequestToModel(req *contract.ListCustomerPointsRequest) *models.ListCustomerPointsQuery {
return &models.ListCustomerPointsQuery{
Page: req.Page,
Limit: req.Limit,
Search: req.Search,
SortBy: req.SortBy,
SortOrder: req.SortOrder,
}
}
func CustomerPointsModelToResponse(model *models.CustomerPointsResponse) *contract.CustomerPointsResponse {
if model == nil {
return nil
}
var customer *contract.CustomerResponse
if model.Customer != nil {
customer = CustomerModelToResponse(model.Customer)
}
return &contract.CustomerPointsResponse{
ID: model.ID,
CustomerID: model.CustomerID,
Balance: model.Balance,
Customer: customer,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
}
}
func PaginatedCustomerPointsResponseToContract(model *models.PaginatedCustomerPointsResponse) *contract.PaginatedCustomerPointsResponse {
if model == nil {
return nil
}
responses := make([]contract.CustomerPointsResponse, len(model.Data))
for i, item := range model.Data {
responses[i] = *CustomerPointsModelToResponse(&item)
}
return &contract.PaginatedCustomerPointsResponse{
Data: responses,
TotalCount: model.TotalCount,
Page: model.Page,
Limit: model.Limit,
TotalPages: model.TotalPages,
}
}
// Customer Tokens Transformers
func CreateCustomerTokensRequestToModel(req *contract.CreateCustomerTokensRequest) *models.CreateCustomerTokensRequest {
return &models.CreateCustomerTokensRequest{
CustomerID: req.CustomerID,
TokenType: req.TokenType,
Balance: req.Balance,
}
}
func UpdateCustomerTokensRequestToModel(req *contract.UpdateCustomerTokensRequest) *models.UpdateCustomerTokensRequest {
return &models.UpdateCustomerTokensRequest{
Balance: req.Balance,
}
}
func ListCustomerTokensRequestToModel(req *contract.ListCustomerTokensRequest) *models.ListCustomerTokensQuery {
return &models.ListCustomerTokensQuery{
Page: req.Page,
Limit: req.Limit,
Search: req.Search,
TokenType: req.TokenType,
SortBy: req.SortBy,
SortOrder: req.SortOrder,
}
}
func CustomerTokensModelToResponse(model *models.CustomerTokensResponse) *contract.CustomerTokensResponse {
if model == nil {
return nil
}
var customer *contract.CustomerResponse
if model.Customer != nil {
customer = CustomerModelToResponse(model.Customer)
}
return &contract.CustomerTokensResponse{
ID: model.ID,
CustomerID: model.CustomerID,
TokenType: model.TokenType,
Balance: model.Balance,
Customer: customer,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
}
}
func PaginatedCustomerTokensResponseToContract(model *models.PaginatedResponse[models.CustomerTokensResponse]) *contract.PaginatedCustomerTokensResponse {
responses := make([]contract.CustomerTokensResponse, len(model.Data))
for i, item := range model.Data {
responses[i] = *CustomerTokensModelToResponse(&item)
}
return &contract.PaginatedCustomerTokensResponse{
Data: responses,
TotalCount: int(model.Pagination.Total),
Page: model.Pagination.Page,
Limit: model.Pagination.Limit,
TotalPages: model.Pagination.TotalPages,
}
}
// Tier Transformers
func CreateTierRequestToModel(req *contract.CreateTierRequest) *models.CreateTierRequest {
return &models.CreateTierRequest{
@@ -9,20 +9,6 @@ import (
)
type GamificationValidator interface {
// Customer Points
ValidateCreateCustomerPointsRequest(req *contract.CreateCustomerPointsRequest) (error, string)
ValidateUpdateCustomerPointsRequest(req *contract.UpdateCustomerPointsRequest) (error, string)
ValidateListCustomerPointsRequest(req *contract.ListCustomerPointsRequest) (error, string)
ValidateAddCustomerPointsRequest(req *contract.AddCustomerPointsRequest) (error, string)
ValidateDeductCustomerPointsRequest(req *contract.DeductCustomerPointsRequest) (error, string)
// Customer Tokens
ValidateCreateCustomerTokensRequest(req *contract.CreateCustomerTokensRequest) (error, string)
ValidateUpdateCustomerTokensRequest(req *contract.UpdateCustomerTokensRequest) (error, string)
ValidateListCustomerTokensRequest(req *contract.ListCustomerTokensRequest) (error, string)
ValidateAddCustomerTokensRequest(req *contract.AddCustomerTokensRequest) (error, string)
ValidateDeductCustomerTokensRequest(req *contract.DeductCustomerTokensRequest) (error, string)
// Tiers
ValidateCreateTierRequest(req *contract.CreateTierRequest) (error, string)
ValidateUpdateTierRequest(req *contract.UpdateTierRequest) (error, string)
@@ -60,152 +46,6 @@ func NewGamificationValidator() *GamificationValidatorImpl {
}
}
// Customer Points Validators
func (v *GamificationValidatorImpl) ValidateCreateCustomerPointsRequest(req *contract.CreateCustomerPointsRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Balance < 0 {
return errors.New("balance cannot be negative"), "INVALID_BALANCE"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateUpdateCustomerPointsRequest(req *contract.UpdateCustomerPointsRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Balance < 0 {
return errors.New("balance cannot be negative"), "INVALID_BALANCE"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateListCustomerPointsRequest(req *contract.ListCustomerPointsRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Page <= 0 {
req.Page = 1
}
if req.Limit <= 0 {
req.Limit = 10
}
if req.Limit > 100 {
req.Limit = 100
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateAddCustomerPointsRequest(req *contract.AddCustomerPointsRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Points <= 0 {
return errors.New("points must be greater than 0"), "INVALID_POINTS"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateDeductCustomerPointsRequest(req *contract.DeductCustomerPointsRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Points <= 0 {
return errors.New("points must be greater than 0"), "INVALID_POINTS"
}
return nil, ""
}
// Customer Tokens Validators
func (v *GamificationValidatorImpl) ValidateCreateCustomerTokensRequest(req *contract.CreateCustomerTokensRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Balance < 0 {
return errors.New("balance cannot be negative"), "INVALID_BALANCE"
}
validTokenTypes := []string{"SPIN", "RAFFLE", "MINIGAME"}
if !contains(validTokenTypes, req.TokenType) {
return errors.New("invalid token type"), "INVALID_TOKEN_TYPE"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateUpdateCustomerTokensRequest(req *contract.UpdateCustomerTokensRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Balance < 0 {
return errors.New("balance cannot be negative"), "INVALID_BALANCE"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateListCustomerTokensRequest(req *contract.ListCustomerTokensRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Page <= 0 {
req.Page = 1
}
if req.Limit <= 0 {
req.Limit = 10
}
if req.Limit > 100 {
req.Limit = 100
}
if req.TokenType != "" {
validTokenTypes := []string{"SPIN", "RAFFLE", "MINIGAME"}
if !contains(validTokenTypes, req.TokenType) {
return errors.New("invalid token type"), "INVALID_TOKEN_TYPE"
}
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateAddCustomerTokensRequest(req *contract.AddCustomerTokensRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Tokens <= 0 {
return errors.New("tokens must be greater than 0"), "INVALID_TOKENS"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateDeductCustomerTokensRequest(req *contract.DeductCustomerTokensRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Tokens <= 0 {
return errors.New("tokens must be greater than 0"), "INVALID_TOKENS"
}
return nil, ""
}
// Tier Validators
func (v *GamificationValidatorImpl) ValidateCreateTierRequest(req *contract.CreateTierRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
@@ -0,0 +1 @@
DROP TABLE IF EXISTS wallet_expiry_reminders;
@@ -0,0 +1,11 @@
-- Which expiry reminders have gone out (docs/prd-point-coin.md F12): one per
-- customer, currency and expiry day. The row is written before the push is sent, so
-- several instances of the job, or a restart, never remind twice.
CREATE TABLE wallet_expiry_reminders (
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
currency VARCHAR(10) NOT NULL CHECK (currency IN ('POINT','COIN')),
expiry_date DATE NOT NULL,
amount BIGINT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
PRIMARY KEY (customer_id, currency, expiry_date)
);