Main #33
+5
-1
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
|
||||
@@ -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 (
|
||||
"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
|
||||
|
||||
@@ -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("/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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user