feat: add percentage loyalti setting mode
This commit is contained in:
@@ -323,13 +323,13 @@ Pada kedua `PUT` setting, field yang tidak dikirim tetap memakai nilai sekarang;
|
||||
|
||||
```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": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
|
||||
}
|
||||
```
|
||||
|
||||
Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (default di atas = 1%), dan `changes` pada PUT. Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `max_payment_percent` 0–100.
|
||||
Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (default di atas = 1%), dan `changes` pada PUT. `earn_mode` adalah `PER_AMOUNT` (setiap `earn_per_amount` rupiah mendapat `earn_value`) atau `PERCENTAGE` (`earn_percent` persen dari basis). Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `earn_percent` 0–100 dengan maks. 2 angka desimal, `max_payment_percent` 0–100.
|
||||
|
||||
### /marketing/loyalty-settings
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari ord
|
||||
|
||||
```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": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
|
||||
}
|
||||
```
|
||||
@@ -41,15 +41,19 @@ Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari ord
|
||||
| Field | Label usulan | Tipe | Default | Validasi |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `point.enabled` / `coin.enabled` | Beri EnakPoint / EnakCoin | toggle | mati | – |
|
||||
| `earn_per_amount` | Setiap belanja Rp … | Rp | 100 (point), 25.000 (coin) | > 0 |
|
||||
| `earn_value` | … mendapat | angka | 1 | ≥ 0 |
|
||||
| `earn_mode` | Cara hitung: per nominal / persentase | pilihan `PER_AMOUNT` / `PERCENTAGE` | `PER_AMOUNT` | salah satu dari keduanya |
|
||||
| `earn_per_amount` | Setiap belanja Rp … (mode `PER_AMOUNT`) | Rp | 100 (point), 25.000 (coin) | > 0 |
|
||||
| `earn_value` | … mendapat (mode `PER_AMOUNT`) | angka | 1 | ≥ 0 |
|
||||
| `earn_percent` | … % dari belanja (mode `PERCENTAGE`) | %, boleh desimal | 1 | 0–100, maks. 2 angka desimal |
|
||||
| `min_order_amount` | Minimal belanja | Rp | 0 | ≥ 0 |
|
||||
| `max_per_order` | Maksimal per order | angka, boleh kosong | kosong = tanpa batas | ≥ 0 |
|
||||
| `point_payment.accept_payment` | Terima pembayaran EnakPoint | toggle | mati | – |
|
||||
| `min_payment_points` | Minimal EnakPoint per pembayaran | angka | 1 | ≥ 1 |
|
||||
| `max_payment_percent` | Maksimal porsi order dibayar EnakPoint | % | 100 | 0–100 |
|
||||
|
||||
**Cashback efektif.** Response membawa `point_cashback_percent` dan `point_value`. Tampilkan persentase di samping field earning EnakPoint, mis. "setara cashback 1%", dan hitung ulang di sisi klien saat owner mengetik: `earn_value × point_value ÷ earn_per_amount × 100`. Tujuannya agar owner tidak salah membaca skala (1 per Rp 100 bukan 1 per Rp 1).
|
||||
**Cashback efektif.** Response membawa `point_cashback_percent` dan `point_value`. Tampilkan persentase di samping field earning EnakPoint, mis. "setara cashback 1%", dan hitung ulang di sisi klien saat owner mengetik: `earn_value × point_value ÷ earn_per_amount × 100`, atau pada mode `PERCENTAGE`: `earn_percent × point_value`. Tujuannya agar owner tidak salah membaca skala (1 per Rp 100 bukan 1 per Rp 1).
|
||||
|
||||
**Mode earning.** Tampilkan hanya field mode yang dipilih (`earn_per_amount` + `earn_value`, atau `earn_percent`). Field mode lain tetap tersimpan di server, jadi tidak perlu dikosongkan saat owner berpindah mode. Pada mode `PERCENTAGE` jumlah yang didapat adalah `floor(basis × earn_percent ÷ 100)`, mis. 2,5% dari Rp 87.500 = 2.187 EnakPoint.
|
||||
|
||||
**Contoh di bawah form.** "Belanja Rp 87.500 mendapat 875 EnakPoint dan 3 EnakCoin." Earning dihitung dari subtotal setelah diskon, sebelum pajak, dan bagian yang dibayar EnakPoint tidak ikut dihitung.
|
||||
|
||||
|
||||
@@ -516,15 +516,16 @@ Semua endpoint di bagian ini butuh login user dengan role Admin atau Manager.
|
||||
|
||||
```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": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
(`earn_value × point_value / earn_per_amount × 100`, atau `earn_percent × point_value`
|
||||
pada `earn_mode` `PERCENTAGE`). **Tampilkan persentase ini di
|
||||
samping setting** supaya owner tidak salah membaca skala: default di atas setara
|
||||
cashback 1%.
|
||||
|
||||
|
||||
+19
-5
@@ -197,20 +197,28 @@ Disimpan di `outlet_settings` (key–value, sudah ada).
|
||||
| Key | Tipe | Default | Arti |
|
||||
|---|---|---|---|
|
||||
| `loyalty.point.enabled` | bool | `false` | Outlet memberi EnakPoint |
|
||||
| `loyalty.point.earn_per_amount` | int (Rp) | `100` | Setiap kelipatan nominal ini… |
|
||||
| `loyalty.point.earn_mode` | `PER_AMOUNT` / `PERCENTAGE` | `PER_AMOUNT` | Cara menghitung earning |
|
||||
| `loyalty.point.earn_per_amount` | int (Rp) | `100` | `PER_AMOUNT`: setiap kelipatan nominal ini… |
|
||||
| `loyalty.point.earn_value` | int | `1` | …mendapat sekian EnakPoint |
|
||||
| `loyalty.point.earn_percent` | desimal (0–100, maks. 2 angka desimal) | `1` | `PERCENTAGE`: sekian persen dari basis menjadi EnakPoint |
|
||||
| `loyalty.point.min_order_amount` | int (Rp) | `0` | Basis minimal agar dapat EnakPoint |
|
||||
| `loyalty.point.max_per_order` | int, nullable | kosong | Batas atas EnakPoint per order |
|
||||
| `loyalty.coin.enabled` | bool | `false` | Outlet memberi EnakCoin |
|
||||
| `loyalty.coin.earn_mode` | `PER_AMOUNT` / `PERCENTAGE` | `PER_AMOUNT` | |
|
||||
| `loyalty.coin.earn_per_amount` | int (Rp) | `25000` | |
|
||||
| `loyalty.coin.earn_value` | int | `1` | |
|
||||
| `loyalty.coin.earn_percent` | desimal (0–100, maks. 2 angka desimal) | `1` | |
|
||||
| `loyalty.coin.min_order_amount` | int (Rp) | `0` | |
|
||||
| `loyalty.coin.max_per_order` | int, nullable | kosong | |
|
||||
|
||||
Dengan nilai EnakPoint default Rp 1, default earning 1 EnakPoint per Rp 100 setara
|
||||
**cashback 1%**. Dashboard selalu menampilkan persentase cashback efektif di samping
|
||||
setting ini: `earn_value × nilai EnakPoint / earn_per_amount`. Tujuannya supaya owner
|
||||
tidak salah mengira skala.
|
||||
setting ini: `earn_value × nilai EnakPoint / earn_per_amount`, atau pada mode
|
||||
`PERCENTAGE`: `earn_percent × nilai EnakPoint`. Tujuannya supaya owner tidak salah
|
||||
mengira skala.
|
||||
|
||||
Setting mode yang sedang tidak dipakai tetap tersimpan, sehingga berpindah mode tidak
|
||||
menghapus nilai mode sebelumnya.
|
||||
|
||||
**Pembayaran EnakPoint.** Hanya ada untuk EnakPoint, tidak ada padanannya untuk
|
||||
EnakCoin.
|
||||
@@ -226,7 +234,8 @@ EnakCoin.
|
||||
```
|
||||
basis = subtotal − discount_amount − dibayar_dengan_enakpoint
|
||||
jumlah = 0 jika basis < min_order_amount
|
||||
jumlah = floor(basis / earn_per_amount) × earn_value
|
||||
jumlah = floor(basis / earn_per_amount) × earn_value mode PER_AMOUNT
|
||||
jumlah = floor(basis × earn_percent / 100) mode PERCENTAGE
|
||||
jumlah = min(jumlah, max_per_order) jika max_per_order diisi
|
||||
```
|
||||
|
||||
@@ -240,7 +249,12 @@ jumlah = min(jumlah, max_per_order) jika max_per_order diisi
|
||||
EnakPoint** dan **3 EnakCoin**. Jika Rp 20.000 dari order itu dibayar dengan EnakPoint,
|
||||
basisnya menjadi Rp 67.500, sehingga customer mendapat 675 EnakPoint dan 2 EnakCoin.
|
||||
|
||||
Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `min_order_amount ≥ 0`,
|
||||
Pada mode `PERCENTAGE`, `earn_percent` adalah persen dari basis yang menjadi
|
||||
**jumlah** EnakPoint/EnakCoin (bukan nilai rupiahnya): 2,5% dari basis Rp 87.500
|
||||
menghasilkan 2.187 EnakPoint.
|
||||
|
||||
Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `0 ≤ earn_percent ≤ 100` dengan
|
||||
paling banyak dua angka desimal, `min_order_amount ≥ 0`,
|
||||
`max_per_order ≥ 0`, `0 ≤ max_payment_percent ≤ 100`. Hanya role Admin/Manager yang
|
||||
bisa mengubah.
|
||||
|
||||
|
||||
@@ -7,12 +7,16 @@ package constants
|
||||
// Per outlet (F1): what an order earns, and whether EnakPoint can pay.
|
||||
const (
|
||||
LoyaltyPointEnabledKey = "loyalty.point.enabled"
|
||||
LoyaltyPointEarnModeKey = "loyalty.point.earn_mode"
|
||||
LoyaltyPointEarnPercentKey = "loyalty.point.earn_percent"
|
||||
LoyaltyPointEarnPerAmountKey = "loyalty.point.earn_per_amount"
|
||||
LoyaltyPointEarnValueKey = "loyalty.point.earn_value"
|
||||
LoyaltyPointMinOrderAmountKey = "loyalty.point.min_order_amount"
|
||||
LoyaltyPointMaxPerOrderKey = "loyalty.point.max_per_order"
|
||||
|
||||
LoyaltyCoinEnabledKey = "loyalty.coin.enabled"
|
||||
LoyaltyCoinEarnModeKey = "loyalty.coin.earn_mode"
|
||||
LoyaltyCoinEarnPercentKey = "loyalty.coin.earn_percent"
|
||||
LoyaltyCoinEarnPerAmountKey = "loyalty.coin.earn_per_amount"
|
||||
LoyaltyCoinEarnValueKey = "loyalty.coin.earn_value"
|
||||
LoyaltyCoinMinOrderAmountKey = "loyalty.coin.min_order_amount"
|
||||
@@ -47,6 +51,14 @@ const (
|
||||
LoyaltyExpiryGraceMonthsSuffix = "expiry_grace_months"
|
||||
)
|
||||
|
||||
// Modes of loyalty.{point,coin}.earn_mode.
|
||||
const (
|
||||
// earn_value for every earn_per_amount rupiah of the basis.
|
||||
LoyaltyEarnModePerAmount = "PER_AMOUNT"
|
||||
// earn_percent percent of the basis.
|
||||
LoyaltyEarnModePercentage = "PERCENTAGE"
|
||||
)
|
||||
|
||||
// Units of loyalty.{point,coin}.expiry_unit.
|
||||
const (
|
||||
LoyaltyExpiryUnitDay = "DAY"
|
||||
@@ -66,6 +78,8 @@ const (
|
||||
LoyaltyPointEarnPerAmountDefault = int64(100)
|
||||
LoyaltyCoinEarnPerAmountDefault = int64(25000)
|
||||
LoyaltyEarnValueDefault = int64(1)
|
||||
LoyaltyEarnModeDefault = LoyaltyEarnModePerAmount
|
||||
LoyaltyEarnPercentDefault = float64(1)
|
||||
|
||||
LoyaltyMinPaymentPointsDefault = int64(1)
|
||||
LoyaltyMaxPaymentPercentDefault = int64(100)
|
||||
|
||||
@@ -91,8 +91,8 @@ func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
|
||||
status, body := call(http.MethodGet, "/manager"+path, "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
got := data(body)
|
||||
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(100), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"])
|
||||
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(25000), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"])
|
||||
assert.Equal(t, map[string]any{"enabled": false, "earn_mode": "PER_AMOUNT", "earn_per_amount": float64(100), "earn_value": float64(1), "earn_percent": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"])
|
||||
assert.Equal(t, map[string]any{"enabled": false, "earn_mode": "PER_AMOUNT", "earn_per_amount": float64(25000), "earn_value": float64(1), "earn_percent": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"])
|
||||
assert.Equal(t, map[string]any{"accept_payment": false, "min_payment_points": float64(1), "max_payment_percent": float64(100)}, got["point_payment"])
|
||||
assert.EqualValues(t, 1, got["point_value"])
|
||||
assert.EqualValues(t, 1, got["point_cashback_percent"])
|
||||
@@ -135,6 +135,8 @@ func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
|
||||
for name, bad := range map[string]string{
|
||||
"earn_per_amount 0": `{"point": {"earn_per_amount": 0}}`,
|
||||
"negative earn_value": `{"coin": {"earn_value": -1}}`,
|
||||
"earn mode unknown": `{"point": {"earn_mode": "PERCENT"}}`,
|
||||
"earn_percent over 100": `{"coin": {"earn_percent": 101}}`,
|
||||
"negative min_order": `{"point": {"min_order_amount": -5}}`,
|
||||
"negative max_per_order": `{"point": {"max_per_order": -1}}`,
|
||||
"payment percent over 100": `{"point_payment": {"max_payment_percent": 101}}`,
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
)
|
||||
|
||||
// OutletLoyaltySettings are an outlet's loyalty settings (docs/prd-point-coin.md F1).
|
||||
@@ -15,13 +17,21 @@ type OutletLoyaltySettings struct {
|
||||
PointPayment LoyaltyPointPaymentSettings `json:"point_payment"`
|
||||
}
|
||||
|
||||
// LoyaltyEarnSettings is how much of one currency an order earns:
|
||||
// floor(basis / EarnPerAmount) × EarnValue, nothing below MinOrderAmount, and at most
|
||||
// MaxPerOrder when set.
|
||||
// LoyaltyEarnSettings is how much of one currency an order earns, nothing below
|
||||
// MinOrderAmount, and at most MaxPerOrder when set:
|
||||
//
|
||||
// - PER_AMOUNT: floor(basis / EarnPerAmount) × EarnValue;
|
||||
// - PERCENTAGE: floor(basis × EarnPercent / 100).
|
||||
//
|
||||
// The settings of the mode not in use are kept, so switching back restores them.
|
||||
type LoyaltyEarnSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// PER_AMOUNT or PERCENTAGE.
|
||||
EarnMode string `json:"earn_mode"`
|
||||
EarnPerAmount int64 `json:"earn_per_amount"`
|
||||
EarnValue int64 `json:"earn_value"`
|
||||
// PERCENTAGE: percent of the basis earned, 0–100 with at most two decimals.
|
||||
EarnPercent float64 `json:"earn_percent"`
|
||||
MinOrderAmount int64 `json:"min_order_amount"`
|
||||
MaxPerOrder *int64 `json:"max_per_order"`
|
||||
}
|
||||
@@ -102,8 +112,8 @@ type OutletLoyaltySettingsView struct {
|
||||
// The organization's rupiah value of one EnakPoint, which the cashback depends on.
|
||||
PointValue int64 `json:"point_value"`
|
||||
// Effective EnakPoint cashback in percent: earn_value × point_value /
|
||||
// earn_per_amount × 100. Shown next to the setting so an owner cannot misread the
|
||||
// scale (F1).
|
||||
// earn_per_amount × 100, or earn_percent × point_value in PERCENTAGE mode. Shown
|
||||
// next to the setting so an owner cannot misread the scale (F1).
|
||||
PointCashbackPercent float64 `json:"point_cashback_percent"`
|
||||
// Set on PUT: the keys that changed.
|
||||
Changes []LoyaltySettingChange `json:"changes,omitempty"`
|
||||
@@ -168,6 +178,15 @@ type LoyaltySettingsImpact struct {
|
||||
CoinRupiahAfter int64 `json:"coin_rupiah_after"`
|
||||
}
|
||||
|
||||
// CashbackPercent is the rupiah value of what an order earns as a percentage of its
|
||||
// basis, in the mode the settings are in, rounded to two decimals.
|
||||
func (s LoyaltyEarnSettings) CashbackPercent(pointValue int64) float64 {
|
||||
if s.EarnMode == constants.LoyaltyEarnModePercentage {
|
||||
return math.Round(s.EarnPercent*float64(pointValue)*100) / 100
|
||||
}
|
||||
return LoyaltyCashbackPercent(s.EarnValue, pointValue, s.EarnPerAmount)
|
||||
}
|
||||
|
||||
// NewLoyaltySettingsImpact computes the impact of moving from one organization setting
|
||||
// to another on the balances in circulation.
|
||||
func NewLoyaltySettingsImpact(points, coins int64, before, after OrganizationLoyaltySettings) LoyaltySettingsImpact {
|
||||
|
||||
@@ -14,6 +14,14 @@ func TestLoyaltyCashbackPercent(t *testing.T) {
|
||||
assert.Equal(t, 0.0, LoyaltyCashbackPercent(1, 1, 0))
|
||||
}
|
||||
|
||||
func TestLoyaltyEarnSettings_CashbackPercent(t *testing.T) {
|
||||
s := LoyaltyEarnSettings{EarnMode: "PER_AMOUNT", EarnPerAmount: 100, EarnValue: 1, EarnPercent: 2.5}
|
||||
assert.Equal(t, 1.0, s.CashbackPercent(1))
|
||||
s.EarnMode = "PERCENTAGE"
|
||||
assert.Equal(t, 2.5, s.CashbackPercent(1))
|
||||
assert.Equal(t, 250.0, s.CashbackPercent(100), "each point earned is worth point_value rupiah")
|
||||
}
|
||||
|
||||
func TestNewLoyaltySettingsImpact(t *testing.T) {
|
||||
before := OrganizationLoyaltySettings{PointValue: 1, Exchange: LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1}}
|
||||
after := OrganizationLoyaltySettings{PointValue: 100, Exchange: LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1}}
|
||||
|
||||
@@ -3,6 +3,7 @@ package processor
|
||||
import (
|
||||
"math"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
@@ -36,6 +37,10 @@ func (r EarningResult) Metadata(line EarningLine) entities.Metadata {
|
||||
"min_order_amount": line.Settings.MinOrderAmount,
|
||||
"capped": line.Capped,
|
||||
}
|
||||
if line.Settings.EarnMode == constants.LoyaltyEarnModePercentage {
|
||||
m["earn_mode"] = line.Settings.EarnMode
|
||||
m["earn_percent"] = line.Settings.EarnPercent
|
||||
}
|
||||
if line.Settings.MaxPerOrder != nil {
|
||||
m["max_per_order"] = *line.Settings.MaxPerOrder
|
||||
}
|
||||
@@ -46,7 +51,8 @@ func (r EarningResult) Metadata(line EarningLine) entities.Metadata {
|
||||
//
|
||||
// basis = subtotal − discount_amount − paid with EnakPoint
|
||||
// amount = 0 if basis < min_order_amount
|
||||
// amount = floor(basis / earn_per_amount) × earn_value
|
||||
// amount = floor(basis / earn_per_amount) × earn_value in PER_AMOUNT mode
|
||||
// amount = floor(basis × earn_percent / 100) in PERCENTAGE mode
|
||||
// amount = min(amount, max_per_order) if max_per_order is set
|
||||
//
|
||||
// The part paid with EnakPoint earns nothing (Q10). Money is handled in whole cents so
|
||||
@@ -66,13 +72,23 @@ func CalculateEarning(order *entities.Order, pointPaidAmount float64, settings m
|
||||
|
||||
func earnLine(basisCents int64, s models.LoyaltyEarnSettings) EarningLine {
|
||||
line := EarningLine{Settings: s}
|
||||
if !s.Enabled || s.EarnPerAmount <= 0 || s.EarnValue <= 0 {
|
||||
if !s.Enabled || basisCents < s.MinOrderAmount*100 {
|
||||
return line
|
||||
}
|
||||
if basisCents < s.MinOrderAmount*100 {
|
||||
if s.EarnMode == constants.LoyaltyEarnModePercentage {
|
||||
// The percent has at most two decimals, so in hundredths it is whole and the
|
||||
// product stays in integers: cents × hundredths / (100 × 100 × 100).
|
||||
hundredths := int64(math.Round(s.EarnPercent * 100))
|
||||
if hundredths <= 0 {
|
||||
return line
|
||||
}
|
||||
line.Amount = basisCents * hundredths / 1_000_000
|
||||
} else {
|
||||
if s.EarnPerAmount <= 0 || s.EarnValue <= 0 {
|
||||
return line
|
||||
}
|
||||
line.Amount = basisCents / (s.EarnPerAmount * 100) * s.EarnValue
|
||||
}
|
||||
if s.MaxPerOrder != nil && line.Amount > *s.MaxPerOrder {
|
||||
line.Amount = *s.MaxPerOrder
|
||||
line.Capped = true
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
@@ -113,6 +114,44 @@ func TestCalculateEarning_EdgeCases(t *testing.T) {
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_Percentage(t *testing.T) {
|
||||
s := prdEarningSettings()
|
||||
s.Point.EarnMode = constants.LoyaltyEarnModePercentage
|
||||
s.Point.EarnPercent = 1
|
||||
|
||||
// 1% of the basis; the per-amount settings are ignored, and coin keeps its own mode.
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 97500, DiscountAmount: 10000}, 0, s)
|
||||
assert.Equal(t, int64(875), got.Point.Amount)
|
||||
assert.Equal(t, int64(3), got.Coin.Amount)
|
||||
// The part paid with EnakPoint earns nothing in this mode either.
|
||||
assert.Equal(t, int64(675), CalculateEarning(&entities.Order{Subtotal: 97500, DiscountAmount: 10000}, 20000, s).Point.Amount)
|
||||
|
||||
// Decimals, and floor, not round: 2.5% of 87.500 is 2187.5.
|
||||
s.Point.EarnPercent = 2.5
|
||||
assert.Equal(t, int64(2187), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||
s.Point.EarnPercent = 0.01
|
||||
assert.Equal(t, int64(8), CalculateEarning(&entities.Order{Subtotal: 87599.99}, 0, s).Point.Amount)
|
||||
|
||||
// The minimum and the cap apply as in the other mode.
|
||||
s.Point.EarnPercent = 10
|
||||
s.Point.MinOrderAmount = 50000
|
||||
max := int64(6000)
|
||||
s.Point.MaxPerOrder = &max
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Point.Amount)
|
||||
assert.Equal(t, int64(5000), CalculateEarning(&entities.Order{Subtotal: 50000}, 0, s).Point.Amount)
|
||||
got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
|
||||
assert.Equal(t, int64(6000), got.Point.Amount)
|
||||
assert.True(t, got.Point.Capped)
|
||||
|
||||
// The mode and percent are frozen on the ledger row.
|
||||
assert.Equal(t, constants.LoyaltyEarnModePercentage, got.Metadata(got.Point)["earn_mode"])
|
||||
assert.Equal(t, 10.0, got.Metadata(got.Point)["earn_percent"])
|
||||
|
||||
// A zero percent earns nothing even when enabled.
|
||||
s.Point.EarnPercent = 0
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_MetadataSnapshot(t *testing.T) {
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, prdEarningSettings())
|
||||
assert.Equal(t, entities.Metadata{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -240,14 +241,20 @@ func loyaltySettingChangeModel(row entities.LoyaltySettingChange) models.Loyalty
|
||||
func outletLoyaltyFields(s *models.OutletLoyaltySettings) []loyaltyField {
|
||||
return []loyaltyField{
|
||||
boolLoyaltyField(constants.LoyaltyPointEnabledKey, &s.Point.Enabled, false),
|
||||
enumLoyaltyField(constants.LoyaltyPointEarnModeKey, &s.Point.EarnMode, constants.LoyaltyEarnModeDefault,
|
||||
constants.LoyaltyEarnModePerAmount, constants.LoyaltyEarnModePercentage),
|
||||
intLoyaltyField(constants.LoyaltyPointEarnPerAmountKey, &s.Point.EarnPerAmount, constants.LoyaltyPointEarnPerAmountDefault, 1, noLoyaltyMax),
|
||||
intLoyaltyField(constants.LoyaltyPointEarnValueKey, &s.Point.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax),
|
||||
percentLoyaltyField(constants.LoyaltyPointEarnPercentKey, &s.Point.EarnPercent, constants.LoyaltyEarnPercentDefault),
|
||||
intLoyaltyField(constants.LoyaltyPointMinOrderAmountKey, &s.Point.MinOrderAmount, 0, 0, noLoyaltyMax),
|
||||
optionalIntLoyaltyField(constants.LoyaltyPointMaxPerOrderKey, &s.Point.MaxPerOrder, 0),
|
||||
|
||||
boolLoyaltyField(constants.LoyaltyCoinEnabledKey, &s.Coin.Enabled, false),
|
||||
enumLoyaltyField(constants.LoyaltyCoinEarnModeKey, &s.Coin.EarnMode, constants.LoyaltyEarnModeDefault,
|
||||
constants.LoyaltyEarnModePerAmount, constants.LoyaltyEarnModePercentage),
|
||||
intLoyaltyField(constants.LoyaltyCoinEarnPerAmountKey, &s.Coin.EarnPerAmount, constants.LoyaltyCoinEarnPerAmountDefault, 1, noLoyaltyMax),
|
||||
intLoyaltyField(constants.LoyaltyCoinEarnValueKey, &s.Coin.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax),
|
||||
percentLoyaltyField(constants.LoyaltyCoinEarnPercentKey, &s.Coin.EarnPercent, constants.LoyaltyEarnPercentDefault),
|
||||
intLoyaltyField(constants.LoyaltyCoinMinOrderAmountKey, &s.Coin.MinOrderAmount, 0, 0, noLoyaltyMax),
|
||||
optionalIntLoyaltyField(constants.LoyaltyCoinMaxPerOrderKey, &s.Coin.MaxPerOrder, 0),
|
||||
|
||||
@@ -329,6 +336,35 @@ func intLoyaltyField(key string, v *int64, def, min, max int64) loyaltyField {
|
||||
}
|
||||
}
|
||||
|
||||
// percentLoyaltyField is a percentage from 0 to 100 with at most two decimals, so it
|
||||
// is whole in hundredths and calculations with it stay in integers.
|
||||
func percentLoyaltyField(key string, v *float64, def float64) loyaltyField {
|
||||
check := func(n float64) error {
|
||||
// The negated form also rejects NaN.
|
||||
if !(n >= 0 && n <= 100) {
|
||||
return fmt.Errorf("%w: %s must be between 0 and 100", ErrInvalidLoyaltySettings, key)
|
||||
}
|
||||
if math.Abs(n*100-math.Round(n*100)) > 1e-6 {
|
||||
return fmt.Errorf("%w: %s must have at most two decimals", ErrInvalidLoyaltySettings, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return loyaltyField{
|
||||
key: key,
|
||||
parse: func(raw string) bool {
|
||||
n, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||||
if err != nil || check(n) != nil {
|
||||
return false
|
||||
}
|
||||
*v = n
|
||||
return true
|
||||
},
|
||||
reset: func() { *v = def },
|
||||
validate: func() error { return check(*v) },
|
||||
format: func() *string { s := strconv.FormatFloat(*v, 'f', -1, 64); return &s },
|
||||
}
|
||||
}
|
||||
|
||||
// optionalIntLoyaltyField is a limit that may be unset, meaning no limit.
|
||||
func optionalIntLoyaltyField(key string, v **int64, min int64) loyaltyField {
|
||||
return loyaltyField{
|
||||
|
||||
@@ -107,8 +107,8 @@ func TestLoyaltySettings_OutletWithoutSettingsGetsEveryDefault(t *testing.T) {
|
||||
s, err := p.Outlet(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, models.OutletLoyaltySettings{
|
||||
Point: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 100, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||
Coin: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 25000, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||
Point: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 100, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||
Coin: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 25000, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||
PointPayment: models.LoyaltyPointPaymentSettings{AcceptPayment: false, MinPaymentPoints: 1, MaxPaymentPercent: 100},
|
||||
}, *s)
|
||||
}
|
||||
@@ -290,6 +290,10 @@ func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
|
||||
for name, mutate := range map[string]func(*models.OutletLoyaltySettings){
|
||||
"earn_per_amount 0": func(s *models.OutletLoyaltySettings) { s.Point.EarnPerAmount = 0 },
|
||||
"negative earn_value": func(s *models.OutletLoyaltySettings) { s.Coin.EarnValue = -1 },
|
||||
"earn mode unknown": func(s *models.OutletLoyaltySettings) { s.Point.EarnMode = "PERCENT" },
|
||||
"negative earn_percent": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = -1 },
|
||||
"earn_percent over 100": func(s *models.OutletLoyaltySettings) { s.Coin.EarnPercent = 100.5 },
|
||||
"earn_percent 3 decimals": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = 1.125 },
|
||||
"negative min_order": func(s *models.OutletLoyaltySettings) { s.Point.MinOrderAmount = -1 },
|
||||
"negative max_per_order": func(s *models.OutletLoyaltySettings) { s.Coin.MaxPerOrder = ptr(int64(-1)) },
|
||||
"payment percent over 100": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = 101 },
|
||||
|
||||
@@ -111,7 +111,7 @@ func (s *LoyaltySettingsServiceImpl) outletView(ctx context.Context, organizatio
|
||||
OutletID: outletID,
|
||||
OutletLoyaltySettings: settings,
|
||||
PointValue: pointValue,
|
||||
PointCashbackPercent: models.LoyaltyCashbackPercent(settings.Point.EarnValue, pointValue, settings.Point.EarnPerAmount),
|
||||
PointCashbackPercent: settings.Point.CashbackPercent(pointValue),
|
||||
Changes: changes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user