feat(loyalty): EnakPoint & EnakCoin #32

Merged
aefril merged 36 commits from feature/point-coint into main 2026-09-30 10:03:08 +02:00
13 changed files with 926 additions and 2 deletions
Showing only changes of commit a6d5a8b056 - Show all commits
+8
View File
@@ -142,6 +142,8 @@ func (a *App) Initialize(cfg *config.Config) error {
validators.expenseValidator, validators.expenseValidator,
services.cashAdvanceService, services.cashAdvanceService,
validators.cashAdvanceValidator, validators.cashAdvanceValidator,
services.walletAdminService,
validators.walletValidator,
a.redisClient, a.redisClient,
) )
@@ -357,6 +359,7 @@ type processors struct {
expenseProcessor *processor.ExpenseProcessorImpl expenseProcessor *processor.ExpenseProcessorImpl
cashAdvanceProcessor *processor.CashAdvanceProcessorImpl cashAdvanceProcessor *processor.CashAdvanceProcessorImpl
walletProcessor *processor.WalletProcessor walletProcessor *processor.WalletProcessor
walletAdminProcessor *processor.WalletAdminProcessor
} }
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
@@ -411,6 +414,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo), expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo),
cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo), cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo),
walletProcessor: processor.NewWalletProcessor(repos.walletRepo), walletProcessor: processor.NewWalletProcessor(repos.walletRepo),
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager),
} }
} }
@@ -453,6 +457,7 @@ type services struct {
productOutletPriceService service.ProductOutletPriceService productOutletPriceService service.ProductOutletPriceService
expenseService *service.ExpenseServiceImpl expenseService *service.ExpenseServiceImpl
cashAdvanceService *service.CashAdvanceServiceImpl cashAdvanceService *service.CashAdvanceServiceImpl
walletAdminService *service.WalletAdminServiceImpl
} }
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
@@ -534,6 +539,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor), productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor),
expenseService: service.NewExpenseService(processors.expenseProcessor), expenseService: service.NewExpenseService(processors.expenseProcessor),
cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor), cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor),
walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor),
} }
} }
@@ -579,6 +585,7 @@ type validators struct {
productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl
expenseValidator *validator.ExpenseValidatorImpl expenseValidator *validator.ExpenseValidatorImpl
cashAdvanceValidator *validator.CashAdvanceValidatorImpl cashAdvanceValidator *validator.CashAdvanceValidatorImpl
walletValidator *validator.WalletValidatorImpl
} }
func (a *App) initValidators() *validators { func (a *App) initValidators() *validators {
@@ -612,6 +619,7 @@ func (a *App) initValidators() *validators {
productOutletPriceValidator: validator.NewProductOutletPriceValidator(), productOutletPriceValidator: validator.NewProductOutletPriceValidator(),
expenseValidator: validator.NewExpenseValidator(), expenseValidator: validator.NewExpenseValidator(),
cashAdvanceValidator: validator.NewCashAdvanceValidator(), cashAdvanceValidator: validator.NewCashAdvanceValidator(),
walletValidator: validator.NewWalletValidator(),
} }
} }
+1
View File
@@ -63,6 +63,7 @@ const (
ProductOutletPriceServiceEntity = "product_outlet_price_service" ProductOutletPriceServiceEntity = "product_outlet_price_service"
ExpenseServiceEntity = "expense_service" ExpenseServiceEntity = "expense_service"
CashAdvanceServiceEntity = "cash_advance_service" CashAdvanceServiceEntity = "cash_advance_service"
WalletServiceEntity = "wallet_service"
) )
var HttpErrorMap = map[string]int{ var HttpErrorMap = map[string]int{
+14
View File
@@ -0,0 +1,14 @@
package contract
// AdjustCustomerWalletRequest is POST /marketing/customers/:id/wallet/adjust
// (docs/prd-point-coin.md F7).
type AdjustCustomerWalletRequest struct {
// POINT or COIN.
Currency string `json:"currency"`
// Signed: positive adds to the balance, negative takes from it. Never zero.
Amount int64 `json:"amount"`
Reason string `json:"reason"`
// Optional. Sending the same key again returns the first result instead of
// adjusting twice, so a retried request is safe.
IdempotencyKey string `json:"idempotency_key"`
}
+203
View File
@@ -0,0 +1,203 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"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/appcontext"
"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"
"apskel-pos-be/internal/validator"
)
// Drives the dashboard wallet endpoints over HTTP down to Postgres. Needs
// TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go.
func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
applogger.Setup("fatal", "json")
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
orgA, orgB := uuid.New(), uuid.New()
adminA, adminB := uuid.New(), uuid.New()
customer := uuid.New()
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 (?, 'A', 'basic'), (?, 'B', 'basic')`, orgA, orgB)
exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES
(?, ?, 'Admin Satu', ?, 'x', 'admin'), (?, ?, 'Admin Lain', ?, 'x', 'admin')`,
adminA, orgA, adminA.String()+"@test", adminB, orgB, adminB.String()+"@test")
exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Budi Santoso', '081234567890')`, customer, orgA)
t.Cleanup(func() {
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id = ?)`, customer)
db.Exec(`DELETE FROM wallet_lots WHERE customer_id = ?`, customer)
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id = ?`, customer)
db.Exec(`DELETE FROM customer_wallets WHERE customer_id = ?`, customer)
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
db.Exec(`DELETE FROM users WHERE id IN ?`, []uuid.UUID{adminA, adminB})
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{orgA, orgB})
})
walletRepo := repository.NewWalletRepository(db)
queryRepo := repository.NewWalletQueryRepository(db)
txm := repository.NewTxManager(db)
wallet := processor.NewWalletProcessor(walletRepo)
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
outlet := uuid.New()
_, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn,
Amount: 100, ReferenceType: constants.WalletRefTypeOrder, ReferenceID: uuid.New(), OutletID: &outlet,
Description: "Belanja #ORD-1"}})
return err
}))
h := NewWalletAdminHandler(
service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, txm)),
validator.NewWalletValidator(),
)
gin.SetMode(gin.TestMode)
router := gin.New()
as := func(org, user uuid.UUID) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String())
ctx = context.WithValue(ctx, appcontext.UserIDKey, user.String())
c.Request = c.Request.WithContext(ctx)
}
}
for prefix, who := range map[string][2]uuid.UUID{"/a": {orgA, adminA}, "/b": {orgB, adminB}} {
g := router.Group(prefix, as(who[0], who[1]))
g.GET("/customers/:id/wallet", h.GetCustomerWallet)
g.POST("/customers/:id/wallet/adjust", h.AdjustCustomerWallet)
}
call := func(method, path string, body any) (int, map[string]any) {
t.Helper()
var buf bytes.Buffer
if body != nil {
require.NoError(t, json.NewEncoder(&buf).Encode(body))
}
req := httptest.NewRequest(method, path, &buf)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var out map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
return rec.Code, out
}
adjust := func(prefix string, body map[string]any) (int, map[string]any) {
return call(http.MethodPost, prefix+"/customers/"+customer.String()+"/wallet/adjust", body)
}
spendable := func() float64 {
t.Helper()
status, body := call(http.MethodGet, "/a/customers/"+customer.String()+"/wallet", nil)
require.Equal(t, http.StatusOK, status, body)
return body["data"].(map[string]any)["spendable_point_balance"].(float64)
}
// Add 50.
status, body := adjust("/a", map[string]any{"currency": "point", "amount": 50, "reason": "komplain #45"})
require.Equal(t, http.StatusOK, status, body)
tx := body["data"].(map[string]any)["transaction"].(map[string]any)
assert.Equal(t, "ADJUSTMENT", tx["type"])
assert.EqualValues(t, 50, tx["amount"])
assert.Equal(t, "komplain #45", tx["reason"])
assert.Equal(t, "Koreksi oleh admin: komplain #45", tx["description"])
assert.Equal(t, map[string]any{"id": adminA.String(), "name": "Admin Satu"}, tx["created_by"])
assert.Equal(t, map[string]any{"type": "USER", "id": adminA.String()}, tx["source"])
assert.EqualValues(t, 150, body["data"].(map[string]any)["spendable_point_balance"])
// Taking more than the customer can spend is refused and changes nothing.
status, body = adjust("/a", map[string]any{"currency": "POINT", "amount": -151, "reason": "salah input"})
assert.Equal(t, http.StatusBadRequest, status, body)
assert.Equal(t, float64(150), spendable())
// Taking away, retried with the same key, happens once.
for i, wantReplayed := range []bool{false, true} {
status, body = adjust("/a", map[string]any{"currency": "POINT", "amount": -30, "reason": "salah input", "idempotency_key": "req-1"})
require.Equal(t, http.StatusOK, status, body)
assert.Equal(t, wantReplayed, body["data"].(map[string]any)["replayed"], "call %d", i+1)
}
assert.Equal(t, float64(120), spendable())
// Bad requests.
for name, req := range map[string]map[string]any{
"cash-out": {"currency": "POINT", "amount": -10, "reason": "pencairan saldo"},
"zero amount": {"currency": "POINT", "amount": 0, "reason": "x"},
"no reason": {"currency": "POINT", "amount": 10},
"bad currency": {"currency": "GOLD", "amount": 10, "reason": "x"},
} {
status, _ = adjust("/a", req)
assert.Equal(t, http.StatusBadRequest, status, name)
}
status, _ = call(http.MethodPost, "/a/customers/not-a-uuid/wallet/adjust", map[string]any{"currency": "POINT", "amount": 1, "reason": "x"})
assert.Equal(t, http.StatusBadRequest, status)
assert.Equal(t, float64(120), spendable())
// Another organization's admin cannot see or touch this customer.
status, _ = call(http.MethodGet, "/b/customers/"+customer.String()+"/wallet", nil)
assert.Equal(t, http.StatusNotFound, status)
status, _ = adjust("/b", map[string]any{"currency": "POINT", "amount": 1000, "reason": "x"})
assert.Equal(t, http.StatusNotFound, status)
assert.Equal(t, float64(120), spendable())
// The dashboard view.
status, body = call(http.MethodGet, "/a/customers/"+customer.String()+"/wallet?type=ADJUSTMENT", nil)
require.Equal(t, http.StatusOK, status, body)
w := body["data"].(map[string]any)
assert.Equal(t, map[string]any{"id": customer.String(), "name": "Budi Santoso", "phone": "081234567890"}, w["customer"])
assert.EqualValues(t, 120, w["point_balance"])
assert.EqualValues(t, 120, w["spendable_point_balance"])
lots := w["lots"].([]any)
var lotTotal float64
for _, l := range lots {
lotTotal += l.(map[string]any)["remaining_amount"].(float64)
}
assert.Equal(t, float64(120), lotTotal)
rows := w["transactions"].(map[string]any)["data"].([]any)
require.Len(t, rows, 2, "the two adjustments, newest first")
newest := rows[0].(map[string]any)
assert.EqualValues(t, -30, newest["amount"])
assert.Equal(t, "salah input", newest["reason"])
assert.Equal(t, map[string]any{"id": adminA.String(), "name": "Admin Satu"}, newest["created_by"])
assert.Equal(t, map[string]any{"type": "USER", "id": adminA.String()}, newest["destination"])
// The customer's own history shows the adjustment too, with the reason in the
// description and without the admin's identity fields.
customerView := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor(
processor.NewWalletQueryProcessor(queryRepo, repository.NewOrganizationSettingRepository(db)), nil)))
crouter := gin.New()
crouter.GET("/wallet/transactions", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerView.GetCustomerWalletTransactions)
rec := httptest.NewRecorder()
crouter.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/wallet/transactions?type=ADJUSTMENT", nil))
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var cbody map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &cbody))
crows := cbody["data"].(map[string]any)["data"].([]any)
require.Len(t, crows, 2)
first := crows[0].(map[string]any)
assert.Equal(t, "Koreksi oleh admin: salah input", first["description"])
assert.NotContains(t, first, "created_by")
assert.NotContains(t, first, "reason")
}
+85
View File
@@ -0,0 +1,85 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
)
// WalletAdminHandler serves /marketing/customers/:id/wallet (docs/prd-point-coin.md F7).
type WalletAdminHandler struct {
walletAdminService service.WalletAdminService
walletValidator validator.WalletValidator
}
func NewWalletAdminHandler(walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator) *WalletAdminHandler {
return &WalletAdminHandler{walletAdminService: walletAdminService, walletValidator: walletValidator}
}
func (h *WalletAdminHandler) GetCustomerWallet(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
customerID, err := uuid.Parse(c.Param("id"))
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid customer ID"),
}), "WalletAdminHandler::GetCustomerWallet")
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()),
}), "WalletAdminHandler::GetCustomerWallet")
return
}
response := h.walletAdminService.GetCustomerWallet(ctx, contextInfo, customerID, query)
if response.HasErrors() {
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::GetCustomerWallet -> service call failed")
}
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::GetCustomerWallet")
}
func (h *WalletAdminHandler) AdjustCustomerWallet(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
customerID, err := uuid.Parse(c.Param("id"))
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid customer ID"),
}), "WalletAdminHandler::AdjustCustomerWallet")
return
}
var req contract.AdjustCustomerWalletRequest
if err := c.ShouldBindJSON(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()),
}), "WalletAdminHandler::AdjustCustomerWallet")
return
}
if validationError, code := h.walletValidator.ValidateAdjustRequest(&req); validationError != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(code, constants.RequestEntity, validationError.Error()),
}), "WalletAdminHandler::AdjustCustomerWallet")
return
}
response := h.walletAdminService.AdjustCustomerWallet(ctx, contextInfo, customerID, &req)
if response.HasErrors() {
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::AdjustCustomerWallet -> service call failed")
}
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::AdjustCustomerWallet")
}
+69
View File
@@ -69,3 +69,72 @@ type ListCustomerWalletTransactionsQuery struct {
From string `form:"from"` From string `form:"from"`
To string `form:"to"` To string `form:"to"`
} }
// AdminCustomerWallet is GET /marketing/customers/:id/wallet (docs/prd-point-coin.md
// F7). Unlike the customer's own view it shows the raw balances next to the spendable
// ones, every lot that still holds something, and the real names behind each row.
type AdminCustomerWallet struct {
Customer AdminWalletCustomer `json:"customer"`
// Balances as the ledger has them.
PointBalance int64 `json:"point_balance"`
CoinBalance int64 `json:"coin_balance"`
// What can be spent now. Lower than the ledger balance only while lots that have
// expired wait for the expiry job.
SpendablePointBalance int64 `json:"spendable_point_balance"`
SpendableCoinBalance int64 `json:"spendable_coin_balance"`
Lots []AdminWalletLot `json:"lots"`
Transactions PaginatedResponse[AdminWalletTransaction] `json:"transactions"`
}
type AdminWalletCustomer struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Phone *string `json:"phone,omitempty"`
}
type AdminWalletLot struct {
ID uuid.UUID `json:"id"`
Currency string `json:"currency"`
OriginalAmount int64 `json:"original_amount"`
RemainingAmount int64 `json:"remaining_amount"`
ExpiresAt *time.Time `json:"expires_at"`
Expired bool `json:"expired"`
SourceTransactionID uuid.UUID `json:"source_transaction_id"`
OriginLotID *uuid.UUID `json:"origin_lot_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// AdminWalletTransaction is a ledger row with the names the customer does not see:
// the real counterparty of a transfer, the admin behind an adjustment, the cashier who
// took a payment, and the outlet.
type AdminWalletTransaction struct {
CustomerWalletTransaction
Counterparty *AdminWalletNamedRef `json:"counterparty,omitempty"`
CreatedBy *AdminWalletNamedRef `json:"created_by,omitempty"`
Outlet *AdminWalletNamedRef `json:"outlet,omitempty"`
Reason *string `json:"reason,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type AdminWalletNamedRef struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
}
// WalletAdjustment is a manual correction by an admin.
type WalletAdjustment struct {
Currency string
// Signed: positive adds, negative takes away.
Amount int64
Reason string
IdempotencyKey string
}
// AdminWalletAdjustmentResult is what POST /marketing/customers/:id/wallet/adjust returns.
type AdminWalletAdjustmentResult struct {
Transaction AdminWalletTransaction `json:"transaction"`
SpendablePointBalance int64 `json:"spendable_point_balance"`
SpendableCoinBalance int64 `json:"spendable_coin_balance"`
// True when the idempotency key had been used before and nothing changed.
Replayed bool `json:"replayed"`
}
@@ -0,0 +1,225 @@
package processor
import (
"context"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// walletDescriptionLimit is the size of wallet_transactions.description.
const walletDescriptionLimit = 255
// WalletAdminProcessor serves the dashboard's view of one customer's wallet and the
// manual adjustments an admin makes to it (docs/prd-point-coin.md F7).
type WalletAdminProcessor struct {
admin repository.WalletAdminRepository
query repository.WalletQueryRepository
wallet *WalletProcessor
tx TxRunner
now func() time.Time
}
func NewWalletAdminProcessor(admin repository.WalletAdminRepository, query repository.WalletQueryRepository, wallet *WalletProcessor, tx TxRunner) *WalletAdminProcessor {
return &WalletAdminProcessor{admin: admin, query: query, wallet: wallet, tx: tx, now: time.Now}
}
// GetCustomerWallet returns the customer's balances, lots and a page of history.
// A customer outside the organization is reported as not found.
func (p *WalletAdminProcessor) GetCustomerWallet(ctx context.Context, organizationID, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) (*models.AdminCustomerWallet, error) {
filter, page, err := walletTransactionFilter(customerID, query)
if err != nil {
return nil, err
}
customer, err := p.admin.GetCustomer(ctx, organizationID, customerID)
if err != nil {
return nil, err
}
now := p.now()
result := &models.AdminCustomerWallet{
Customer: models.AdminWalletCustomer{ID: customer.ID, Name: customer.Name, Phone: customer.Phone},
Lots: []models.AdminWalletLot{},
}
wallet, err := p.admin.GetWallet(ctx, customerID)
if err != nil {
return nil, err
}
if wallet != nil {
result.PointBalance = wallet.PointBalance
result.CoinBalance = wallet.CoinBalance
}
spendable, err := p.query.SpendableBalances(ctx, customerID, now)
if err != nil {
return nil, err
}
result.SpendablePointBalance = spendable[constants.WalletCurrencyPoint]
result.SpendableCoinBalance = spendable[constants.WalletCurrencyCoin]
lots, err := p.admin.ListLotsWithBalance(ctx, customerID)
if err != nil {
return nil, err
}
for _, lot := range lots {
result.Lots = append(result.Lots, models.AdminWalletLot{
ID: lot.ID,
Currency: lot.Currency,
OriginalAmount: lot.OriginalAmount,
RemainingAmount: lot.RemainingAmount,
ExpiresAt: lot.ExpiresAt,
Expired: lot.ExpiresAt != nil && !lot.ExpiresAt.After(now),
SourceTransactionID: lot.SourceTransactionID,
OriginLotID: lot.OriginLotID,
CreatedAt: lot.CreatedAt,
})
}
rows, total, err := p.query.ListTransactions(ctx, filter)
if err != nil {
return nil, err
}
transactions, err := p.adminTransactions(ctx, rows)
if err != nil {
return nil, err
}
result.Transactions = models.PaginatedResponse[models.AdminWalletTransaction]{
Data: transactions,
Pagination: models.Pagination{
Page: page,
Limit: filter.Limit,
Total: total,
TotalPages: int((total + int64(filter.Limit) - 1) / int64(filter.Limit)),
},
}
return result, nil
}
// Adjust adds to or takes from a customer's balance by hand. It is recorded as an
// ADJUSTMENT pointing at the admin, with the reason. Taking more than the customer
// can spend is refused with repository.ErrWalletInsufficientBalance.
func (p *WalletAdminProcessor) Adjust(ctx context.Context, organizationID, adminID, customerID uuid.UUID, in models.WalletAdjustment) (*models.AdminWalletAdjustmentResult, error) {
reason := strings.TrimSpace(in.Reason)
if adminID == uuid.Nil {
return nil, fmt.Errorf("%w: the admin making the adjustment is unknown", ErrWalletInvalidEntry)
}
if in.Amount == 0 {
return nil, fmt.Errorf("%w: amount must not be zero", ErrWalletInvalidEntry)
}
entry := WalletEntry{
CustomerID: customerID,
Currency: in.Currency,
Type: constants.WalletTxTypeAdjustment,
Amount: abs(in.Amount),
ReferenceType: constants.WalletRefTypeUser,
ReferenceID: adminID,
CreatedByUser: &adminID,
Reason: &reason,
Description: truncateRunes("Koreksi oleh admin: "+reason, walletDescriptionLimit),
}
if in.IdempotencyKey != "" {
// Scoped to the customer so a key chosen by the dashboard cannot collide with
// the keys other flows use.
entry.IdempotencyKey = fmt.Sprintf("adjust:%s:%s", customerID, in.IdempotencyKey)
}
var res *WalletResult
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
if _, err := p.admin.GetCustomer(ctx, organizationID, customerID); err != nil {
return err
}
var err error
if in.Amount > 0 {
// Adjustments never expire until expiry is decided (F12, note N4).
res, err = p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: entry})
} else {
res, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: entry})
}
return err
})
if err != nil {
return nil, err
}
transactions, err := p.adminTransactions(ctx, []entities.WalletTransaction{*res.Transaction})
if err != nil {
return nil, err
}
spendable, err := p.query.SpendableBalances(ctx, customerID, p.now())
if err != nil {
return nil, err
}
return &models.AdminWalletAdjustmentResult{
Transaction: transactions[0],
SpendablePointBalance: spendable[constants.WalletCurrencyPoint],
SpendableCoinBalance: spendable[constants.WalletCurrencyCoin],
Replayed: res.Replayed,
}, nil
}
// adminTransactions adds, to what the customer sees, the names behind each row.
func (p *WalletAdminProcessor) adminTransactions(ctx context.Context, rows []entities.WalletTransaction) ([]models.AdminWalletTransaction, error) {
base, err := buildWalletTransactions(ctx, p.query, rows)
if err != nil {
return nil, err
}
var customerIDs, userIDs, outletIDs []uuid.UUID
for _, row := range rows {
if row.CounterpartyCustomerID != nil {
customerIDs = append(customerIDs, *row.CounterpartyCustomerID)
}
if row.CreatedByUser != nil {
userIDs = append(userIDs, *row.CreatedByUser)
}
if row.OutletID != nil {
outletIDs = append(outletIDs, *row.OutletID)
}
}
customers, err := p.admin.CustomerNames(ctx, customerIDs)
if err != nil {
return nil, err
}
users, err := p.admin.UserNames(ctx, userIDs)
if err != nil {
return nil, err
}
outlets, err := p.admin.OutletNames(ctx, outletIDs)
if err != nil {
return nil, err
}
named := func(id *uuid.UUID, names map[uuid.UUID]string) *models.AdminWalletNamedRef {
if id == nil {
return nil
}
return &models.AdminWalletNamedRef{ID: *id, Name: names[*id]}
}
out := make([]models.AdminWalletTransaction, 0, len(rows))
for i, row := range rows {
out = append(out, models.AdminWalletTransaction{
CustomerWalletTransaction: base[i],
Counterparty: named(row.CounterpartyCustomerID, customers),
CreatedBy: named(row.CreatedByUser, users),
Outlet: named(row.OutletID, outlets),
Reason: row.Reason,
Metadata: row.Metadata,
})
}
return out, nil
}
func truncateRunes(s string, limit int) string {
if utf8.RuneCountInString(s) <= limit {
return s
}
return string([]rune(s)[:limit])
}
+7 -1
View File
@@ -210,13 +210,19 @@ func (p *WalletQueryProcessor) pointValue(ctx context.Context, organizationID uu
} }
func (p *WalletQueryProcessor) toTransactions(ctx context.Context, rows []entities.WalletTransaction) ([]models.CustomerWalletTransaction, error) { func (p *WalletQueryProcessor) toTransactions(ctx context.Context, rows []entities.WalletTransaction) ([]models.CustomerWalletTransaction, error) {
return buildWalletTransactions(ctx, p.repo, rows)
}
// buildWalletTransactions turns ledger rows into what the apps show, in the same
// order: where each row came from or went to, and the lots each credit created.
func buildWalletTransactions(ctx context.Context, repo repository.WalletQueryRepository, rows []entities.WalletTransaction) ([]models.CustomerWalletTransaction, error) {
var credits []uuid.UUID var credits []uuid.UUID
for _, row := range rows { for _, row := range rows {
if row.Amount > 0 { if row.Amount > 0 {
credits = append(credits, row.ID) credits = append(credits, row.ID)
} }
} }
lots, err := p.repo.ListLotsBySourceTransactions(ctx, credits) lots, err := repo.ListLotsBySourceTransactions(ctx, credits)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -0,0 +1,126 @@
package repository
import (
"context"
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
"apskel-pos-be/internal/entities"
)
// WalletCustomer is the customer a dashboard wallet screen is about.
type WalletCustomer struct {
ID uuid.UUID
Name string
Phone *string
}
// WalletAdminRepository serves what the dashboard adds on top of the customer's own
// wallet view (docs/prd-point-coin.md F7): the raw balances, every lot with balance,
// and the real names behind a ledger row. Nothing here changes a balance.
type WalletAdminRepository interface {
// GetCustomer returns ErrWalletNotFound unless the customer exists and belongs to
// the organization, so one organization can never read another's customers.
GetCustomer(ctx context.Context, organizationID, customerID uuid.UUID) (*WalletCustomer, error)
// GetWallet returns nil, nil for a customer who has never had a wallet.
GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error)
// ListLotsWithBalance returns every lot that still holds something, expired or
// not, in K9 spending order.
ListLotsWithBalance(ctx context.Context, customerID uuid.UUID) ([]entities.WalletLot, error)
CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
UserNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
OutletNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
}
type walletAdminRepository struct {
db *gorm.DB
}
func NewWalletAdminRepository(db *gorm.DB) WalletAdminRepository {
return &walletAdminRepository{db: db}
}
func (r *walletAdminRepository) GetCustomer(ctx context.Context, organizationID, customerID uuid.UUID) (*WalletCustomer, error) {
var customers []WalletCustomer
err := DBFromContext(ctx, r.db).WithContext(ctx).
Table("customers").
Select("id, name, COALESCE(phone_number, phone) AS phone").
Where("id = ? AND organization_id = ?", customerID, organizationID).
Limit(1).
Scan(&customers).Error
if err != nil {
return nil, fmt.Errorf("failed to get customer: %w", err)
}
if len(customers) == 0 {
return nil, ErrWalletNotFound
}
return &customers[0], nil
}
func (r *walletAdminRepository) GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) {
var wallets []entities.CustomerWallet
err := DBFromContext(ctx, r.db).WithContext(ctx).
Where("customer_id = ?", customerID).
Limit(1).
Find(&wallets).Error
if err != nil {
return nil, fmt.Errorf("failed to get customer wallet: %w", err)
}
if len(wallets) == 0 {
return nil, nil
}
return &wallets[0], nil
}
func (r *walletAdminRepository) ListLotsWithBalance(ctx context.Context, customerID uuid.UUID) ([]entities.WalletLot, error) {
var lots []entities.WalletLot
err := DBFromContext(ctx, r.db).WithContext(ctx).
Where("customer_id = ? AND remaining_amount > 0", customerID).
Order("currency, expires_at NULLS LAST, created_at, id").
Find(&lots).Error
if err != nil {
return nil, fmt.Errorf("failed to list wallet lots: %w", err)
}
return lots, nil
}
func (r *walletAdminRepository) CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
return r.names(ctx, "customers", ids)
}
func (r *walletAdminRepository) UserNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
return r.names(ctx, "users", ids)
}
func (r *walletAdminRepository) OutletNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
return r.names(ctx, "outlets", ids)
}
// names looks up the name column of a table by id. The table name is never taken
// from user input.
func (r *walletAdminRepository) names(ctx context.Context, table string, ids []uuid.UUID) (map[uuid.UUID]string, error) {
names := make(map[uuid.UUID]string, len(ids))
if len(ids) == 0 {
return names, nil
}
var rows []struct {
ID string
Name string
}
err := DBFromContext(ctx, r.db).WithContext(ctx).
Table(table).
Select("id::text AS id, name").
Where("id IN ?", ids).
Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to look up %s names: %w", table, err)
}
for _, row := range rows {
if id, err := uuid.Parse(row.ID); err == nil {
names[id] = row.Name
}
}
return names, nil
}
+10 -1
View File
@@ -54,12 +54,13 @@ type Router struct {
productOutletPriceHandler *handler.ProductOutletPriceHandler productOutletPriceHandler *handler.ProductOutletPriceHandler
expenseHandler *handler.ExpenseHandler expenseHandler *handler.ExpenseHandler
cashAdvanceHandler *handler.CashAdvanceHandler cashAdvanceHandler *handler.CashAdvanceHandler
walletAdminHandler *handler.WalletAdminHandler
authMiddleware *middleware.AuthMiddleware authMiddleware *middleware.AuthMiddleware
customerAuthMiddleware *middleware.CustomerAuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware
redisClient *redis.Client redisClient *redis.Client
} }
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, redisClient *redis.Client) *Router { func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, redisClient *redis.Client) *Router {
return &Router{ return &Router{
config: cfg, config: cfg,
@@ -105,6 +106,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator), productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator),
expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator), expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator),
cashAdvanceHandler: handler.NewCashAdvanceHandler(cashAdvanceService, cashAdvanceValidator), cashAdvanceHandler: handler.NewCashAdvanceHandler(cashAdvanceService, cashAdvanceValidator),
walletAdminHandler: handler.NewWalletAdminHandler(walletAdminService, walletValidator),
redisClient: redisClient, redisClient: redisClient,
} }
} }
@@ -615,6 +617,13 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
// Campaign Rules // Campaign Rules
// EnakPoint & EnakCoin wallet of one customer (docs/prd-point-coin.md F7)
marketingCustomers := gamification.Group("/customers")
{
marketingCustomers.GET("/:id/wallet", r.walletAdminHandler.GetCustomerWallet)
marketingCustomers.POST("/:id/wallet/adjust", r.walletAdminHandler.AdjustCustomerWallet)
}
campaignRules := gamification.Group("/campaign-rules") campaignRules := gamification.Group("/campaign-rules")
{ {
campaignRules.POST("", r.campaignHandler.CreateCampaignRule) campaignRules.POST("", r.campaignHandler.CreateCampaignRule)
+69
View File
@@ -0,0 +1,69 @@
package service
import (
"context"
"errors"
"github.com/google/uuid"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
)
// WalletAdminService is the dashboard's access to a customer's wallet
// (docs/prd-point-coin.md F7). Every call is scoped to the caller's organization.
type WalletAdminService interface {
GetCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) *contract.Response
AdjustCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.AdjustCustomerWalletRequest) *contract.Response
}
type WalletAdminServiceImpl struct {
walletAdminProcessor *processor.WalletAdminProcessor
}
func NewWalletAdminService(walletAdminProcessor *processor.WalletAdminProcessor) *WalletAdminServiceImpl {
return &WalletAdminServiceImpl{walletAdminProcessor: walletAdminProcessor}
}
func (s *WalletAdminServiceImpl) GetCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) *contract.Response {
wallet, err := s.walletAdminProcessor.GetCustomerWallet(ctx, apctx.OrganizationID, customerID, query)
if err != nil {
return walletErrorResponse(err)
}
return contract.BuildSuccessResponse(wallet)
}
func (s *WalletAdminServiceImpl) AdjustCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.AdjustCustomerWalletRequest) *contract.Response {
result, err := s.walletAdminProcessor.Adjust(ctx, apctx.OrganizationID, apctx.UserID, customerID, models.WalletAdjustment{
Currency: req.Currency,
Amount: req.Amount,
Reason: req.Reason,
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
return walletErrorResponse(err)
}
return contract.BuildSuccessResponse(result)
}
// walletErrorResponse tells the dashboard's mistakes apart from server failures.
func walletErrorResponse(err error) *contract.Response {
code, message := constants.InternalServerErrorCode, err.Error()
switch {
case errors.Is(err, repository.ErrWalletNotFound):
code, message = constants.NotFoundErrorCode, "customer not found"
case errors.Is(err, repository.ErrWalletInsufficientBalance):
code, message = constants.ValidationErrorCode, "the adjustment would take more than the customer can spend"
case errors.Is(err, processor.ErrInvalidWalletQuery),
errors.Is(err, processor.ErrWalletInvalidEntry),
errors.Is(err, processor.ErrWalletIdempotencyConflict):
code = constants.ValidationErrorCode
}
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(code, constants.WalletServiceEntity, message),
})
}
+66
View File
@@ -0,0 +1,66 @@
package validator
import (
"errors"
"strings"
"unicode/utf8"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
)
// walletAdjustmentReasonLimit is the size of wallet_transactions.reason.
const walletAdjustmentReasonLimit = 255
// walletCashOutWords are reasons that describe turning balance into money. EnakPoint
// and EnakCoin can never be cashed out, and an adjustment must not be used to record
// that it happened (docs/prd-point-coin.md K7). Words like "tunai" are left out on
// purpose: "paid in cash but the points never arrived" is a legitimate reason.
var walletCashOutWords = []string{"pencairan", "cairkan", "cash out", "cashout", "withdraw"}
type WalletValidator interface {
ValidateAdjustRequest(req *contract.AdjustCustomerWalletRequest) (error, string)
}
type WalletValidatorImpl struct{}
func NewWalletValidator() *WalletValidatorImpl {
return &WalletValidatorImpl{}
}
func (v *WalletValidatorImpl) ValidateAdjustRequest(req *contract.AdjustCustomerWalletRequest) (error, string) {
if req == nil {
return errors.New("request body is required"), constants.MissingFieldErrorCode
}
req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency))
if req.Currency == "" {
return errors.New("currency is required"), constants.MissingFieldErrorCode
}
if !constants.IsValidWalletCurrency(req.Currency) {
return errors.New("currency must be POINT or COIN"), constants.MalformedFieldErrorCode
}
if req.Amount == 0 {
return errors.New("amount must not be zero: use a positive amount to add and a negative one to take away"), constants.ValidationErrorCode
}
req.Reason = strings.TrimSpace(req.Reason)
if req.Reason == "" {
return errors.New("reason is required"), constants.MissingFieldErrorCode
}
if utf8.RuneCountInString(req.Reason) > walletAdjustmentReasonLimit {
return errors.New("reason must be at most 255 characters"), constants.MalformedFieldErrorCode
}
lower := strings.ToLower(req.Reason)
for _, word := range walletCashOutWords {
if strings.Contains(lower, word) {
return errors.New("EnakPoint and EnakCoin cannot be cashed out, so an adjustment cannot be for a cash-out"), constants.ValidationErrorCode
}
}
if len(req.IdempotencyKey) > 50 {
return errors.New("idempotency_key must be at most 50 characters"), constants.MalformedFieldErrorCode
}
return nil, ""
}
@@ -0,0 +1,43 @@
package validator
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
)
func TestWalletValidator_AdjustRequest(t *testing.T) {
v := NewWalletValidator()
ok := &contract.AdjustCustomerWalletRequest{Currency: " point ", Amount: -25, Reason: " komplain #45 "}
err, _ := v.ValidateAdjustRequest(ok)
assert.NoError(t, err)
assert.Equal(t, constants.WalletCurrencyPoint, ok.Currency, "currency is normalised")
assert.Equal(t, "komplain #45", ok.Reason, "reason is trimmed")
legit := &contract.AdjustCustomerWalletRequest{Currency: "COIN", Amount: 5, Reason: "Bayar tunai tapi poin tidak masuk"}
err, _ = v.ValidateAdjustRequest(legit)
assert.NoError(t, err, "mentioning a cash payment is not a cash-out")
for name, req := range map[string]*contract.AdjustCustomerWalletRequest{
"nil": nil,
"no currency": {Amount: 5, Reason: "x"},
"unknown currency": {Currency: "GOLD", Amount: 5, Reason: "x"},
"zero amount": {Currency: "POINT", Amount: 0, Reason: "x"},
"no reason": {Currency: "POINT", Amount: 5, Reason: " "},
"reason too long": {Currency: "POINT", Amount: 5, Reason: strings.Repeat("a", 256)},
"cash-out reason": {Currency: "POINT", Amount: -500, Reason: "Pencairan saldo ke rekening"},
"cash-out, English": {Currency: "POINT", Amount: -500, Reason: "customer asked to cash out"},
"long key": {Currency: "POINT", Amount: 5, Reason: "x", IdempotencyKey: strings.Repeat("k", 51)},
} {
t.Run(name, func(t *testing.T) {
err, code := v.ValidateAdjustRequest(req)
assert.Error(t, err)
assert.NotEmpty(t, code)
})
}
}