Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts commit 4e24f9bbb0.
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user