From 5baed18b223793df0b021e5a046010919d996ee4 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 10:31:09 +0700 Subject: [PATCH] feat(loyalty): earning calculator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CalculateEarning (docs/prd-point-coin.md F1, Q1, Q10, PC-202), a pure function returning, per currency, the amount an order earns and the settings that produced it, plus the basis: basis = subtotal − discount − paid with EnakPoint (never negative) amount = 0 below min_order_amount, else floor(basis / earn_per_amount) × earn_value, capped by max_per_order Tax and anything added on top of the subtotal are not part of the basis, and the part paid with EnakPoint earns nothing. Money is handled in whole cents: in float64 some baskets divide to 4956.999… and a naive floor would lose a point, which a test reproduces. Metadata() gives the snapshot the EARN row will freeze. Co-Authored-By: Claude Opus 5.5 --- internal/processor/earning_calculator.go | 85 ++++++++++++ internal/processor/earning_calculator_test.go | 121 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 internal/processor/earning_calculator.go create mode 100644 internal/processor/earning_calculator_test.go diff --git a/internal/processor/earning_calculator.go b/internal/processor/earning_calculator.go new file mode 100644 index 0000000..28825c4 --- /dev/null +++ b/internal/processor/earning_calculator.go @@ -0,0 +1,85 @@ +package processor + +import ( + "math" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +// EarningLine is what an order earns in one currency, and the settings that produced +// it. The settings are kept so the ledger row can freeze them: a later change of +// setting must not change what an earning meant, and a reversal is computed with the +// same numbers (docs/prd-point-coin.md F3). +type EarningLine struct { + Amount int64 + Settings models.LoyaltyEarnSettings + // True when max_per_order cut the amount down. + Capped bool +} + +// EarningResult is what an order earns. +type EarningResult struct { + // subtotal − discount − the part paid with EnakPoint, in rupiah, never negative. + // Tax and anything else added on top of the subtotal are not part of it (Q1). + Basis float64 + Point EarningLine + Coin EarningLine +} + +// Metadata is the snapshot stored on the currency's EARN ledger row. +func (r EarningResult) Metadata(line EarningLine) entities.Metadata { + m := entities.Metadata{ + "basis": r.Basis, + "earn_per_amount": line.Settings.EarnPerAmount, + "earn_value": line.Settings.EarnValue, + "min_order_amount": line.Settings.MinOrderAmount, + "capped": line.Capped, + } + if line.Settings.MaxPerOrder != nil { + m["max_per_order"] = *line.Settings.MaxPerOrder + } + return m +} + +// CalculateEarning applies the earning formula of docs/prd-point-coin.md F1: +// +// basis = subtotal − discount_amount − paid with EnakPoint +// amount = 0 if basis < min_order_amount +// amount = floor(basis / earn_per_amount) × earn_value +// amount = min(amount, max_per_order) if max_per_order is set +// +// The part paid with EnakPoint earns nothing (Q10). Money is handled in whole cents so +// floor never lands one short on a value like 87500.00 that float64 cannot hold +// exactly. It has no side effects. +func CalculateEarning(order *entities.Order, pointPaidAmount float64, settings models.OutletLoyaltySettings) EarningResult { + basisCents := toCents(order.Subtotal) - toCents(order.DiscountAmount) - toCents(pointPaidAmount) + if basisCents < 0 { + basisCents = 0 + } + return EarningResult{ + Basis: float64(basisCents) / 100, + Point: earnLine(basisCents, settings.Point), + Coin: earnLine(basisCents, settings.Coin), + } +} + +func earnLine(basisCents int64, s models.LoyaltyEarnSettings) EarningLine { + line := EarningLine{Settings: s} + if !s.Enabled || s.EarnPerAmount <= 0 || s.EarnValue <= 0 { + return line + } + if basisCents < s.MinOrderAmount*100 { + return line + } + line.Amount = basisCents / (s.EarnPerAmount * 100) * s.EarnValue + if s.MaxPerOrder != nil && line.Amount > *s.MaxPerOrder { + line.Amount = *s.MaxPerOrder + line.Capped = true + } + return line +} + +func toCents(rupiah float64) int64 { + return int64(math.Round(rupiah * 100)) +} diff --git a/internal/processor/earning_calculator_test.go b/internal/processor/earning_calculator_test.go new file mode 100644 index 0000000..b49efab --- /dev/null +++ b/internal/processor/earning_calculator_test.go @@ -0,0 +1,121 @@ +package processor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +// 1 EnakPoint per Rp 100 and 1 EnakCoin per Rp 25.000, as in the F1 example. +func prdEarningSettings() models.OutletLoyaltySettings { + return models.OutletLoyaltySettings{ + Point: models.LoyaltyEarnSettings{Enabled: true, EarnPerAmount: 100, EarnValue: 1}, + Coin: models.LoyaltyEarnSettings{Enabled: true, EarnPerAmount: 25000, EarnValue: 1}, + } +} + +func TestCalculateEarning_PRDExample(t *testing.T) { + // Subtotal after discount Rp 87.500, paid in full in cash. + order := &entities.Order{Subtotal: 97500, DiscountAmount: 10000, TaxAmount: 9625, TotalAmount: 97125} + + got := CalculateEarning(order, 0, prdEarningSettings()) + assert.Equal(t, 87500.0, got.Basis) + assert.Equal(t, int64(875), got.Point.Amount) + assert.Equal(t, int64(3), got.Coin.Amount) + + // Rp 20.000 of it paid with EnakPoint earns nothing. + got = CalculateEarning(order, 20000, prdEarningSettings()) + assert.Equal(t, 67500.0, got.Basis) + assert.Equal(t, int64(675), got.Point.Amount) + assert.Equal(t, int64(2), got.Coin.Amount) +} + +func TestCalculateEarning_TaxIsNotPartOfTheBasis(t *testing.T) { + withoutTax := &entities.Order{Subtotal: 50000} + withTax := &entities.Order{Subtotal: 50000, TaxAmount: 5500, TotalAmount: 55500} + assert.Equal(t, CalculateEarning(withoutTax, 0, prdEarningSettings()), CalculateEarning(withTax, 0, prdEarningSettings())) + assert.Equal(t, int64(500), CalculateEarning(withTax, 0, prdEarningSettings()).Point.Amount) +} + +func TestCalculateEarning_BelowMinimum(t *testing.T) { + s := prdEarningSettings() + s.Point.MinOrderAmount = 50000 + + assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Point.Amount) + assert.Equal(t, int64(500), CalculateEarning(&entities.Order{Subtotal: 50000}, 0, s).Point.Amount, "the minimum itself earns") + // The minimum applies to the basis, after discount and EnakPoint. + assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 60000}, 15000, s).Point.Amount) + // Coin has its own minimum. + assert.Equal(t, int64(1), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Coin.Amount) +} + +func TestCalculateEarning_MaxPerOrder(t *testing.T) { + s := prdEarningSettings() + max := int64(300) + s.Point.MaxPerOrder = &max + + got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s) + assert.Equal(t, int64(300), got.Point.Amount) + assert.True(t, got.Point.Capped) + assert.Equal(t, int64(300), got.Metadata(got.Point)["max_per_order"]) + + got = CalculateEarning(&entities.Order{Subtotal: 20000}, 0, s) + assert.Equal(t, int64(200), got.Point.Amount) + assert.False(t, got.Point.Capped) + + zero := int64(0) + s.Point.MaxPerOrder = &zero + assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount) +} + +func TestCalculateEarning_DisabledEarnsNothing(t *testing.T) { + s := prdEarningSettings() + s.Point.Enabled = false + got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s) + assert.Equal(t, int64(0), got.Point.Amount) + assert.Equal(t, int64(3), got.Coin.Amount, "each currency is switched on its own") + + s.Coin.Enabled = false + got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s) + assert.Equal(t, int64(0), got.Coin.Amount) + assert.Equal(t, 87500.0, got.Basis, "the basis is still reported") + + // The defaults of an outlet that never set anything earn nothing. + var defaults models.OutletLoyaltySettings + loadLoyaltyFields(outletLoyaltyFields(&defaults), nil, "test") + got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, defaults) + assert.Equal(t, int64(0), got.Point.Amount) + assert.Equal(t, int64(0), got.Coin.Amount) +} + +func TestCalculateEarning_EdgeCases(t *testing.T) { + s := prdEarningSettings() + + // floor, not round. + assert.Equal(t, int64(875), CalculateEarning(&entities.Order{Subtotal: 87599.99}, 0, s).Point.Amount) + // Values float64 cannot hold exactly do not lose a point: computed in float64 this + // basis divides to 4956.999…, which a naive floor turns into 4956. + per250 := prdEarningSettings() + per250.Point.EarnPerAmount = 250 + assert.Equal(t, int64(4957), CalculateEarning(&entities.Order{Subtotal: 1240155.48, DiscountAmount: 749.11}, 156.37, per250).Point.Amount) + // Paying more with EnakPoint than the basis leaves nothing, never a negative amount. + got := CalculateEarning(&entities.Order{Subtotal: 10000}, 15000, s) + assert.Equal(t, 0.0, got.Basis) + assert.Equal(t, int64(0), got.Point.Amount) + // earn_value multiplies. + s.Point.EarnValue = 5 + assert.Equal(t, int64(4375), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount) + // A zero earn_value earns nothing even when enabled. + s.Point.EarnValue = 0 + assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount) +} + +func TestCalculateEarning_MetadataSnapshot(t *testing.T) { + got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, prdEarningSettings()) + assert.Equal(t, entities.Metadata{ + "basis": 87500.0, "earn_per_amount": int64(100), "earn_value": int64(1), "min_order_amount": int64(0), "capped": false, + }, got.Metadata(got.Point)) +}