Files
apskel-pos-backend/internal/models/loyalty.go
T
efrilmandClaude Opus 5.5 798a36bd6c feat(enakgame): game sessions, rewards, vouchers, budgets and events
EnakGame phases 1-8 of docs/tasks-enakgame.md (EG-101 to EG-803), built on the
existing EnakPoint/EnakCoin wallet (docs/rfc-enakgame.md).

Foundation (phase 1)
- Migrations 000103-000106: games extended with organization, slug, status,
  entry cost and result rules, old games archived (not deleted); budgets,
  versioned reward configs, sessions and session rewards; the ledger types
  GAME_SPEND_REFUND, GAME_REWARD and REWARD_REDEEM_REFUND; audit_logs.
- AuditLogger writes in the caller's transaction only.
- enakgame.limit.user_daily and global_daily organization settings.

Games and sessions (phases 2-4)
- Admin /marketing/enakgame: games, reward config versions (immutable but for
  status, one ACTIVE per game), budgets with non-overlapping global periods and
  a daily job opening the next month.
- Customer /customer/enakgame: start (Idempotency-Key, entry cost and config
  frozen on the session), complete (result validation, reward engine, max_reward
  cap, daily limits via game_reward_counters, one GAME_REWARD per budget),
  automatic refunds for system errors and deactivated games, and a session job.
- Reward engine: FIXED, SCORE_BASED, OUTCOME_BASED, PROBABILITY (crypto/rand),
  rounded down.

Vouchers and budgets (phases 5-6)
- Migration 000108 and 000107: vouchers, codes, redemptions, cost attribution;
  Economy Guard counters.
- STATIC and CODE_POOL redemption in one transaction with the REDEEM PIN action;
  realized cost traced through the lots to the budget that paid the reward.
- Budget metrics: realized cost, forecast, exposure and status. Migrations
  000109-000110 add the wallet_lots indexes they need, built CONCURRENTLY.

Events (phase 7)
- Migration 000111: game events, each with its own EVENT budget. Event extras
  stack per PRD §16 defaults, with event and per-customer limits.

External vouchers (phase 8)
- VoucherProvider contract, two-step PENDING redemption and a recovery job,
  tested with a fake provider. No provider adapter is registered yet, so
  EXTERNAL vouchers stay out of the catalog.

Not yet decided before release: reward rounding, event stacking, budget
exhaustion policy and thresholds (RFC §19.2). Migrations 000103-000111 have
not been run on any shared database.

Also fixes a leftover PAYMENT filter in a wallet test and a data race in a
test PIN fake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:53:14 +07:00

