package store import ( "context" "encoding/json" "fmt" "strconv" "strings" "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/jackc/pgx/v5" ) type GatewayWalletAccount struct { ID string `json:"id"` GatewayTenantID string `json:"gatewayTenantId,omitempty"` GatewayUserID string `json:"gatewayUserId"` TenantID string `json:"tenantId,omitempty"` TenantKey string `json:"tenantKey,omitempty"` UserID string `json:"userId,omitempty"` Currency string `json:"currency"` Balance float64 `json:"balance"` FrozenBalance float64 `json:"frozenBalance"` TotalRecharged float64 `json:"totalRecharged"` TotalSpent float64 `json:"totalSpent"` Status string `json:"status"` Metadata map[string]any `json:"metadata,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type GatewayWalletTransaction struct { ID string `json:"id"` AccountID string `json:"accountId"` Currency string `json:"currency,omitempty"` GatewayTenantID string `json:"gatewayTenantId,omitempty"` GatewayUserID string `json:"gatewayUserId,omitempty"` Direction string `json:"direction"` TransactionType string `json:"transactionType"` Amount float64 `json:"amount"` BalanceBefore float64 `json:"balanceBefore"` BalanceAfter float64 `json:"balanceAfter"` IdempotencyKey string `json:"idempotencyKey,omitempty"` ReferenceType string `json:"referenceType,omitempty"` ReferenceID string `json:"referenceId,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` CreatedAt time.Time `json:"createdAt"` } type WalletAvailability struct { Account GatewayWalletAccount `json:"account"` Currency string `json:"currency"` RequiredAmount float64 `json:"requiredAmount"` AvailableAmount float64 `json:"availableAmount"` Enough bool `json:"enough"` } type WalletSummary struct { Accounts []GatewayWalletAccount `json:"accounts"` PrimaryAccount GatewayWalletAccount `json:"primaryAccount"` } type WalletTransactionListFilter struct { Query string Direction string TransactionType string CreatedFrom *time.Time CreatedTo *time.Time Page int PageSize int } type WalletTransactionListResult struct { Items []GatewayWalletTransaction Total int Page int PageSize int } type WalletBalanceAdjustmentInput struct { GatewayUserID string `json:"gatewayUserId"` Currency string `json:"currency"` Balance float64 `json:"balance"` Reason string `json:"reason"` IdempotencyKey string `json:"idempotencyKey"` Metadata map[string]any `json:"metadata"` } type WalletAdjustmentResult struct { Account GatewayWalletAccount `json:"account"` Before GatewayWalletAccount `json:"before"` Transaction GatewayWalletTransaction `json:"transaction"` } type WalletBillingReservation struct { TaskID string `json:"taskId"` AccountID string `json:"accountId"` GatewayUserID string `json:"gatewayUserId"` GatewayTenantID string `json:"gatewayTenantId,omitempty"` Currency string `json:"currency"` Amount float64 `json:"amount"` IdempotencyKey string `json:"idempotencyKey"` } func (s *Store) WalletAvailability(ctx context.Context, user *auth.User, currency string, requiredAmount float64) (WalletAvailability, error) { gatewayUserID := localGatewayUserID(user) if gatewayUserID == "" { return WalletAvailability{Currency: normalizeWalletCurrency(currency), RequiredAmount: requiredAmount, Enough: true}, nil } account, err := s.ensureWalletAccount(ctx, s.pool, gatewayUserID, currency) if err != nil { return WalletAvailability{}, err } available := roundMoney(account.Balance - account.FrozenBalance) result := WalletAvailability{ Account: account, Currency: account.Currency, RequiredAmount: roundMoney(requiredAmount), AvailableAmount: available, Enough: available+0.000001 >= requiredAmount, } if !result.Enough { return result, fmt.Errorf("%w: required %.6f %s, available %.6f", ErrInsufficientWalletBalance, requiredAmount, account.Currency, available) } return result, nil } func (s *Store) ReserveTaskBilling(ctx context.Context, task GatewayTask, user *auth.User, billings []any) ([]WalletBillingReservation, error) { gatewayUserID := taskGatewayUserID(task, user) if gatewayUserID == "" { return nil, nil } taskID := strings.TrimSpace(task.ID) if taskID == "" { return nil, fmt.Errorf("task id is required for wallet reservation") } amounts := walletBillingAmounts(billings) if len(amounts) == 0 { return nil, nil } reservations := make([]WalletBillingReservation, 0, len(amounts)) err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { for currency, rawAmount := range amounts { amount := roundMoney(rawAmount) if amount <= 0 { continue } account, err := s.ensureWalletAccount(ctx, tx, gatewayUserID, currency) if err != nil { return err } locked, err := lockWalletAccount(ctx, tx, account.ID) if err != nil { return err } activeKey, activeAmount, err := activeWalletReservation(ctx, tx, locked.ID, taskID) if err != nil { return err } if activeAmount > 0 { reservation := WalletBillingReservation{ TaskID: taskID, AccountID: locked.ID, GatewayUserID: gatewayUserID, GatewayTenantID: firstNonEmpty(locked.GatewayTenantID, task.GatewayTenantID), Currency: locked.Currency, Amount: activeAmount, IdempotencyKey: activeKey, } reservations = append(reservations, reservation) continue } sequence, err := nextWalletReservationSequence(ctx, tx, locked.ID, taskID) if err != nil { return err } key := billingReservationIdempotencyKey(taskID, locked.Currency, sequence) reservation := WalletBillingReservation{ TaskID: taskID, AccountID: locked.ID, GatewayUserID: gatewayUserID, GatewayTenantID: firstNonEmpty(locked.GatewayTenantID, task.GatewayTenantID), Currency: locked.Currency, Amount: amount, IdempotencyKey: key, } available := roundMoney(locked.Balance - locked.FrozenBalance) if available+0.000001 < amount { return fmt.Errorf("%w: required %.6f %s, available %.6f", ErrInsufficientWalletBalance, amount, locked.Currency, available) } frozenAfter := roundMoney(locked.FrozenBalance + amount) if _, err := tx.Exec(ctx, ` UPDATE gateway_wallet_accounts SET frozen_balance = $2, updated_at = now() WHERE id = $1::uuid`, locked.ID, frozenAfter); err != nil { return err } metadata, _ := json.Marshal(map[string]any{ "taskId": taskID, "kind": task.Kind, "model": task.Model, "reserved": amount, "balance": roundMoney(locked.Balance), "frozenBefore": roundMoney(locked.FrozenBalance), "frozenAfter": frozenAfter, }) if _, err := tx.Exec(ctx, ` INSERT INTO gateway_wallet_transactions ( account_id, gateway_tenant_id, gateway_user_id, direction, transaction_type, amount, balance_before, balance_after, idempotency_key, reference_type, reference_id, metadata ) VALUES ( $1::uuid, NULLIF($2, '')::uuid, $3::uuid, 'debit', 'reserve', $4, $5, $6, $7, 'gateway_task', $8, $9::jsonb )`, locked.ID, firstNonEmpty(locked.GatewayTenantID, task.GatewayTenantID), gatewayUserID, amount, roundMoney(locked.Balance), roundMoney(locked.Balance), key, taskID, string(metadata), ); err != nil { return err } reservations = append(reservations, reservation) } return nil }) if err != nil { return nil, err } return reservations, err } func (s *Store) ReleaseTaskBillingReservations(ctx context.Context, reservations []WalletBillingReservation, reason string) error { if len(reservations) == 0 { return nil } reason = strings.TrimSpace(reason) if reason == "" { reason = "task_not_settled" } return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { for _, reservation := range reservations { if reservation.Amount <= 0 || strings.TrimSpace(reservation.AccountID) == "" { continue } reserveKey := strings.TrimSpace(reservation.IdempotencyKey) if reserveKey == "" { reserveKey = billingReservationIdempotencyKey(reservation.TaskID, reservation.Currency, 1) } releaseKey := billingReservationReleaseIdempotencyKey(reserveKey) locked, err := lockWalletAccount(ctx, tx, reservation.AccountID) if err != nil { if err == pgx.ErrNoRows { continue } return err } var alreadyReleased bool if err := tx.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM gateway_wallet_transactions WHERE account_id = $1::uuid AND idempotency_key = $2 )`, reservation.AccountID, releaseKey).Scan(&alreadyReleased); err != nil { return err } if alreadyReleased { continue } var storedReservedAmount float64 if err := tx.QueryRow(ctx, ` SELECT COALESCE(( SELECT amount::float8 FROM gateway_wallet_transactions WHERE account_id = $1::uuid AND idempotency_key = $2 AND transaction_type = 'reserve' LIMIT 1 ), 0)::float8`, reservation.AccountID, reserveKey).Scan(&storedReservedAmount); err != nil { return err } if storedReservedAmount <= 0 { continue } amount := roundMoney(storedReservedAmount) frozenAfter := roundMoney(locked.FrozenBalance - amount) if frozenAfter < 0 { frozenAfter = 0 } if _, err := tx.Exec(ctx, ` UPDATE gateway_wallet_accounts SET frozen_balance = $2, updated_at = now() WHERE id = $1::uuid`, locked.ID, frozenAfter); err != nil { return err } metadata, _ := json.Marshal(map[string]any{ "taskId": reservation.TaskID, "reason": reason, "reserved": amount, "frozenBefore": roundMoney(locked.FrozenBalance), "frozenAfter": frozenAfter, }) if _, err := tx.Exec(ctx, ` INSERT INTO gateway_wallet_transactions ( account_id, gateway_tenant_id, gateway_user_id, direction, transaction_type, amount, balance_before, balance_after, idempotency_key, reference_type, reference_id, metadata ) VALUES ( $1::uuid, NULLIF($2, '')::uuid, $3::uuid, 'credit', 'release', $4, $5, $6, $7, 'gateway_task', $8, $9::jsonb ) ON CONFLICT (account_id, idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING`, locked.ID, locked.GatewayTenantID, locked.GatewayUserID, amount, roundMoney(locked.Balance), roundMoney(locked.Balance), releaseKey, reservation.TaskID, string(metadata), ); err != nil { return err } } return nil }) } func (s *Store) GetWalletSummary(ctx context.Context, user *auth.User, currency string) (WalletSummary, error) { gatewayUserID := localGatewayUserID(user) if gatewayUserID == "" { account := GatewayWalletAccount{ Currency: normalizeWalletCurrency(currency), Status: "active", } return WalletSummary{Accounts: []GatewayWalletAccount{account}, PrimaryAccount: account}, nil } primary, err := s.ensureWalletAccount(ctx, s.pool, gatewayUserID, currency) if err != nil { return WalletSummary{}, err } rows, err := s.pool.Query(ctx, ` SELECT id::text, COALESCE(gateway_tenant_id::text, ''), gateway_user_id::text, COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), COALESCE(user_id, ''), currency, balance::float8, frozen_balance::float8, total_recharged::float8, total_spent::float8, status, metadata, created_at, updated_at FROM gateway_wallet_accounts WHERE gateway_user_id = $1::uuid ORDER BY CASE WHEN currency = $2 THEN 0 WHEN currency = 'resource' THEN 1 ELSE 2 END, currency ASC`, gatewayUserID, primary.Currency) if err != nil { return WalletSummary{}, err } defer rows.Close() accounts := make([]GatewayWalletAccount, 0) for rows.Next() { account, err := scanWalletAccount(rows) if err != nil { return WalletSummary{}, err } accounts = append(accounts, account) } if err := rows.Err(); err != nil { return WalletSummary{}, err } if len(accounts) == 0 { accounts = append(accounts, primary) } return WalletSummary{Accounts: accounts, PrimaryAccount: accounts[0]}, nil } func (s *Store) ListWalletTransactions(ctx context.Context, user *auth.User, filter WalletTransactionListFilter) (WalletTransactionListResult, error) { page := filter.Page if page <= 0 { page = 1 } pageSize := filter.PageSize if pageSize <= 0 { pageSize = 50 } if pageSize > 100 { pageSize = 100 } gatewayUserID := localGatewayUserID(user) if gatewayUserID == "" { return WalletTransactionListResult{Items: []GatewayWalletTransaction{}, Page: page, PageSize: pageSize}, nil } queryPattern := "" if query := strings.TrimSpace(filter.Query); query != "" { queryPattern = "%" + query + "%" } args := []any{ gatewayUserID, queryPattern, strings.TrimSpace(filter.Direction), strings.TrimSpace(filter.TransactionType), nullableTaskListTime(filter.CreatedFrom), nullableTaskListTime(filter.CreatedTo), } whereSQL := ` WHERE a.gateway_user_id = $1::uuid AND ( NULLIF($2, '') IS NULL OR t.id::text ILIKE $2 OR COALESCE(t.reference_id, '') ILIKE $2 OR COALESCE(t.reference_type, '') ILIKE $2 OR COALESCE(t.idempotency_key, '') ILIKE $2 OR t.transaction_type ILIKE $2 OR t.direction ILIKE $2 OR COALESCE(task.id::text, '') ILIKE $2 OR COALESCE(task.request_id, '') ILIKE $2 OR COALESCE(task.kind, '') ILIKE $2 OR COALESCE(task.model, '') ILIKE $2 OR COALESCE(task.requested_model, '') ILIKE $2 OR COALESCE(task.resolved_model, '') ILIKE $2 OR COALESCE(task.model_type, '') ILIKE $2 OR COALESCE(task.api_key_id, '') ILIKE $2 OR COALESCE(task.api_key_name, '') ILIKE $2 OR COALESCE(task.api_key_prefix, '') ILIKE $2 OR COALESCE(task.status, '') ILIKE $2 OR COALESCE(task.billing_summary->>'currency', '') ILIKE $2 OR COALESCE(task.billing_summary->>'totalAmount', '') ILIKE $2 OR COALESCE(attempt.client_id, '') ILIKE $2 OR COALESCE(attempt.request_id, '') ILIKE $2 OR COALESCE(platform.provider, '') ILIKE $2 OR COALESCE(platform.platform_key, '') ILIKE $2 OR COALESCE(platform.name, '') ILIKE $2 OR COALESCE(platform_model.model_name, '') ILIKE $2 OR COALESCE(platform_model.provider_model_name, '') ILIKE $2 OR COALESCE(platform_model.model_alias, '') ILIKE $2 OR COALESCE(platform_model.display_name, '') ILIKE $2 OR COALESCE(task.metrics->>'provider', '') ILIKE $2 OR COALESCE(task.metrics->>'platformName', '') ILIKE $2 OR COALESCE(task.metrics->>'modelAlias', '') ILIKE $2 OR COALESCE(task.metrics->>'providerModel', '') ILIKE $2 ) AND (NULLIF($3, '') IS NULL OR t.direction = $3) AND (NULLIF($4, '') IS NULL OR t.transaction_type = $4) AND ($5::timestamptz IS NULL OR t.created_at >= $5::timestamptz) AND ($6::timestamptz IS NULL OR t.created_at <= $6::timestamptz)` var total int if err := s.pool.QueryRow(ctx, ` SELECT count(*) FROM gateway_wallet_transactions t JOIN gateway_wallet_accounts a ON a.id = t.account_id LEFT JOIN gateway_tasks task ON t.reference_type = 'gateway_task' AND t.reference_id = task.id::text LEFT JOIN LATERAL ( SELECT platform_id, platform_model_id, client_id, request_id FROM gateway_task_attempts WHERE task_id = task.id ORDER BY attempt_no DESC, started_at DESC LIMIT 1 ) attempt ON true LEFT JOIN integration_platforms platform ON platform.id = attempt.platform_id LEFT JOIN platform_models platform_model ON platform_model.id = attempt.platform_model_id `+whereSQL, args...).Scan(&total); err != nil { return WalletTransactionListResult{}, err } offset := (page - 1) * pageSize queryArgs := append(args, pageSize, offset) rows, err := s.pool.Query(ctx, ` SELECT t.id::text, t.account_id::text, a.currency, COALESCE(t.gateway_tenant_id::text, ''), COALESCE(t.gateway_user_id::text, ''), t.direction, t.transaction_type, t.amount::float8, t.balance_before::float8, t.balance_after::float8, COALESCE(t.idempotency_key, ''), COALESCE(t.reference_type, ''), COALESCE(t.reference_id, ''), t.metadata || jsonb_strip_nulls(jsonb_build_object( 'taskId', task.id::text, 'kind', task.kind, 'model', task.model, 'requestedModel', task.requested_model, 'resolvedModel', task.resolved_model, 'modelType', task.model_type, 'taskStatus', task.status, 'runMode', task.run_mode, 'requestId', COALESCE(task.request_id, attempt.request_id), 'apiKeyId', task.api_key_id, 'apiKeyName', task.api_key_name, 'apiKeyPrefix', task.api_key_prefix, 'provider', COALESCE(platform.provider, task.metrics->>'provider'), 'platformId', COALESCE(platform.id::text, task.metrics->>'platformId'), 'platformName', COALESCE(platform.name, task.metrics->>'platformName'), 'platformKey', platform.platform_key, 'platformModelId', COALESCE(platform_model.id::text, task.metrics->>'platformModelId'), 'platformModelName', platform_model.model_name, 'platformModelAlias', platform_model.model_alias, 'providerModel', COALESCE(platform_model.provider_model_name, task.metrics->>'providerModel'), 'clientId', attempt.client_id, 'usage', CASE WHEN task.id IS NULL THEN NULL ELSE COALESCE(task.usage, attempt.usage, '{}'::jsonb) END, 'billings', CASE WHEN task.id IS NULL THEN NULL ELSE COALESCE(task.billings, '[]'::jsonb) END, 'billingSummary', CASE WHEN task.id IS NULL THEN NULL ELSE COALESCE(task.billing_summary, '{}'::jsonb) END, 'finalChargeAmount', CASE WHEN task.id IS NULL THEN NULL ELSE COALESCE(task.final_charge_amount, 0)::float8 END, 'responseStartedAt', COALESCE(task.response_started_at::text, attempt.response_started_at::text), 'responseFinishedAt', COALESCE(task.response_finished_at::text, attempt.response_finished_at::text), 'responseDurationMs', COALESCE(task.response_duration_ms, attempt.response_duration_ms) )), t.created_at FROM gateway_wallet_transactions t JOIN gateway_wallet_accounts a ON a.id = t.account_id LEFT JOIN gateway_tasks task ON t.reference_type = 'gateway_task' AND t.reference_id = task.id::text LEFT JOIN LATERAL ( SELECT platform_id, platform_model_id, client_id, request_id, usage, response_started_at, response_finished_at, response_duration_ms FROM gateway_task_attempts WHERE task_id = task.id ORDER BY attempt_no DESC, started_at DESC LIMIT 1 ) attempt ON true LEFT JOIN integration_platforms platform ON platform.id = attempt.platform_id LEFT JOIN platform_models platform_model ON platform_model.id = attempt.platform_model_id `+whereSQL+` ORDER BY t.created_at DESC, t.id DESC LIMIT $7 OFFSET $8`, queryArgs...) if err != nil { return WalletTransactionListResult{}, err } defer rows.Close() items := make([]GatewayWalletTransaction, 0) for rows.Next() { item, err := scanWalletTransactionWithCurrency(rows) if err != nil { return WalletTransactionListResult{}, err } items = append(items, item) } if err := rows.Err(); err != nil { return WalletTransactionListResult{}, err } return WalletTransactionListResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } func (s *Store) SetUserWalletBalance(ctx context.Context, input WalletBalanceAdjustmentInput) (WalletAdjustmentResult, error) { var result WalletAdjustmentResult err := s.InTx(ctx, func(tx Tx) error { next, err := s.SetUserWalletBalanceTx(ctx, tx, input) if err != nil { return err } result = next return nil }) return result, err } func (s *Store) SetUserWalletBalanceTx(ctx context.Context, tx Tx, input WalletBalanceAdjustmentInput) (WalletAdjustmentResult, error) { input.GatewayUserID = strings.TrimSpace(input.GatewayUserID) if input.GatewayUserID == "" { return WalletAdjustmentResult{}, ErrLocalUserRequired } if input.Balance < 0 { return WalletAdjustmentResult{}, fmt.Errorf("wallet balance cannot be negative") } account, err := s.ensureWalletAccount(ctx, tx, input.GatewayUserID, input.Currency) if err != nil { return WalletAdjustmentResult{}, err } var locked GatewayWalletAccount locked, err = scanWalletAccount(tx.QueryRow(ctx, ` SELECT id::text, COALESCE(gateway_tenant_id::text, ''), gateway_user_id::text, COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), COALESCE(user_id, ''), currency, balance::float8, frozen_balance::float8, total_recharged::float8, total_spent::float8, status, metadata, created_at, updated_at FROM gateway_wallet_accounts WHERE id = $1::uuid FOR UPDATE`, account.ID)) if err != nil { return WalletAdjustmentResult{}, err } before := locked nextBalance := roundMoney(input.Balance) delta := roundMoney(nextBalance - locked.Balance) if delta == 0 { return WalletAdjustmentResult{}, ErrWalletBalanceUnchanged } direction := "credit" amount := delta if delta < 0 { direction = "debit" amount = -delta } reason := strings.TrimSpace(input.Reason) if reason == "" { reason = "后台余额调整" } if _, err := tx.Exec(ctx, ` UPDATE gateway_wallet_accounts SET balance = $2, total_recharged = total_recharged + CASE WHEN $3 = 'credit' THEN $4 ELSE 0 END, updated_at = now() WHERE id = $1::uuid`, locked.ID, nextBalance, direction, amount, ); err != nil { return WalletAdjustmentResult{}, err } metadata := mergeObjects(input.Metadata, map[string]any{ "reason": reason, "previousBalance": roundMoney(before.Balance), "targetBalance": nextBalance, }) metadataJSON, _ := json.Marshal(emptyObjectIfNil(metadata)) transaction, err := scanWalletTransaction(tx.QueryRow(ctx, ` INSERT INTO gateway_wallet_transactions ( account_id, gateway_tenant_id, gateway_user_id, direction, transaction_type, amount, balance_before, balance_after, idempotency_key, reference_type, reference_id, metadata ) VALUES ( $1::uuid, NULLIF($2, '')::uuid, $3::uuid, $4, 'admin_adjust', $5, $6, $7, NULLIF($8, ''), 'gateway_user', $9, $10::jsonb ) RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COALESCE(gateway_user_id::text, ''), direction, transaction_type, amount::float8, balance_before::float8, balance_after::float8, COALESCE(idempotency_key, ''), COALESCE(reference_type, ''), COALESCE(reference_id, ''), metadata, created_at`, locked.ID, locked.GatewayTenantID, locked.GatewayUserID, direction, amount, roundMoney(before.Balance), nextBalance, strings.TrimSpace(input.IdempotencyKey), locked.GatewayUserID, string(metadataJSON), )) if err != nil { return WalletAdjustmentResult{}, err } locked.Balance = nextBalance if direction == "credit" { locked.TotalRecharged = roundMoney(locked.TotalRecharged + amount) } locked.UpdatedAt = time.Now() return WalletAdjustmentResult{Account: locked, Before: before, Transaction: transaction}, nil } func (s *Store) ensureWalletAccount(ctx context.Context, q Tx, gatewayUserID string, currency string) (GatewayWalletAccount, error) { currency = normalizeWalletCurrency(currency) if _, err := q.Exec(ctx, ` INSERT INTO gateway_wallet_accounts ( gateway_tenant_id, gateway_user_id, tenant_id, tenant_key, user_id, currency ) SELECT gateway_tenant_id, id, NULLIF(tenant_id, ''), NULLIF(tenant_key, ''), COALESCE(NULLIF(external_user_id, ''), id::text), $2 FROM gateway_users WHERE id = $1::uuid AND deleted_at IS NULL ON CONFLICT (gateway_user_id, currency) DO UPDATE SET gateway_tenant_id = COALESCE(gateway_wallet_accounts.gateway_tenant_id, EXCLUDED.gateway_tenant_id), tenant_id = COALESCE(NULLIF(gateway_wallet_accounts.tenant_id, ''), EXCLUDED.tenant_id), tenant_key = COALESCE(NULLIF(gateway_wallet_accounts.tenant_key, ''), EXCLUDED.tenant_key), user_id = COALESCE(NULLIF(gateway_wallet_accounts.user_id, ''), EXCLUDED.user_id), updated_at = now() WHERE gateway_wallet_accounts.gateway_tenant_id IS NULL OR COALESCE(gateway_wallet_accounts.tenant_id, '') = '' OR COALESCE(gateway_wallet_accounts.tenant_key, '') = '' OR COALESCE(gateway_wallet_accounts.user_id, '') = ''`, gatewayUserID, currency); err != nil { return GatewayWalletAccount{}, err } account, err := scanWalletAccount(q.QueryRow(ctx, ` SELECT id::text, COALESCE(gateway_tenant_id::text, ''), gateway_user_id::text, COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), COALESCE(user_id, ''), currency, balance::float8, frozen_balance::float8, total_recharged::float8, total_spent::float8, status, metadata, created_at, updated_at FROM gateway_wallet_accounts WHERE gateway_user_id = $1::uuid AND currency = $2`, gatewayUserID, currency)) if err != nil { if err == pgx.ErrNoRows { return GatewayWalletAccount{}, pgx.ErrNoRows } return GatewayWalletAccount{}, err } return account, nil } func lockWalletAccount(ctx context.Context, tx pgx.Tx, accountID string) (GatewayWalletAccount, error) { return scanWalletAccount(tx.QueryRow(ctx, ` SELECT id::text, COALESCE(gateway_tenant_id::text, ''), gateway_user_id::text, COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), COALESCE(user_id, ''), currency, balance::float8, frozen_balance::float8, total_recharged::float8, total_spent::float8, status, metadata, created_at, updated_at FROM gateway_wallet_accounts WHERE id = $1::uuid FOR UPDATE`, accountID)) } func activeWalletReservation(ctx context.Context, tx pgx.Tx, accountID string, taskID string) (string, float64, error) { var key string var amount float64 err := tx.QueryRow(ctx, ` SELECT COALESCE(t.idempotency_key, ''), t.amount::float8 FROM gateway_wallet_transactions t WHERE t.account_id = $1::uuid AND t.reference_type = 'gateway_task' AND t.reference_id = $2 AND t.transaction_type = 'reserve' AND COALESCE(t.idempotency_key, '') <> '' AND NOT EXISTS ( SELECT 1 FROM gateway_wallet_transactions r WHERE r.account_id = t.account_id AND r.transaction_type = 'release' AND r.idempotency_key = t.idempotency_key || ':release' ) ORDER BY t.created_at DESC LIMIT 1`, accountID, taskID).Scan(&key, &amount) if err == pgx.ErrNoRows { return "", 0, nil } if err != nil { return "", 0, err } return key, roundMoney(amount), nil } func nextWalletReservationSequence(ctx context.Context, tx pgx.Tx, accountID string, taskID string) (int, error) { var count int if err := tx.QueryRow(ctx, ` SELECT COUNT(*)::int FROM gateway_wallet_transactions WHERE account_id = $1::uuid AND reference_type = 'gateway_task' AND reference_id = $2 AND transaction_type = 'reserve'`, accountID, taskID).Scan(&count); err != nil { return 0, err } return count + 1, nil } func walletBillingAmounts(billings []any) map[string]float64 { amounts := map[string]float64{} for _, raw := range billings { line, _ := raw.(map[string]any) if line == nil { continue } amount := roundMoney(walletFloat(line["amount"])) if amount <= 0 { continue } currency := normalizeWalletCurrency(walletString(line["currency"])) amounts[currency] = roundMoney(amounts[currency] + amount) } return amounts } func taskGatewayUserID(task GatewayTask, user *auth.User) string { return firstNonEmpty(strings.TrimSpace(task.GatewayUserID), localGatewayUserID(user)) } func billingReservationIdempotencyKey(taskID string, currency string, sequence int) string { if sequence <= 0 { sequence = 1 } return "task:" + strings.TrimSpace(taskID) + ":wallet-reservation:" + normalizeWalletCurrency(currency) + ":" + strconv.Itoa(sequence) } func billingReservationReleaseIdempotencyKey(reservationKey string) string { return strings.TrimSpace(reservationKey) + ":release" } func walletString(value any) string { if text, ok := value.(string); ok { return strings.TrimSpace(text) } return "" } func walletFloat(value any) float64 { switch typed := value.(type) { case float64: return typed case float32: return float64(typed) case int: return float64(typed) case int64: return float64(typed) case json.Number: next, _ := typed.Float64() return next case string: next := strings.TrimSpace(typed) if next == "" { return 0 } parsed, _ := strconv.ParseFloat(next, 64) return parsed default: return 0 } } func normalizeWalletCurrency(currency string) string { currency = strings.TrimSpace(currency) if currency == "" { return "resource" } return currency } func scanWalletAccount(row scanner) (GatewayWalletAccount, error) { var item GatewayWalletAccount var metadata []byte if err := row.Scan( &item.ID, &item.GatewayTenantID, &item.GatewayUserID, &item.TenantID, &item.TenantKey, &item.UserID, &item.Currency, &item.Balance, &item.FrozenBalance, &item.TotalRecharged, &item.TotalSpent, &item.Status, &metadata, &item.CreatedAt, &item.UpdatedAt, ); err != nil { return GatewayWalletAccount{}, err } item.Metadata = decodeObject(metadata) return item, nil } func scanWalletTransaction(row scanner) (GatewayWalletTransaction, error) { var item GatewayWalletTransaction var metadata []byte if err := row.Scan( &item.ID, &item.AccountID, &item.GatewayTenantID, &item.GatewayUserID, &item.Direction, &item.TransactionType, &item.Amount, &item.BalanceBefore, &item.BalanceAfter, &item.IdempotencyKey, &item.ReferenceType, &item.ReferenceID, &metadata, &item.CreatedAt, ); err != nil { return GatewayWalletTransaction{}, err } item.Metadata = decodeObject(metadata) return item, nil } func scanWalletTransactionWithCurrency(row scanner) (GatewayWalletTransaction, error) { var item GatewayWalletTransaction var metadata []byte if err := row.Scan( &item.ID, &item.AccountID, &item.Currency, &item.GatewayTenantID, &item.GatewayUserID, &item.Direction, &item.TransactionType, &item.Amount, &item.BalanceBefore, &item.BalanceAfter, &item.IdempotencyKey, &item.ReferenceType, &item.ReferenceID, &metadata, &item.CreatedAt, ); err != nil { return GatewayWalletTransaction{}, err } item.Metadata = decodeObject(metadata) return item, nil } func decodeWalletAccounts(data []byte) []GatewayWalletAccount { if len(data) == 0 { return nil } var items []GatewayWalletAccount if err := json.Unmarshal(data, &items); err != nil { return nil } return items }