2026-09-30 09:15:26 +07:00
|
|
|
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)
|
2026-09-30 11:26:16 +07:00
|
|
|
// OrganizationOutstanding sums every wallet balance of an organization.
|
|
|
|
|
OrganizationOutstanding(ctx context.Context, organizationID uuid.UUID) (points, coins int64, err error)
|
2026-09-30 09:15:26 +07:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-09-30 11:26:16 +07:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|