Adds GET and PUT /marketing/loyalty-settings and GET /marketing/loyalty-settings/history (docs/prd-point-coin.md F2, PC-302). The settings are the point value, the exchange rate, transfer limits and the stored expiry settings. PUT merges the body like the outlet settings and is limited to loyalty managers. Every response carries the impact of the change on the balances in circulation: outstanding EnakPoint and EnakCoin, their rupiah value, and the coins exchanged into points, before and after. With ?dry_run=true nothing is saved and the response lists the keys that would change, for the warning shown before saving. Saving records each change in loyalty_setting_changes with who made it; history can be filtered to one outlet. Changing the value leaves what was already written alone. The diff behind saving and previewing is shared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
6.5 KiB
Go
184 lines
6.5 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
|
|
"apskel-pos-be/internal/entities"
|
|
)
|
|
|
|
// walletDisplayTimeZone is the zone expiry dates are grouped in for display, the same
|
|
// one the database connection uses (config.Database.DSN).
|
|
const walletDisplayTimeZone = "Asia/Jakarta"
|
|
|
|
// WalletTransactionFilter narrows a customer's ledger. Zero values mean no filter.
|
|
type WalletTransactionFilter struct {
|
|
CustomerID uuid.UUID
|
|
Currency string
|
|
Types []string
|
|
// From is inclusive and To is exclusive.
|
|
From *time.Time
|
|
To *time.Time
|
|
Offset int
|
|
Limit int
|
|
}
|
|
|
|
// WalletExpiringAmount is how much of one currency expires on one day.
|
|
type WalletExpiringAmount struct {
|
|
Currency string
|
|
// A calendar date in walletDisplayTimeZone, formatted YYYY-MM-DD.
|
|
Date string
|
|
Amount int64
|
|
}
|
|
|
|
// WalletQueryRepository serves the read side of the wallet: balances, history and
|
|
// upcoming expiry for display. Nothing here changes a balance.
|
|
type WalletQueryRepository interface {
|
|
// GetCustomerOrganizationID returns ErrWalletNotFound when the customer does not exist.
|
|
GetCustomerOrganizationID(ctx context.Context, customerID uuid.UUID) (uuid.UUID, error)
|
|
// SpendableBalances sums, per currency, the lots that still have balance and have
|
|
// not expired at asOf. It differs from the wallet balance only for lots that have
|
|
// expired but that the expiry job has not processed yet.
|
|
SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error)
|
|
// NearestExpiring returns, per currency, the earliest day after asOf on which some
|
|
// balance expires, and how much expires that day.
|
|
NearestExpiring(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error)
|
|
// ListTransactions returns a page of the ledger, newest first, and the total count.
|
|
ListTransactions(ctx context.Context, filter WalletTransactionFilter) ([]entities.WalletTransaction, int64, error)
|
|
// OrganizationOutstanding sums every wallet balance of an organization.
|
|
OrganizationOutstanding(ctx context.Context, organizationID uuid.UUID) (points, coins int64, err error)
|
|
// ListLotsBySourceTransactions returns the lots created by the given credits.
|
|
ListLotsBySourceTransactions(ctx context.Context, transactionIDs []uuid.UUID) ([]entities.WalletLot, error)
|
|
}
|
|
|
|
type walletQueryRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewWalletQueryRepository(db *gorm.DB) WalletQueryRepository {
|
|
return &walletQueryRepository{db: db}
|
|
}
|
|
|
|
func (r *walletQueryRepository) GetCustomerOrganizationID(ctx context.Context, customerID uuid.UUID) (uuid.UUID, error) {
|
|
var ids []string
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
|
Table("customers").
|
|
Where("id = ?", customerID).
|
|
Limit(1).
|
|
Pluck("organization_id::text", &ids).Error
|
|
if err != nil {
|
|
return uuid.Nil, fmt.Errorf("failed to get customer organization: %w", err)
|
|
}
|
|
if len(ids) == 0 {
|
|
return uuid.Nil, ErrWalletNotFound
|
|
}
|
|
return uuid.Parse(ids[0])
|
|
}
|
|
|
|
func (r *walletQueryRepository) SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) {
|
|
var rows []struct {
|
|
Currency string
|
|
Amount int64
|
|
}
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
|
Model(&entities.WalletLot{}).
|
|
Select("currency, SUM(remaining_amount) AS amount").
|
|
Where("customer_id = ? AND remaining_amount > 0", customerID).
|
|
Where("(expires_at IS NULL OR expires_at > ?)", asOf).
|
|
Group("currency").
|
|
Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to sum spendable wallet balance: %w", err)
|
|
}
|
|
balances := make(map[string]int64, len(rows))
|
|
for _, row := range rows {
|
|
balances[row.Currency] = row.Amount
|
|
}
|
|
return balances, nil
|
|
}
|
|
|
|
func (r *walletQueryRepository) NearestExpiring(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) {
|
|
var rows []WalletExpiringAmount
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
|
WITH by_day AS (
|
|
SELECT currency,
|
|
(expires_at AT TIME ZONE ?)::date AS day,
|
|
SUM(remaining_amount) AS amount
|
|
FROM wallet_lots
|
|
WHERE customer_id = ? AND remaining_amount > 0 AND expires_at > ?
|
|
GROUP BY currency, day
|
|
)
|
|
SELECT DISTINCT ON (currency) currency, to_char(day, 'YYYY-MM-DD') AS date, amount
|
|
FROM by_day
|
|
ORDER BY currency, day`, walletDisplayTimeZone, customerID, asOf).
|
|
Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get nearest expiring wallet balance: %w", err)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func (r *walletQueryRepository) ListTransactions(ctx context.Context, filter WalletTransactionFilter) ([]entities.WalletTransaction, int64, error) {
|
|
query := DBFromContext(ctx, r.db).WithContext(ctx).
|
|
Model(&entities.WalletTransaction{}).
|
|
Where("customer_id = ?", filter.CustomerID)
|
|
if filter.Currency != "" {
|
|
query = query.Where("currency = ?", filter.Currency)
|
|
}
|
|
if len(filter.Types) > 0 {
|
|
query = query.Where("type IN ?", filter.Types)
|
|
}
|
|
if filter.From != nil {
|
|
query = query.Where("created_at >= ?", *filter.From)
|
|
}
|
|
if filter.To != nil {
|
|
query = query.Where("created_at < ?", *filter.To)
|
|
}
|
|
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, fmt.Errorf("failed to count wallet transactions: %w", err)
|
|
}
|
|
|
|
var transactions []entities.WalletTransaction
|
|
err := query.
|
|
Order("created_at DESC, id DESC").
|
|
Offset(filter.Offset).
|
|
Limit(filter.Limit).
|
|
Find(&transactions).Error
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("failed to list wallet transactions: %w", err)
|
|
}
|
|
return transactions, total, nil
|
|
}
|
|
|
|
func (r *walletQueryRepository) ListLotsBySourceTransactions(ctx context.Context, transactionIDs []uuid.UUID) ([]entities.WalletLot, error) {
|
|
var lots []entities.WalletLot
|
|
if len(transactionIDs) == 0 {
|
|
return lots, nil
|
|
}
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
|
Where("source_transaction_id IN ?", transactionIDs).
|
|
Order("expires_at NULLS LAST, created_at, id").
|
|
Find(&lots).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list wallet lots: %w", err)
|
|
}
|
|
return lots, nil
|
|
}
|
|
|
|
func (r *walletQueryRepository) OrganizationOutstanding(ctx context.Context, organizationID uuid.UUID) (int64, int64, error) {
|
|
var totals struct{ Points, Coins int64 }
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
|
SELECT COALESCE(SUM(point_balance), 0) AS points, COALESCE(SUM(coin_balance), 0) AS coins
|
|
FROM customer_wallets WHERE organization_id = ?`, organizationID).Scan(&totals).Error
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("failed to sum outstanding balances: %w", err)
|
|
}
|
|
return totals.Points, totals.Coins, nil
|
|
}
|