Files
apskel-pos-backend/internal/repository/wallet_query_repository.go
T
efrilmandClaude Opus 5.5 fe2f459b03 feat(loyalty): organization loyalty settings API
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>
2026-09-30 11:26:16 +07:00

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
}