Files
apskel-pos-backend/internal/models/loyalty.go
2026-09-30 15:31:11 +07:00

194 lines
7.6 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 models
import (
"math"
"time"
"github.com/google/uuid"
)
// OutletLoyaltySettings are an outlet's loyalty settings (docs/prd-point-coin.md F1).
type OutletLoyaltySettings struct {
Point LoyaltyEarnSettings `json:"point"`
Coin LoyaltyEarnSettings `json:"coin"`
// Paying with EnakPoint. EnakCoin cannot pay, so it has no counterpart.
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.
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"`
}
type LoyaltyPointPaymentSettings struct {
AcceptPayment bool `json:"accept_payment"`
MinPaymentPoints int64 `json:"min_payment_points"`
// Largest share of the order total, 0–100, that EnakPoint may pay.
MaxPaymentPercent int64 `json:"max_payment_percent"`
}
// 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"`
}
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. 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"`
}
// 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
}