feat(billing): 完成异步结算与请求执行闭环

统一任务成功、Attempt 与结算 Outbox 的事务边界,增加多实例安全结算、人工复核、请求幂等与执行租约。钱包决策使用九位精确金额,并通过审计保护约束保留流水事实;同时补充管理接口、指标与 PostgreSQL 集成测试。
This commit is contained in:
2026-07-21 00:25:53 +08:00
parent 7cea21f765
commit 5b2b94b1bd
33 changed files with 2884 additions and 418 deletions
+334 -60
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
@@ -82,6 +83,7 @@ type WalletBalanceAdjustmentInput struct {
GatewayUserID string `json:"gatewayUserId"`
Currency string `json:"currency"`
Balance float64 `json:"balance"`
BalanceText string `json:"-"`
Reason string `json:"reason"`
IdempotencyKey string `json:"idempotencyKey"`
Metadata map[string]any `json:"metadata"`
@@ -91,6 +93,7 @@ type WalletRechargeInput struct {
GatewayUserID string `json:"gatewayUserId"`
Currency string `json:"currency"`
Amount float64 `json:"amount"`
AmountText string `json:"-"`
Reason string `json:"reason"`
IdempotencyKey string `json:"idempotencyKey"`
Metadata map[string]any `json:"metadata"`
@@ -135,7 +138,7 @@ func (s *Store) WalletAvailability(ctx context.Context, user *auth.User, currenc
return result, nil
}
func (s *Store) ReserveTaskBilling(ctx context.Context, task GatewayTask, user *auth.User, billings []any) ([]WalletBillingReservation, error) {
func (s *Store) ReserveTaskBilling(ctx context.Context, task GatewayTask, user *auth.User, billings []any, pricingSnapshots ...map[string]any) ([]WalletBillingReservation, error) {
gatewayUserID := taskGatewayUserID(task, user)
if gatewayUserID == "" {
return nil, nil
@@ -145,12 +148,21 @@ func (s *Store) ReserveTaskBilling(ctx context.Context, task GatewayTask, user *
return nil, fmt.Errorf("task id is required for wallet reservation")
}
pricingSnapshot := map[string]any{}
if len(pricingSnapshots) > 0 {
pricingSnapshot = emptyObjectIfNil(pricingSnapshots[0])
}
if exactAmount := walletString(pricingSnapshot["reservationAmount"]); exactAmount != "" {
return s.reserveTaskBillingExact(ctx, task, gatewayUserID, exactAmount, pricingSnapshot)
}
amounts := walletBillingAmounts(billings)
if len(amounts) == 0 {
return nil, nil
}
reservations := make([]WalletBillingReservation, 0, len(amounts))
pricingSnapshotJSON, _ := json.Marshal(pricingSnapshot)
requestFingerprint := walletString(pricingSnapshot["requestFingerprint"])
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
for currency, rawAmount := range amounts {
amount := roundMoney(rawAmount)
@@ -257,7 +269,30 @@ VALUES (
}
reservations = append(reservations, reservation)
}
return nil
_, err := tx.Exec(ctx, `
UPDATE gateway_tasks task
SET reservation_amount = COALESCE((
SELECT SUM(reserve.amount)
FROM gateway_wallet_transactions reserve
WHERE reserve.reference_type = 'gateway_task'
AND reserve.reference_id = task.id::text
AND reserve.transaction_type = 'reserve'
AND NOT EXISTS (
SELECT 1
FROM gateway_wallet_transactions release
WHERE release.account_id = reserve.account_id
AND release.transaction_type = 'release'
AND release.idempotency_key = reserve.idempotency_key || ':release'
)
), 0),
billing_status = 'pending',
billing_currency = 'resource',
pricing_snapshot = $2::jsonb,
request_fingerprint = NULLIF($3, ''),
billing_updated_at = now(),
updated_at = now()
WHERE task.id = $1::uuid`, taskID, string(pricingSnapshotJSON), requestFingerprint)
return err
})
if err != nil {
return nil, err
@@ -265,6 +300,106 @@ VALUES (
return reservations, err
}
func (s *Store) reserveTaskBillingExact(ctx context.Context, task GatewayTask, gatewayUserID string, amount string, pricingSnapshot map[string]any) ([]WalletBillingReservation, error) {
taskID := strings.TrimSpace(task.ID)
currency := normalizeWalletCurrency(walletString(pricingSnapshot["currency"]))
if currency != "resource" {
return nil, fmt.Errorf("unsupported billing currency %q", currency)
}
pricingSnapshotJSON, _ := json.Marshal(pricingSnapshot)
requestFingerprint := walletString(pricingSnapshot["requestFingerprint"])
var reservations []WalletBillingReservation
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
var positive bool
if err := tx.QueryRow(ctx, `SELECT $1::numeric(38, 9) > 0`, amount).Scan(&positive); err != nil {
return fmt.Errorf("invalid exact reservation amount: %w", err)
}
if !positive {
return nil
}
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 := activeWalletReservationExact(ctx, tx, locked.ID, taskID)
if err != nil {
return err
}
if activeAmount != "" {
presentationAmount, _ := strconv.ParseFloat(activeAmount, 64)
reservations = []WalletBillingReservation{{
TaskID: taskID, AccountID: locked.ID, GatewayUserID: gatewayUserID,
GatewayTenantID: firstNonEmpty(locked.GatewayTenantID, task.GatewayTenantID),
Currency: locked.Currency, Amount: presentationAmount, IdempotencyKey: activeKey,
}}
return nil
}
sequence, err := nextWalletReservationSequence(ctx, tx, locked.ID, taskID)
if err != nil {
return err
}
key := billingReservationIdempotencyKey(taskID, locked.Currency, sequence)
var balanceBefore string
var frozenBefore string
if err := tx.QueryRow(ctx, `
SELECT balance::text, frozen_balance::text
FROM gateway_wallet_accounts
WHERE id = $1::uuid`, locked.ID).Scan(&balanceBefore, &frozenBefore); err != nil {
return err
}
var frozenAfter string
err = tx.QueryRow(ctx, `
UPDATE gateway_wallet_accounts
SET frozen_balance = frozen_balance + $2::numeric(38, 9), updated_at = now()
WHERE id = $1::uuid
AND balance - frozen_balance >= $2::numeric(38, 9)
RETURNING frozen_balance::text`, locked.ID, amount).Scan(&frozenAfter)
if errors.Is(err, pgx.ErrNoRows) {
return ErrInsufficientWalletBalance
}
if err != nil {
return err
}
metadata, _ := json.Marshal(map[string]any{
"taskId": taskID, "reserved": amount, "balance": balanceBefore,
"frozenBefore": frozenBefore, "frozenAfter": frozenAfter,
"pricingVersion": walletString(pricingSnapshot["pricingVersion"]),
})
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::numeric(38, 9), $5::numeric, $5::numeric, $6, 'gateway_task', $7, $8::jsonb
)`, locked.ID, firstNonEmpty(locked.GatewayTenantID, task.GatewayTenantID), gatewayUserID,
amount, balanceBefore, key, taskID, string(metadata)); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
UPDATE gateway_tasks
SET reservation_amount = $2::numeric(38, 9), billing_status = 'pending',
billing_currency = $3, pricing_snapshot = $4::jsonb,
request_fingerprint = NULLIF($5, ''), billing_updated_at = now(), updated_at = now()
WHERE id = $1::uuid`, taskID, amount, currency, string(pricingSnapshotJSON), requestFingerprint); err != nil {
return err
}
presentationAmount, _ := strconv.ParseFloat(amount, 64)
reservations = []WalletBillingReservation{{
TaskID: taskID, AccountID: locked.ID, GatewayUserID: gatewayUserID,
GatewayTenantID: firstNonEmpty(locked.GatewayTenantID, task.GatewayTenantID),
Currency: locked.Currency, Amount: presentationAmount, IdempotencyKey: key,
}}
return nil
})
return reservations, err
}
func (s *Store) ReleaseTaskBillingReservations(ctx context.Context, reservations []WalletBillingReservation, reason string) error {
if len(reservations) == 0 {
return nil
@@ -274,10 +409,14 @@ func (s *Store) ReleaseTaskBillingReservations(ctx context.Context, reservations
reason = "task_not_settled"
}
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
taskIDs := map[string]struct{}{}
for _, reservation := range reservations {
if reservation.Amount <= 0 || strings.TrimSpace(reservation.AccountID) == "" {
if strings.TrimSpace(reservation.AccountID) == "" {
continue
}
if taskID := strings.TrimSpace(reservation.TaskID); taskID != "" {
taskIDs[taskID] = struct{}{}
}
reserveKey := strings.TrimSpace(reservation.IdempotencyKey)
if reserveKey == "" {
reserveKey = billingReservationIdempotencyKey(reservation.TaskID, reservation.Currency, 1)
@@ -303,39 +442,44 @@ SELECT EXISTS (
if alreadyReleased {
continue
}
var storedReservedAmount float64
var storedReservedAmount string
if err := tx.QueryRow(ctx, `
SELECT COALESCE((
SELECT amount::float8
SELECT amount::text
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 {
), '0')`, reservation.AccountID, reserveKey).Scan(&storedReservedAmount); err != nil {
return err
}
if storedReservedAmount <= 0 {
var positive bool
if err := tx.QueryRow(ctx, `SELECT $1::numeric > 0`, storedReservedAmount).Scan(&positive); err != nil {
return err
}
if !positive {
continue
}
amount := roundMoney(storedReservedAmount)
frozenAfter := roundMoney(locked.FrozenBalance - amount)
if frozenAfter < 0 {
frozenAfter = 0
}
if _, err := tx.Exec(ctx, `
var balanceBefore string
var frozenBefore string
var frozenAfter string
if err := tx.QueryRow(ctx, `
UPDATE gateway_wallet_accounts
SET frozen_balance = $2,
SET frozen_balance = frozen_balance - $2::numeric,
updated_at = now()
WHERE id = $1::uuid`, locked.ID, frozenAfter); err != nil {
WHERE id = $1::uuid
AND frozen_balance >= $2::numeric
RETURNING balance::text, (frozen_balance + $2::numeric)::text, frozen_balance::text`,
locked.ID, storedReservedAmount).Scan(&balanceBefore, &frozenBefore, &frozenAfter); err != nil {
return err
}
metadata, _ := json.Marshal(map[string]any{
"taskId": reservation.TaskID,
"reason": reason,
"reserved": amount,
"frozenBefore": roundMoney(locked.FrozenBalance),
"reserved": storedReservedAmount,
"frozenBefore": frozenBefore,
"frozenAfter": frozenAfter,
})
if _, err := tx.Exec(ctx, `
@@ -345,15 +489,14 @@ INSERT INTO gateway_wallet_transactions (
)
VALUES (
$1::uuid, NULLIF($2, '')::uuid, $3::uuid, 'credit', 'release',
$4, $5, $6, $7, 'gateway_task', $8, $9::jsonb
$4::numeric, $5::numeric, $5::numeric, $6, 'gateway_task', $7, $8::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),
storedReservedAmount,
balanceBefore,
releaseKey,
reservation.TaskID,
string(metadata),
@@ -361,6 +504,33 @@ ON CONFLICT (account_id, idempotency_key) WHERE idempotency_key IS NOT NULL DO N
return err
}
}
for taskID := range taskIDs {
if _, err := tx.Exec(ctx, `
UPDATE gateway_tasks task
SET reservation_amount = COALESCE((
SELECT SUM(reserve.amount)
FROM gateway_wallet_transactions reserve
WHERE reserve.reference_type = 'gateway_task'
AND reserve.reference_id = task.id::text
AND reserve.transaction_type = 'reserve'
AND NOT EXISTS (
SELECT 1 FROM gateway_wallet_transactions release
WHERE release.account_id = reserve.account_id
AND release.transaction_type = 'release'
AND release.idempotency_key = reserve.idempotency_key || ':release'
)
), 0),
billing_status = CASE
WHEN status IN ('failed', 'cancelled') THEN 'released'
WHEN status = 'succeeded' THEN billing_status
ELSE 'not_started'
END,
billing_updated_at = now(),
updated_at = now()
WHERE id = $1::uuid`, taskID); err != nil {
return err
}
}
return nil
})
}
@@ -588,8 +758,20 @@ func (s *Store) SetUserWalletBalanceTx(ctx context.Context, tx Tx, input WalletB
if input.GatewayUserID == "" {
return WalletAdjustmentResult{}, ErrLocalUserRequired
}
if input.Balance < 0 {
return WalletAdjustmentResult{}, fmt.Errorf("wallet balance cannot be negative")
targetInput := strings.TrimSpace(input.BalanceText)
if targetInput == "" {
targetInput = strconv.FormatFloat(input.Balance, 'f', 9, 64)
}
targetBalance, err := canonicalWalletAmount(ctx, tx, targetInput)
if err != nil {
return WalletAdjustmentResult{}, err
}
var nonnegative bool
if err := tx.QueryRow(ctx, `SELECT $1::numeric >= 0`, targetBalance).Scan(&nonnegative); err != nil {
return WalletAdjustmentResult{}, err
}
if !nonnegative {
return WalletAdjustmentResult{}, ErrInvalidWalletAmount
}
account, err := s.ensureWalletAccount(ctx, tx, input.GatewayUserID, input.Currency)
if err != nil {
@@ -608,38 +790,68 @@ FOR UPDATE`, account.ID))
return WalletAdjustmentResult{}, err
}
before := locked
nextBalance := roundMoney(input.Balance)
delta := roundMoney(nextBalance - locked.Balance)
if delta == 0 {
var balanceBefore string
var frozenBalance string
if err := tx.QueryRow(ctx, `
SELECT balance::text, frozen_balance::text
FROM gateway_wallet_accounts
WHERE id = $1::uuid`, locked.ID).Scan(&balanceBefore, &frozenBalance); err != nil {
return WalletAdjustmentResult{}, err
}
var unchanged bool
var belowFrozen bool
if err := tx.QueryRow(ctx, `
SELECT $1::numeric = $2::numeric, $1::numeric < $3::numeric`,
targetBalance, balanceBefore, frozenBalance).Scan(&unchanged, &belowFrozen); err != nil {
return WalletAdjustmentResult{}, err
}
if unchanged {
return WalletAdjustmentResult{}, ErrWalletBalanceUnchanged
}
if belowFrozen {
return WalletAdjustmentResult{}, ErrBalanceBelowFrozen
}
direction := "credit"
amount := delta
if delta < 0 {
if strings.HasPrefix(balanceBefore, "-") {
return WalletAdjustmentResult{}, ErrInvalidWalletAmount
}
var targetGreater bool
if err := tx.QueryRow(ctx, `SELECT $1::numeric > $2::numeric`, targetBalance, balanceBefore).Scan(&targetGreater); err != nil {
return WalletAdjustmentResult{}, err
}
if !targetGreater {
direction = "debit"
amount = -delta
}
var amount string
if err := tx.QueryRow(ctx, `SELECT abs($1::numeric - $2::numeric)::text`, targetBalance, balanceBefore).Scan(&amount); err != nil {
return WalletAdjustmentResult{}, err
}
reason := strings.TrimSpace(input.Reason)
if reason == "" {
reason = "后台余额调整"
}
if _, err := tx.Exec(ctx, `
tag, err := tx.Exec(ctx, `
UPDATE gateway_wallet_accounts
SET balance = $2,
total_recharged = total_recharged + CASE WHEN $3 = 'credit' THEN $4 ELSE 0 END,
SET balance = $2::numeric,
total_recharged = total_recharged + CASE WHEN $3 = 'credit' THEN $4::numeric ELSE 0 END,
updated_at = now()
WHERE id = $1::uuid`,
WHERE id = $1::uuid
AND $2::numeric >= frozen_balance`,
locked.ID,
nextBalance,
targetBalance,
direction,
amount,
); err != nil {
)
if err != nil {
return WalletAdjustmentResult{}, err
}
if tag.RowsAffected() != 1 {
return WalletAdjustmentResult{}, ErrBalanceBelowFrozen
}
metadata := mergeObjects(input.Metadata, map[string]any{
"reason": reason,
"previousBalance": roundMoney(before.Balance),
"targetBalance": nextBalance,
"previousBalance": balanceBefore,
"targetBalance": targetBalance,
})
metadataJSON, _ := json.Marshal(emptyObjectIfNil(metadata))
transaction, err := scanWalletTransaction(tx.QueryRow(ctx, `
@@ -649,7 +861,7 @@ INSERT INTO gateway_wallet_transactions (
)
VALUES (
$1::uuid, NULLIF($2, '')::uuid, $3::uuid, $4, 'admin_adjust',
$5, $6, $7, NULLIF($8, ''), 'gateway_user', $9, $10::jsonb
$5::numeric, $6::numeric, $7::numeric, 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,
@@ -660,8 +872,8 @@ RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COA
locked.GatewayUserID,
direction,
amount,
roundMoney(before.Balance),
nextBalance,
balanceBefore,
targetBalance,
strings.TrimSpace(input.IdempotencyKey),
locked.GatewayUserID,
string(metadataJSON),
@@ -669,11 +881,10 @@ RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COA
if err != nil {
return WalletAdjustmentResult{}, err
}
locked.Balance = nextBalance
if direction == "credit" {
locked.TotalRecharged = roundMoney(locked.TotalRecharged + amount)
locked, err = s.ensureWalletAccount(ctx, tx, input.GatewayUserID, input.Currency)
if err != nil {
return WalletAdjustmentResult{}, err
}
locked.UpdatedAt = time.Now()
return WalletAdjustmentResult{Account: locked, Before: before, Transaction: transaction}, nil
}
@@ -695,9 +906,20 @@ func (s *Store) RechargeUserWalletBalanceTx(ctx context.Context, tx Tx, input Wa
if input.GatewayUserID == "" {
return WalletAdjustmentResult{}, ErrLocalUserRequired
}
amount := roundMoney(input.Amount)
if amount <= 0 {
return WalletAdjustmentResult{}, fmt.Errorf("wallet recharge amount must be positive")
amountInput := strings.TrimSpace(input.AmountText)
if amountInput == "" {
amountInput = strconv.FormatFloat(input.Amount, 'f', 9, 64)
}
amount, err := canonicalWalletAmount(ctx, tx, amountInput)
if err != nil {
return WalletAdjustmentResult{}, err
}
var positive bool
if err := tx.QueryRow(ctx, `SELECT $1::numeric > 0`, amount).Scan(&positive); err != nil {
return WalletAdjustmentResult{}, err
}
if !positive {
return WalletAdjustmentResult{}, ErrInvalidWalletAmount
}
account, err := s.ensureWalletAccount(ctx, tx, input.GatewayUserID, input.Currency)
if err != nil {
@@ -715,26 +937,30 @@ FOR UPDATE`, account.ID))
return WalletAdjustmentResult{}, err
}
before := locked
nextBalance := roundMoney(locked.Balance + amount)
var balanceBefore string
if err := tx.QueryRow(ctx, `SELECT balance::text FROM gateway_wallet_accounts WHERE id = $1::uuid`, locked.ID).Scan(&balanceBefore); err != nil {
return WalletAdjustmentResult{}, err
}
reason := strings.TrimSpace(input.Reason)
if reason == "" {
reason = "后台余额充值"
}
if _, err := tx.Exec(ctx, `
var nextBalance string
if err := tx.QueryRow(ctx, `
UPDATE gateway_wallet_accounts
SET balance = $2,
total_recharged = total_recharged + $3,
SET balance = balance + $2::numeric,
total_recharged = total_recharged + $2::numeric,
updated_at = now()
WHERE id = $1::uuid`,
WHERE id = $1::uuid
RETURNING balance::text`,
locked.ID,
nextBalance,
amount,
); err != nil {
).Scan(&nextBalance); err != nil {
return WalletAdjustmentResult{}, err
}
metadata := mergeObjects(input.Metadata, map[string]any{
"reason": reason,
"previousBalance": roundMoney(before.Balance),
"previousBalance": balanceBefore,
"rechargeAmount": amount,
"targetBalance": nextBalance,
})
@@ -746,7 +972,7 @@ INSERT INTO gateway_wallet_transactions (
)
VALUES (
$1::uuid, NULLIF($2, '')::uuid, $3::uuid, 'credit', 'recharge',
$4, $5, $6, NULLIF($7, ''), 'gateway_user', $8, $9::jsonb
$4::numeric, $5::numeric, $6::numeric, NULLIF($7, ''), 'gateway_user', $8, $9::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,
@@ -756,7 +982,7 @@ RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COA
locked.GatewayTenantID,
locked.GatewayUserID,
amount,
roundMoney(before.Balance),
balanceBefore,
nextBalance,
strings.TrimSpace(input.IdempotencyKey),
locked.GatewayUserID,
@@ -765,12 +991,21 @@ RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COA
if err != nil {
return WalletAdjustmentResult{}, err
}
locked.Balance = nextBalance
locked.TotalRecharged = roundMoney(locked.TotalRecharged + amount)
locked.UpdatedAt = time.Now()
locked, err = s.ensureWalletAccount(ctx, tx, input.GatewayUserID, input.Currency)
if err != nil {
return WalletAdjustmentResult{}, err
}
return WalletAdjustmentResult{Account: locked, Before: before, Transaction: transaction}, nil
}
func canonicalWalletAmount(ctx context.Context, q Tx, value string) (string, error) {
var canonical string
if err := q.QueryRow(ctx, `SELECT $1::numeric(38, 9)::text`, strings.TrimSpace(value)).Scan(&canonical); err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidWalletAmount, err)
}
return canonical, nil
}
func (s *Store) ensureWalletAccount(ctx context.Context, q Tx, gatewayUserID string, currency string) (GatewayWalletAccount, error) {
currency = normalizeWalletCurrency(currency)
if _, err := q.Exec(ctx, `
@@ -794,6 +1029,9 @@ WHERE gateway_wallet_accounts.gateway_tenant_id IS NULL
OR COALESCE(gateway_wallet_accounts.user_id, '') = ''`, gatewayUserID, currency); err != nil {
return GatewayWalletAccount{}, err
}
if err := ensureWalletAccountAuditGuard(ctx, q, 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, ''),
@@ -811,6 +1049,17 @@ WHERE gateway_user_id = $1::uuid
return account, nil
}
func ensureWalletAccountAuditGuard(ctx context.Context, q Tx, gatewayUserID string, currency string) error {
_, err := q.Exec(ctx, `
INSERT INTO gateway_wallet_account_audit_guards (account_id)
SELECT id
FROM gateway_wallet_accounts
WHERE gateway_user_id = $1::uuid
AND currency = $2
ON CONFLICT (account_id) DO NOTHING`, gatewayUserID, normalizeWalletCurrency(currency))
return err
}
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,
@@ -851,6 +1100,31 @@ LIMIT 1`, accountID, taskID).Scan(&key, &amount)
return key, roundMoney(amount), nil
}
func activeWalletReservationExact(ctx context.Context, tx pgx.Tx, accountID string, taskID string) (string, string, error) {
var key string
var amount string
err := tx.QueryRow(ctx, `
SELECT COALESCE(t.idempotency_key, ''), t.amount::text
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 release
WHERE release.account_id = t.account_id
AND release.transaction_type = 'release'
AND release.idempotency_key = t.idempotency_key || ':release'
)
ORDER BY t.created_at DESC
LIMIT 1`, accountID, taskID).Scan(&key, &amount)
if errors.Is(err, pgx.ErrNoRows) {
return "", "", nil
}
return key, amount, err
}
func nextWalletReservationSequence(ctx context.Context, tx pgx.Tx, accountID string, taskID string) (int, error) {
var count int
if err := tx.QueryRow(ctx, `