214 lines
8.5 KiB
Go
Raw 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 models
import (
"math"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
)
// OutletLoyaltySettings are an outlet's loyalty settings (docs/prd-point-coin.md F1).
type OutletLoyaltySettings struct {
Point LoyaltyEarnSettings `json:"point"`
Coin LoyaltyEarnSettings `json:"coin"`
}
// 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"`
}
// OrganizationLoyaltySettings are the loyalty settings shared by every outlet of an
// organization (docs/prd-point-coin.md F2, F12).
type OrganizationLoyaltySettings struct {
// Rupiah value of one EnakPoint when paying.
PointValue int64 `json:"point_value"`
// CoinAmount EnakCoin exchange into PointAmount EnakPoint.
Exchange LoyaltyExchangeSettings `json:"exchange"`
Transfer LoyaltyTransferSettings `json:"transfer"`
PointExpiry LoyaltyExpirySettings `json:"point_expiry"`
CoinExpiry LoyaltyExpirySettings `json:"coin_expiry"`
EnakGame EnakGameLimitSettings `json:"enakgame"`
}
// EnakGameLimitSettings caps the EnakCoin EnakGame rewards per day, in Asia/Jakarta
// (docs/rfc-enakgame.md §5.10). 0 means no limit.
type EnakGameLimitSettings struct {
// What one customer may receive.
UserDailyLimit int64 `json:"user_daily_limit"`
// What the whole organization may give out.
GlobalDailyLimit int64 `json:"global_daily_limit"`
}
type LoyaltyExchangeSettings struct {
CoinAmount int64 `json:"coin_amount"`
PointAmount int64 `json:"point_amount"`
}
type LoyaltyTransferSettings struct {
Enabled bool `json:"enabled"`
MinAmount int64 `json:"min_amount"`
MaxPerTransaction *int64 `json:"max_per_transaction"`
DailyLimit *int64 `json:"daily_limit"`
}
// LoyaltyExpirySettings is when one currency expires once received (F12). Both
// models of note N4 are supported, and the owner picks one:
//
// - FIXED_DATE: everything expires on the next of FixedDates falling on or after
// the day received + GraceMonths, so a balance received just before a date moves
// on to the one after.
// - ROLLING: everything lasts Period Units from the day received, to the end of
// that month when EndOfMonth is set.
type LoyaltyExpirySettings struct {
Enabled bool `json:"enabled"`
// FIXED_DATE or ROLLING.
Mode string `json:"mode"`
// FIXED_DATE: the days of the year balances expire on, as MM-DD, sorted.
FixedDates []string `json:"fixed_dates"`
// FIXED_DATE: how many months a balance lasts at least before a fixed date takes it.
GraceMonths int64 `json:"grace_months"`
// ROLLING: how long a balance lasts.
Period int64 `json:"period"`
// ROLLING: DAY or MONTH.
Unit string `json:"unit"`
EndOfMonth bool `json:"end_of_month"`
// Days before expiry the customer is reminded; 0 for no reminder.
ReminderDays int64 `json:"reminder_days"`
}
// LoyaltySettingChange is one row of the loyalty settings history.
type LoyaltySettingChange struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
Key string `json:"key"`
// Nil when the key had no stored value, that is it was on its default.
OldValue *string `json:"old_value"`
NewValue *string `json:"new_value"`
ChangedBy uuid.UUID `json:"changed_by"`
CreatedAt time.Time `json:"created_at"`
}
// OutletLoyaltySettingsView is GET and PUT /outlets/:id/loyalty-settings.
type OutletLoyaltySettingsView struct {
OutletID uuid.UUID `json:"outlet_id"`
OutletLoyaltySettings
// 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, 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"`
}
// LoyaltyCashbackPercent is earnValue × pointValue / earnPerAmount as a percentage,
// rounded to two decimals.
func LoyaltyCashbackPercent(earnValue, pointValue, earnPerAmount int64) float64 {
if earnPerAmount <= 0 {
return 0
}
return math.Round(float64(earnValue)*float64(pointValue)*10000/float64(earnPerAmount)) / 100
}
// OrganizationLoyaltySettingsView is GET and PUT /marketing/loyalty-settings.
type OrganizationLoyaltySettingsView struct {
OrganizationLoyaltySettings
// What the balances in circulation are worth, before and after the change.
Impact LoyaltySettingsImpact `json:"impact"`
// When a balance received now would expire under these settings (F12).
ExpiryPreview LoyaltyExpiryPreview `json:"expiry_preview"`
// The currencies this change turns expiry on for, and the balances affected.
ExpiryActivations []LoyaltyExpiryActivation `json:"expiry_activations"`
// On PUT, the keys that changed; on a dry run, the keys that would.
Changes []LoyaltySettingChange `json:"changes"`
// True when nothing was saved.
DryRun bool `json:"dry_run"`
}
// LoyaltyExpiryActivation is expiry being turned on for a currency: the balances that
// had no expiry and the expiry they get (F12). On a dry run nothing is dated yet.
type LoyaltyExpiryActivation struct {
Currency string `json:"currency"`
Lots int64 `json:"lots"`
Amount int64 `json:"amount"`
ExpiresAt time.Time `json:"expires_at"`
}
// LoyaltyExpiryPreview is what the dashboard shows next to the expiry settings: "the
// EnakPoint received today expire on …". Nil means they never expire.
type LoyaltyExpiryPreview struct {
Point *time.Time `json:"point"`
Coin *time.Time `json:"coin"`
}
// LoyaltySettingsImpact shows how a change of point value or exchange rate changes what
// the balances in circulation are worth (F2). Before and after are equal when neither
// changes.
type LoyaltySettingsImpact struct {
OutstandingPoints int64 `json:"outstanding_points"`
OutstandingCoins int64 `json:"outstanding_coins"`
PointValueBefore int64 `json:"point_value_before"`
PointValueAfter int64 `json:"point_value_after"`
PointRupiahBefore int64 `json:"point_rupiah_before"`
PointRupiahAfter int64 `json:"point_rupiah_after"`
// The coins in circulation exchanged at the rate, in EnakPoint and in rupiah.
CoinsAsPointsBefore int64 `json:"coins_as_points_before"`
CoinsAsPointsAfter int64 `json:"coins_as_points_after"`
CoinRupiahBefore int64 `json:"coin_rupiah_before"`
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 {
asPoints := func(s OrganizationLoyaltySettings) int64 {
if s.Exchange.CoinAmount <= 0 {
return 0
}
return coins * s.Exchange.PointAmount / s.Exchange.CoinAmount
}
impact := LoyaltySettingsImpact{
OutstandingPoints: points,
OutstandingCoins: coins,
PointValueBefore: before.PointValue,
PointValueAfter: after.PointValue,
PointRupiahBefore: points * before.PointValue,
PointRupiahAfter: points * after.PointValue,
CoinsAsPointsBefore: asPoints(before),
CoinsAsPointsAfter: asPoints(after),
}
impact.CoinRupiahBefore = impact.CoinsAsPointsBefore * before.PointValue
impact.CoinRupiahAfter = impact.CoinsAsPointsAfter * after.PointValue
return impact
}