feat(wallet): serve customer balances and history from the wallet
GET /customer/wallet now reads the EnakPoint & EnakCoin wallet (docs/prd-point-coin.md F6, PC-106): spendable point and coin balances, the rupiah value of one EnakPoint and of the balance, the nearest day each currency loses balance (grouped by Asia/Jakarta day), and recent ledger rows. The fields of the pre-wallet response stay, filled from the wallet, so app versions that read them keep working. Adds GET /customer/wallet/transactions with pagination and filters for currency, one or more types, and an inclusive date range. Each row shows where the value came from (additions) or went to (deductions) as in §8.1, and additions list their lots and earliest expiry. The counterparty id, the admin and the metadata are left out; the description already carries the masked name. A malformed query answers 400, a missing customer 404. /customer/points and /customer/tokens keep their shape and now read the wallet too, so customer_points_repository is no longer used for balances. Balances are what the customer can spend: lots that have expired but that the expiry job has not processed are not counted. The point value is read from organization_settings (loyalty.point.value, default 1) through a small repository that the typed settings reader in PC-109 will build on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
41b75810fd
commit
e5db0325cc
@@ -0,0 +1,170 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user