Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts commit 4e24f9bbb0.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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)
|
||||
// ExpiringByDay returns, per currency and day, everything that expires after asOf,
|
||||
// soonest first.
|
||||
ExpiringByDay(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
|
||||
}
|
||||
|
||||
func (r *walletQueryRepository) ExpiringByDay(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) {
|
||||
var rows []WalletExpiringAmount
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT currency, to_char((expires_at AT TIME ZONE ?)::date, 'YYYY-MM-DD') AS date,
|
||||
SUM(remaining_amount) AS amount
|
||||
FROM wallet_lots
|
||||
WHERE customer_id = ? AND remaining_amount > 0 AND expires_at > ?
|
||||
GROUP BY currency, date
|
||||
ORDER BY date, currency`, walletDisplayTimeZone, customerID, asOf).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list expiring wallet balance: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
Reference in New Issue
Block a user