diff --git a/internal/app/app.go b/internal/app/app.go index 932aeb4..1183350 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -248,6 +248,8 @@ type repositories struct { expenseRepo *repository.ExpenseRepositoryImpl cashAdvanceRepo *repository.CashAdvanceRepositoryImpl walletRepo repository.WalletRepository + walletQueryRepo repository.WalletQueryRepository + organizationSettingRepo repository.OrganizationSettingRepository } func (a *App) initRepositories() *repositories { @@ -304,6 +306,8 @@ func (a *App) initRepositories() *repositories { expenseRepo: repository.NewExpenseRepositoryImpl(a.db), cashAdvanceRepo: repository.NewCashAdvanceRepositoryImpl(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), campaignRuleProcessor: processor.NewCampaignRuleProcessor(repos.campaignRuleRepo), 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, fileClient: fileClient, inventoryMovementService: inventoryMovementService, diff --git a/internal/constants/wallet.go b/internal/constants/wallet.go index 2892fdd..6182154 100644 --- a/internal/constants/wallet.go +++ b/internal/constants/wallet.go @@ -42,3 +42,10 @@ const ( WalletRefTypeLegacyTokens = "LEGACY_TOKENS" 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) +) diff --git a/internal/handler/customer_points_handler.go b/internal/handler/customer_points_handler.go index 8e98a26..818d914 100644 --- a/internal/handler/customer_points_handler.go +++ b/internal/handler/customer_points_handler.go @@ -1,9 +1,14 @@ package handler import ( + "errors" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" "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/util" @@ -46,7 +51,7 @@ func (h *CustomerPointsHandler) GetCustomerPoints(c *gin.Context) { if err != nil { logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerPoints -> service call failed") 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") return } @@ -80,7 +85,7 @@ func (h *CustomerPointsHandler) GetCustomerTokens(c *gin.Context) { if err != nil { logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerTokens -> service call failed") 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") return } @@ -114,7 +119,7 @@ func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) { if err != nil { logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWallet -> service call failed") 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") return } @@ -151,3 +156,51 @@ func (h *CustomerPointsHandler) GetFerrisWheelGame(c *gin.Context) { 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 + } +} diff --git a/internal/handler/customer_wallet_db_test.go b/internal/handler/customer_wallet_db_test.go new file mode 100644 index 0000000..e3713b2 --- /dev/null +++ b/internal/handler/customer_wallet_db_test.go @@ -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 +} diff --git a/internal/models/customer_points.go b/internal/models/customer_points.go index cfe9b45..c4b4ca9 100644 --- a/internal/models/customer_points.go +++ b/internal/models/customer_points.go @@ -109,7 +109,20 @@ type GetCustomerWalletResponse struct { Data *GetCustomerWalletResponseData `json:"data,omitempty"` } +// GetCustomerWalletResponseData is GET /customer/wallet (docs/prd-point-coin.md F6). 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"` TotalTokens int64 `json:"total_tokens"` PointsHistory []PointsHistoryItem `json:"points_history,omitempty"` diff --git a/internal/models/wallet.go b/internal/models/wallet.go new file mode 100644 index 0000000..27f20bc --- /dev/null +++ b/internal/models/wallet.go @@ -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"` +} diff --git a/internal/processor/customer_points_processor.go b/internal/processor/customer_points_processor.go index d65ef2b..020c705 100644 --- a/internal/processor/customer_points_processor.go +++ b/internal/processor/customer_points_processor.go @@ -3,7 +3,6 @@ package processor import ( "context" "fmt" - "time" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" @@ -12,14 +11,14 @@ import ( ) type CustomerPointsProcessor struct { - customerPointsRepo repository.CustomerPointsRepository - gameRepo *repository.GameRepository + walletQuery *WalletQueryProcessor + gameRepo *repository.GameRepository } -func NewCustomerPointsProcessor(customerPointsRepo repository.CustomerPointsRepository, gameRepo *repository.GameRepository) *CustomerPointsProcessor { +func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor, gameRepo *repository.GameRepository) *CustomerPointsProcessor { return &CustomerPointsProcessor{ - customerPointsRepo: customerPointsRepo, - gameRepo: gameRepo, + walletQuery: walletQuery, + gameRepo: gameRepo, } } @@ -70,159 +69,74 @@ func (p *CustomerPointsProcessor) DeductPoints(ctx context.Context, customerID u 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) { - // Get total points - totalPoints, err := p.customerPointsRepo.GetCustomerTotalPoints(ctx, customerID) + id, err := parseWalletCustomerID(customerID) if err != nil { - return nil, fmt.Errorf("failed to get customer total points: %w", err) + return nil, err } - - // Get points history (last 10 records) - pointsHistory, err := p.customerPointsRepo.GetCustomerPointsHistory(ctx, customerID, 10) + data, err := p.walletQuery.LegacyPoints(ctx, id) 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{ Status: "SUCCESS", Message: "Customer points retrieved successfully.", - Data: &models.GetCustomerPointsResponseData{ - TotalPoints: totalPoints, - PointsHistory: historyItems, - LastUpdated: lastUpdated, - }, + Data: data, }, nil } func (p *CustomerPointsProcessor) GetCustomerTotalTokensAPI(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) { - // Get total tokens - totalTokens, err := p.customerPointsRepo.GetCustomerTotalTokens(ctx, customerID) + id, err := parseWalletCustomerID(customerID) if err != nil { - return nil, fmt.Errorf("failed to get customer total tokens: %w", err) + return nil, err } - - // Get tokens history (last 10 records) - tokensHistory, err := p.customerPointsRepo.GetCustomerTokensHistory(ctx, customerID, 10) + data, err := p.walletQuery.LegacyTokens(ctx, id) 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{ Status: "SUCCESS", Message: "Customer tokens retrieved successfully.", - Data: &models.GetCustomerTokensResponseData{ - TotalTokens: totalTokens, - TokensHistory: historyItems, - LastUpdated: lastUpdated, - }, + Data: data, }, nil } func (p *CustomerPointsProcessor) GetCustomerWalletAPI(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) { - // Get total points - totalPoints, err := p.customerPointsRepo.GetCustomerTotalPoints(ctx, customerID) + id, err := parseWalletCustomerID(customerID) if err != nil { - return nil, fmt.Errorf("failed to get customer total points: %w", err) + return nil, err } - - // Get total tokens - totalTokens, err := p.customerPointsRepo.GetCustomerTotalTokens(ctx, customerID) + data, err := p.walletQuery.Summary(ctx, id) 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{ Status: "SUCCESS", Message: "Customer wallet retrieved successfully.", - Data: &models.GetCustomerWalletResponseData{ - TotalPoints: totalPoints, - TotalTokens: totalTokens, - PointsHistory: pointsHistoryItems, - TokensHistory: tokensHistoryItems, - LastUpdated: lastUpdated, - }, + Data: data, }, 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 func (p *CustomerPointsProcessor) GetCustomerGamesAPI(ctx context.Context) (*models.GetCustomerGamesResponse, error) { // Get active SPIN games diff --git a/internal/processor/wallet_query_processor.go b/internal/processor/wallet_query_processor.go new file mode 100644 index 0000000..4649eda --- /dev/null +++ b/internal/processor/wallet_query_processor.go @@ -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 +} diff --git a/internal/processor/wallet_query_processor_test.go b/internal/processor/wallet_query_processor_test.go new file mode 100644 index 0000000..573f3df --- /dev/null +++ b/internal/processor/wallet_query_processor_test.go @@ -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) +} diff --git a/internal/repository/organization_setting_repository.go b/internal/repository/organization_setting_repository.go new file mode 100644 index 0000000..7c47697 --- /dev/null +++ b/internal/repository/organization_setting_repository.go @@ -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 +} diff --git a/internal/repository/wallet_query_repository.go b/internal/repository/wallet_query_repository.go new file mode 100644 index 0000000..f59b75c --- /dev/null +++ b/internal/repository/wallet_query_repository.go @@ -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 +} diff --git a/internal/router/router.go b/internal/router/router.go index 6da7d3f..0a5dda6 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -157,6 +157,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.GET("/points", r.customerPointsHandler.GetCustomerPoints) customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens) customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet) + customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions) customer.GET("/games", r.customerPointsHandler.GetCustomerGames) customer.GET("/ferris-wheel", r.customerPointsHandler.GetFerrisWheelGame) customer.POST("/spin", r.spinGameHandler.PlaySpinGame) diff --git a/internal/service/customer_points_service.go b/internal/service/customer_points_service.go index 20b473d..a247845 100644 --- a/internal/service/customer_points_service.go +++ b/internal/service/customer_points_service.go @@ -12,6 +12,7 @@ type CustomerPointsService interface { GetCustomerPoints(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, 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) GetFerrisWheelGame(ctx context.Context) (*models.GetFerrisWheelGameResponse, error) } @@ -82,3 +83,10 @@ func (s *customerPointsService) GetFerrisWheelGame(ctx context.Context) (*models 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) +}