feat(loyalty): EnakPoint & EnakCoin #32
+5
-1
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user