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:
efrilm
2026-09-30 09:15:26 +07:00
co-authored by Claude Opus 5.5
parent 41b75810fd
commit e5db0325cc
13 changed files with 1199 additions and 130 deletions
+5 -1
View File
@@ -248,6 +248,8 @@ type repositories struct {
expenseRepo *repository.ExpenseRepositoryImpl expenseRepo *repository.ExpenseRepositoryImpl
cashAdvanceRepo *repository.CashAdvanceRepositoryImpl cashAdvanceRepo *repository.CashAdvanceRepositoryImpl
walletRepo repository.WalletRepository walletRepo repository.WalletRepository
walletQueryRepo repository.WalletQueryRepository
organizationSettingRepo repository.OrganizationSettingRepository
} }
func (a *App) initRepositories() *repositories { func (a *App) initRepositories() *repositories {
@@ -304,6 +306,8 @@ func (a *App) initRepositories() *repositories {
expenseRepo: repository.NewExpenseRepositoryImpl(a.db), expenseRepo: repository.NewExpenseRepositoryImpl(a.db),
cashAdvanceRepo: repository.NewCashAdvanceRepositoryImpl(a.db), cashAdvanceRepo: repository.NewCashAdvanceRepositoryImpl(a.db),
walletRepo: repository.NewWalletRepository(a.db), walletRepo: repository.NewWalletRepository(a.db),
walletQueryRepo: repository.NewWalletQueryRepository(a.db),
organizationSettingRepo: repository.NewOrganizationSettingRepository(a.db),
} }
} }
@@ -397,7 +401,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo), campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo),
campaignRuleProcessor: processor.NewCampaignRuleProcessor(repos.campaignRuleRepo), campaignRuleProcessor: processor.NewCampaignRuleProcessor(repos.campaignRuleRepo),
customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()), customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()),
customerPointsProcessor: processor.NewCustomerPointsProcessor(repos.customerPointsRepo, repos.gameRepo), customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, repos.organizationSettingRepo), repos.gameRepo),
otpProcessor: otpProcessor, otpProcessor: otpProcessor,
fileClient: fileClient, fileClient: fileClient,
inventoryMovementService: inventoryMovementService, inventoryMovementService: inventoryMovementService,
+7
View File
@@ -42,3 +42,10 @@ const (
WalletRefTypeLegacyTokens = "LEGACY_TOKENS" WalletRefTypeLegacyTokens = "LEGACY_TOKENS"
WalletRefTypeRewardRedemption = "REWARD_REDEMPTION" WalletRefTypeRewardRedemption = "REWARD_REDEMPTION"
) )
// Organization loyalty settings (docs/prd-point-coin.md F2), stored in
// organization_settings. A key that was never set takes its default.
const (
LoyaltyPointValueKey = "loyalty.point.value"
LoyaltyPointValueDefault = int64(1)
)
+56 -3
View File
@@ -1,9 +1,14 @@
package handler package handler
import ( import (
"errors"
"apskel-pos-be/internal/constants" "apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract" "apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger" "apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
"apskel-pos-be/internal/service" "apskel-pos-be/internal/service"
"apskel-pos-be/internal/util" "apskel-pos-be/internal/util"
@@ -46,7 +51,7 @@ func (h *CustomerPointsHandler) GetCustomerPoints(c *gin.Context) {
if err != nil { if err != nil {
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerPoints -> service call failed") logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerPoints -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
}), "CustomerPointsHandler::GetCustomerPoints") }), "CustomerPointsHandler::GetCustomerPoints")
return return
} }
@@ -80,7 +85,7 @@ func (h *CustomerPointsHandler) GetCustomerTokens(c *gin.Context) {
if err != nil { if err != nil {
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerTokens -> service call failed") logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerTokens -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
}), "CustomerPointsHandler::GetCustomerTokens") }), "CustomerPointsHandler::GetCustomerTokens")
return return
} }
@@ -114,7 +119,7 @@ func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) {
if err != nil { if err != nil {
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWallet -> service call failed") logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWallet -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
}), "CustomerPointsHandler::GetCustomerWallet") }), "CustomerPointsHandler::GetCustomerWallet")
return return
} }
@@ -151,3 +156,51 @@ func (h *CustomerPointsHandler) GetFerrisWheelGame(c *gin.Context) {
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetFerrisWheelGame") util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetFerrisWheelGame")
} }
// GetCustomerWalletTransactions is GET /customer/wallet/transactions: the ledger, newest
// first, filterable by currency, type and date (docs/prd-point-coin.md F6).
func (h *CustomerPointsHandler) GetCustomerWalletTransactions(c *gin.Context) {
ctx := c.Request.Context()
customerID, ok := c.Get("customer_id")
customerIDStr, isString := customerID.(string)
if !ok || !isString {
logger.FromContext(ctx).Error("Customer ID not found in context")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"),
}), "CustomerPointsHandler::GetCustomerWalletTransactions")
return
}
var query models.ListCustomerWalletTransactionsQuery
if err := c.ShouldBindQuery(&query); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()),
}), "CustomerPointsHandler::GetCustomerWalletTransactions")
return
}
response, err := h.customerPointsService.GetCustomerWalletTransactions(ctx, customerIDStr, query)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWalletTransactions -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
}), "CustomerPointsHandler::GetCustomerWalletTransactions")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWalletTransactions")
}
// walletErrorCode tells a bad request apart from a server failure on the wallet
// endpoints.
func walletErrorCode(err error) string {
switch {
case errors.Is(err, processor.ErrInvalidWalletQuery):
return constants.ValidationErrorCode
case errors.Is(err, repository.ErrWalletNotFound):
return constants.NotFoundErrorCode
default:
return constants.InternalServerErrorCode
}
}
+218
View File
@@ -0,0 +1,218 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"apskel-pos-be/internal/constants"
applogger "apskel-pos-be/internal/logger"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
"apskel-pos-be/internal/service"
)
// Drives the customer wallet endpoints over HTTP down to Postgres, to show the app
// gets the right numbers from the new endpoints and from the old /points, /tokens
// and /wallet shapes. Needs TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go.
func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
applogger.Setup("fatal", "json") // the handlers log their errors; the server sets this up at startup
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
org, customer, other := uuid.New(), uuid.New(), uuid.New()
customers := []uuid.UUID{customer, other}
exec := func(q string, args ...any) {
t.Helper()
require.NoError(t, db.Exec(q, args...).Error)
}
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet api test', 'basic')`, org)
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'A'), (?, ?, 'B')`, customer, org, other, org)
exec(`INSERT INTO organization_settings (organization_id, key, value) VALUES (?, ?, '100')`, org, constants.LoyaltyPointValueKey)
t.Cleanup(func() {
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers)
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers)
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers)
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers)
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
})
// Balance: 300 earned in two lots that expire on the same Jakarta day but on
// different UTC days, 8 coins, 50 paid, and 20 in a lot that has expired but
// that the expiry job has not processed.
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
txm := repository.NewTxManager(db)
next := time.Now().Year() + 1
lateNightUTC := time.Date(next, 7, 1, 17, 30, 0, 0, time.UTC) // 2 July 00:30 WIB
afternoonUTC := time.Date(next, 7, 2, 10, 0, 0, 0, time.UTC) // 2 July 17:00 WIB
expired := time.Now().Add(-time.Hour)
outlet, order, payment := uuid.New(), uuid.New(), uuid.New()
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
if _, err := wallet.Credit(ctx, processor.WalletCreditInput{
WalletEntry: processor.WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypeEarn, Amount: 300, ReferenceType: constants.WalletRefTypeOrder,
ReferenceID: order, OutletID: &outlet, Description: "Belanja #ORD-1"},
Lots: []processor.WalletLotInput{{Amount: 200, ExpiresAt: &afternoonUTC}, {Amount: 100, ExpiresAt: &lateNightUTC}},
}); err != nil {
return err
}
if _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
CustomerID: customer, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration,
Amount: 8, ReferenceType: constants.WalletRefTypeLegacyTokens, ReferenceID: customer,
Description: "Saldo awal dari sistem lama"}}); err != nil {
return err
}
if _, err := wallet.Debit(ctx, processor.WalletDebitInput{WalletEntry: processor.WalletEntry{
CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment,
Amount: 50, ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, OutletID: &outlet,
Description: "Bayar #ORD-2"}}); err != nil {
return err
}
_, err := wallet.Credit(ctx, processor.WalletCreditInput{
WalletEntry: processor.WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypeEarn, Amount: 20, ReferenceType: constants.WalletRefTypeOrder,
ReferenceID: uuid.New(), OutletID: &outlet, Description: "Belanja #ORD-0"},
Lots: []processor.WalletLotInput{{Amount: 20, ExpiresAt: &expired}},
})
return err
}))
h := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor(
processor.NewWalletQueryProcessor(repository.NewWalletQueryRepository(db), repository.NewOrganizationSettingRepository(db)),
nil,
)))
gin.SetMode(gin.TestMode)
router := gin.New()
as := func(id uuid.UUID) gin.HandlerFunc {
return func(c *gin.Context) { c.Set("customer_id", id.String()) }
}
for _, who := range []struct {
prefix string
id uuid.UUID
}{{"/a", customer}, {"/b", other}, {"/gone", uuid.New()}} {
g := router.Group(who.prefix, as(who.id))
g.GET("/points", h.GetCustomerPoints)
g.GET("/tokens", h.GetCustomerTokens)
g.GET("/wallet", h.GetCustomerWallet)
g.GET("/wallet/transactions", h.GetCustomerWalletTransactions)
}
get := func(path string) (int, map[string]any) {
t.Helper()
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
var body map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body), rec.Body.String())
return rec.Code, body
}
// The balance endpoints wrap their data in {status, message, data}.
inner := func(body map[string]any) map[string]any {
return body["data"].(map[string]any)["data"].(map[string]any)
}
// Old app: /points and /tokens keep their shape and show the spendable balance.
status, body := get("/a/points")
require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 250, inner(body)["total_points"], "300 - 50; the expired 20 is not spendable")
assert.Len(t, inner(body)["points_history"], 3)
status, body = get("/a/tokens")
require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 8, inner(body)["total_tokens"])
// /wallet: new fields, with the old ones still there.
status, body = get("/a/wallet")
require.Equal(t, http.StatusOK, status, body)
w := inner(body)
assert.EqualValues(t, 250, w["point_balance"])
assert.EqualValues(t, 8, w["coin_balance"])
assert.EqualValues(t, 100, w["point_value"])
assert.EqualValues(t, 25000, w["point_discount_value"])
nearest := w["nearest_expiring"].(map[string]any)
// The payment took 50 from the lot expiring first, leaving 50 + 200.
assert.Equal(t, map[string]any{"amount": float64(250), "date": time.Date(next, 7, 2, 0, 0, 0, 0, time.UTC).Format("2006-01-02")},
nearest["point"], "both lots fall on 2 July in Jakarta although they are on different UTC days")
assert.Nil(t, nearest["coin"])
for _, legacy := range []string{"total_points", "total_tokens", "points_history", "tokens_history", "last_updated"} {
assert.Contains(t, w, legacy)
}
assert.EqualValues(t, 250, w["total_points"])
recent := w["recent_transactions"].([]any)
require.Len(t, recent, 4)
for _, raw := range recent {
tx := raw.(map[string]any)
assert.NotContains(t, tx, "counterparty_customer_id")
assert.NotContains(t, tx, "metadata")
if tx["amount"].(float64) > 0 {
assert.Contains(t, tx, "source", tx["type"])
assert.NotContains(t, tx, "destination")
} else {
assert.Equal(t, map[string]any{"type": "PAYMENT", "id": payment.String()}, tx["destination"])
assert.NotContains(t, tx, "source")
}
}
// History with filters.
status, body = get("/a/wallet/transactions?currency=POINT&type=EARN&limit=1&page=2")
require.Equal(t, http.StatusOK, status, body)
page := body["data"].(map[string]any)
assert.Equal(t, map[string]any{"page": float64(2), "limit": float64(1), "total_count": float64(2), "total_pages": float64(2)}, page["pagination"])
rows := page["data"].([]any)
require.Len(t, rows, 1)
first := rows[0].(map[string]any)
assert.Equal(t, "Belanja #ORD-1", first["description"], "newest first, so page 2 is the older earning")
assert.Equal(t, map[string]any{"type": "ORDER", "id": order.String()}, first["source"])
assert.Equal(t, lateNightUTC.Format(time.RFC3339), parseTime(t, first["expires_at"]).UTC().Format(time.RFC3339))
assert.Len(t, first["lots"], 2)
today := time.Now().In(time.FixedZone("WIB", 7*3600)).Format("2006-01-02")
status, body = get("/a/wallet/transactions?from=" + today + "&to=" + today + "&type=PAYMENT,MIGRATION")
require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 2, body["data"].(map[string]any)["pagination"].(map[string]any)["total_count"])
status, _ = get("/a/wallet/transactions?currency=GOLD")
assert.Equal(t, http.StatusBadRequest, status)
status, _ = get("/a/wallet/transactions?limit=abc")
assert.Equal(t, http.StatusBadRequest, status)
// A customer who has never had a wallet sees zeros, not an error.
status, body = get("/b/wallet")
require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 0, inner(body)["point_balance"])
assert.Equal(t, []any{}, inner(body)["recent_transactions"])
status, body = get("/b/wallet/transactions")
require.Equal(t, http.StatusOK, status, body)
assert.Equal(t, []any{}, body["data"].(map[string]any)["data"])
// A token for a customer that no longer exists.
status, _ = get("/gone/wallet")
assert.Equal(t, http.StatusNotFound, status)
}
func parseTime(t *testing.T, v any) time.Time {
t.Helper()
s, ok := v.(string)
require.True(t, ok, "expected a time string, got %v", v)
parsed, err := time.Parse(time.RFC3339Nano, s)
require.NoError(t, err)
return parsed
}
+13
View File
@@ -109,7 +109,20 @@ type GetCustomerWalletResponse struct {
Data *GetCustomerWalletResponseData `json:"data,omitempty"` Data *GetCustomerWalletResponseData `json:"data,omitempty"`
} }
// GetCustomerWalletResponseData is GET /customer/wallet (docs/prd-point-coin.md F6).
type GetCustomerWalletResponseData struct { type GetCustomerWalletResponseData struct {
// Balances the customer can spend now.
PointBalance int64 `json:"point_balance"`
CoinBalance int64 `json:"coin_balance"`
// Rupiah value of one EnakPoint, and of the whole point balance. EnakPoint only
// ever pays for orders, so the app shows this as "setara potongan Rp …" (K7).
PointValue int64 `json:"point_value"`
PointDiscountValue int64 `json:"point_discount_value"`
NearestExpiring CustomerWalletNearestExpiring `json:"nearest_expiring"`
RecentTransactions []CustomerWalletTransaction `json:"recent_transactions"`
// Deprecated: the shape of this endpoint before the wallet, kept for app versions
// that still read it. Filled from the wallet like the fields above.
TotalPoints int64 `json:"total_points"` TotalPoints int64 `json:"total_points"`
TotalTokens int64 `json:"total_tokens"` TotalTokens int64 `json:"total_tokens"`
PointsHistory []PointsHistoryItem `json:"points_history,omitempty"` PointsHistory []PointsHistoryItem `json:"points_history,omitempty"`
+71
View File
@@ -0,0 +1,71 @@
package models
import (
"time"
"github.com/google/uuid"
)
// CustomerWalletTransaction is one ledger row as the customer app shows it
// (docs/prd-point-coin.md F6).
type CustomerWalletTransaction struct {
ID uuid.UUID `json:"id"`
Currency string `json:"currency"`
Type string `json:"type"`
// Signed: positive added to the balance, negative taken from it.
Amount int64 `json:"amount"`
BalanceAfter int64 `json:"balance_after"`
Description string `json:"description"`
// Where the value came from, set on additions.
Source *CustomerWalletTransactionRef `json:"source,omitempty"`
// Where the value went, set on deductions.
Destination *CustomerWalletTransactionRef `json:"destination,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
ReversesTransactionID *uuid.UUID `json:"reverses_transaction_id,omitempty"`
// Shared by the two rows of an exchange or a transfer.
GroupID *uuid.UUID `json:"group_id,omitempty"`
// Additions only: the earliest expiry among the lots it created, nil when none of
// them expire, and the lots themselves.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Lots []CustomerWalletLot `json:"lots,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CustomerWalletTransactionRef points at what a ledger row came from or went to, as
// listed in §8.1: ORDER, PAYMENT, WALLET_TX, GAME_PLAY, LOT, USER and so on.
type CustomerWalletTransactionRef struct {
Type string `json:"type"`
ID uuid.UUID `json:"id"`
}
type CustomerWalletLot struct {
Amount int64 `json:"amount"`
Remaining int64 `json:"remaining"`
ExpiresAt *time.Time `json:"expires_at"`
}
// CustomerWalletExpiring is how much expires on one day.
type CustomerWalletExpiring struct {
Amount int64 `json:"amount"`
// YYYY-MM-DD, Asia/Jakarta.
Date string `json:"date"`
}
// CustomerWalletNearestExpiring is the next day each currency loses balance, nil when
// nothing is due to expire.
type CustomerWalletNearestExpiring struct {
Point *CustomerWalletExpiring `json:"point"`
Coin *CustomerWalletExpiring `json:"coin"`
}
// ListCustomerWalletTransactionsQuery is GET /customer/wallet/transactions.
type ListCustomerWalletTransactionsQuery struct {
Page int `form:"page"`
Limit int `form:"limit"`
Currency string `form:"currency"`
// One type, or several separated by commas.
Type string `form:"type"`
// Inclusive calendar dates, YYYY-MM-DD, Asia/Jakarta.
From string `form:"from"`
To string `form:"to"`
}
+40 -126
View File
@@ -3,7 +3,6 @@ package processor
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"apskel-pos-be/internal/models" "apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository" "apskel-pos-be/internal/repository"
@@ -12,14 +11,14 @@ import (
) )
type CustomerPointsProcessor struct { type CustomerPointsProcessor struct {
customerPointsRepo repository.CustomerPointsRepository walletQuery *WalletQueryProcessor
gameRepo *repository.GameRepository gameRepo *repository.GameRepository
} }
func NewCustomerPointsProcessor(customerPointsRepo repository.CustomerPointsRepository, gameRepo *repository.GameRepository) *CustomerPointsProcessor { func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor, gameRepo *repository.GameRepository) *CustomerPointsProcessor {
return &CustomerPointsProcessor{ return &CustomerPointsProcessor{
customerPointsRepo: customerPointsRepo, walletQuery: walletQuery,
gameRepo: gameRepo, gameRepo: gameRepo,
} }
} }
@@ -70,159 +69,74 @@ func (p *CustomerPointsProcessor) DeductPoints(ctx context.Context, customerID u
return nil, fmt.Errorf("not implemented") return nil, fmt.Errorf("not implemented")
} }
// The three endpoints below are the customer app's balance screens. They read the
// EnakPoint & EnakCoin wallet; customer_points and customer_tokens are no longer used
// for balances (docs/prd-point-coin.md §9).
func (p *CustomerPointsProcessor) GetCustomerTotalPointsAPI(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) { func (p *CustomerPointsProcessor) GetCustomerTotalPointsAPI(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) {
// Get total points id, err := parseWalletCustomerID(customerID)
totalPoints, err := p.customerPointsRepo.GetCustomerTotalPoints(ctx, customerID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get customer total points: %w", err) return nil, err
} }
data, err := p.walletQuery.LegacyPoints(ctx, id)
// Get points history (last 10 records)
pointsHistory, err := p.customerPointsRepo.GetCustomerPointsHistory(ctx, customerID, 10)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get customer points history: %w", err) return nil, fmt.Errorf("failed to get customer points: %w", err)
} }
// Convert to response format
var historyItems []models.PointsHistoryItem
for _, point := range pointsHistory {
historyItems = append(historyItems, models.PointsHistoryItem{
ID: point.ID.String(),
Points: point.Balance,
Type: "BALANCE",
Description: "Points balance",
CreatedAt: point.CreatedAt,
})
}
var lastUpdated time.Time
if len(pointsHistory) > 0 {
lastUpdated = pointsHistory[0].CreatedAt
}
return &models.GetCustomerPointsResponse{ return &models.GetCustomerPointsResponse{
Status: "SUCCESS", Status: "SUCCESS",
Message: "Customer points retrieved successfully.", Message: "Customer points retrieved successfully.",
Data: &models.GetCustomerPointsResponseData{ Data: data,
TotalPoints: totalPoints,
PointsHistory: historyItems,
LastUpdated: lastUpdated,
},
}, nil }, nil
} }
func (p *CustomerPointsProcessor) GetCustomerTotalTokensAPI(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) { func (p *CustomerPointsProcessor) GetCustomerTotalTokensAPI(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) {
// Get total tokens id, err := parseWalletCustomerID(customerID)
totalTokens, err := p.customerPointsRepo.GetCustomerTotalTokens(ctx, customerID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get customer total tokens: %w", err) return nil, err
} }
data, err := p.walletQuery.LegacyTokens(ctx, id)
// Get tokens history (last 10 records)
tokensHistory, err := p.customerPointsRepo.GetCustomerTokensHistory(ctx, customerID, 10)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get customer tokens history: %w", err) return nil, fmt.Errorf("failed to get customer tokens: %w", err)
} }
// Convert to response format
var historyItems []models.TokensHistoryItem
for _, token := range tokensHistory {
historyItems = append(historyItems, models.TokensHistoryItem{
ID: token.ID.String(),
Tokens: token.Balance,
Type: string(token.TokenType),
Description: "Tokens balance",
CreatedAt: token.CreatedAt,
})
}
var lastUpdated time.Time
if len(tokensHistory) > 0 {
lastUpdated = tokensHistory[0].CreatedAt
}
return &models.GetCustomerTokensResponse{ return &models.GetCustomerTokensResponse{
Status: "SUCCESS", Status: "SUCCESS",
Message: "Customer tokens retrieved successfully.", Message: "Customer tokens retrieved successfully.",
Data: &models.GetCustomerTokensResponseData{ Data: data,
TotalTokens: totalTokens,
TokensHistory: historyItems,
LastUpdated: lastUpdated,
},
}, nil }, nil
} }
func (p *CustomerPointsProcessor) GetCustomerWalletAPI(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) { func (p *CustomerPointsProcessor) GetCustomerWalletAPI(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) {
// Get total points id, err := parseWalletCustomerID(customerID)
totalPoints, err := p.customerPointsRepo.GetCustomerTotalPoints(ctx, customerID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get customer total points: %w", err) return nil, err
} }
data, err := p.walletQuery.Summary(ctx, id)
// Get total tokens
totalTokens, err := p.customerPointsRepo.GetCustomerTotalTokens(ctx, customerID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get customer total tokens: %w", err) return nil, fmt.Errorf("failed to get customer wallet: %w", err)
} }
// Get points history (last 5 records)
pointsHistory, err := p.customerPointsRepo.GetCustomerPointsHistory(ctx, customerID, 5)
if err != nil {
return nil, fmt.Errorf("failed to get customer points history: %w", err)
}
// Get tokens history (last 5 records)
tokensHistory, err := p.customerPointsRepo.GetCustomerTokensHistory(ctx, customerID, 5)
if err != nil {
return nil, fmt.Errorf("failed to get customer tokens history: %w", err)
}
// Convert to response format
var pointsHistoryItems []models.PointsHistoryItem
var tokensHistoryItems []models.TokensHistoryItem
var lastUpdated time.Time
for _, point := range pointsHistory {
pointsHistoryItems = append(pointsHistoryItems, models.PointsHistoryItem{
ID: point.ID.String(),
Points: point.Balance,
Type: "BALANCE",
Description: "Points balance",
CreatedAt: point.CreatedAt,
})
if point.CreatedAt.After(lastUpdated) {
lastUpdated = point.CreatedAt
}
}
for _, token := range tokensHistory {
tokensHistoryItems = append(tokensHistoryItems, models.TokensHistoryItem{
ID: token.ID.String(),
Tokens: token.Balance,
Type: string(token.TokenType),
Description: "Tokens balance",
CreatedAt: token.CreatedAt,
})
if token.CreatedAt.After(lastUpdated) {
lastUpdated = token.CreatedAt
}
}
return &models.GetCustomerWalletResponse{ return &models.GetCustomerWalletResponse{
Status: "SUCCESS", Status: "SUCCESS",
Message: "Customer wallet retrieved successfully.", Message: "Customer wallet retrieved successfully.",
Data: &models.GetCustomerWalletResponseData{ Data: data,
TotalPoints: totalPoints,
TotalTokens: totalTokens,
PointsHistory: pointsHistoryItems,
TokensHistory: tokensHistoryItems,
LastUpdated: lastUpdated,
},
}, nil }, nil
} }
func (p *CustomerPointsProcessor) GetCustomerWalletTransactionsAPI(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) {
id, err := parseWalletCustomerID(customerID)
if err != nil {
return nil, err
}
return p.walletQuery.Transactions(ctx, id, query)
}
func parseWalletCustomerID(customerID string) (uuid.UUID, error) {
id, err := uuid.Parse(customerID)
if err != nil {
return uuid.Nil, fmt.Errorf("%w: invalid customer id", ErrInvalidWalletQuery)
}
return id, nil
}
// GetCustomerGamesAPI gets active SPIN games for customers // GetCustomerGamesAPI gets active SPIN games for customers
func (p *CustomerPointsProcessor) GetCustomerGamesAPI(ctx context.Context) (*models.GetCustomerGamesResponse, error) { func (p *CustomerPointsProcessor) GetCustomerGamesAPI(ctx context.Context) (*models.GetCustomerGamesResponse, error) {
// Get active SPIN games // Get active SPIN games
@@ -0,0 +1,323 @@
package processor
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// ErrInvalidWalletQuery wraps every rejection of a malformed history query, so the
// handler can answer with a validation error rather than a server error.
var ErrInvalidWalletQuery = errors.New("invalid wallet query")
const (
walletRecentTransactions = 5
walletLegacyHistorySize = 10
walletTransactionsPageLimit = 20
walletTransactionsMaxLimit = 100
)
// walletDisplayLocation is where the customer's calendar days start and end, for
// date filters. Same zone as the database connection and the expiry grouping.
var walletDisplayLocation = func() *time.Location {
loc, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return time.FixedZone("WIB", 7*60*60)
}
return loc
}()
// WalletQueryProcessor answers what the customer app shows about a wallet: balances,
// history and upcoming expiry (docs/prd-point-coin.md F6). It only reads.
type WalletQueryProcessor struct {
repo repository.WalletQueryRepository
settings repository.OrganizationSettingRepository
now func() time.Time
}
func NewWalletQueryProcessor(repo repository.WalletQueryRepository, settings repository.OrganizationSettingRepository) *WalletQueryProcessor {
return &WalletQueryProcessor{repo: repo, settings: settings, now: time.Now}
}
// Summary is GET /customer/wallet. A customer who has never had a wallet gets zeros.
func (p *WalletQueryProcessor) Summary(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerWalletResponseData, error) {
organizationID, err := p.repo.GetCustomerOrganizationID(ctx, customerID)
if err != nil {
return nil, err
}
now := p.now()
balances, err := p.repo.SpendableBalances(ctx, customerID, now)
if err != nil {
return nil, err
}
pointValue, err := p.pointValue(ctx, organizationID)
if err != nil {
return nil, err
}
expiring, err := p.repo.NearestExpiring(ctx, customerID, now)
if err != nil {
return nil, err
}
recent, err := p.recent(ctx, customerID, "", walletRecentTransactions)
if err != nil {
return nil, err
}
points, err := p.legacyPoints(ctx, customerID, balances)
if err != nil {
return nil, err
}
tokens, err := p.legacyTokens(ctx, customerID, balances)
if err != nil {
return nil, err
}
data := &models.GetCustomerWalletResponseData{
PointBalance: balances[constants.WalletCurrencyPoint],
CoinBalance: balances[constants.WalletCurrencyCoin],
PointValue: pointValue,
PointDiscountValue: balances[constants.WalletCurrencyPoint] * pointValue,
RecentTransactions: recent,
TotalPoints: points.TotalPoints,
TotalTokens: tokens.TotalTokens,
PointsHistory: points.PointsHistory,
TokensHistory: tokens.TokensHistory,
LastUpdated: points.LastUpdated,
}
if tokens.LastUpdated.After(data.LastUpdated) {
data.LastUpdated = tokens.LastUpdated
}
for _, e := range expiring {
entry := &models.CustomerWalletExpiring{Amount: e.Amount, Date: e.Date}
switch e.Currency {
case constants.WalletCurrencyPoint:
data.NearestExpiring.Point = entry
case constants.WalletCurrencyCoin:
data.NearestExpiring.Coin = entry
}
}
return data, nil
}
// Transactions is GET /customer/wallet/transactions.
func (p *WalletQueryProcessor) Transactions(ctx context.Context, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) {
filter, page, err := walletTransactionFilter(customerID, query)
if err != nil {
return nil, err
}
rows, total, err := p.repo.ListTransactions(ctx, filter)
if err != nil {
return nil, err
}
items, err := p.toTransactions(ctx, rows)
if err != nil {
return nil, err
}
totalPages := int((total + int64(filter.Limit) - 1) / int64(filter.Limit))
return &models.PaginatedResponse[models.CustomerWalletTransaction]{
Data: items,
Pagination: models.Pagination{Page: page, Limit: filter.Limit, Total: total, TotalPages: totalPages},
}, nil
}
// LegacyPoints is the pre-wallet GET /customer/points, now read from the wallet.
func (p *WalletQueryProcessor) LegacyPoints(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerPointsResponseData, error) {
balances, err := p.repo.SpendableBalances(ctx, customerID, p.now())
if err != nil {
return nil, err
}
return p.legacyPoints(ctx, customerID, balances)
}
func (p *WalletQueryProcessor) legacyPoints(ctx context.Context, customerID uuid.UUID, balances map[string]int64) (*models.GetCustomerPointsResponseData, error) {
recent, err := p.recent(ctx, customerID, constants.WalletCurrencyPoint, walletLegacyHistorySize)
if err != nil {
return nil, err
}
data := &models.GetCustomerPointsResponseData{TotalPoints: balances[constants.WalletCurrencyPoint]}
for _, tx := range recent {
data.PointsHistory = append(data.PointsHistory, models.PointsHistoryItem{
ID: tx.ID.String(), Points: tx.Amount, Type: tx.Type, Description: tx.Description, CreatedAt: tx.CreatedAt,
})
}
if len(recent) > 0 {
data.LastUpdated = recent[0].CreatedAt
}
return data, nil
}
// LegacyTokens is the pre-wallet GET /customer/tokens. Tokens are EnakCoin now.
func (p *WalletQueryProcessor) LegacyTokens(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerTokensResponseData, error) {
balances, err := p.repo.SpendableBalances(ctx, customerID, p.now())
if err != nil {
return nil, err
}
return p.legacyTokens(ctx, customerID, balances)
}
func (p *WalletQueryProcessor) legacyTokens(ctx context.Context, customerID uuid.UUID, balances map[string]int64) (*models.GetCustomerTokensResponseData, error) {
recent, err := p.recent(ctx, customerID, constants.WalletCurrencyCoin, walletLegacyHistorySize)
if err != nil {
return nil, err
}
data := &models.GetCustomerTokensResponseData{TotalTokens: balances[constants.WalletCurrencyCoin]}
for _, tx := range recent {
data.TokensHistory = append(data.TokensHistory, models.TokensHistoryItem{
ID: tx.ID.String(), Tokens: tx.Amount, Type: tx.Type, Description: tx.Description, CreatedAt: tx.CreatedAt,
})
}
if len(recent) > 0 {
data.LastUpdated = recent[0].CreatedAt
}
return data, nil
}
func (p *WalletQueryProcessor) recent(ctx context.Context, customerID uuid.UUID, currency string, limit int) ([]models.CustomerWalletTransaction, error) {
rows, _, err := p.repo.ListTransactions(ctx, repository.WalletTransactionFilter{
CustomerID: customerID, Currency: currency, Limit: limit,
})
if err != nil {
return nil, err
}
return p.toTransactions(ctx, rows)
}
// pointValue reads the organization's rupiah value of one EnakPoint. A missing or
// unusable value falls back to the default rather than failing the whole screen.
func (p *WalletQueryProcessor) pointValue(ctx context.Context, organizationID uuid.UUID) (int64, error) {
raw, ok, err := p.settings.Get(ctx, organizationID, constants.LoyaltyPointValueKey)
if err != nil {
return 0, err
}
if !ok {
return constants.LoyaltyPointValueDefault, nil
}
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || value < 1 {
return constants.LoyaltyPointValueDefault, nil
}
return value, nil
}
func (p *WalletQueryProcessor) toTransactions(ctx context.Context, rows []entities.WalletTransaction) ([]models.CustomerWalletTransaction, error) {
var credits []uuid.UUID
for _, row := range rows {
if row.Amount > 0 {
credits = append(credits, row.ID)
}
}
lots, err := p.repo.ListLotsBySourceTransactions(ctx, credits)
if err != nil {
return nil, err
}
lotsByTx := make(map[uuid.UUID][]entities.WalletLot, len(credits))
for _, lot := range lots {
lotsByTx[lot.SourceTransactionID] = append(lotsByTx[lot.SourceTransactionID], lot)
}
items := make([]models.CustomerWalletTransaction, 0, len(rows))
for _, row := range rows {
ref := &models.CustomerWalletTransactionRef{Type: row.ReferenceType, ID: row.ReferenceID}
item := models.CustomerWalletTransaction{
ID: row.ID,
Currency: row.Currency,
Type: row.Type,
Amount: row.Amount,
BalanceAfter: row.BalanceAfter,
Description: row.Description,
OutletID: row.OutletID,
ReversesTransactionID: row.ReversesTransactionID,
GroupID: row.GroupID,
CreatedAt: row.CreatedAt,
}
// The counterparty's id, the admin and the metadata are left out on purpose:
// the customer sees the masked name in the description (§8.1).
if row.Amount > 0 {
item.Source = ref
for _, lot := range lotsByTx[row.ID] {
item.Lots = append(item.Lots, models.CustomerWalletLot{
Amount: lot.OriginalAmount, Remaining: lot.RemainingAmount, ExpiresAt: lot.ExpiresAt,
})
if lot.ExpiresAt != nil && (item.ExpiresAt == nil || lot.ExpiresAt.Before(*item.ExpiresAt)) {
item.ExpiresAt = lot.ExpiresAt
}
}
} else {
item.Destination = ref
}
items = append(items, item)
}
return items, nil
}
func walletTransactionFilter(customerID uuid.UUID, q models.ListCustomerWalletTransactionsQuery) (repository.WalletTransactionFilter, int, error) {
invalid := func(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrInvalidWalletQuery, fmt.Sprintf(format, args...))
}
filter := repository.WalletTransactionFilter{CustomerID: customerID}
page := q.Page
if page == 0 {
page = 1
}
if page < 1 {
return filter, 0, invalid("page must be at least 1")
}
filter.Limit = q.Limit
if filter.Limit == 0 {
filter.Limit = walletTransactionsPageLimit
}
if filter.Limit < 1 || filter.Limit > walletTransactionsMaxLimit {
return filter, 0, invalid("limit must be between 1 and %d", walletTransactionsMaxLimit)
}
filter.Offset = (page - 1) * filter.Limit
if q.Currency != "" {
currency := strings.ToUpper(q.Currency)
if !constants.IsValidWalletCurrency(currency) {
return filter, 0, invalid("currency must be POINT or COIN")
}
filter.Currency = currency
}
for _, t := range strings.Split(q.Type, ",") {
t = strings.ToUpper(strings.TrimSpace(t))
if t == "" {
continue
}
if _, ok := walletTypeRules[t]; !ok {
return filter, 0, invalid("unknown type %q", t)
}
filter.Types = append(filter.Types, t)
}
if q.From != "" {
from, err := time.ParseInLocation("2006-01-02", q.From, walletDisplayLocation)
if err != nil {
return filter, 0, invalid("from must be a date like 2026-01-31")
}
filter.From = &from
}
if q.To != "" {
to, err := time.ParseInLocation("2006-01-02", q.To, walletDisplayLocation)
if err != nil {
return filter, 0, invalid("to must be a date like 2026-01-31")
}
// To is inclusive for the customer, exclusive in the query.
end := to.AddDate(0, 0, 1)
filter.To = &end
}
if filter.From != nil && filter.To != nil && !filter.From.Before(*filter.To) {
return filter, 0, invalid("from must not be after to")
}
return filter, page, nil
}
@@ -0,0 +1,246 @@
package processor
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
type walletQueryRepoFake struct {
org uuid.UUID
spendable map[string]int64
expiring []repository.WalletExpiringAmount
transactions []entities.WalletTransaction
total int64
lots []entities.WalletLot
filters []repository.WalletTransactionFilter
}
func (f *walletQueryRepoFake) GetCustomerOrganizationID(context.Context, uuid.UUID) (uuid.UUID, error) {
if f.org == uuid.Nil {
return uuid.Nil, repository.ErrWalletNotFound
}
return f.org, nil
}
func (f *walletQueryRepoFake) SpendableBalances(context.Context, uuid.UUID, time.Time) (map[string]int64, error) {
return f.spendable, nil
}
func (f *walletQueryRepoFake) NearestExpiring(context.Context, uuid.UUID, time.Time) ([]repository.WalletExpiringAmount, error) {
return f.expiring, nil
}
func (f *walletQueryRepoFake) ListTransactions(_ context.Context, filter repository.WalletTransactionFilter) ([]entities.WalletTransaction, int64, error) {
f.filters = append(f.filters, filter)
var out []entities.WalletTransaction
for _, tx := range f.transactions {
if filter.Currency == "" || tx.Currency == filter.Currency {
out = append(out, tx)
}
}
if len(out) > filter.Limit {
out = out[:filter.Limit]
}
return out, f.total, nil
}
func (f *walletQueryRepoFake) ListLotsBySourceTransactions(_ context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) {
var out []entities.WalletLot
for _, lot := range f.lots {
for _, id := range ids {
if lot.SourceTransactionID == id {
out = append(out, lot)
}
}
}
return out, nil
}
type orgSettingsFake map[string]string
func (f orgSettingsFake) Get(_ context.Context, _ uuid.UUID, key string) (string, bool, error) {
v, ok := f[key]
return v, ok, nil
}
func newWalletQueryTest(repo *walletQueryRepoFake, settings orgSettingsFake) *WalletQueryProcessor {
p := NewWalletQueryProcessor(repo, settings)
p.now = func() time.Time { return time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) }
return p
}
func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing.T) {
order, payment := uuid.New(), uuid.New()
earnID, payID := uuid.New(), uuid.New()
soon := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
later := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
created := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
repo := &walletQueryRepoFake{
org: uuid.New(),
spendable: map[string]int64{constants.WalletCurrencyPoint: 250, constants.WalletCurrencyCoin: 8},
expiring: []repository.WalletExpiringAmount{
{Currency: constants.WalletCurrencyPoint, Date: "2026-07-01", Amount: 100},
},
transactions: []entities.WalletTransaction{
{ID: payID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, Amount: -50, BalanceAfter: 250,
ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, Description: "Bayar #ORD-1", CreatedAt: created.Add(time.Hour),
CounterpartyCustomerID: ptr(uuid.New()), Metadata: entities.Metadata{"point_value": 100}},
{ID: earnID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn, Amount: 300, BalanceAfter: 300,
ReferenceType: constants.WalletRefTypeOrder, ReferenceID: order, Description: "Belanja #ORD-1", CreatedAt: created},
},
lots: []entities.WalletLot{
{SourceTransactionID: earnID, OriginalAmount: 200, RemainingAmount: 150, ExpiresAt: &later},
{SourceTransactionID: earnID, OriginalAmount: 100, RemainingAmount: 100, ExpiresAt: &soon},
},
}
p := newWalletQueryTest(repo, orgSettingsFake{constants.LoyaltyPointValueKey: "100"})
data, err := p.Summary(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, int64(250), data.PointBalance)
assert.Equal(t, int64(8), data.CoinBalance)
assert.Equal(t, int64(100), data.PointValue)
assert.Equal(t, int64(25000), data.PointDiscountValue)
assert.Equal(t, &models.CustomerWalletExpiring{Amount: 100, Date: "2026-07-01"}, data.NearestExpiring.Point)
assert.Nil(t, data.NearestExpiring.Coin)
require.Len(t, data.RecentTransactions, 2)
pay, earn := data.RecentTransactions[0], data.RecentTransactions[1]
assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypePayment, ID: payment}, pay.Destination)
assert.Nil(t, pay.Source)
assert.Empty(t, pay.Lots)
assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypeOrder, ID: order}, earn.Source)
assert.Nil(t, earn.Destination)
assert.Equal(t, &soon, earn.ExpiresAt, "earliest expiry among the lots the credit created")
assert.Len(t, earn.Lots, 2)
// The pre-wallet fields are still filled, from the wallet.
assert.Equal(t, int64(250), data.TotalPoints)
assert.Equal(t, int64(8), data.TotalTokens)
require.Len(t, data.PointsHistory, 2)
assert.Equal(t, int64(-50), data.PointsHistory[0].Points)
assert.Equal(t, constants.WalletTxTypePayment, data.PointsHistory[0].Type)
assert.Equal(t, created.Add(time.Hour), data.LastUpdated)
}
func TestWalletQueryProcessor_PointValueFallsBackToDefault(t *testing.T) {
for name, settings := range map[string]orgSettingsFake{
"not set": {},
"garbage": {constants.LoyaltyPointValueKey: "abc"},
"zero": {constants.LoyaltyPointValueKey: "0"},
"negative": {constants.LoyaltyPointValueKey: "-5"},
} {
t.Run(name, func(t *testing.T) {
repo := &walletQueryRepoFake{org: uuid.New(), spendable: map[string]int64{constants.WalletCurrencyPoint: 40}}
data, err := newWalletQueryTest(repo, settings).Summary(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, constants.LoyaltyPointValueDefault, data.PointValue)
assert.Equal(t, int64(40), data.PointDiscountValue)
})
}
}
func TestWalletQueryProcessor_SummaryForCustomerWithoutWallet(t *testing.T) {
repo := &walletQueryRepoFake{org: uuid.New(), spendable: map[string]int64{}}
data, err := newWalletQueryTest(repo, orgSettingsFake{}).Summary(context.Background(), uuid.New())
require.NoError(t, err)
assert.Zero(t, data.PointBalance)
assert.Zero(t, data.CoinBalance)
assert.NotNil(t, data.RecentTransactions, "an empty list, not null, for the app")
_, err = newWalletQueryTest(&walletQueryRepoFake{}, orgSettingsFake{}).Summary(context.Background(), uuid.New())
assert.ErrorIs(t, err, repository.ErrWalletNotFound)
}
func TestWalletQueryProcessor_LegacyEndpointsSplitByCurrency(t *testing.T) {
repo := &walletQueryRepoFake{
org: uuid.New(),
spendable: map[string]int64{constants.WalletCurrencyPoint: 100, constants.WalletCurrencyCoin: 8},
transactions: []entities.WalletTransaction{
{ID: uuid.New(), Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration, Amount: 8},
{ID: uuid.New(), Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, Amount: 100},
},
}
p := newWalletQueryTest(repo, orgSettingsFake{})
points, err := p.LegacyPoints(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, int64(100), points.TotalPoints)
require.Len(t, points.PointsHistory, 1)
assert.Equal(t, int64(100), points.PointsHistory[0].Points)
tokens, err := p.LegacyTokens(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, int64(8), tokens.TotalTokens)
require.Len(t, tokens.TokensHistory, 1)
assert.Equal(t, int64(8), tokens.TokensHistory[0].Tokens)
}
func TestWalletQueryProcessor_TransactionsQuery(t *testing.T) {
customer := uuid.New()
jakarta := walletDisplayLocation
repo := &walletQueryRepoFake{org: uuid.New(), total: 45}
page, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), customer, models.ListCustomerWalletTransactionsQuery{
Page: 3, Limit: 10, Currency: "point", Type: "earn, PAYMENT", From: "2026-05-01", To: "2026-05-31",
})
require.NoError(t, err)
assert.Equal(t, models.Pagination{Page: 3, Limit: 10, Total: 45, TotalPages: 5}, page.Pagination)
assert.NotNil(t, page.Data)
f := repo.filters[0]
assert.Equal(t, customer, f.CustomerID)
assert.Equal(t, 20, f.Offset)
assert.Equal(t, 10, f.Limit)
assert.Equal(t, constants.WalletCurrencyPoint, f.Currency)
assert.Equal(t, []string{constants.WalletTxTypeEarn, constants.WalletTxTypePayment}, f.Types)
assert.True(t, f.From.Equal(time.Date(2026, 5, 1, 0, 0, 0, 0, jakarta)))
assert.True(t, f.To.Equal(time.Date(2026, 6, 1, 0, 0, 0, 0, jakarta)), "to covers the whole last day")
// Defaults.
repo = &walletQueryRepoFake{org: uuid.New()}
page, err = newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), customer, models.ListCustomerWalletTransactionsQuery{})
require.NoError(t, err)
assert.Equal(t, 1, page.Pagination.Page)
assert.Equal(t, walletTransactionsPageLimit, repo.filters[0].Limit)
assert.Zero(t, repo.filters[0].Offset)
assert.Nil(t, repo.filters[0].From)
assert.Empty(t, repo.filters[0].Types)
}
func TestWalletQueryProcessor_RejectsBadQueries(t *testing.T) {
for name, q := range map[string]models.ListCustomerWalletTransactionsQuery{
"negative page": {Page: -1},
"limit too big": {Limit: walletTransactionsMaxLimit + 1},
"negative limit": {Limit: -1},
"unknown currency": {Currency: "GOLD"},
"unknown type": {Type: "EARN,BONUS"},
"bad from": {From: "01-05-2026"},
"bad to": {To: "2026-13-01"},
"from after to": {From: "2026-06-02", To: "2026-06-01"},
} {
t.Run(name, func(t *testing.T) {
repo := &walletQueryRepoFake{org: uuid.New()}
_, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), uuid.New(), q)
assert.ErrorIs(t, err, ErrInvalidWalletQuery)
assert.Empty(t, repo.filters, "nothing is queried")
})
}
// A single day is a valid range.
repo := &walletQueryRepoFake{org: uuid.New()}
_, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), uuid.New(),
models.ListCustomerWalletTransactionsQuery{From: "2026-06-01", To: "2026-06-01"})
assert.NoError(t, err)
}
@@ -0,0 +1,41 @@
package repository
import (
"context"
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
)
// OrganizationSettingRepository reads organization_settings, the key-value settings
// that apply to every outlet of an organization (docs/prd-point-coin.md F2).
type OrganizationSettingRepository interface {
// Get returns the stored value of a key and whether one is stored. A key that has
// never been set is not an error: the caller falls back to the key's default.
Get(ctx context.Context, organizationID uuid.UUID, key string) (string, bool, error)
}
type organizationSettingRepository struct {
db *gorm.DB
}
func NewOrganizationSettingRepository(db *gorm.DB) OrganizationSettingRepository {
return &organizationSettingRepository{db: db}
}
func (r *organizationSettingRepository) Get(ctx context.Context, organizationID uuid.UUID, key string) (string, bool, error) {
var values []*string
err := DBFromContext(ctx, r.db).WithContext(ctx).
Table("organization_settings").
Where("organization_id = ? AND key = ?", organizationID, key).
Limit(1).
Pluck("value", &values).Error
if err != nil {
return "", false, fmt.Errorf("failed to get organization setting %s: %w", key, err)
}
if len(values) == 0 || values[0] == nil {
return "", false, nil
}
return *values[0], true, nil
}
@@ -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
}
+1
View File
@@ -157,6 +157,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
customer.GET("/points", r.customerPointsHandler.GetCustomerPoints) customer.GET("/points", r.customerPointsHandler.GetCustomerPoints)
customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens) customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens)
customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet) customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet)
customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions)
customer.GET("/games", r.customerPointsHandler.GetCustomerGames) customer.GET("/games", r.customerPointsHandler.GetCustomerGames)
customer.GET("/ferris-wheel", r.customerPointsHandler.GetFerrisWheelGame) customer.GET("/ferris-wheel", r.customerPointsHandler.GetFerrisWheelGame)
customer.POST("/spin", r.spinGameHandler.PlaySpinGame) customer.POST("/spin", r.spinGameHandler.PlaySpinGame)
@@ -12,6 +12,7 @@ type CustomerPointsService interface {
GetCustomerPoints(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) GetCustomerPoints(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error)
GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error)
GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error)
GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error)
GetCustomerGames(ctx context.Context) (*models.GetCustomerGamesResponse, error) GetCustomerGames(ctx context.Context) (*models.GetCustomerGamesResponse, error)
GetFerrisWheelGame(ctx context.Context) (*models.GetFerrisWheelGameResponse, error) GetFerrisWheelGame(ctx context.Context) (*models.GetFerrisWheelGameResponse, error)
} }
@@ -82,3 +83,10 @@ func (s *customerPointsService) GetFerrisWheelGame(ctx context.Context) (*models
return response, nil return response, nil
} }
func (s *customerPointsService) GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) {
if customerID == "" {
return nil, fmt.Errorf("customer ID is required")
}
return s.customerPointsProcessor.GetCustomerWalletTransactionsAPI(ctx, customerID, query)
}