Files
apskel-pos-backend/internal/processor/earning_calculator.go
efrilmandClaude Opus 5.5 5baed18b22 feat(loyalty): earning calculator
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 <noreply@anthropic.com>
2026-09-30 10:31:09 +07:00

86 lines
2.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}