Adds GET and PUT /marketing/loyalty-settings and GET /marketing/loyalty-settings/history (docs/prd-point-coin.md F2, PC-302). The settings are the point value, the exchange rate, transfer limits and the stored expiry settings. PUT merges the body like the outlet settings and is limited to loyalty managers. Every response carries the impact of the change on the balances in circulation: outstanding EnakPoint and EnakCoin, their rupiah value, and the coins exchanged into points, before and after. With ?dry_run=true nothing is saved and the response lists the keys that would change, for the warning shown before saving. Saving records each change in loyalty_setting_changes with who made it; history can be filtered to one outlet. Changing the value leaves what was already written alone. The diff behind saving and previewing is shared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
160 lines
6.0 KiB
Go
160 lines
6.0 KiB
Go
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 how long one currency lasts once received. The expiry
|
||
// model is still open (note N4); these are only the stored settings.
|
||
type LoyaltyExpirySettings struct {
|
||
Enabled bool `json:"enabled"`
|
||
Period int64 `json:"period"`
|
||
// DAY or MONTH.
|
||
Unit string `json:"unit"`
|
||
EndOfMonth bool `json:"end_of_month"`
|
||
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"`
|
||
// 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"`
|
||
}
|
||
|
||
// 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
|
||
}
|