Files
apskel-pos-backend/internal/repository/wallet_query_repository.go
T
efrilmandClaude Opus 5.5 550122f29c feat(loyalty): remind customers before balances expire
Adds what the customer sees of expiry (docs/prd-point-coin.md F6, F12,
PC-504).

GET /customer/wallet/expiring lists everything that will expire, per
currency and day, soonest first. GET /customer/wallet already had the
nearest expiry per currency.

The expiry job now also sends reminders, with the settings of note N4 as
decided: once, reminder_days before (7 by default, 0 for none), per
currency. A customer gets one FCM push per currency and expiry day,
however many lots make it up: "150 EnakPoint akan kedaluwarsa pada 31 Okt
2026. Pakai sebelum hangus.", with type WALLET_EXPIRING, the currency,
amount and expiry_date in its data. Reminders cover whatever falls within
the window, so a run that was missed catches up rather than skipping a day.

Migration 000097 adds wallet_expiry_reminders, one row per customer,
currency and expiry day. The row is written before the push is sent, so
several instances of the job or a restart never remind twice; a push that
then fails is logged and not retried. Lots that expire later on the same
day as an earlier reminder are not reminded of again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:35:12 +07:00

203 lines
7.3 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)
// 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
}