Treasury bot + ledger fix
This commit is contained in:
411
api/gateway/tgsettle/internal/service/treasury/service.go
Normal file
411
api/gateway/tgsettle/internal/service/treasury/service.go
Normal file
@@ -0,0 +1,411 @@
|
||||
package treasury
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/tgsettle/internal/service/treasury/ledger"
|
||||
"github.com/tech/sendico/gateway/tgsettle/storage"
|
||||
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var ErrActiveTreasuryRequest = errors.New("active treasury request exists")
|
||||
|
||||
type CreateRequestInput struct {
|
||||
OperationType storagemodel.TreasuryOperationType
|
||||
TelegramUserID string
|
||||
LedgerAccountID string
|
||||
ChatID string
|
||||
Amount string
|
||||
}
|
||||
|
||||
type ExecutionResult struct {
|
||||
Request *storagemodel.TreasuryRequest
|
||||
NewBalance *ledger.Balance
|
||||
ExecutionError error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
logger mlogger.Logger
|
||||
repo storage.TreasuryRequestsStore
|
||||
ledger ledger.Client
|
||||
|
||||
validator *Validator
|
||||
executionDelay time.Duration
|
||||
}
|
||||
|
||||
func NewService(
|
||||
logger mlogger.Logger,
|
||||
repo storage.TreasuryRequestsStore,
|
||||
ledgerClient ledger.Client,
|
||||
executionDelay time.Duration,
|
||||
maxPerOperation string,
|
||||
maxDaily string,
|
||||
) (*Service, error) {
|
||||
if logger == nil {
|
||||
return nil, merrors.InvalidArgument("logger is required", "logger")
|
||||
}
|
||||
if repo == nil {
|
||||
return nil, merrors.InvalidArgument("treasury repository is required", "repo")
|
||||
}
|
||||
if ledgerClient == nil {
|
||||
return nil, merrors.InvalidArgument("ledger client is required", "ledger_client")
|
||||
}
|
||||
if executionDelay <= 0 {
|
||||
executionDelay = 30 * time.Second
|
||||
}
|
||||
validator, err := NewValidator(repo, maxPerOperation, maxDaily)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Service{
|
||||
logger: logger.Named("treasury_service"),
|
||||
repo: repo,
|
||||
ledger: ledgerClient,
|
||||
validator: validator,
|
||||
executionDelay: executionDelay,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ExecutionDelay() time.Duration {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
return s.executionDelay
|
||||
}
|
||||
|
||||
func (s *Service) MaxPerOperationLimit() string {
|
||||
if s == nil || s.validator == nil {
|
||||
return ""
|
||||
}
|
||||
return s.validator.MaxPerOperation()
|
||||
}
|
||||
|
||||
func (s *Service) GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return s.repo.FindActiveByLedgerAccountID(ctx, ledgerAccountID)
|
||||
}
|
||||
|
||||
func (s *Service) GetRequest(ctx context.Context, requestID string) (*storagemodel.TreasuryRequest, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return s.repo.FindByRequestID(ctx, requestID)
|
||||
}
|
||||
|
||||
func (s *Service) CreateRequest(ctx context.Context, input CreateRequestInput) (*storagemodel.TreasuryRequest, error) {
|
||||
if s == nil || s.repo == nil || s.ledger == nil || s.validator == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
input.TelegramUserID = strings.TrimSpace(input.TelegramUserID)
|
||||
input.LedgerAccountID = strings.TrimSpace(input.LedgerAccountID)
|
||||
input.ChatID = strings.TrimSpace(input.ChatID)
|
||||
input.Amount = strings.TrimSpace(input.Amount)
|
||||
|
||||
switch input.OperationType {
|
||||
case storagemodel.TreasuryOperationFund, storagemodel.TreasuryOperationWithdraw:
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("treasury operation is invalid", "operation_type")
|
||||
}
|
||||
if input.TelegramUserID == "" {
|
||||
return nil, merrors.InvalidArgument("telegram_user_id is required", "telegram_user_id")
|
||||
}
|
||||
if input.LedgerAccountID == "" {
|
||||
return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
|
||||
}
|
||||
if input.ChatID == "" {
|
||||
return nil, merrors.InvalidArgument("chat_id is required", "chat_id")
|
||||
}
|
||||
|
||||
active, err := s.repo.FindActiveByLedgerAccountID(ctx, input.LedgerAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if active != nil {
|
||||
return active, ErrActiveTreasuryRequest
|
||||
}
|
||||
|
||||
amountRat, normalizedAmount, err := s.validator.ValidateAmount(input.Amount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validator.ValidateDailyLimit(ctx, input.LedgerAccountID, amountRat, time.Now()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
account, err := s.ledger.GetAccount(ctx, input.LedgerAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if account == nil || strings.TrimSpace(account.Currency) == "" {
|
||||
return nil, merrors.Internal("ledger account currency is unavailable")
|
||||
}
|
||||
if strings.TrimSpace(account.OrganizationRef) == "" {
|
||||
return nil, merrors.Internal("ledger account organization is unavailable")
|
||||
}
|
||||
|
||||
requestID := newRequestID()
|
||||
record := &storagemodel.TreasuryRequest{
|
||||
RequestID: requestID,
|
||||
OperationType: input.OperationType,
|
||||
TelegramUserID: input.TelegramUserID,
|
||||
LedgerAccountID: input.LedgerAccountID,
|
||||
OrganizationRef: account.OrganizationRef,
|
||||
ChatID: input.ChatID,
|
||||
Amount: normalizedAmount,
|
||||
Currency: strings.ToUpper(strings.TrimSpace(account.Currency)),
|
||||
Status: storagemodel.TreasuryRequestStatusCreated,
|
||||
IdempotencyKey: fmt.Sprintf("tgsettle:%s", requestID),
|
||||
Active: true,
|
||||
}
|
||||
if err := s.repo.Create(ctx, record); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicate) {
|
||||
active, fetchErr := s.repo.FindActiveByLedgerAccountID(ctx, input.LedgerAccountID)
|
||||
if fetchErr != nil {
|
||||
return nil, fetchErr
|
||||
}
|
||||
if active != nil {
|
||||
return active, ErrActiveTreasuryRequest
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logRequest(record, "created", nil)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Service) ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
||||
if requestID == "" {
|
||||
return nil, merrors.InvalidArgument("request_id is required", "request_id")
|
||||
}
|
||||
record, err := s.repo.FindByRequestID(ctx, requestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, merrors.NoData("treasury request not found")
|
||||
}
|
||||
if telegramUserID != "" && record.TelegramUserID != telegramUserID {
|
||||
return nil, merrors.Unauthorized("treasury request ownership mismatch")
|
||||
}
|
||||
|
||||
switch record.Status {
|
||||
case storagemodel.TreasuryRequestStatusScheduled:
|
||||
return record, nil
|
||||
case storagemodel.TreasuryRequestStatusCreated, storagemodel.TreasuryRequestStatusConfirmed:
|
||||
now := time.Now()
|
||||
record.ConfirmedAt = now
|
||||
record.ScheduledAt = now.Add(s.executionDelay)
|
||||
record.Status = storagemodel.TreasuryRequestStatusScheduled
|
||||
record.Active = true
|
||||
record.ErrorMessage = ""
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("treasury request cannot be confirmed in current status", "status")
|
||||
}
|
||||
if err := s.repo.Update(ctx, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.logRequest(record, "scheduled", nil)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Service) CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
||||
if requestID == "" {
|
||||
return nil, merrors.InvalidArgument("request_id is required", "request_id")
|
||||
}
|
||||
record, err := s.repo.FindByRequestID(ctx, requestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, merrors.NoData("treasury request not found")
|
||||
}
|
||||
if telegramUserID != "" && record.TelegramUserID != telegramUserID {
|
||||
return nil, merrors.Unauthorized("treasury request ownership mismatch")
|
||||
}
|
||||
|
||||
switch record.Status {
|
||||
case storagemodel.TreasuryRequestStatusCancelled:
|
||||
return record, nil
|
||||
case storagemodel.TreasuryRequestStatusCreated, storagemodel.TreasuryRequestStatusConfirmed, storagemodel.TreasuryRequestStatusScheduled:
|
||||
record.Status = storagemodel.TreasuryRequestStatusCancelled
|
||||
record.CancelledAt = time.Now()
|
||||
record.Active = false
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("treasury request cannot be cancelled in current status", "status")
|
||||
}
|
||||
|
||||
if err := s.repo.Update(ctx, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.logRequest(record, "cancelled", nil)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Service) ExecuteRequest(ctx context.Context, requestID string) (*ExecutionResult, error) {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" {
|
||||
return nil, merrors.InvalidArgument("request_id is required", "request_id")
|
||||
}
|
||||
record, err := s.repo.FindByRequestID(ctx, requestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch record.Status {
|
||||
case storagemodel.TreasuryRequestStatusExecuted,
|
||||
storagemodel.TreasuryRequestStatusCancelled,
|
||||
storagemodel.TreasuryRequestStatusFailed:
|
||||
return nil, nil
|
||||
case storagemodel.TreasuryRequestStatusScheduled:
|
||||
claimed, err := s.repo.ClaimScheduled(ctx, requestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !claimed {
|
||||
return nil, nil
|
||||
}
|
||||
record, err = s.repo.FindByRequestID(ctx, requestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
if record.Status != storagemodel.TreasuryRequestStatusConfirmed {
|
||||
return nil, nil
|
||||
}
|
||||
return s.executeClaimed(ctx, record)
|
||||
}
|
||||
|
||||
func (s *Service) executeClaimed(ctx context.Context, record *storagemodel.TreasuryRequest) (*ExecutionResult, error) {
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("treasury request is required", "request")
|
||||
}
|
||||
postReq := ledger.PostRequest{
|
||||
AccountID: record.LedgerAccountID,
|
||||
OrganizationRef: record.OrganizationRef,
|
||||
Amount: record.Amount,
|
||||
Currency: record.Currency,
|
||||
Reference: record.RequestID,
|
||||
IdempotencyKey: record.IdempotencyKey,
|
||||
}
|
||||
|
||||
var (
|
||||
opResult *ledger.OperationResult
|
||||
err error
|
||||
)
|
||||
switch record.OperationType {
|
||||
case storagemodel.TreasuryOperationFund:
|
||||
opResult, err = s.ledger.ExternalCredit(ctx, postReq)
|
||||
case storagemodel.TreasuryOperationWithdraw:
|
||||
opResult, err = s.ledger.ExternalDebit(ctx, postReq)
|
||||
default:
|
||||
err = merrors.InvalidArgument("treasury operation is invalid", "operation_type")
|
||||
}
|
||||
now := time.Now()
|
||||
if err != nil {
|
||||
record.Status = storagemodel.TreasuryRequestStatusFailed
|
||||
record.Active = false
|
||||
record.ExecutedAt = now
|
||||
record.ErrorMessage = strings.TrimSpace(err.Error())
|
||||
if saveErr := s.repo.Update(ctx, record); saveErr != nil {
|
||||
return nil, saveErr
|
||||
}
|
||||
s.logRequest(record, "failed", err)
|
||||
return &ExecutionResult{
|
||||
Request: record,
|
||||
ExecutionError: err,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if opResult != nil {
|
||||
record.LedgerReference = strings.TrimSpace(opResult.Reference)
|
||||
}
|
||||
record.Status = storagemodel.TreasuryRequestStatusExecuted
|
||||
record.Active = false
|
||||
record.ExecutedAt = now
|
||||
record.ErrorMessage = ""
|
||||
|
||||
balance, balanceErr := s.ledger.GetBalance(ctx, record.LedgerAccountID)
|
||||
if balanceErr != nil {
|
||||
record.ErrorMessage = strings.TrimSpace(balanceErr.Error())
|
||||
}
|
||||
|
||||
if saveErr := s.repo.Update(ctx, record); saveErr != nil {
|
||||
return nil, saveErr
|
||||
}
|
||||
s.logRequest(record, "executed", nil)
|
||||
return &ExecutionResult{
|
||||
Request: record,
|
||||
NewBalance: balance,
|
||||
ExecutionError: balanceErr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DueRequests(ctx context.Context, statuses []storagemodel.TreasuryRequestStatus, now time.Time, limit int64) ([]*storagemodel.TreasuryRequest, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return s.repo.FindDueByStatus(ctx, statuses, now, limit)
|
||||
}
|
||||
|
||||
func (s *Service) ScheduledRequests(ctx context.Context, limit int64) ([]*storagemodel.TreasuryRequest, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return s.repo.FindDueByStatus(
|
||||
ctx,
|
||||
[]storagemodel.TreasuryRequestStatus{storagemodel.TreasuryRequestStatusScheduled},
|
||||
time.Now().Add(10*365*24*time.Hour),
|
||||
limit,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) ParseAmount(value string) (*big.Rat, error) {
|
||||
return parseAmountRat(value)
|
||||
}
|
||||
|
||||
func (s *Service) logRequest(record *storagemodel.TreasuryRequest, status string, err error) {
|
||||
if s == nil || s.logger == nil || record == nil {
|
||||
return
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("request_id", strings.TrimSpace(record.RequestID)),
|
||||
zap.String("telegram_user_id", strings.TrimSpace(record.TelegramUserID)),
|
||||
zap.String("ledger_account_id", strings.TrimSpace(record.LedgerAccountID)),
|
||||
zap.String("operation_type", strings.TrimSpace(string(record.OperationType))),
|
||||
zap.String("amount", strings.TrimSpace(record.Amount)),
|
||||
zap.String("currency", strings.TrimSpace(record.Currency)),
|
||||
zap.String("status", status),
|
||||
}
|
||||
if err != nil {
|
||||
fields = append(fields, zap.Error(err))
|
||||
}
|
||||
s.logger.Info("treasury_request", fields...)
|
||||
}
|
||||
|
||||
func newRequestID() string {
|
||||
return "TGSETTLE-" + strings.ToUpper(bson.NewObjectID().Hex()[:8])
|
||||
}
|
||||
Reference in New Issue
Block a user