Files
apskel-pos-backend/internal/processor/earning_calculator.go
efrilmandClaude Opus 5.5 2c9753fae7 feat(loyalty): remove paying with EnakPoint
EnakPoint can only be redeemed for vouchers now: it can no longer pay for
orders and is never cashed out (docs/enakgame-prd.md §3.2, EG-001,
EG-002). No order was ever paid with EnakPoint, so there is no data to
move.

Removed:
- POST /customer/wallet/payment-code, POST /customer/orders/:id/pay-with-points
  and GET /orders/:id/point-payment/preview, with their processors,
  repositories, services, handlers and tests.
- The point payment method type: paying, splitting and refunding with it,
  the outlet filter on the method list, and the system-method guard.
- points and payment_code on CreatePayment; points_used and point_value
  on payments; accepts_point_payment on the customer outlets.
- The outlet point_payment settings. A PUT that still sends them is
  rejected as an unknown field.
- The EnakPoint split in the payment method analytics.
- PAYMENT and PAYMENT_REFUND from the wallet type rules. Tests that used
  them as a generic EnakPoint debit use REWARD_REDEEM.
- The EnakPoint-paid part from the earning basis, which is
  subtotal − discount again.

Migration 000102 drops the trigger, the point methods and their index,
the payments columns, and the outlet settings, and restores the method
type CHECK without point. payments.payment_method_id is ON DELETE
RESTRICT, so it fails rather than lose a payment made with EnakPoint.

The integration docs list the removed endpoints and fields, and the
EnakPoint & EnakCoin PRD and tasks note what is superseded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:48:29 +07:00

101 lines
3.3 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/constants"
"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, 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.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
}
return m
}
// CalculateEarning applies the earning formula of docs/prd-point-coin.md F1:
//
// basis = subtotal − discount_amount
// amount = 0 if basis < min_order_amount
// 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
//
// 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, settings models.OutletLoyaltySettings) EarningResult {
basisCents := toCents(order.Subtotal) - toCents(order.DiscountAmount)
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 || 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
}
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))
}