171 lines
5.8 KiB
Go
171 lines
5.8 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)
|
||
|
|
// 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
|
||
|
|
}
|