diff --git a/cmd/list_balances.go b/cmd/list_balances.go new file mode 100644 index 0000000..cda56f8 --- /dev/null +++ b/cmd/list_balances.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/olekukonko/tablewriter" + "github.com/oxygenpay/oxygen/internal/app" + "github.com/oxygenpay/oxygen/internal/money" + "github.com/oxygenpay/oxygen/internal/service/wallet" + "github.com/spf13/cobra" +) + +var listBalancesCommand = &cobra.Command{ + Use: "list-balances", + Short: "List all balances including system balances", + Run: listBalances, +} + +func listBalances(_ *cobra.Command, _ []string) { + var ( + ctx = context.Background() + cfg = resolveConfig() + service = app.New(ctx, cfg) + walletsService = service.Locator().WalletService() + blockchainService = service.Locator().BlockchainService() + logger = service.Logger() + ) + + opts := wallet.ListAllBalancesOpts{ + WithUSD: true, + WithSystemBalances: true, + HideEmpty: true, + } + + balances, err := walletsService.ListAllBalances(ctx, opts) + if err != nil { + logger.Error().Err(err).Msg("Unable to list wallets") + } + + t := tablewriter.NewWriter(os.Stdout) + defer t.Render() + + t.SetBorder(false) + t.SetAutoWrapText(false) + t.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + t.SetAlignment(tablewriter.ALIGN_LEFT) + + t.SetHeader([]string{"id", "type", "entity Id", "currency", "test", "amount", "usd"}) + + add := func(b *wallet.Balance) { + currency, err := blockchainService.GetCurrencyByTicker(b.Currency) + if err != nil { + logger.Error().Err(err) + return + } + + t.Append(balanceAsRow(currency, b)) + } + + for _, b := range balances[wallet.EntityTypeMerchant] { + add(b) + } + for _, b := range balances[wallet.EntityTypeWallet] { + add(b) + } + for _, b := range balances[wallet.EntityTypeSystem] { + add(b) + } +} + +func balanceAsRow(currency money.CryptoCurrency, b *wallet.Balance) []string { + isTest := b.NetworkID != currency.NetworkID + + line := fmt.Sprintf( + "%d,%s,%d,%s,%t,%s,%s", + b.ID, + b.EntityType, + b.EntityID, + b.Currency, + isTest, + b.Amount.String(), + b.UsdAmount.String(), + ) + + return strings.Split(line, ",") +} diff --git a/cmd/root.go b/cmd/root.go index 51ea361..86ef73b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -77,6 +77,10 @@ func init() { createUserCommand.PersistentFlags().BoolVar(&overridePassword, "override-password", false, "overrides password if user already exists") rootCmd.AddCommand(listWalletsCommand) + rootCmd.AddCommand(listBalancesCommand) + + topupBalanceSetup(topupBalanceCommand) + rootCmd.AddCommand(topupBalanceCommand) rand.Seed(time.Now().Unix()) } diff --git a/cmd/topup_balance.go b/cmd/topup_balance.go new file mode 100644 index 0000000..c117a2d --- /dev/null +++ b/cmd/topup_balance.go @@ -0,0 +1,159 @@ +package cmd + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/oxygenpay/oxygen/internal/app" + "github.com/oxygenpay/oxygen/internal/money" + "github.com/oxygenpay/oxygen/internal/service/processing" + "github.com/oxygenpay/oxygen/internal/service/wallet" + "github.com/oxygenpay/oxygen/internal/util" + "github.com/samber/lo" + "github.com/spf13/cobra" +) + +var topupBalanceCommand = &cobra.Command{ + Use: "topup-balance", + Short: "Topup Merchant's balance using 'system' funds", + Run: topupBalance, +} + +var topupBalanceArgs = struct { + MerchantID *int64 + Ticker *string + Amount *string + Comment *string + IsTest *bool +}{ + MerchantID: util.Ptr(int64(0)), + Ticker: util.Ptr(""), + Amount: util.Ptr(""), + Comment: util.Ptr(""), + IsTest: util.Ptr(false), +} + +func topupBalance(_ *cobra.Command, _ []string) { + var ( + ctx = context.Background() + cfg = resolveConfig() + service = app.New(ctx, cfg) + blockchainService = service.Locator().BlockchainService() + merchantService = service.Locator().MerchantService() + walletService = service.Locator().WalletService() + processingService = service.Locator().ProcessingService() + logger = service.Logger() + exit = func(err error, message string) { logger.Fatal().Err(err).Msg(message) } + ) + + // 1. Get input + currency, err := blockchainService.GetCurrencyByTicker(*topupBalanceArgs.Ticker) + if err != nil { + exit(err, "invalid ticker") + } + + amount, err := money.CryptoFromStringFloat(currency.Ticker, *topupBalanceArgs.Amount, currency.Decimals) + if err != nil { + exit(err, "invalid amount") + } + + merchant, err := merchantService.GetByID(ctx, *topupBalanceArgs.MerchantID, false) + if err != nil { + exit(err, "invalid merchant id") + } + + if *topupBalanceArgs.Comment == "" { + exit(nil, "comment should not be empty") + } + + isTest := *topupBalanceArgs.IsTest + comment := *topupBalanceArgs.Comment + + // 2. Locate system balance + balances, err := walletService.ListAllBalances(ctx, wallet.ListAllBalancesOpts{WithSystemBalances: true}) + if err != nil { + exit(err, "unable to list balances") + } + + systemBalance, found := lo.Find(balances[wallet.EntityTypeSystem], func(b *wallet.Balance) bool { + tickerMatches := b.Currency == currency.Ticker + networkMatches := b.NetworkID == currency.ChooseNetwork(isTest) + + return tickerMatches && networkMatches + }) + + if !found { + exit(err, "unable to locate system balance") + } + + logger.Info(). + Str("amount", amount.String()). + Str("currency", currency.Ticker). + Str("merchant.name", merchant.Name). + Int64("merchant.id", merchant.ID). + Str("merchant.uuid", merchant.UUID.String()). + Str("system_balance", systemBalance.Amount.String()). + Msg("Performing internal topup from the system balance") + + // 3. Confirm + if !confirm("Are you sure you want to continue?") { + logger.Info().Msg("Aborting.") + return + } + + // 4. Perform topup + logger.Info().Msg("Sending...") + + input := processing.TopupInput{ + Currency: currency, + Amount: amount, + Comment: comment, + IsTest: isTest, + } + + out, err := processingService.TopupMerchantFromSystem(ctx, merchant.ID, input) + if err != nil { + exit(err, "unable to topup the balance") + } + + logger. + Info(). + Int64("payment.id", out.Payment.ID). + Int64("tx.id", out.Transaction.ID). + Str("tx.usd_amount", out.Transaction.USDAmount.String()). + Str("merchant.balance", out.MerchantBalance.Amount.String()). + Msg("Done") +} + +func topupBalanceSetup(cmd *cobra.Command) { + f := cmd.Flags() + + f.Int64Var(topupBalanceArgs.MerchantID, "merchant-id", 0, "Merchant ID") + f.StringVar(topupBalanceArgs.Ticker, "ticker", "", "Ticker") + f.StringVar(topupBalanceArgs.Amount, "amount", "0", "Amount") + f.StringVar(topupBalanceArgs.Comment, "comment", "", "Comment") + f.BoolVar(topupBalanceArgs.IsTest, "is-test", false, "Test balance") + + for _, name := range []string{"merchant-id", "ticker", "amount", "comment"} { + if err := cmd.MarkFlagRequired(name); err != nil { + panic(name + ": " + err.Error()) + } + } +} + +func confirm(message string) bool { + reader := bufio.NewReader(os.Stdin) + fmt.Printf("%s (y/n): ", message) + + response, err := reader.ReadString('\n') + if err != nil { + return false + } + + response = strings.ToLower(strings.TrimSpace(response)) + + return response == "y" || response == "yes" +} diff --git a/internal/db/repository/balances.sql.go b/internal/db/repository/balances.sql.go index 8019cd4..7265ecc 100644 --- a/internal/db/repository/balances.sql.go +++ b/internal/db/repository/balances.sql.go @@ -248,6 +248,51 @@ func (q *Queries) InsertBalanceAuditLog(ctx context.Context, arg InsertBalanceAu return err } +const listAllBalancesByType = `-- name: ListAllBalancesByType :many +select id, created_at, updated_at, entity_id, entity_type, network, network_id, currency_type, currency, decimals, amount, uuid from balances +where entity_type = $1 +and (CASE WHEN $2::boolean THEN amount > 0 ELSE true END) +order by id desc +` + +type ListAllBalancesByTypeParams struct { + EntityType string + HideEmpty bool +} + +func (q *Queries) ListAllBalancesByType(ctx context.Context, arg ListAllBalancesByTypeParams) ([]Balance, error) { + rows, err := q.db.Query(ctx, listAllBalancesByType, arg.EntityType, arg.HideEmpty) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Balance + for rows.Next() { + var i Balance + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.EntityID, + &i.EntityType, + &i.Network, + &i.NetworkID, + &i.CurrencyType, + &i.Currency, + &i.Decimals, + &i.Amount, + &i.Uuid, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listBalances = `-- name: ListBalances :many select id, created_at, updated_at, entity_id, entity_type, network, network_id, currency_type, currency, decimals, amount, uuid from balances where entity_type = $1 and entity_id = $2 diff --git a/internal/db/repository/helpers.go b/internal/db/repository/helpers.go index b3ac667..7989f00 100644 --- a/internal/db/repository/helpers.go +++ b/internal/db/repository/helpers.go @@ -112,6 +112,5 @@ func MoneyToNumeric(m money.Money) pgtype.Numeric { func MoneyToNegNumeric(m money.Money) pgtype.Numeric { bigInt, _ := m.BigInt() - return BigIntToNumeric(big.NewInt(0).Neg(bigInt)) } diff --git a/internal/db/repository/querier.go b/internal/db/repository/querier.go index 564c3e9..0a368f8 100644 --- a/internal/db/repository/querier.go +++ b/internal/db/repository/querier.go @@ -77,6 +77,7 @@ type Querier interface { GetWalletLock(ctx context.Context, arg GetWalletLockParams) (WalletLock, error) InsertBalanceAuditLog(ctx context.Context, arg InsertBalanceAuditLogParams) error ListAPITokensByEntity(ctx context.Context, arg ListAPITokensByEntityParams) ([]ApiToken, error) + ListAllBalancesByType(ctx context.Context, arg ListAllBalancesByTypeParams) ([]Balance, error) ListBalances(ctx context.Context, arg ListBalancesParams) ([]Balance, error) ListJobLogsByID(ctx context.Context, arg ListJobLogsByIDParams) ([]JobLog, error) ListMerchantAddresses(ctx context.Context, merchantID int64) ([]MerchantAddress, error) diff --git a/internal/money/money.go b/internal/money/money.go index 929ad6e..92a24f8 100644 --- a/internal/money/money.go +++ b/internal/money/money.go @@ -144,6 +144,12 @@ func (m Money) Decimals() int64 { func (m Money) String() string { stringRaw := m.StringRaw() + + isNegative := m.IsNegative() + if isNegative { + stringRaw = stringRaw[1:] + } + l, d := len(stringRaw), int(m.decimals) var result string @@ -159,10 +165,16 @@ func (m Money) String() string { } if m.moneyType == Fiat { - return strings.TrimSuffix(result, ".00") + result = strings.TrimSuffix(result, ".00") + } else { + result = strings.TrimRight(strings.TrimRight(result, "0"), ".") } - return strings.TrimRight(strings.TrimRight(result, "0"), ".") + if isNegative { + result = "-" + result + } + + return result } func (m Money) StringRaw() string { @@ -202,8 +214,22 @@ func (m Money) Add(amount Money) (Money, error) { return NewFromBigInt(m.moneyType, m.ticker, a.Add(a, b), m.decimals) } -// Sub subtracts money of the same type. +// Sub subtracts money of the same type. Restricts having negative values. func (m Money) Sub(amount Money) (Money, error) { + out, err := m.SubNegative(amount) + if err != nil { + return Money{}, err + } + + if out.IsNegative() { + return Money{}, ErrNegative + } + + return out, nil +} + +// SubNegative subtracts money allowing negative outcome +func (m Money) SubNegative(amount Money) (Money, error) { if !m.CompatibleTo(amount) { return Money{}, errors.Wrapf( ErrIncompatibleMoney, @@ -220,10 +246,6 @@ func (m Money) Sub(amount Money) (Money, error) { return Money{}, nil } - if m.IsNegative() { - return Money{}, ErrNegative - } - return m, nil } diff --git a/internal/money/money_test.go b/internal/money/money_test.go index da37ba2..2ecd6e4 100644 --- a/internal/money/money_test.go +++ b/internal/money/money_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_FiatCurrencies(t *testing.T) { @@ -72,6 +73,20 @@ func Test_FiatCurrencies(t *testing.T) { } } +func TestMoney_SubNegative(t *testing.T) { + m, err := FiatFromFloat64(USD, 5) + require.NoError(t, err) + + huge, err := FiatFromFloat64(USD, 1000) + require.NoError(t, err) + + out, err := m.SubNegative(huge) + assert.NoError(t, err) + assert.True(t, out.IsNegative()) + assert.Equal(t, "-995", out.String()) + assert.Equal(t, "-99500", out.StringRaw()) +} + func Test_CryptoCurrencies(t *testing.T) { testCases := []struct { ticker string @@ -100,6 +115,7 @@ func Test_CryptoCurrencies(t *testing.T) { {ticker: "ETH", decimals: 18, value: "123_456__000_000_000_031_631_000", expString: "123_456.000_000_000_031_631"}, {ticker: "MATIC", decimals: 18, value: "118__746_301_720_649_360_000", expString: "118.746_301_720_649_36"}, + {ticker: "MATIC", decimals: 18, value: "-118__746_301_720_649_360_000", expString: "-118.746_301_720_649_36"}, } for _, tc := range testCases { @@ -327,6 +343,12 @@ func TestCryptoToFiat(t *testing.T) { exchangeRate: 1, expectedFiat: mustCreateUSD("100"), }, + { + // negative case + crypto: mustCreateCrypto("-1000000", 6), + exchangeRate: 1, + expectedFiat: mustCreateUSD("-100"), + }, { crypto: mustCreateCrypto("1000000", 6), exchangeRate: 0.5, @@ -350,7 +372,7 @@ func TestCryptoToFiat(t *testing.T) { } { t.Run(strconv.Itoa(i+1), func(t *testing.T) { actual, err := CryptoToFiat(tc.crypto, USD, tc.exchangeRate) - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, actual, tc.expectedFiat) }) } diff --git a/internal/service/payment/model.go b/internal/service/payment/model.go index e603810..b648a14 100644 --- a/internal/service/payment/model.go +++ b/internal/service/payment/model.go @@ -46,6 +46,8 @@ const ( MetaBalanceID wallet.MetaDataKey = "balanceID" MetaAddressID wallet.MetaDataKey = "addressID" + MetaInternalPayment wallet.MetaDataKey = "internalPayment" + MetaLinkID wallet.MetaDataKey = "linkID" MetaLinkSuccessAction wallet.MetaDataKey = "linkSuccessAction" MetaLinkSuccessMessage wallet.MetaDataKey = "linkSuccessMessage" diff --git a/internal/service/payment/service.go b/internal/service/payment/service.go index 7c84eaa..1d0a1a7 100644 --- a/internal/service/payment/service.go +++ b/internal/service/payment/service.go @@ -531,6 +531,64 @@ func (s *Service) CreatePayment( return s.entryToPayment(p) } +type CreateInternalPaymentProps struct { + MerchantOrderUUID uuid.UUID + Money money.Money + Description string + IsTest bool +} + +// CreateSystemTopup creates an internal payment that is reflected only within OxygenPay. +// This payment is treated as successful. +func (s *Service) CreateSystemTopup(ctx context.Context, merchantID int64, props CreateInternalPaymentProps) (*Payment, error) { + if props.Money.Type() != money.Crypto { + return nil, errors.Wrap(ErrValidation, "internal payments should be in crypto") + } + + mt, err := s.merchants.GetByID(ctx, merchantID, false) + if err != nil { + return nil, err + } + + if _, errGet := s.GetByMerchantOrderID(ctx, merchantID, props.MerchantOrderUUID); errGet == nil { + return nil, ErrAlreadyExists + } + + var ( + price, decimals = props.Money.BigInt() + now = time.Now() + meta = Metadata{MetaInternalPayment: "system topup"} + ) + + pt, err := s.repo.CreatePayment(ctx, repository.CreatePaymentParams{ + PublicID: uuid.New(), + + CreatedAt: now, + UpdatedAt: now, + ExpiresAt: sql.NullTime{}, + + Type: TypePayment.String(), + Status: StatusSuccess.String(), + + MerchantID: mt.ID, + MerchantOrderUuid: props.MerchantOrderUUID, + + Price: repository.BigIntToNumeric(price), + Decimals: int32(decimals), + Currency: props.Money.Ticker(), + + Description: repository.StringToNullable(props.Description), + IsTest: props.IsTest, + Metadata: meta.ToJSONB(), + }) + + if err != nil { + return nil, err + } + + return s.entryToPayment(pt) +} + func fillPaymentMetaWithLink(meta Metadata, p CreatePaymentProps) Metadata { meta[MetaLinkID] = strconv.Itoa(int(p.linkID)) meta[MetaLinkSuccessAction] = string(p.linkSuccessAction) @@ -632,7 +690,14 @@ func MakeMethod(tx *transaction.Transaction, currency money.CryptoCurrency) *Met } func (s *Service) entryToPayment(p repository.Payment) (*Payment, error) { - price, err := paymentPrice(p) + metadata := make(Metadata) + if p.Metadata.Status == pgtype.Present { + if err := json.Unmarshal(p.Metadata.Bytes, &metadata); err != nil { + return nil, err + } + } + + price, err := paymentPrice(p, metadata) if err != nil { return nil, err } @@ -642,13 +707,6 @@ func (s *Service) entryToPayment(p repository.Payment) (*Payment, error) { paymentURL = s.paymentURL(p.PublicID) } - metadata := make(Metadata) - if p.Metadata.Status == pgtype.Present { - if err := json.Unmarshal(p.Metadata.Bytes, &metadata); err != nil { - return nil, err - } - } - entity := &Payment{ ID: p.ID, PublicID: p.PublicID, @@ -695,23 +753,26 @@ func (s *Service) entriesToPayments(results []repository.Payment) ([]*Payment, e return payments, nil } -func paymentPrice(p repository.Payment) (money.Money, error) { +func paymentPrice(p repository.Payment, metadata Metadata) (money.Money, error) { decimals := int64(p.Decimals) bigInt, err := repository.NumericToBigInt(p.Price) if err != nil { return money.Money{}, err } - switch p.Type { - case TypePayment.String(): + t := Type(p.Type) + _, isInternal := metadata[MetaInternalPayment] + + switch { + case t == TypeWithdrawal || (t == TypePayment && isInternal): + return money.NewFromBigInt(money.Crypto, p.Currency, bigInt, decimals) + case t == TypePayment: currency, err := money.MakeFiatCurrency(p.Currency) if err != nil { return money.Money{}, err } return money.NewFromBigInt(money.Fiat, currency.String(), bigInt, decimals) - case TypeWithdrawal.String(): - return money.NewFromBigInt(money.Crypto, p.Currency, bigInt, decimals) } return money.Money{}, errors.New("unable to get payment price") diff --git a/internal/service/processing/service_topup.go b/internal/service/processing/service_topup.go new file mode 100644 index 0000000..77b998f --- /dev/null +++ b/internal/service/processing/service_topup.go @@ -0,0 +1,118 @@ +package processing + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/oxygenpay/oxygen/internal/money" + "github.com/oxygenpay/oxygen/internal/service/payment" + "github.com/oxygenpay/oxygen/internal/service/transaction" + "github.com/oxygenpay/oxygen/internal/service/wallet" + "github.com/pkg/errors" + "github.com/samber/lo" +) + +type TopupInput struct { + Currency money.CryptoCurrency + Amount money.Money + Comment string + IsTest bool +} + +type TopupOut struct { + Payment *payment.Payment + Transaction *transaction.Transaction + MerchantBalance *wallet.Balance +} + +// TopupMerchantFromSystem performs an internal transfer from a non-materialized system balance to merchant's balance. +// Useful for resolving customer support requests. +func (s *Service) TopupMerchantFromSystem(ctx context.Context, merchantID int64, in TopupInput) (TopupOut, error) { + merchant, err := s.merchants.GetByID(ctx, merchantID, false) + if err != nil { + return TopupOut{}, errors.Wrap(err, "unable to find merchant") + } + + systemBalance, err := s.locateSystemBalance(ctx, in.Currency, in.IsTest) + if err != nil { + return TopupOut{}, errors.Wrap(err, "unable to find system balance") + } + + if err = systemBalance.Covers(in.Amount); err != nil { + return TopupOut{}, errors.Wrap(err, "system balance has no sufficient funds") + } + + conv, err := s.blockchain.CryptoToFiat(ctx, in.Amount, money.USD) + if err != nil { + return TopupOut{}, errors.Wrap(err, "unable to convert crypto to USD") + } + + usdAmount := conv.To + + paymentProps := payment.CreateInternalPaymentProps{ + MerchantOrderUUID: uuid.New(), + Money: in.Amount, + Description: fmt.Sprintf("*internal* topup of %s %s (%s)", in.Amount.String(), in.Currency.Ticker, in.Comment), + IsTest: in.IsTest, + } + + pt, err := s.payments.CreateSystemTopup(ctx, merchant.ID, paymentProps) + if err != nil { + return TopupOut{}, errors.Wrap(err, "unable to create the payment") + } + + txProps := transaction.CreateTransaction{ + Type: transaction.TypeVirtual, + EntityID: pt.ID, + Currency: in.Currency, + Amount: in.Amount, + USDAmount: usdAmount, + IsTest: in.IsTest, + } + + tx, err := s.transactions.CreateSystemTopup(ctx, merchant.ID, txProps) + if err != nil { + if errFail := s.payments.Fail(ctx, pt); errFail != nil { + return TopupOut{}, errors.Wrap(errFail, "unable to delete internal payment after failed system topup tx") + } + + return TopupOut{}, errors.Wrap(err, "unable to create internal transaction") + } + + balance, err := s.wallets.GetMerchantBalance(ctx, merchant.ID, in.Currency.Ticker, in.Currency.ChooseNetwork(in.IsTest)) + if err != nil { + return TopupOut{}, errors.Wrap(err, "unable to get merchants balance") + } + + return TopupOut{ + Payment: pt, + Transaction: tx, + MerchantBalance: balance, + }, nil +} + +// this operation might be slow and expensive in the future as it lists ALL balances +// and calculates "system" ones under the hood. +func (s *Service) locateSystemBalance(ctx context.Context, currency money.CryptoCurrency, isTest bool) (*wallet.Balance, error) { + balances, err := s.wallets.ListAllBalances(ctx, wallet.ListAllBalancesOpts{WithSystemBalances: true}) + if err != nil { + return nil, err + } + + desiredNetworkID := currency.ChooseNetwork(isTest) + + search := func(b *wallet.Balance) bool { + tickerMatches := b.Currency == currency.Ticker + networkMatches := b.NetworkID == desiredNetworkID + + return tickerMatches && networkMatches + } + + systemBalance, ok := lo.Find(balances[wallet.EntityTypeSystem], search) + if !ok { + return nil, errors.New("system balance not found") + } + + return systemBalance, nil +} diff --git a/internal/service/processing/service_topup_test.go b/internal/service/processing/service_topup_test.go new file mode 100644 index 0000000..d622a6d --- /dev/null +++ b/internal/service/processing/service_topup_test.go @@ -0,0 +1,156 @@ +package processing_test + +import ( + "testing" + + "github.com/oxygenpay/oxygen/internal/money" + "github.com/oxygenpay/oxygen/internal/service/payment" + "github.com/oxygenpay/oxygen/internal/service/processing" + "github.com/oxygenpay/oxygen/internal/service/transaction" + "github.com/oxygenpay/oxygen/internal/service/wallet" + "github.com/oxygenpay/oxygen/internal/test" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestService_TopupMerchantFromSystem(t *testing.T) { + tc := test.NewIntegrationTest(t) + + // ARRANGE + // Given a currency + tron := tc.Must.GetCurrency(t, "TRON") + tc.Providers.TatumMock.SetupRates("TRON", money.USD, 0.1) + + // Given several wallets with balances + // in total we should have 100 trx => $10 on mainnet and 50 trx => $5 on testnet + _, b1 := tc.Must.CreateWalletWithBalance(t, "TRON", wallet.TypeInbound, test.WithBalanceFromCurrency(tron, "50_000_000", false)) + _, b2 := tc.Must.CreateWalletWithBalance(t, "TRON", wallet.TypeOutbound, test.WithBalanceFromCurrency(tron, "50_000_000", false)) + _, b3 := tc.Must.CreateWalletWithBalance(t, "TRON", wallet.TypeInbound, test.WithBalanceFromCurrency(tron, "50_000_000", true)) + + // Given a function that ensures wallets balances are untouched + ensureWalletBalances := func(t *testing.T) { + for _, b := range []*wallet.Balance{b1, b2, b3} { + fresh, err := tc.Services.Wallet.GetBalanceByID(tc.Context, wallet.EntityTypeWallet, b.EntityID, b.ID) + require.NoError(t, err) + assert.Equal(t, "50", fresh.Amount.String()) + } + } + + // Given a merchant + mt, _ := tc.Must.CreateMerchant(t, 1) + + for _, tt := range []struct { + name string + in processing.TopupInput + assert func(t *testing.T, out processing.TopupOut) + expectsErr string + }{ + { + name: "success: merchant balance does not exist yet in the db yet", + in: processing.TopupInput{ + Currency: tron, + Amount: lo.Must(tron.MakeAmount("10_000_000")), + Comment: "hello world", + IsTest: false, + }, + assert: func(t *testing.T, out processing.TopupOut) { + assert.Equal(t, "1", out.Transaction.USDAmount.String()) + assert.Equal(t, "TRON", out.MerchantBalance.Amount.Ticker()) + assert.Equal(t, "10", out.MerchantBalance.Amount.String()) + }, + }, + { + name: "success: merchant balance exists", + in: processing.TopupInput{ + Currency: tron, + Amount: lo.Must(tron.MakeAmount("50_000_000")), + Comment: "hello world", + IsTest: false, + }, + assert: func(t *testing.T, out processing.TopupOut) { + assert.Equal(t, "5", out.Transaction.USDAmount.String()) + assert.Equal(t, "TRON", out.MerchantBalance.Amount.Ticker()) + assert.Equal(t, "60", out.MerchantBalance.Amount.String()) + }, + }, + { + name: "success: works for test balances", + in: processing.TopupInput{ + Currency: tron, + Amount: lo.Must(tron.MakeAmount("20_000_000")), + Comment: "hello world", + IsTest: true, + }, + assert: func(t *testing.T, out processing.TopupOut) { + assert.Equal(t, "2", out.Transaction.USDAmount.String()) + assert.Equal(t, "TRON", out.MerchantBalance.Amount.Ticker()) + assert.Equal(t, "20", out.MerchantBalance.Amount.String()) + }, + }, + { + name: "fail: system balance is negative", + in: processing.TopupInput{ + Currency: tron, + Amount: lo.Must(tron.MakeAmount("50_000_000")), + Comment: "one more time", + IsTest: false, + }, + expectsErr: "system balance has no sufficient funds", + }, + } { + t.Run(tt.name, func(t *testing.T) { + // ACT + out, err := tc.Services.Processing.TopupMerchantFromSystem(tc.Context, mt.ID, tt.in) + + // ASSERT + // Check that regardless of outcome, wallet balances remain the same + ensureWalletBalances(t) + + // optionally expect an error + if tt.expectsErr != "" { + assert.ErrorContains(t, err, tt.expectsErr) + return + } + + assert.NoError(t, err) + + // check payment props + assert.NotNil(t, out.Payment) + assert.Equal(t, payment.StatusSuccess, out.Payment.Status) + assert.Equal(t, payment.TypePayment, out.Payment.Type) + assert.Contains(t, *out.Payment.Description, tt.in.Comment) + assert.Equal(t, tt.in.IsTest, out.Payment.IsTest) + + // check tx props + assert.NotNil(t, out.Transaction) + assert.Equal(t, mt.ID, out.Transaction.MerchantID) + assert.Equal(t, out.Payment.ID, out.Transaction.EntityID) + + assert.Equal(t, transaction.TypeVirtual, out.Transaction.Type) + assert.Equal(t, transaction.StatusCompleted, out.Transaction.Status) + + assert.Empty(t, out.Transaction.SenderAddress) + assert.Empty(t, out.Transaction.SenderWalletID) + assert.Empty(t, out.Transaction.RecipientAddress) + assert.Empty(t, out.Transaction.RecipientWalletID) + assert.Empty(t, out.Transaction.HashID) + + assert.Equal(t, tt.in.Currency.Ticker, out.Transaction.Currency.Ticker) + assert.Equal(t, tt.in.Amount.String(), out.Transaction.Amount.String()) + assert.Equal(t, tt.in.Amount.String(), out.Transaction.FactAmount.String()) + + assert.True(t, out.Transaction.NetworkFee.IsZero()) + assert.True(t, out.Transaction.ServiceFee.IsZero()) + + assert.Equal(t, tt.in.IsTest, out.Transaction.IsTest) + + // check balance props + assert.NotNil(t, out.MerchantBalance) + + if tt.assert != nil { + tt.assert(t, out) + } + }) + } +} diff --git a/internal/service/transaction/model.go b/internal/service/transaction/model.go index efdfe82..5d69813 100644 --- a/internal/service/transaction/model.go +++ b/internal/service/transaction/model.go @@ -132,11 +132,20 @@ var finalizedTransactionStatuses = map[Status]struct{}{ type Type string const ( - TypeIncoming Type = "incoming" - TypeInternal Type = "internal" + // TypeIncoming is for incoming payments + TypeIncoming Type = "incoming" + + // TypeInternal is for moving assets from inbound to outbound wallets on blockchain + TypeInternal Type = "internal" + + // TypeWithdrawal is for moving assets from outbound wallets to merchant's address TypeWithdrawal Type = "withdrawal" + + // TypeVirtual is for moving assets within OxygenPay w/o reflecting it on blockchain + // (e.g. merchant to merchant, system to merchant, ...) + TypeVirtual Type = "virtual" ) func (t Type) valid() bool { - return t == TypeIncoming || t == TypeInternal || t == TypeWithdrawal + return t == TypeIncoming || t == TypeInternal || t == TypeWithdrawal || t == TypeVirtual } diff --git a/internal/service/transaction/service_topup.go b/internal/service/transaction/service_topup.go new file mode 100644 index 0000000..388eba7 --- /dev/null +++ b/internal/service/transaction/service_topup.go @@ -0,0 +1,132 @@ +package transaction + +import ( + "context" + "math/big" + "time" + + "github.com/oxygenpay/oxygen/internal/db/repository" + "github.com/oxygenpay/oxygen/internal/money" + "github.com/pkg/errors" +) + +func (c *CreateTransaction) validateForSystemTopup() error { + if !c.Type.valid() { + return errors.New("invalid type") + } + + if !c.ServiceFee.IsZero() { + return errors.New("service fee should be empty") + } + + if c.Currency.Ticker != c.Amount.Ticker() { + return errors.New("invalid currency specified") + } + + if c.USDAmount.Type() != money.Fiat || c.USDAmount.Ticker() != money.USD.String() { + return errors.New("invalid usd amount") + } + + if c.Amount.IsZero() { + return errors.New("amount can't be zero") + } + + if c.Type != TypeVirtual { + return errors.New("invalid type") + } + + if c.EntityID == 0 { + return errors.New("entity id should be present") + } + + if c.SenderAddress != "" || c.SenderWallet != nil { + return errors.New("sender should be empty") + } + + if c.RecipientAddress != "" || c.RecipientWallet != nil { + return errors.New("recipient should be empty") + } + + if c.TransactionHash != "" { + return errors.New("transaction hash should be empty") + } + + return nil +} + +func (s *Service) CreateSystemTopup(ctx context.Context, merchantID int64, params CreateTransaction) (*Transaction, error) { + tx, err := s.createSystemTopup(ctx, merchantID, params) + if err != nil { + return nil, errors.Wrap(err, "unable to create system transaction") + } + + if tx.FactAmount == nil { + return nil, errors.New("fact amount is nil") + } + + tx, err = s.confirm(ctx, s.store, merchantID, tx.ID, ConfirmTransaction{ + Status: StatusCompleted, + FactAmount: *tx.FactAmount, + MetaData: nil, + allowZeroNetworkFee: true, + }) + if err != nil { + return nil, errors.Wrap(err, "unable to confirm system transaction") + } + + return tx, nil +} + +func (s *Service) createSystemTopup(ctx context.Context, merchantID int64, params CreateTransaction) (*Transaction, error) { + if err := params.validateForSystemTopup(); err != nil { + return nil, err + } + + networkCurrency, err := s.blockchain.GetNativeCoin(params.Currency.Blockchain) + if err != nil { + return nil, errors.Wrap(err, "unable to get network currency") + } + + var ( + now = time.Now() + status = StatusInProgress + meta = MetaData{MetaComment: "internal system topup"} + ) + + create := repository.CreateTransactionParams{ + CreatedAt: now, + UpdatedAt: now, + + MerchantID: merchantID, + EntityID: repository.Int64ToNullable(params.EntityID), + + Status: string(status), + Type: string(params.Type), + + Blockchain: params.Currency.Blockchain.String(), + NetworkID: repository.StringToNullable(params.Currency.ChooseNetwork(params.IsTest)), + CurrencyType: string(params.Currency.Type), + Currency: params.Currency.Ticker, + Decimals: int32(params.Amount.Decimals()), + NetworkDecimals: int32(networkCurrency.Decimals), + + // note that amount == factAmount + Amount: repository.MoneyToNumeric(params.Amount), + FactAmount: repository.MoneyToNumeric(params.Amount), + UsdAmount: repository.MoneyToNumeric(params.USDAmount), + + // note that fees are set to zero. + NetworkFee: repository.BigIntToNumeric(big.NewInt(0)), + ServiceFee: repository.BigIntToNumeric(big.NewInt(0)), + + Metadata: meta.toJSONB(), + IsTest: params.IsTest, + } + + tx, err := s.store.CreateTransaction(ctx, create) + if err != nil { + return nil, err + } + + return s.entryToTransaction(tx) +} diff --git a/internal/service/transaction/service_update.go b/internal/service/transaction/service_update.go index 913d941..a1c4cf2 100644 --- a/internal/service/transaction/service_update.go +++ b/internal/service/transaction/service_update.go @@ -257,8 +257,13 @@ func (s *Service) updateBalancesAfterTxConfirmation( tx *Transaction, params ConfirmTransaction, ) error { - if tx.FactAmount == nil { + switch { + case tx.FactAmount == nil: return errors.New("factAmount is nil") + case tx.FactAmount.IsZero(): + return errors.New("factAmount is zero") + case tx.FactAmount.IsNegative(): + return errors.New("factAmount is negative") } incrementRecipientBalance := func(ctx context.Context) (string, MetaData, error) { @@ -434,6 +439,40 @@ func (s *Service) updateBalancesAfterTxConfirmation( return nil } + if tx.Type == TypeVirtual { + // In that case there was an "underpayment". + // So, skip merchant's balance increment + if tx.Status != StatusCompleted || tx.MerchantID == 0 { + return errors.New("invalid tx data for type=virtual") + } + + comment := "virtual system topup" + meta := wallet.MetaData{ + MetaMerchantID: strconv.FormatInt(tx.MerchantID, 10), + MetaTransactionID: strconv.FormatInt(tx.ID, 10), + } + + updateMerchantBalance := wallet.UpdateBalanceQuery{ + EntityID: tx.MerchantID, + EntityType: wallet.EntityTypeMerchant, + + Currency: tx.Currency, + Amount: *tx.FactAmount, + + Operation: wallet.OperationIncrement, + + Comment: comment, + MetaData: meta, + IsTest: tx.IsTest, + } + + if _, errIncrement := wallet.UpdateBalance(ctx, q, updateMerchantBalance); errIncrement != nil { + return errors.Wrap(errIncrement, "unable to update merchant balance") + } + + return nil + } + return fmt.Errorf("unknown transaction type %q", tx.Type) } diff --git a/internal/service/wallet/service_balance.go b/internal/service/wallet/service_balance.go index f4b9acf..a0a5ec0 100644 --- a/internal/service/wallet/service_balance.go +++ b/internal/service/wallet/service_balance.go @@ -3,6 +3,7 @@ package wallet import ( "context" "encoding/json" + "fmt" "math/big" "strconv" "time" @@ -13,6 +14,8 @@ import ( "github.com/oxygenpay/oxygen/internal/db/repository" "github.com/oxygenpay/oxygen/internal/money" "github.com/pkg/errors" + "github.com/samber/lo" + "golang.org/x/exp/slices" ) type Balance struct { @@ -65,6 +68,7 @@ type ( const ( EntityTypeMerchant EntityType = "merchant" EntityTypeWallet EntityType = "wallet" + EntityTypeSystem EntityType = "system" OperationIncrement BalanceOperation = "increment" OperationDecrement BalanceOperation = "decrement" @@ -107,6 +111,143 @@ func (s *Service) ListBalances(ctx context.Context, entityType EntityType, entit return balances, nil } +type Balances map[EntityType][]*Balance + +type ListAllBalancesOpts struct { + WithUSD bool + WithSystemBalances bool + HideEmpty bool +} + +func (s *Service) ListAllBalances(ctx context.Context, opts ListAllBalancesOpts) (Balances, error) { + balances := make(Balances) + + // merchant balances + res, err := s.store.ListAllBalancesByType(ctx, repository.ListAllBalancesByTypeParams{ + EntityType: string(EntityTypeMerchant), + HideEmpty: opts.HideEmpty, + }) + if err != nil { + return nil, errors.Wrap(err, "unable to list merchant balances") + } + + merchantBalances, err := entitiesToBalances(res) + if err != nil { + return nil, err + } + + balances[EntityTypeMerchant] = merchantBalances + + // wallet balances + res, err = s.store.ListAllBalancesByType(ctx, repository.ListAllBalancesByTypeParams{ + EntityType: string(EntityTypeWallet), + HideEmpty: opts.HideEmpty, + }) + + if err != nil { + return nil, errors.Wrap(err, "unable to list wallet balances") + } + + walletBalances, err := entitiesToBalances(res) + if err != nil { + return nil, err + } + + balances[EntityTypeWallet] = walletBalances + + if opts.WithSystemBalances { + systemBalances, err := composeSystemBalances(merchantBalances, walletBalances) + if err != nil { + return nil, errors.Wrap(err, "unable to compose system balances") + } + + balances[EntityTypeSystem] = systemBalances + } + + if opts.WithUSD { + for _, items := range balances { + if err := s.loadUSDBalances(ctx, items); err != nil { + return nil, errors.Wrap(err, "unable to load USD balances") + } + } + } + + return balances, nil +} + +// composeSystemBalances currently we have no system balances as distinct DB records, +// so we calculate them on the fly. +func composeSystemBalances(merchants, wallets []*Balance) ([]*Balance, error) { + balancesMap := map[string]*Balance{} + + keyFunc := func(b *Balance) string { + return fmt.Sprintf("%s/%s/%s", b.Currency, b.Network, b.NetworkID) + } + + // add + for _, w := range wallets { + key := keyFunc(w) + + systemBalance, ok := balancesMap[key] + if !ok { + balancesMap[key] = &Balance{ + EntityType: EntityTypeSystem, + Network: w.Network, + NetworkID: w.NetworkID, + CurrencyType: w.CurrencyType, + Currency: w.Currency, + Amount: w.Amount, + } + continue + } + + total, err := systemBalance.Amount.Add(w.Amount) + if err != nil { + return nil, errors.Wrap(err, "unable to add wallet's amount") + } + + systemBalance.Amount = total + } + + // subtract + // system balance might be negative! + for _, w := range merchants { + key := keyFunc(w) + systemBalance, ok := balancesMap[key] + if !ok { + fmt.Printf("%+v", balancesMap) + return nil, errors.New("unable to find balance " + key) + } + + total, err := systemBalance.Amount.SubNegative(w.Amount) + if err != nil { + return nil, errors.Wrapf(err, "unable to subtract merchant's amount %s", key) + } + + systemBalance.Amount = total + } + + balances := lo.Values(balancesMap) + slices.SortFunc(balances, func(a, b *Balance) bool { return keyFunc(a) < keyFunc(b) }) + + return balances, nil +} + +func entitiesToBalances(entries []repository.Balance) ([]*Balance, error) { + balances := make([]*Balance, len(entries)) + + for i := range entries { + balance, err := entryToBalance(entries[i]) + if err != nil { + return nil, err + } + + balances[i] = balance + } + + return balances, nil +} + func (s *Service) loadUSDBalances(ctx context.Context, balances []*Balance) error { for i, b := range balances { conv, err := s.blockchain.CryptoToFiat(ctx, b.Amount, money.USD) diff --git a/internal/test/must.go b/internal/test/must.go index d4bd286..9c7e1df 100644 --- a/internal/test/must.go +++ b/internal/test/must.go @@ -62,7 +62,7 @@ func (m *Must) CreateUserToken(t *testing.T, u *user.User) string { return token.Token } -// CreateUser creates user with api token. +// CreateMerchant creates merchant with api token. func (m *Must) CreateMerchant(t *testing.T, userID int64) (*merchant.Merchant, string) { mt, err := m.tc.Services.Merchants.Create(m.tc.Context, userID, "my-store", "https://site.com", nil) require.NoError(t, err) diff --git a/scripts/queries/balances.sql b/scripts/queries/balances.sql index 47ff1b2..20fc8d6 100644 --- a/scripts/queries/balances.sql +++ b/scripts/queries/balances.sql @@ -3,6 +3,12 @@ select * from balances where entity_type = $1 and entity_id = $2 order by id desc; +-- name: ListAllBalancesByType :many +select * from balances +where entity_type = $1 +and (CASE WHEN @hide_empty::boolean THEN amount > 0 ELSE true END) +order by id desc; + -- name: GetBalanceByUUID :one select * from balances where entity_id = $1 and entity_type = $2 and uuid = $3; diff --git a/ui-dashboard/src/components/payment-desc-card/payment-desc-card.tsx b/ui-dashboard/src/components/payment-desc-card/payment-desc-card.tsx index daf9f1e..d086618 100644 --- a/ui-dashboard/src/components/payment-desc-card/payment-desc-card.tsx +++ b/ui-dashboard/src/components/payment-desc-card/payment-desc-card.tsx @@ -32,6 +32,15 @@ const emptyState: Payment = { const b = bevis("payment-desc-card"); +const displayPrice = (record: Payment) => { + let ticker = record.currency + " "; + if (record.currency in CURRENCY_SYMBOL && CURRENCY_SYMBOL[record.currency] !== "") { + ticker = CURRENCY_SYMBOL[record.currency]; + } + + return ticker + record.price; +}; + const PaymentDescCard: React.FC = ({data, openNotificationFunc}) => { React.useEffect(() => { if (!data) { @@ -59,7 +68,7 @@ const PaymentDescCard: React.FC = ({data, openNotificationFunc}) => { {data.orderId ?? "Not provided"} Price}> - {`${CURRENCY_SYMBOL[data.currency]}${data.price}`} + {displayPrice(data)} Description}> {data.description ?? "Not provided"} diff --git a/ui-dashboard/src/pages/payments-page/payments-page.tsx b/ui-dashboard/src/pages/payments-page/payments-page.tsx index d6adec9..bd1a175 100644 --- a/ui-dashboard/src/pages/payments-page/payments-page.tsx +++ b/ui-dashboard/src/pages/payments-page/payments-page.tsx @@ -19,6 +19,15 @@ import PaymentStatusLabel from "src/components/payment-status/payment-status"; import TimeLabel from "src/components/time-label/time-label"; import {sleep} from "src/utils"; +const displayPrice = (record: Payment) => { + let ticker = record.currency + " "; + if (record.currency in CURRENCY_SYMBOL && CURRENCY_SYMBOL[record.currency] !== "") { + ticker = CURRENCY_SYMBOL[record.currency]; + } + + return ticker + record.price; +}; + const columns: ColumnsType = [ { title: "Created At", @@ -37,11 +46,7 @@ const columns: ColumnsType = [ dataIndex: "price", key: "price", width: "min-content", - render: (_, record) => ( - - {`${record.currency in CURRENCY_SYMBOL ? CURRENCY_SYMBOL[record.currency] : ""}${record.price}`} - - ) + render: (_, record) => {displayPrice(record)} }, { title: "Order ID",