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
+19 -8
View File
@@ -33,7 +33,14 @@ type PaymentMethodAnalyticsResponse struct {
// PaymentMethodSummary represents the summary of payment method analytics
type PaymentMethodSummary struct {
TotalAmount float64 `json:"total_amount"`
// Money actually received. EnakPoint is not money in (docs/prd-point-coin.md F9,
// K7) and is reported apart; its accounting treatment waits on note N2.
TotalAmount float64 `json:"total_amount"`
// Rupiah paid with EnakPoint, and how many EnakPoint that was.
PointAmount float64 `json:"point_amount"`
PointsUsed int64 `json:"points_used"`
// TotalAmount plus PointAmount: the value of the orders paid.
TotalWithPoints float64 `json:"total_with_points"`
TotalOrders int64 `json:"total_orders"`
TotalPayments int64 `json:"total_payments"`
AverageOrderValue float64 `json:"average_order_value"`
@@ -47,7 +54,11 @@ type PaymentMethodAnalyticsData struct {
TotalAmount float64 `json:"total_amount"`
OrderCount int64 `json:"order_count"`
PaymentCount int64 `json:"payment_count"`
Percentage float64 `json:"percentage"`
// Share of the money received; 0 for EnakPoint, which is not money in.
Percentage float64 `json:"percentage"`
PointsUsed int64 `json:"points_used"`
// False for EnakPoint.
CountsAsCashIn bool `json:"counts_as_cash_in"`
}
// SalesAnalyticsRequest represents the request for sales analytics
@@ -464,12 +475,12 @@ type ProfitLossAnalyticsResponse struct {
}
type ProfitLossPurchasing struct {
TodayTotal float64 `json:"today_total"`
MtdTotal float64 `json:"mtd_total"`
TodayRawMaterial float64 `json:"today_raw_material"`
MtdRawMaterial float64 `json:"mtd_raw_material"`
TodayExpense float64 `json:"today_expense"`
MtdExpense float64 `json:"mtd_expense"`
TodayTotal float64 `json:"today_total"`
MtdTotal float64 `json:"mtd_total"`
TodayRawMaterial float64 `json:"today_raw_material"`
MtdRawMaterial float64 `json:"mtd_raw_material"`
TodayExpense float64 `json:"today_expense"`
MtdExpense float64 `json:"mtd_expense"`
Items []ProfitLossPurchasingItem `json:"items"`
}
+5 -5
View File
@@ -11,7 +11,7 @@ import (
type CreateCampaignRequest struct {
Name string `json:"name" binding:"required,min=1,max=150"`
Description *string `json:"description,omitempty"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS TOKENS MIXED"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"`
StartDate time.Time `json:"start_date" binding:"required"`
EndDate time.Time `json:"end_date" binding:"required"`
IsActive bool `json:"is_active"`
@@ -25,7 +25,7 @@ type UpdateCampaignRequest struct {
ID uuid.UUID `json:"id" binding:"required"`
Name string `json:"name" binding:"required,min=1,max=150"`
Description *string `json:"description,omitempty"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS TOKENS MIXED"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"`
StartDate time.Time `json:"start_date" binding:"required"`
EndDate time.Time `json:"end_date" binding:"required"`
IsActive bool `json:"is_active"`
@@ -88,7 +88,7 @@ type CreateCampaignRuleRequest struct {
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -100,7 +100,7 @@ type UpdateCampaignRuleRequest struct {
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -126,7 +126,7 @@ type ListCampaignRulesResponse struct {
type CampaignRuleStruct struct {
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
+47
View File
@@ -0,0 +1,47 @@
package models
import (
"time"
"github.com/google/uuid"
)
// CustomerPinStatus is GET /customer/pin/status.
type CustomerPinStatus struct {
HasPin bool `json:"has_pin"`
LockedUntil *time.Time `json:"locked_until"`
TransferBlockedUntil *time.Time `json:"transfer_blocked_until"`
}
// CustomerPinOtp is what POST /customer/pin/otp returns: the token to send back with
// the code the customer received.
type CustomerPinOtp struct {
Purpose string `json:"purpose"`
OtpToken string `json:"otp_token"`
ExpiresAt time.Time `json:"expires_at"`
}
// CustomerSecurityEventView is one row of GET /marketing/customers/:id/security-events.
type CustomerSecurityEventView struct {
ID uuid.UUID `json:"id"`
Event string `json:"event"`
ActorUser *uuid.UUID `json:"actor_user,omitempty"`
Reason *string `json:"reason,omitempty"`
IPAddress *string `json:"ip_address,omitempty"`
UserAgent *string `json:"user_agent,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CustomerPinRequestInfo is where a PIN request came from, for the security log.
type CustomerPinRequestInfo struct {
IPAddress string
UserAgent string
}
// PaymentCode is what POST /customer/wallet/payment-code returns: a one-time code the
// customer shows the cashier, as digits or as a QR of QRPayload.
type PaymentCode struct {
Code string `json:"code"`
QRPayload string `json:"qr_payload"`
ExpiresAt time.Time `json:"expires_at"`
}
+13 -58
View File
@@ -2,66 +2,8 @@ package models
import (
"time"
"github.com/google/uuid"
)
// Existing gamification models
type CreateCustomerPointsRequest struct {
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
Balance int64 `json:"balance" validate:"min=0"`
}
type UpdateCustomerPointsRequest struct {
Balance int64 `json:"balance" validate:"min=0"`
}
type AddCustomerPointsRequest struct {
Balance int64 `json:"balance" validate:"required,min=1"`
}
type DeductCustomerPointsRequest struct {
Balance int64 `json:"balance" validate:"required,min=1"`
}
type CustomerPointsResponse struct {
ID uuid.UUID `json:"id"`
CustomerID uuid.UUID `json:"customer_id"`
Balance int64 `json:"balance"`
Customer *CustomerResponse `json:"customer,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListCustomerPointsQuery struct {
Page int `json:"page" validate:"min=1"`
Limit int `json:"limit" validate:"min=1,max=100"`
Search string `json:"search"`
SortBy string `json:"sort_by" validate:"omitempty,oneof=balance created_at updated_at"`
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
}
type PaginatedCustomerPointsResponse struct {
Data []CustomerPointsResponse `json:"data"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
// New customer API models
type GetCustomerPointsRequest struct {
// No additional fields needed - customer ID comes from JWT token
}
type GetCustomerTokensRequest struct {
// No additional fields needed - customer ID comes from JWT token
}
type GetCustomerWalletRequest struct {
// No additional fields needed - customer ID comes from JWT token
}
// Response Models
type GetCustomerPointsResponse struct {
Status string `json:"status"`
@@ -109,7 +51,20 @@ type GetCustomerWalletResponse struct {
Data *GetCustomerWalletResponseData `json:"data,omitempty"`
}
// GetCustomerWalletResponseData is GET /customer/wallet (docs/prd-point-coin.md F6).
type GetCustomerWalletResponseData struct {
// Balances the customer can spend now.
PointBalance int64 `json:"point_balance"`
CoinBalance int64 `json:"coin_balance"`
// Rupiah value of one EnakPoint, and of the whole point balance. EnakPoint only
// ever pays for orders, so the app shows this as "setara potongan Rp …" (K7).
PointValue int64 `json:"point_value"`
PointDiscountValue int64 `json:"point_discount_value"`
NearestExpiring CustomerWalletNearestExpiring `json:"nearest_expiring"`
RecentTransactions []CustomerWalletTransaction `json:"recent_transactions"`
// Deprecated: the shape of this endpoint before the wallet, kept for app versions
// that still read it. Filled from the wallet like the fields above.
TotalPoints int64 `json:"total_points"`
TotalTokens int64 `json:"total_tokens"`
PointsHistory []PointsHistoryItem `json:"points_history,omitempty"`
-44
View File
@@ -1,44 +0,0 @@
package models
import (
"time"
"github.com/google/uuid"
)
type CreateCustomerTokensRequest struct {
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
TokenType string `json:"token_type" validate:"required,oneof=SPIN RAFFLE MINIGAME"`
Balance int64 `json:"balance" validate:"min=0"`
}
type UpdateCustomerTokensRequest struct {
Balance int64 `json:"balance" validate:"min=0"`
}
type AddCustomerTokensRequest struct {
Tokens int64 `json:"tokens" validate:"required,min=1"`
}
type DeductCustomerTokensRequest struct {
Tokens int64 `json:"tokens" validate:"required,min=1"`
}
type CustomerTokensResponse struct {
ID uuid.UUID `json:"id"`
CustomerID uuid.UUID `json:"customer_id"`
TokenType string `json:"token_type"`
Balance int64 `json:"balance"`
Customer *CustomerResponse `json:"customer,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListCustomerTokensQuery struct {
Page int `query:"page" validate:"min=1"`
Limit int `query:"limit" validate:"min=1,max=100"`
Search string `query:"search"`
TokenType string `query:"token_type" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=balance token_type created_at updated_at"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
}
+7 -7
View File
@@ -9,7 +9,7 @@ import (
type CreateGamePlayRequest struct {
GameID uuid.UUID `json:"game_id" validate:"required"`
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
TokenUsed int `json:"token_used" validate:"min=0"`
CoinsUsed int `json:"coins_used" validate:"min=0"`
RandomSeed *string `json:"random_seed,omitempty"`
}
@@ -18,7 +18,7 @@ type GamePlayResponse struct {
GameID uuid.UUID `json:"game_id"`
CustomerID uuid.UUID `json:"customer_id"`
PrizeID *uuid.UUID `json:"prize_id,omitempty"`
TokenUsed int `json:"token_used"`
CoinsUsed int `json:"coins_used"`
RandomSeed *string `json:"random_seed,omitempty"`
CreatedAt time.Time `json:"created_at"`
Game *GameResponse `json:"game,omitempty"`
@@ -33,18 +33,18 @@ type ListGamePlaysQuery struct {
GameID *uuid.UUID `query:"game_id"`
CustomerID *uuid.UUID `query:"customer_id"`
PrizeID *uuid.UUID `query:"prize_id"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at token_used"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at coins_used token_used"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
}
// PlayGameRequest plays a game for a customer, at the game's metadata.coin_cost.
type PlayGameRequest struct {
GameID uuid.UUID `json:"game_id" validate:"required"`
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
TokenUsed int `json:"token_used" validate:"min=0"`
}
type PlayGameResponse struct {
GamePlay GamePlayResponse `json:"game_play"`
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
TokensRemaining int64 `json:"tokens_remaining"`
GamePlay GamePlayResponse `json:"game_play"`
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
CoinsRemaining int64 `json:"coins_remaining"`
}
+193
View File
@@ -0,0 +1,193 @@
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
}
+32
View File
@@ -0,0 +1,32 @@
package models
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestLoyaltyCashbackPercent(t *testing.T) {
assert.Equal(t, 1.0, LoyaltyCashbackPercent(1, 1, 100))
assert.Equal(t, 0.1, LoyaltyCashbackPercent(1, 1, 1000))
assert.Equal(t, 0.33, LoyaltyCashbackPercent(1, 1, 300), "rounded to two decimals")
assert.Equal(t, 0.67, LoyaltyCashbackPercent(2, 1, 300), "rounded, not cut")
assert.Equal(t, 0.0, LoyaltyCashbackPercent(1, 1, 0))
}
func TestNewLoyaltySettingsImpact(t *testing.T) {
before := OrganizationLoyaltySettings{PointValue: 1, Exchange: LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1}}
after := OrganizationLoyaltySettings{PointValue: 100, Exchange: LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1}}
got := NewLoyaltySettingsImpact(50000, 105, before, after)
assert.Equal(t, LoyaltySettingsImpact{
OutstandingPoints: 50000, OutstandingCoins: 105,
PointValueBefore: 1, PointValueAfter: 100,
PointRupiahBefore: 50000, PointRupiahAfter: 5000000,
CoinsAsPointsBefore: 105, CoinsAsPointsAfter: 10, // floor(105 / 10)
CoinRupiahBefore: 105, CoinRupiahAfter: 1000,
}, got)
same := NewLoyaltySettingsImpact(10, 10, before, before)
assert.Equal(t, same.PointRupiahBefore, same.PointRupiahAfter)
}
+3
View File
@@ -182,6 +182,9 @@ type OrderResponse struct {
TotalPaid float64
PaymentCount int
SplitType *string
// What the order earned (docs/prd-point-coin.md F3), from its EARN rows.
PointsEarned int64
CoinsEarned int64
}
type OrderItemResponse struct {
+7 -2
View File
@@ -28,8 +28,10 @@ type Payment struct {
}
type CreatePaymentRequest struct {
OrderID uuid.UUID `validate:"required"`
PaymentMethodID uuid.UUID `validate:"required"`
OrderID uuid.UUID `validate:"required"`
PaymentMethodID uuid.UUID `validate:"required"`
Points *int64
PaymentCode *string
Amount float64 `validate:"required,min=0"`
TransactionID *string `validate:"omitempty"`
SplitNumber int `validate:"omitempty,min=1"`
@@ -60,6 +62,9 @@ type PaymentResponse struct {
SplitType *string
SplitDescription *string
RefundAmount float64
// Set for a payment with EnakPoint.
PointsUsed *int64
PointValue *float64
RefundReason *string
RefundedAt *time.Time
RefundedBy *uuid.UUID
+7 -5
View File
@@ -51,11 +51,13 @@ type PaymentMethodResponse struct {
type ListPaymentMethodsRequest struct {
OrganizationID *uuid.UUID
Type *constants.PaymentMethodType
IsActive *bool
Search string
Page int `validate:"min=1"`
Limit int `validate:"min=1,max=100"`
// When set, EnakPoint is left out unless the outlet accepts it (F9).
OutletID *uuid.UUID
Type *constants.PaymentMethodType
IsActive *bool
Search string
Page int `validate:"min=1"`
Limit int `validate:"min=1,max=100"`
}
type ListPaymentMethodsResponse struct {
+3 -3
View File
@@ -20,9 +20,9 @@ type SpinGameResponse struct {
// SpinGameResponseData contains the game play result
type SpinGameResponseData struct {
GamePlay GamePlayResponse `json:"game_play"`
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
TokensRemaining int64 `json:"tokens_remaining"`
GamePlay GamePlayResponse `json:"game_play"`
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
CoinsRemaining int64 `json:"coins_remaining"`
}
// SpinGamePrizeResponse represents a prize won in the spin game
+166
View File
@@ -0,0 +1,166 @@
package models
import (
"time"
"github.com/google/uuid"
)
// CustomerWalletTransaction is one ledger row as the customer app shows it
// (docs/prd-point-coin.md F6).
type CustomerWalletTransaction struct {
ID uuid.UUID `json:"id"`
Currency string `json:"currency"`
Type string `json:"type"`
// Signed: positive added to the balance, negative taken from it.
Amount int64 `json:"amount"`
BalanceAfter int64 `json:"balance_after"`
Description string `json:"description"`
// Where the value came from, set on additions.
Source *CustomerWalletTransactionRef `json:"source,omitempty"`
// Where the value went, set on deductions.
Destination *CustomerWalletTransactionRef `json:"destination,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
ReversesTransactionID *uuid.UUID `json:"reverses_transaction_id,omitempty"`
// Shared by the two rows of an exchange or a transfer.
GroupID *uuid.UUID `json:"group_id,omitempty"`
// Additions only: the earliest expiry among the lots it created, nil when none of
// them expire, and the lots themselves.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Lots []CustomerWalletLot `json:"lots,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CustomerWalletTransactionRef points at what a ledger row came from or went to, as
// listed in §8.1: ORDER, PAYMENT, WALLET_TX, GAME_PLAY, LOT, USER and so on.
type CustomerWalletTransactionRef struct {
Type string `json:"type"`
ID uuid.UUID `json:"id"`
}
type CustomerWalletLot struct {
Amount int64 `json:"amount"`
Remaining int64 `json:"remaining"`
ExpiresAt *time.Time `json:"expires_at"`
}
// CustomerWalletExpiring is how much expires on one day.
type CustomerWalletExpiring struct {
Amount int64 `json:"amount"`
// YYYY-MM-DD, Asia/Jakarta.
Date string `json:"date"`
}
// CustomerWalletNearestExpiring is the next day each currency loses balance, nil when
// nothing is due to expire.
type CustomerWalletNearestExpiring struct {
Point *CustomerWalletExpiring `json:"point"`
Coin *CustomerWalletExpiring `json:"coin"`
}
// ListCustomerWalletTransactionsQuery is GET /customer/wallet/transactions.
type ListCustomerWalletTransactionsQuery struct {
Page int `form:"page"`
Limit int `form:"limit"`
Currency string `form:"currency"`
// One type, or several separated by commas.
Type string `form:"type"`
// Inclusive calendar dates, YYYY-MM-DD, Asia/Jakarta.
From string `form:"from"`
To string `form:"to"`
}
// AdminCustomerWallet is GET /marketing/customers/:id/wallet (docs/prd-point-coin.md
// F7). Unlike the customer's own view it shows the raw balances next to the spendable
// ones, every lot that still holds something, and the real names behind each row.
type AdminCustomerWallet struct {
Customer AdminWalletCustomer `json:"customer"`
// Balances as the ledger has them.
PointBalance int64 `json:"point_balance"`
CoinBalance int64 `json:"coin_balance"`
// What can be spent now. Lower than the ledger balance only while lots that have
// expired wait for the expiry job.
SpendablePointBalance int64 `json:"spendable_point_balance"`
SpendableCoinBalance int64 `json:"spendable_coin_balance"`
Lots []AdminWalletLot `json:"lots"`
Transactions PaginatedResponse[AdminWalletTransaction] `json:"transactions"`
}
type AdminWalletCustomer struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Phone *string `json:"phone,omitempty"`
}
type AdminWalletLot struct {
ID uuid.UUID `json:"id"`
Currency string `json:"currency"`
OriginalAmount int64 `json:"original_amount"`
RemainingAmount int64 `json:"remaining_amount"`
ExpiresAt *time.Time `json:"expires_at"`
Expired bool `json:"expired"`
SourceTransactionID uuid.UUID `json:"source_transaction_id"`
OriginLotID *uuid.UUID `json:"origin_lot_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// AdminWalletTransaction is a ledger row with the names the customer does not see:
// the real counterparty of a transfer, the admin behind an adjustment, the cashier who
// took a payment, and the outlet.
type AdminWalletTransaction struct {
CustomerWalletTransaction
Counterparty *AdminWalletNamedRef `json:"counterparty,omitempty"`
CreatedBy *AdminWalletNamedRef `json:"created_by,omitempty"`
Outlet *AdminWalletNamedRef `json:"outlet,omitempty"`
Reason *string `json:"reason,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type AdminWalletNamedRef struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
}
// WalletAdjustment is a manual correction by an admin.
type WalletAdjustment struct {
Currency string
// Signed: positive adds, negative takes away.
Amount int64
Reason string
IdempotencyKey string
}
// AdminWalletAdjustmentResult is what POST /marketing/customers/:id/wallet/adjust returns.
type AdminWalletAdjustmentResult struct {
Transaction AdminWalletTransaction `json:"transaction"`
SpendablePointBalance int64 `json:"spendable_point_balance"`
SpendableCoinBalance int64 `json:"spendable_coin_balance"`
// True when the idempotency key had been used before and nothing changed.
Replayed bool `json:"replayed"`
}
// PointPaymentPreview is GET /orders/:id/point-payment/preview (docs/prd-point-coin.md
// F9): whether the order can be paid with EnakPoint and at most how much, for the
// cashier's "use maximum" button.
type PointPaymentPreview struct {
OrderID uuid.UUID `json:"order_id"`
CustomerID *uuid.UUID `json:"customer_id"`
Eligible bool `json:"eligible"`
// Why not, when not eligible.
Reason string `json:"reason,omitempty"`
PointBalance int64 `json:"point_balance"`
PointValue int64 `json:"point_value"`
RemainingAmount float64 `json:"remaining_amount"`
MinPaymentPoints int64 `json:"min_payment_points"`
MaxPaymentPercent int64 `json:"max_payment_percent"`
MaxPoints int64 `json:"max_points"`
// Rupiah covered by MaxPoints.
MaxAmount int64 `json:"max_amount"`
}
// CustomerWalletExpiringList is GET /customer/wallet/expiring (docs/prd-point-coin.md
// F6): everything that will expire, per currency and day, soonest first.
type CustomerWalletExpiringList struct {
Point []CustomerWalletExpiring `json:"point"`
Coin []CustomerWalletExpiring `json:"coin"`
}
+73
View File
@@ -0,0 +1,73 @@
package models
import (
"time"
"github.com/google/uuid"
)
// WalletExchangePreview is GET /customer/wallet/exchange/preview
// (docs/prd-point-coin.md F4): the rate, and what exchanging Coins would give.
type WalletExchangePreview struct {
// The rate: CoinAmount EnakCoin exchange into PointAmount EnakPoint.
CoinAmount int64 `json:"coin_amount"`
PointAmount int64 `json:"point_amount"`
CoinBalance int64 `json:"coin_balance"`
Coins int64 `json:"coins"`
Points int64 `json:"points"`
// Whether Coins can be exchanged now, and why not when it cannot.
Valid bool `json:"valid"`
Reason string `json:"reason,omitempty"`
}
// WalletMovedLot is part of what an exchange or a transfer delivered, with the
// expiry it carried over from the lot it came from (K9).
type WalletMovedLot struct {
Amount int64 `json:"amount"`
// Nil when it never expires.
ExpiresAt *time.Time `json:"expires_at"`
}
// WalletExchangeResult is POST /customer/wallet/exchange.
type WalletExchangeResult struct {
GroupID uuid.UUID `json:"group_id"`
Coins int64 `json:"coins"`
Points int64 `json:"points"`
CoinAmount int64 `json:"coin_amount"`
PointAmount int64 `json:"point_amount"`
// The EnakPoint received, split by expiry.
Lots []WalletMovedLot `json:"lots"`
CoinBalance int64 `json:"coin_balance"`
PointBalance int64 `json:"point_balance"`
// True when this was a retry of an exchange already made; nothing moved again.
Replayed bool `json:"replayed"`
}
// WalletTransferRecipient is GET /customer/wallet/transfer/recipient: who a phone
// number belongs to, masked, so the sender can check before confirming (F5).
type WalletTransferRecipient struct {
Name string `json:"name"`
PhoneNumber string `json:"phone_number"`
}
// WalletTransfer is what a customer asks to send (F5).
type WalletTransfer struct {
// POINT or COIN.
Currency string
Amount int64
RecipientPhone string
}
// WalletTransferResult is POST /customer/wallet/transfer.
type WalletTransferResult struct {
GroupID uuid.UUID `json:"group_id"`
Currency string `json:"currency"`
Amount int64 `json:"amount"`
Recipient WalletTransferRecipient `json:"recipient"`
// What the recipient received, split by the expiry it carried over.
Lots []WalletMovedLot `json:"lots"`
// The sender's balance in the currency sent.
Balance int64 `json:"balance"`
// True when this was a retry of a transfer already made; nothing moved again.
Replayed bool `json:"replayed"`
}
+46
View File
@@ -0,0 +1,46 @@
package models
import (
"time"
"github.com/google/uuid"
)
// AdminWalletTrace is GET /marketing/wallet-transactions/:id/trace
// (docs/prd-point-coin.md F7, §8.1): the lots a ledger row took from or created, and
// where each of them came from, lot by lot, back to the lot that first brought the
// balance into a wallet. It follows balance across transfers, exchanges and refunds.
type AdminWalletTrace struct {
Transaction AdminWalletTraceRow `json:"transaction"`
// One per lot the row took from (a debit) or created (a credit).
Lots []AdminWalletTraceLot `json:"lots"`
}
// AdminWalletTraceLot is how much of a row went through one lot, and that lot's chain.
type AdminWalletTraceLot struct {
Amount int64 `json:"amount"`
// The lot itself first, then the lot it was carried over from, and so on. The
// last step is the lot an EARN, ADJUSTMENT or MIGRATION created.
Chain []AdminWalletTraceStep `json:"chain"`
}
// AdminWalletTraceStep is one lot on a chain and the row that created it.
type AdminWalletTraceStep struct {
Lot AdminWalletLot `json:"lot"`
Source AdminWalletTraceRow `json:"source"`
}
// AdminWalletTraceRow is a ledger row as a trace shows it, with the real name of the
// customer it belongs to.
type AdminWalletTraceRow struct {
ID uuid.UUID `json:"id"`
Customer AdminWalletNamedRef `json:"customer"`
Currency string `json:"currency"`
Type string `json:"type"`
Amount int64 `json:"amount"`
Description string `json:"description"`
ReferenceType string `json:"reference_type"`
ReferenceID uuid.UUID `json:"reference_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}