Every lot now gets its expiry when it is created (docs/prd-point-coin.md F12, PC-502), where it used to never expire until note N4 was settled: - EARN and an ADJUSTMENT that adds: ComputeExpiry of the organization's settings for that currency, from the moment received. - EXCHANGE_IN: the sooner of the EnakCoin lot's expiry and when EnakPoint received now expire (F4). - PAYMENT_REFUND: the expiry of the lot the EnakPoint came from, but at least seven days from the refund (N4, decided). A lot that never expired stays so. - TRANSFER_IN: unchanged, exactly the sender's expiry. Turning expiry on for a currency for the first time dates every lot of the organization that still holds something and has no expiry, MIGRATION lots included, in the same transaction as the setting: a full period from now when ROLLING, the second fixed date on or after today when FIXED_DATE, so no customer loses a balance soon after the rule is announced (N4, decided). Turning it off leaves dated lots as they are. PUT /marketing/loyalty-settings reports these as expiry_activations (currency, lots, amount, expires_at); a dry run counts them without dating anything. The earning processor now also reads the organization settings, and the wallet admin processor takes the settings reader. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
194 lines
7.6 KiB
Go
194 lines
7.6 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 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
|
||
}
|