diff --git a/docs/api-enakpoint.md b/docs/api-enakpoint.md index 0f168b1..6ccf437 100644 --- a/docs/api-enakpoint.md +++ b/docs/api-enakpoint.md @@ -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 diff --git a/docs/backoffice-enakpoint.md b/docs/backoffice-enakpoint.md index 599eb34..82a992a 100644 --- a/docs/backoffice-enakpoint.md +++ b/docs/backoffice-enakpoint.md @@ -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. diff --git a/docs/integration-enakpoint.md b/docs/integration-enakpoint.md index 58b459a..f7afdf0 100644 --- a/docs/integration-enakpoint.md +++ b/docs/integration-enakpoint.md @@ -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%. diff --git a/docs/prd-point-coin.md b/docs/prd-point-coin.md index 5af5fbd..5e07b65 100644 --- a/docs/prd-point-coin.md +++ b/docs/prd-point-coin.md @@ -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. diff --git a/internal/constants/loyalty.go b/internal/constants/loyalty.go index 6721edf..3c1283f 100644 --- a/internal/constants/loyalty.go +++ b/internal/constants/loyalty.go @@ -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) diff --git a/internal/handler/loyalty_settings_db_test.go b/internal/handler/loyalty_settings_db_test.go index c427002..57d6804 100644 --- a/internal/handler/loyalty_settings_db_test.go +++ b/internal/handler/loyalty_settings_db_test.go @@ -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}}`, diff --git a/internal/models/loyalty.go b/internal/models/loyalty.go index 3a0f8b1..5733bdb 100644 --- a/internal/models/loyalty.go +++ b/internal/models/loyalty.go @@ -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,15 +17,23 @@ 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"` - EarnPerAmount int64 `json:"earn_per_amount"` - EarnValue int64 `json:"earn_value"` - MinOrderAmount int64 `json:"min_order_amount"` - MaxPerOrder *int64 `json:"max_per_order"` + 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"` } type LoyaltyPointPaymentSettings struct { @@ -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 { diff --git a/internal/models/loyalty_test.go b/internal/models/loyalty_test.go index 2fc48e5..2d4afbf 100644 --- a/internal/models/loyalty_test.go +++ b/internal/models/loyalty_test.go @@ -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}} diff --git a/internal/processor/earning_calculator.go b/internal/processor/earning_calculator.go index 28825c4..f4b7b84 100644 --- a/internal/processor/earning_calculator.go +++ b/internal/processor/earning_calculator.go @@ -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 { - return line + 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 } - line.Amount = basisCents / (s.EarnPerAmount * 100) * s.EarnValue if s.MaxPerOrder != nil && line.Amount > *s.MaxPerOrder { line.Amount = *s.MaxPerOrder line.Capped = true diff --git a/internal/processor/earning_calculator_test.go b/internal/processor/earning_calculator_test.go index b49efab..2fe987d 100644 --- a/internal/processor/earning_calculator_test.go +++ b/internal/processor/earning_calculator_test.go @@ -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{ diff --git a/internal/processor/loyalty_settings_processor.go b/internal/processor/loyalty_settings_processor.go index 2b78e45..437e897 100644 --- a/internal/processor/loyalty_settings_processor.go +++ b/internal/processor/loyalty_settings_processor.go @@ -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{ diff --git a/internal/processor/loyalty_settings_processor_test.go b/internal/processor/loyalty_settings_processor_test.go index ecbdb1e..7248c37 100644 --- a/internal/processor/loyalty_settings_processor_test.go +++ b/internal/processor/loyalty_settings_processor_test.go @@ -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 }, diff --git a/internal/service/loyalty_settings_service.go b/internal/service/loyalty_settings_service.go index 1d81921..20e592c 100644 --- a/internal/service/loyalty_settings_service.go +++ b/internal/service/loyalty_settings_service.go @@ -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 }