feat(wallet): customer wallet and manual adjustments in the dashboard
Adds the dashboard side of a customer's wallet (docs/prd-point-coin.md F7, PC-107), under /marketing for admins and managers: - GET /marketing/customers/:id/wallet returns the customer, the ledger and spendable balances, every lot that still holds something (flagged when expired), and a page of history. Unlike the customer's own view, each row carries the real names behind it: the transfer counterparty, the admin or cashier, and the outlet, plus the reason and metadata. - POST /marketing/customers/:id/wallet/adjust takes a signed amount and a required reason. It writes an ADJUSTMENT pointing at the admin through the wallet engine, refuses to take more than the customer can spend, and accepts an optional idempotency key so a retried request adjusts once. Reasons describing a cash-out are refused (K7). The customer must belong to the caller's organization; otherwise both endpoints answer 404. Positive adjustments create non-expiring lots until the expiry model is decided (F12, note N4). The mapping from ledger rows to what the apps show is now shared between the customer and dashboard views. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e5db0325cc
commit
a6d5a8b056
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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,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])
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user