feat: add wallet settlement audit flow
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
type walletBalanceRequest struct {
|
||||
Currency string `json:"currency"`
|
||||
Balance float64 `json:"balance"`
|
||||
Reason string `json:"reason"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
|
||||
func (s *Server) setUserWalletBalance(w http.ResponseWriter, r *http.Request) {
|
||||
actor, _ := auth.UserFromContext(r.Context())
|
||||
var input walletBalanceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if input.Balance < 0 {
|
||||
writeError(w, http.StatusBadRequest, "wallet balance cannot be negative")
|
||||
return
|
||||
}
|
||||
gatewayUserID := strings.TrimSpace(r.PathValue("userID"))
|
||||
reason := strings.TrimSpace(input.Reason)
|
||||
if reason == "" {
|
||||
writeError(w, http.StatusBadRequest, "reason is required")
|
||||
return
|
||||
}
|
||||
|
||||
var result store.WalletAdjustmentResult
|
||||
var auditLog store.AuditLog
|
||||
err := s.store.InTx(r.Context(), func(tx store.Tx) error {
|
||||
next, err := s.store.SetUserWalletBalanceTx(r.Context(), tx, store.WalletBalanceAdjustmentInput{
|
||||
GatewayUserID: gatewayUserID,
|
||||
Currency: input.Currency,
|
||||
Balance: input.Balance,
|
||||
Reason: reason,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
Metadata: input.Metadata,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result = next
|
||||
record, err := s.store.RecordAuditLogTx(r.Context(), tx, walletAdjustmentAuditInput(r, actor, reason, result))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auditLog = record
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case store.IsNotFound(err):
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
case errors.Is(err, store.ErrWalletBalanceUnchanged):
|
||||
writeError(w, http.StatusBadRequest, "wallet balance is unchanged")
|
||||
default:
|
||||
s.logger.Error("set user wallet balance failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "set user wallet balance failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"account": result.Account,
|
||||
"before": result.Before,
|
||||
"transaction": result.Transaction,
|
||||
"auditLog": auditLog,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) listAuditLogs(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
limit, err := positiveQueryInt(query.Get("limit"), 100)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
items, err := s.store.ListAuditLogs(r.Context(), store.AuditLogFilter{
|
||||
Category: query.Get("category"),
|
||||
Action: query.Get("action"),
|
||||
TargetType: query.Get("targetType"),
|
||||
TargetID: query.Get("targetId"),
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("list audit logs failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list audit logs failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func walletAdjustmentAuditInput(r *http.Request, actor *auth.User, reason string, result store.WalletAdjustmentResult) store.AuditLogInput {
|
||||
actorGatewayUserID := ""
|
||||
actorUserID := ""
|
||||
actorUsername := ""
|
||||
actorSource := ""
|
||||
actorRoles := []string(nil)
|
||||
if actor != nil {
|
||||
actorGatewayUserID = uuidText(firstNonEmptyText(actor.GatewayUserID, actor.ID))
|
||||
actorUserID = actor.ID
|
||||
actorUsername = actor.Username
|
||||
actorSource = actor.Source
|
||||
actorRoles = actor.Roles
|
||||
}
|
||||
return store.AuditLogInput{
|
||||
Category: "billing",
|
||||
Action: "wallet.balance.set",
|
||||
ActorGatewayUserID: actorGatewayUserID,
|
||||
ActorUserID: actorUserID,
|
||||
ActorUsername: actorUsername,
|
||||
ActorSource: actorSource,
|
||||
ActorRoles: actorRoles,
|
||||
TargetType: "gateway_user",
|
||||
TargetID: result.Account.GatewayUserID,
|
||||
TargetGatewayUserID: result.Account.GatewayUserID,
|
||||
TargetGatewayTenantID: result.Account.GatewayTenantID,
|
||||
RequestIP: requestIP(r),
|
||||
UserAgent: r.UserAgent(),
|
||||
BeforeState: map[string]any{
|
||||
"walletAccount": result.Before,
|
||||
},
|
||||
AfterState: map[string]any{
|
||||
"walletAccount": result.Account,
|
||||
"transaction": result.Transaction,
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"reason": reason,
|
||||
"currency": result.Account.Currency,
|
||||
"transactionId": result.Transaction.ID,
|
||||
"amount": result.Transaction.Amount,
|
||||
"direction": result.Transaction.Direction,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func requestIP(r *http.Request) string {
|
||||
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwarded != "" {
|
||||
parts := strings.Split(forwarded, ",")
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
|
||||
return realIP
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return strings.TrimSpace(r.RemoteAddr)
|
||||
}
|
||||
|
||||
func firstNonEmptyText(values ...string) string {
|
||||
for _, value := range values {
|
||||
if text := strings.TrimSpace(value); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func uuidText(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) != 36 {
|
||||
return ""
|
||||
}
|
||||
if value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-' {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -128,6 +128,31 @@ func TestCoreLocalFlow(t *testing.T) {
|
||||
if err := testPool.QueryRow(ctx, `SELECT id::text FROM gateway_users WHERE username = $1`, username).Scan(&smokeGatewayUserID); err != nil {
|
||||
t.Fatalf("read smoke gateway user id: %v", err)
|
||||
}
|
||||
var walletAdjustment struct {
|
||||
Account struct {
|
||||
Balance float64 `json:"balance"`
|
||||
} `json:"account"`
|
||||
AuditLog struct {
|
||||
Action string `json:"action"`
|
||||
} `json:"auditLog"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPatch, "/api/admin/users/"+smokeGatewayUserID+"/wallet", loginResponse.AccessToken, map[string]any{
|
||||
"currency": "resource",
|
||||
"balance": 1000,
|
||||
"reason": "seed integration wallet",
|
||||
}, http.StatusOK, &walletAdjustment)
|
||||
if !floatNear(walletAdjustment.Account.Balance, 1000) || walletAdjustment.AuditLog.Action != "wallet.balance.set" {
|
||||
t.Fatalf("wallet adjustment did not update balance and audit log: %+v", walletAdjustment)
|
||||
}
|
||||
var auditResponse struct {
|
||||
Items []struct {
|
||||
Action string `json:"action"`
|
||||
} `json:"items"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/admin/audit-logs?category=billing&limit=5", loginResponse.AccessToken, nil, http.StatusOK, &auditResponse)
|
||||
if len(auditResponse.Items) == 0 || auditResponse.Items[0].Action != "wallet.balance.set" {
|
||||
t.Fatalf("wallet adjustment audit log not found: %+v", auditResponse)
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/admin/models", apiKeyResponse.Secret, nil, http.StatusForbidden, nil)
|
||||
|
||||
var chatOnlyAPIKeyResponse struct {
|
||||
|
||||
@@ -612,6 +612,8 @@ func statusFromRunError(err error) int {
|
||||
return http.StatusNotFound
|
||||
case errors.Is(err, store.ErrRateLimited):
|
||||
return http.StatusTooManyRequests
|
||||
case errors.Is(err, store.ErrInsufficientWalletBalance):
|
||||
return http.StatusPaymentRequired
|
||||
default:
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
|
||||
@@ -56,7 +56,9 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han
|
||||
mux.Handle("GET /api/admin/users", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listUsers)))
|
||||
mux.Handle("POST /api/admin/users", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createGatewayUser)))
|
||||
mux.Handle("PATCH /api/admin/users/{userID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateGatewayUser)))
|
||||
mux.Handle("PATCH /api/admin/users/{userID}/wallet", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.setUserWalletBalance)))
|
||||
mux.Handle("DELETE /api/admin/users/{userID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteGatewayUser)))
|
||||
mux.Handle("GET /api/admin/audit-logs", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listAuditLogs)))
|
||||
mux.Handle("GET /api/admin/user-groups", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listUserGroups)))
|
||||
mux.Handle("POST /api/admin/user-groups", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createUserGroup)))
|
||||
mux.Handle("PATCH /api/admin/user-groups/{groupID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateUserGroup)))
|
||||
|
||||
Reference in New Issue
Block a user