Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)

This reverts commit 4e24f9bbb0.
This commit is contained in:
efrilm
2026-09-30 15:31:44 +07:00
parent 4e24f9bbb0
commit 582dc75543
195 changed files with 20058 additions and 2177 deletions
+11 -9
View File
@@ -14,6 +14,8 @@ type PaymentMethodAnalytics struct {
TotalAmount float64 `json:"total_amount"`
OrderCount int64 `json:"order_count"`
PaymentCount int64 `json:"payment_count"`
// EnakPoint used, for the EnakPoint method.
PointsUsed int64 `json:"points_used"`
}
// SalesAnalytics represents sales analytics data
@@ -173,15 +175,15 @@ type BudgetCutOffWeek struct {
// DashboardOverview represents dashboard overview data
type DashboardOverview struct {
TotalSales float64 `json:"total_sales"`
TotalOrders int64 `json:"total_orders"`
AverageOrderValue float64 `json:"average_order_value"`
TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"`
TotalSales float64 `json:"total_sales"`
TotalOrders int64 `json:"total_orders"`
AverageOrderValue float64 `json:"average_order_value"`
TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"`
}
type ProfitLossAnalytics struct {
+23 -2
View File
@@ -12,10 +12,31 @@ type CampaignType string
const (
CampaignTypeReward CampaignType = "REWARD"
CampaignTypePoints CampaignType = "POINTS"
CampaignTypeTokens CampaignType = "TOKENS"
CampaignTypeCoins CampaignType = "COINS"
CampaignTypeMixed CampaignType = "MIXED"
)
// legacyCampaignTokens is what COINS was called before tokens became EnakCoin
// (docs/prd-point-coin.md §10). Requests may still send it; it is stored as COINS.
const legacyCampaignTokens = "TOKENS"
// NormalizeCampaignType maps the legacy TOKENS to COINS and leaves anything else as is.
func NormalizeCampaignType(t string) CampaignType {
if t == legacyCampaignTokens {
return CampaignTypeCoins
}
return CampaignType(t)
}
// NormalizeCampaignRewardType maps the legacy TOKENS to COINS and leaves anything
// else as is.
func NormalizeCampaignRewardType(t string) CampaignRewardType {
if t == legacyCampaignTokens {
return CampaignRewardTypeCoins
}
return CampaignRewardType(t)
}
type RuleType string
const (
@@ -31,7 +52,7 @@ type CampaignRewardType string
const (
CampaignRewardTypePoints CampaignRewardType = "POINTS"
CampaignRewardTypeTokens CampaignRewardType = "TOKENS"
CampaignRewardTypeCoins CampaignRewardType = "COINS"
CampaignRewardTypeReward CampaignRewardType = "REWARD"
)
+18
View File
@@ -0,0 +1,18 @@
package entities
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNormalizeCampaignTypesMapsLegacyTokensToCoins(t *testing.T) {
assert.Equal(t, CampaignTypeCoins, NormalizeCampaignType("TOKENS"))
assert.Equal(t, CampaignTypeCoins, NormalizeCampaignType("COINS"))
assert.Equal(t, CampaignTypePoints, NormalizeCampaignType("POINTS"))
assert.Equal(t, CampaignType(""), NormalizeCampaignType(""), "an empty filter stays empty")
assert.Equal(t, CampaignRewardTypeCoins, NormalizeCampaignRewardType("TOKENS"))
assert.Equal(t, CampaignRewardTypeReward, NormalizeCampaignRewardType("REWARD"))
assert.Equal(t, CampaignRewardType(""), NormalizeCampaignRewardType(""))
}
+6
View File
@@ -44,6 +44,12 @@ func GetAllEntities() []interface{} {
&ProductOutletPrice{},
&Expense{},
&CashAdvance{},
// Wallet entities
&CustomerWallet{},
&WalletTransaction{},
&WalletLot{},
&WalletLotAllocation{},
&LoyaltySettingChange{},
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ type GamePlay struct {
GameID uuid.UUID `gorm:"type:uuid;not null;index" json:"game_id" validate:"required"`
CustomerID uuid.UUID `gorm:"type:uuid;not null;index" json:"customer_id" validate:"required"`
PrizeID *uuid.UUID `gorm:"type:uuid" json:"prize_id,omitempty"`
TokenUsed int `gorm:"default:0" json:"token_used" validate:"min=0"`
CoinsUsed int `gorm:"column:coins_used;default:0" json:"coins_used" validate:"min=0"`
RandomSeed *string `gorm:"type:varchar(255)" json:"random_seed,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
@@ -0,0 +1,34 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// LoyaltySettingChange records one change to a loyalty setting: which key, from what,
// to what, by whom (docs/prd-point-coin.md F2). Append-only.
type LoyaltySettingChange struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
// Nil for an organization setting.
OutletID *uuid.UUID `gorm:"type:uuid" json:"outlet_id"`
Key string `gorm:"not null;size:100" json:"key"`
// Nil when the key had no stored value, or has none any more.
OldValue *string `gorm:"type:text" json:"old_value"`
NewValue *string `gorm:"type:text" json:"new_value"`
ChangedBy uuid.UUID `gorm:"type:uuid;not null" json:"changed_by"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (c *LoyaltySettingChange) BeforeCreate(tx *gorm.DB) error {
if c.ID == uuid.Nil {
c.ID = uuid.New()
}
return nil
}
func (LoyaltySettingChange) TableName() string {
return "loyalty_setting_changes"
}
+14 -7
View File
@@ -13,13 +13,16 @@ const (
PaymentMethodTypeCash PaymentMethodType = "cash"
PaymentMethodTypeCard PaymentMethodType = "card"
PaymentMethodTypeDigitalWallet PaymentMethodType = "digital_wallet"
// The system method for paying with EnakPoint (docs/prd-point-coin.md F9). One per
// organization; it cannot be created, deleted or retyped through the API.
PaymentMethodTypePoint PaymentMethodType = "point"
)
type PaymentMethod struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
Name string `gorm:"not null;size:100" json:"name" validate:"required,min=1,max=100"`
Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet"`
Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet point"`
Processor *string `gorm:"size:100" json:"processor"`
Configuration Metadata `gorm:"type:jsonb;default:'{}'" json:"configuration"`
IsActive bool `gorm:"default:true" json:"is_active"`
@@ -69,12 +72,16 @@ type Payment struct {
SplitType *SplitType `gorm:"size:20" json:"split_type,omitempty"`
SplitDescription *string `gorm:"size:255" json:"split_description,omitempty"`
RefundAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"refund_amount"`
RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"`
RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"`
RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
// Set only for a payment with EnakPoint: how many were used, and the rupiah value of
// one then, frozen so a refund returns exactly what was used.
PointsUsed *int64 `json:"points_used,omitempty"`
PointValue *float64 `gorm:"type:decimal(10,2)" json:"point_value,omitempty"`
RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"`
RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"`
RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
PaymentMethod PaymentMethod `gorm:"foreignKey:PaymentMethodID" json:"payment_method,omitempty"`
+113
View File
@@ -0,0 +1,113 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// CustomerWallet holds a customer's EnakPoint and EnakCoin balances. The row is also
// the lock every wallet operation for the customer takes first, so concurrent
// operations on one customer queue up instead of spending the same balance twice.
//
// Balances are never written directly: they only move together with a ledger row, and
// only through the wallet processor.
type CustomerWallet struct {
CustomerID uuid.UUID `gorm:"type:uuid;primary_key" json:"customer_id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
PointBalance int64 `gorm:"not null;default:0" json:"point_balance"`
CoinBalance int64 `gorm:"not null;default:0" json:"coin_balance"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (CustomerWallet) TableName() string {
return "customer_wallets"
}
// WalletTransaction is one ledger row. The ledger is append-only: a correction is a
// new row pointing at the one it corrects, never an update.
type WalletTransaction struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
CustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"`
Currency string `gorm:"not null;size:10" json:"currency"`
Type string `gorm:"not null;size:30" json:"type"`
// Signed: positive credits the wallet, negative debits it.
Amount int64 `gorm:"not null" json:"amount"`
BalanceAfter int64 `gorm:"not null" json:"balance_after"`
GroupID *uuid.UUID `gorm:"type:uuid" json:"group_id"`
// Where the value came from (credit) or went to (debit).
ReferenceType string `gorm:"not null;size:30" json:"reference_type"`
ReferenceID uuid.UUID `gorm:"type:uuid;not null" json:"reference_id"`
CounterpartyCustomerID *uuid.UUID `gorm:"type:uuid" json:"counterparty_customer_id"`
ReversesTransactionID *uuid.UUID `gorm:"type:uuid" json:"reverses_transaction_id"`
OutletID *uuid.UUID `gorm:"type:uuid" json:"outlet_id"`
CreatedByUser *uuid.UUID `gorm:"type:uuid" json:"created_by_user"`
Reason *string `gorm:"size:255" json:"reason"`
// Frozen at creation, so later renames do not rewrite history.
Description string `gorm:"not null;size:255" json:"description"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
IdempotencyKey *string `gorm:"size:100;unique" json:"idempotency_key"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (t *WalletTransaction) BeforeCreate(tx *gorm.DB) error {
if t.ID == uuid.Nil {
t.ID = uuid.New()
}
// A nil map would be stored as JSON null rather than an empty object.
if t.Metadata == nil {
t.Metadata = Metadata{}
}
return nil
}
func (WalletTransaction) TableName() string {
return "wallet_transactions"
}
// WalletLot is one credited piece of balance with its own expiry (K9). Debits draw from
// the lots that expire soonest. A lot created by a transfer, exchange or refund carries
// the expiry of the lot it came from and points back at it through OriginLotID.
type WalletLot struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
CustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"`
Currency string `gorm:"not null;size:10" json:"currency"`
SourceTransactionID uuid.UUID `gorm:"type:uuid;not null" json:"source_transaction_id"`
OriginLotID *uuid.UUID `gorm:"type:uuid" json:"origin_lot_id"`
OriginalAmount int64 `gorm:"not null" json:"original_amount"`
// A cache of OriginalAmount minus the lot's allocations, and the only wallet column
// that is ever updated.
RemainingAmount int64 `gorm:"not null" json:"remaining_amount"`
// Nil means the lot never expires.
ExpiresAt *time.Time `json:"expires_at"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (l *WalletLot) BeforeCreate(tx *gorm.DB) error {
if l.ID == uuid.Nil {
l.ID = uuid.New()
}
return nil
}
func (WalletLot) TableName() string {
return "wallet_lots"
}
// WalletLotAllocation records how much a debit ledger row drew from one lot.
type WalletLotAllocation struct {
TransactionID uuid.UUID `gorm:"type:uuid;primary_key" json:"transaction_id"`
LotID uuid.UUID `gorm:"type:uuid;primary_key" json:"lot_id"`
Amount int64 `gorm:"not null" json:"amount"`
}
func (WalletLotAllocation) TableName() string {
return "wallet_lot_allocations"
}