1 Commits

Author SHA1 Message Date
Arseni
3ddd7718c2 Moved payment data preparation into providers 2025-12-25 19:24:45 +03:00
35 changed files with 535 additions and 912 deletions

View File

@@ -17,21 +17,21 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
managedRef := strings.TrimSpace(dest.GetManagedWalletRef())
external := strings.TrimSpace(dest.GetExternalAddress())
if managedRef != "" && external != "" {
deps.Logger.Warn("Both managed and external destination provided")
deps.Logger.Warn("both managed and external destination provided")
return model.TransferDestination{}, merrors.InvalidArgument("destination must be managed_wallet_ref or external_address")
}
if managedRef != "" {
wallet, err := deps.Storage.Wallets().Get(ctx, managedRef)
if err != nil {
deps.Logger.Warn("Destination wallet lookup failed", zap.Error(err), zap.String("managed_wallet_ref", managedRef))
deps.Logger.Warn("destination wallet lookup failed", zap.Error(err), zap.String("managed_wallet_ref", managedRef))
return model.TransferDestination{}, err
}
if !strings.EqualFold(wallet.Network, source.Network) {
deps.Logger.Warn("Destination wallet network mismatch", zap.String("source_network", source.Network), zap.String("dest_network", wallet.Network))
deps.Logger.Warn("destination wallet network mismatch", zap.String("source_network", source.Network), zap.String("dest_network", wallet.Network))
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch")
}
if strings.TrimSpace(wallet.DepositAddress) == "" {
deps.Logger.Warn("Destination wallet missing deposit address", zap.String("managed_wallet_ref", managedRef))
deps.Logger.Warn("destination wallet missing deposit address", zap.String("managed_wallet_ref", managedRef))
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet missing deposit address")
}
return model.TransferDestination{
@@ -40,21 +40,21 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
}, nil
}
if external == "" {
deps.Logger.Warn("Destination external address missing")
deps.Logger.Warn("destination external address missing")
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
}
if deps.Drivers == nil {
deps.Logger.Warn("Chain drivers missing", zap.String("network", source.Network))
deps.Logger.Warn("chain drivers missing", zap.String("network", source.Network))
return model.TransferDestination{}, merrors.Internal("chain drivers not configured")
}
chainDriver, err := deps.Drivers.Driver(source.Network)
if err != nil {
deps.Logger.Warn("Unsupported chain driver", zap.String("network", source.Network), zap.Error(err))
deps.Logger.Warn("unsupported chain driver", zap.String("network", source.Network), zap.Error(err))
return model.TransferDestination{}, merrors.InvalidArgument("unsupported chain for wallet")
}
normalized, err := chainDriver.NormalizeAddress(external)
if err != nil {
deps.Logger.Warn("Invalid external address", zap.Error(err))
deps.Logger.Warn("invalid external address", zap.Error(err))
return model.TransferDestination{}, err
}
return model.TransferDestination{

View File

@@ -6,7 +6,6 @@ import (
"strings"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
@@ -24,11 +23,11 @@ func NewEstimateTransfer(deps Deps) *estimateTransferFeeCommand {
func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) gsresponse.Responder[chainv1.EstimateTransferFeeResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if req == nil {
c.deps.Logger.Warn("Empty request received")
c.deps.Logger.Warn("nil request")
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
}
@@ -46,67 +45,58 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("Source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
c.deps.Logger.Warn("source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
c.deps.Logger.Warn("Storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
c.deps.Logger.Warn("storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
networkCfg, ok := c.deps.Networks.Network(networkKey)
if !ok {
c.deps.Logger.Warn("Unsupported chain", zap.String("network", networkKey))
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
}
if c.deps.Drivers == nil {
c.deps.Logger.Warn("Chain drivers missing", zap.String("network", networkKey))
c.deps.Logger.Warn("chain drivers missing", zap.String("network", networkKey))
return gsresponse.Internal[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
}
chainDriver, err := c.deps.Drivers.Driver(networkKey)
if err != nil {
c.deps.Logger.Warn("Unsupported chain driver", zap.String("network", networkKey), zap.Error(err))
c.deps.Logger.Warn("unsupported chain driver", zap.String("network", networkKey), zap.Error(err))
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
}
dest, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("Destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
c.deps.Logger.Warn("destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
c.deps.Logger.Warn("Invalid destination", zap.Error(err))
c.deps.Logger.Warn("invalid destination", zap.Error(err))
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
destinationAddress, err := destinationAddress(ctx, c.deps, chainDriver, dest)
if err != nil {
c.deps.Logger.Warn("Failed to resolve destination address", zap.Error(err))
c.deps.Logger.Warn("failed to resolve destination address", zap.Error(err))
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
walletForFee := sourceWallet
nativeCurrency := shared.NativeCurrency(networkCfg)
if nativeCurrency != "" && strings.EqualFold(nativeCurrency, amount.GetCurrency()) {
copyWallet := *sourceWallet
copyWallet.ContractAddress = ""
copyWallet.TokenSymbol = nativeCurrency
walletForFee = &copyWallet
}
driverDeps := driver.Deps{
Logger: c.deps.Logger,
Registry: c.deps.Networks,
RPCTimeout: c.deps.RPCTimeout,
}
feeMoney, err := chainDriver.EstimateFee(ctx, driverDeps, networkCfg, walletForFee, destinationAddress, amount)
feeMoney, err := chainDriver.EstimateFee(ctx, driverDeps, networkCfg, sourceWallet, destinationAddress, amount)
if err != nil {
c.deps.Logger.Warn("Fee estimation failed", zap.Error(err))
c.deps.Logger.Warn("fee estimation failed", zap.Error(err))
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
contextLabel := "erc20_transfer"
if strings.TrimSpace(walletForFee.ContractAddress) == "" {
if strings.TrimSpace(sourceWallet.ContractAddress) == "" {
contextLabel = "native_transfer"
}
resp := &chainv1.EstimateTransferFeeResponse{

View File

@@ -25,7 +25,7 @@ func NewSubmitTransfer(deps Deps) *submitTransferCommand {
func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.SubmitTransferRequest) gsresponse.Responder[chainv1.SubmitTransferResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if req == nil {
@@ -35,92 +35,84 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" {
c.deps.Logger.Warn("Missing idempotency key")
c.deps.Logger.Warn("missing idempotency key")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
}
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
if organizationRef == "" {
c.deps.Logger.Warn("mMssing organization ref")
c.deps.Logger.Warn("missing organization ref")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
}
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
if sourceWalletRef == "" {
c.deps.Logger.Warn("Missing source wallet ref")
c.deps.Logger.Warn("missing source wallet ref")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
}
amount := req.GetAmount()
if amount == nil {
c.deps.Logger.Warn("Missing amount")
c.deps.Logger.Warn("missing amount")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
}
amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
if amountCurrency == "" {
c.deps.Logger.Warn("Missing amount currency")
c.deps.Logger.Warn("missing amount currency")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.currency is required"))
}
amountValue := strings.TrimSpace(amount.GetAmount())
if amountValue == "" {
c.deps.Logger.Warn("Missing amount value")
c.deps.Logger.Warn("missing amount value")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.amount is required"))
}
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("Source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
c.deps.Logger.Warn("source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
c.deps.Logger.Warn("Storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
c.deps.Logger.Warn("storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if !strings.EqualFold(sourceWallet.OrganizationRef, organizationRef) {
c.deps.Logger.Warn("Organization mismatch", zap.String("wallet_org", sourceWallet.OrganizationRef), zap.String("req_org", organizationRef))
c.deps.Logger.Warn("organization mismatch", zap.String("wallet_org", sourceWallet.OrganizationRef), zap.String("req_org", organizationRef))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
}
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
networkCfg, ok := c.deps.Networks.Network(networkKey)
if !ok {
c.deps.Logger.Warn("Unsupported chain", zap.String("network", networkKey))
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
}
destination, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("Destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
c.deps.Logger.Warn("destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
c.deps.Logger.Warn("Invalid destination", zap.Error(err))
c.deps.Logger.Warn("invalid destination", zap.Error(err))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
fees, feeSum, err := convertFees(req.GetFees(), amountCurrency)
if err != nil {
c.deps.Logger.Warn("Fee conversion failed", zap.Error(err))
c.deps.Logger.Warn("fee conversion failed", zap.Error(err))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
amountDec, err := decimal.NewFromString(amountValue)
if err != nil {
c.deps.Logger.Warn("Invalid amount", zap.Error(err))
c.deps.Logger.Warn("invalid amount", zap.Error(err))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("invalid amount"))
}
netDec := amountDec.Sub(feeSum)
if netDec.IsNegative() {
c.deps.Logger.Warn("Fees exceed amount", zap.String("amount", amountValue), zap.String("fee_sum", feeSum.String()))
c.deps.Logger.Warn("fees exceed amount", zap.String("amount", amountValue), zap.String("fee_sum", feeSum.String()))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("fees exceed amount"))
}
netAmount := shared.CloneMoney(amount)
netAmount.Amount = netDec.String()
effectiveTokenSymbol := sourceWallet.TokenSymbol
effectiveContractAddress := sourceWallet.ContractAddress
nativeCurrency := shared.NativeCurrency(networkCfg)
if nativeCurrency != "" && strings.EqualFold(nativeCurrency, amountCurrency) {
effectiveTokenSymbol = nativeCurrency
effectiveContractAddress = ""
}
transfer := &model.Transfer{
IdempotencyKey: idempotencyKey,
TransferRef: shared.GenerateTransferRef(),
@@ -128,8 +120,8 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
SourceWalletRef: sourceWalletRef,
Destination: destination,
Network: sourceWallet.Network,
TokenSymbol: effectiveTokenSymbol,
ContractAddress: effectiveContractAddress,
TokenSymbol: sourceWallet.TokenSymbol,
ContractAddress: sourceWallet.ContractAddress,
RequestedAmount: shared.CloneMoney(amount),
NetAmount: netAmount,
Fees: fees,
@@ -141,10 +133,10 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
saved, err := c.deps.Storage.Transfers().Create(ctx, transfer)
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
c.deps.Logger.Debug("Transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey))
c.deps.Logger.Debug("transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey))
return gsresponse.Success(&chainv1.SubmitTransferResponse{Transfer: toProtoTransfer(saved)})
}
c.deps.Logger.Warn("Storage create failed", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
c.deps.Logger.Warn("storage create failed", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}

View File

@@ -95,7 +95,7 @@ func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network sh
}
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
d.logger.Debug("Estimate fee request",
d.logger.Debug("estimate fee request",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("destination", destination),
@@ -104,13 +104,13 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
driverDeps.Logger = d.logger
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
if err != nil {
d.logger.Warn("Estimate fee failed",
d.logger.Warn("estimate fee failed",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.Error(err),
)
} else if result != nil {
d.logger.Debug("Estimate fee result",
d.logger.Debug("estimate fee result",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("amount", result.Amount),

View File

@@ -95,7 +95,7 @@ func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network sh
}
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
d.logger.Debug("Estimate fee request",
d.logger.Debug("estimate fee request",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("destination", destination),
@@ -104,13 +104,13 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
driverDeps.Logger = d.logger
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
if err != nil {
d.logger.Warn("Estimate fee failed",
d.logger.Warn("estimate fee failed",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.Error(err),
)
} else if result != nil {
d.logger.Debug("Estimate fee result",
d.logger.Debug("estimate fee result",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("amount", result.Amount),

View File

@@ -1,31 +0,0 @@
package evm
import (
"math/big"
"strings"
"testing"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/require"
)
func TestTronEstimateCallUsesData(t *testing.T) {
from := common.HexToAddress("0xfa89b4d534bdeb2713d4ffd893e79d6535fb58f8")
to := common.HexToAddress("0xa614f803b6fd780986a42c78ec9c7f77e6ded13c")
callMsg := ethereum.CallMsg{
From: from,
To: &to,
GasPrice: big.NewInt(100),
Data: []byte{0xa9, 0x05, 0x9c, 0xbb},
}
call := tronEstimateCall(callMsg)
require.Equal(t, strings.ToLower(from.Hex()), call["from"])
require.Equal(t, strings.ToLower(to.Hex()), call["to"])
require.Equal(t, "0x64", call["gasPrice"])
require.Equal(t, "0xa9059cbb", call["data"])
_, hasInput := call["input"]
require.False(t, hasInput)
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/rpc"
"github.com/shopspring/decimal"
@@ -96,7 +95,7 @@ func parseBaseUnitAmount(amount string) (*big.Int, error) {
// Balance fetches ERC20 token balance for the provided address.
func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) {
logger := deps.Logger.Named("evm")
logger := deps.Logger
registry := deps.Registry
if registry == nil {
@@ -176,7 +175,7 @@ func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wall
// NativeBalance fetches native token balance for the provided address.
func NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) {
logger := deps.Logger.Named("evm")
logger := deps.Logger
registry := deps.Registry
if registry == nil {
@@ -234,7 +233,7 @@ func NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network
// EstimateFee estimates ERC20 transfer fees for the given parameters.
func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, fromAddress, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
logger := deps.Logger.Named("evm")
logger := deps.Logger
registry := deps.Registry
if registry == nil {
@@ -260,12 +259,10 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
client, err := registry.Client(network.Name)
if err != nil {
logger.Warn("Failed to resolve client", zap.Error(err), zap.String("network_name", network.Name))
return nil, err
}
rpcClient, err := registry.RPCClient(network.Name)
if err != nil {
logger.Warn("Failed to resolve RPC client", zap.Error(err), zap.String("network_name", network.Name))
return nil, err
}
@@ -283,12 +280,10 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
if contract == "" {
amountBase, err := parseBaseUnitAmount(amount.GetAmount())
if err != nil {
logger.Warn("Failed to parse base unit amount", zap.Error(err), zap.String("amount", amount.GetAmount()))
return nil, err
}
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
if err != nil {
logger.Warn("Failed to suggest gas price", zap.Error(err))
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
}
callMsg := ethereum.CallMsg{
@@ -297,9 +292,8 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
GasPrice: gasPrice,
Value: amountBase,
}
gasLimit, err := estimateGas(timeoutCtx, network, client, rpcClient, callMsg)
gasLimit, err := client.EstimateGas(timeoutCtx, callMsg)
if err != nil {
logger.Warn("Failed to estimate gas", zap.Error(err), zap.Any("call_mesasge", callMsg))
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
}
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
@@ -310,7 +304,6 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
}, nil
}
if !common.IsHexAddress(contract) {
logger.Warn("Failed to validate contract", zap.String("contract", contract))
return nil, merrors.InvalidArgument("invalid token contract address")
}
@@ -329,13 +322,11 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
input, err := erc20ABI.Pack("transfer", toAddr, amountBase)
if err != nil {
logger.Warn("Failed to encode transfer call", zap.Error(err))
return nil, merrors.Internal("failed to encode transfer call: " + err.Error())
}
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
if err != nil {
logger.Warn("Failed to suggest gas price", zap.Error(err))
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
}
@@ -345,9 +336,8 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
GasPrice: gasPrice,
Data: input,
}
gasLimit, err := estimateGas(timeoutCtx, network, client, rpcClient, callMsg)
gasLimit, err := client.EstimateGas(timeoutCtx, callMsg)
if err != nil {
logger.Warn("Failed to estimate gas", zap.Error(err), zap.Any("call_message", callMsg))
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
}
@@ -362,7 +352,7 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
// SubmitTransfer submits an ERC20 transfer on an EVM-compatible chain.
func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, fromAddress, destination string) (string, error) {
logger := deps.Logger.Named("evm")
logger := deps.Logger
registry := deps.Registry
if deps.KeyManager == nil {
@@ -394,7 +384,7 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
return "", executorInvalid("invalid destination address " + destination)
}
logger.Info("Submitting transfer",
logger.Info("submitting transfer",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("source_wallet_ref", source.WalletRef),
zap.String("network", network.Name),
@@ -403,12 +393,12 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
client, err := registry.Client(network.Name)
if err != nil {
logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", network.Name))
logger.Warn("Failed to initialise rpc client", zap.Error(err), zap.String("network", network.Name))
return "", err
}
rpcClient, err := registry.RPCClient(network.Name)
if err != nil {
logger.Warn("Failed to initialise RPC client", zap.String("network", network.Name))
logger.Warn("failed to initialise rpc client", zap.String("network", network.Name))
return "", err
}
@@ -458,7 +448,7 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
GasPrice: gasPrice,
Value: amountInt,
}
gasLimit, err := estimateGas(ctx, network, client, rpcClient, callMsg)
gasLimit, err := client.EstimateGas(ctx, callMsg)
if err != nil {
logger.Warn("Failed to estimate gas", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
@@ -508,7 +498,7 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
GasPrice: gasPrice,
Data: input,
}
gasLimit, err := estimateGas(ctx, network, client, rpcClient, callMsg)
gasLimit, err := client.EstimateGas(ctx, callMsg)
if err != nil {
logger.Warn("Failed to estimate gas", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
@@ -547,7 +537,7 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
// AwaitConfirmation waits for the transaction receipt.
func AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
logger := deps.Logger.Named("evm")
logger := deps.Logger
registry := deps.Registry
if strings.TrimSpace(txHash) == "" {
@@ -662,63 +652,6 @@ func erc20Decimals(ctx context.Context, client *rpc.Client, token common.Address
return val, nil
}
type gasEstimator interface {
EstimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error)
}
func estimateGas(ctx context.Context, network shared.Network, client gasEstimator, rpcClient *rpc.Client, callMsg ethereum.CallMsg) (uint64, error) {
if isTronNetwork(network) {
if rpcClient == nil {
return 0, merrors.Internal("rpc client not initialised")
}
return estimateGasTron(ctx, rpcClient, callMsg)
}
return client.EstimateGas(ctx, callMsg)
}
func estimateGasTron(ctx context.Context, rpcClient *rpc.Client, callMsg ethereum.CallMsg) (uint64, error) {
call := tronEstimateCall(callMsg)
var hexResp string
if err := rpcClient.CallContext(ctx, &hexResp, "eth_estimateGas", call); err != nil {
return 0, err
}
val, err := shared.DecodeHexBig(hexResp)
if err != nil {
return 0, err
}
if val == nil {
return 0, merrors.Internal("failed to decode gas estimate")
}
return val.Uint64(), nil
}
func tronEstimateCall(callMsg ethereum.CallMsg) map[string]string {
call := make(map[string]string)
if callMsg.From != (common.Address{}) {
call["from"] = strings.ToLower(callMsg.From.Hex())
}
if callMsg.To != nil {
call["to"] = strings.ToLower(callMsg.To.Hex())
}
if callMsg.Gas > 0 {
call["gas"] = hexutil.EncodeUint64(callMsg.Gas)
}
if callMsg.GasPrice != nil {
call["gasPrice"] = hexutil.EncodeBig(callMsg.GasPrice)
}
if callMsg.Value != nil {
call["value"] = hexutil.EncodeBig(callMsg.Value)
}
if len(callMsg.Data) > 0 {
call["data"] = hexutil.Encode(callMsg.Data)
}
return call
}
func isTronNetwork(network shared.Network) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(network.Name)), "tron")
}
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
value, err := decimal.NewFromString(strings.TrimSpace(amount))
if err != nil {

View File

@@ -2,7 +2,6 @@ package tron
import (
"context"
"strings"
"github.com/ethereum/go-ethereum/core/types"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
@@ -114,9 +113,6 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
if wallet == nil {
return nil, merrors.InvalidArgument("wallet is required")
}
if amount == nil {
return nil, merrors.InvalidArgument("amount is required")
}
d.logger.Debug("Estimate fee request",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
@@ -138,12 +134,6 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
)
return nil, err
}
if rpcFrom == rpcTo {
return &moneyv1.Money{
Currency: nativeCurrency(network),
Amount: "0",
}, nil
}
driverDeps := deps
driverDeps.Logger = d.logger
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, rpcFrom, rpcTo, amount)
@@ -151,10 +141,6 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
d.logger.Warn("Estimate fee failed", zap.Error(err),
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("from_address", wallet.DepositAddress),
zap.String("from_rpc", rpcFrom),
zap.String("to_address", destination),
zap.String("to_rpc", rpcTo),
)
} else if result != nil {
d.logger.Debug("Estimate fee result",
@@ -234,12 +220,4 @@ func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, networ
return receipt, err
}
func nativeCurrency(network shared.Network) string {
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
if currency == "" {
currency = strings.ToUpper(strings.TrimSpace(network.Name))
}
return currency
}
var _ driver.Driver = (*Driver)(nil)

View File

@@ -1,33 +0,0 @@
package tron
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.uber.org/zap"
)
func TestEstimateFeeSelfTransferReturnsZero(t *testing.T) {
logger := zap.NewNop()
d := New(logger)
wallet := &model.ManagedWallet{
WalletRef: "wallet_ref",
DepositAddress: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF",
}
network := shared.Network{
Name: "tron_mainnet",
NativeToken: "TRX",
}
amount := &moneyv1.Money{Currency: "TRX", Amount: "1000000"}
fee, err := d.EstimateFee(context.Background(), driver.Deps{}, network, wallet, wallet.DepositAddress, amount)
require.NoError(t, err)
require.NotNil(t, fee)
require.Equal(t, "TRX", fee.GetCurrency())
require.Equal(t, "0", fee.GetAmount())
}

View File

@@ -81,12 +81,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
client, err := o.clients.Client(network.Name)
if err != nil {
o.logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", network.Name))
o.logger.Warn("failed to initialise rpc client", zap.Error(err), zap.String("network", network.Name))
return "", err
}
rpcClient, err := o.clients.RPCClient(network.Name)
if err != nil {
o.logger.Warn("Failed to initialise RPC client",
o.logger.Warn("failed to initialise rpc client",
zap.String("network", network.Name),
zap.Error(err),
)
@@ -101,7 +101,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
if err != nil {
o.logger.Warn("Failed to fetch nonce", zap.Error(err),
o.logger.Warn("failed to fetch nonce", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("wallet_ref", source.WalletRef),
)
@@ -110,7 +110,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
gasPrice, err := client.SuggestGasPrice(ctx)
if err != nil {
o.logger.Warn("Failed to suggest gas price",
o.logger.Warn("failed to suggest gas price",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name),
zap.Error(err),
@@ -124,12 +124,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
chainID := new(big.Int).SetUint64(network.ChainID)
if strings.TrimSpace(transfer.ContractAddress) == "" {
o.logger.Warn("Native token transfer requested but not supported", zap.String("transfer_ref", transfer.TransferRef))
o.logger.Warn("native token transfer requested but not supported", zap.String("transfer_ref", transfer.TransferRef))
return "", merrors.NotImplemented("executor: native token transfers not yet supported")
}
if !common.IsHexAddress(transfer.ContractAddress) {
o.logger.Warn("Invalid token contract address",
o.logger.Warn("invalid token contract address",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("contract", transfer.ContractAddress),
)
@@ -139,7 +139,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress)
if err != nil {
o.logger.Warn("Failed to read token decimals", zap.Error(err),
o.logger.Warn("failed to read token decimals", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("contract", transfer.ContractAddress),
)
@@ -148,12 +148,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
amount := transfer.NetAmount
if amount == nil || strings.TrimSpace(amount.Amount) == "" {
o.logger.Warn("Transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
o.logger.Warn("transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
return "", executorInvalid("transfer missing net amount")
}
amountInt, err := toBaseUnits(amount.Amount, decimals)
if err != nil {
o.logger.Warn("Failed to convert amount to base units", zap.Error(err),
o.logger.Warn("failed to convert amount to base units", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("amount", amount.Amount),
)
@@ -177,7 +177,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
}
gasLimit, err := client.EstimateGas(ctx, callMsg)
if err != nil {
o.logger.Warn("Failed to estimate gas",
o.logger.Warn("failed to estimate gas",
zap.String("transfer_ref", transfer.TransferRef),
zap.Error(err),
)
@@ -188,7 +188,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
if err != nil {
o.logger.Warn("Failed to sign transaction", zap.Error(err),
o.logger.Warn("failed to sign transaction", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("wallet_ref", source.WalletRef),
)
@@ -196,14 +196,14 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
}
if err := client.SendTransaction(ctx, signedTx); err != nil {
o.logger.Warn("Failed to send transaction", zap.Error(err),
o.logger.Warn("failed to send transaction", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
)
return "", executorInternal("failed to send transaction", err)
}
txHash = signedTx.Hash().Hex()
o.logger.Info("Transaction submitted",
o.logger.Info("transaction submitted",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
@@ -214,12 +214,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) {
if strings.TrimSpace(txHash) == "" {
o.logger.Warn("Missing transaction hash for confirmation", zap.String("network", network.Name))
o.logger.Warn("missing transaction hash for confirmation", zap.String("network", network.Name))
return nil, executorInvalid("tx hash is required")
}
rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" {
o.logger.Warn("Network RPC url missing while awaiting confirmation", zap.String("tx_hash", txHash))
o.logger.Warn("network rpc url missing while awaiting confirmation", zap.String("tx_hash", txHash))
return nil, executorInvalid("network rpc url is not configured")
}
@@ -238,27 +238,27 @@ func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.
if errors.Is(err, ethereum.NotFound) {
select {
case <-ticker.C:
o.logger.Debug("Transaction not yet mined",
o.logger.Debug("transaction not yet mined",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
)
continue
case <-ctx.Done():
o.logger.Warn("Context cancelled while awaiting confirmation",
o.logger.Warn("context cancelled while awaiting confirmation",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
)
return nil, ctx.Err()
}
}
o.logger.Warn("Failed to fetch transaction receipt",
o.logger.Warn("failed to fetch transaction receipt",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.Error(err),
)
return nil, executorInternal("failed to fetch transaction receipt", err)
}
o.logger.Info("Transaction confirmed",
o.logger.Info("transaction confirmed",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),

View File

@@ -2,7 +2,6 @@ package rpcclient
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -71,7 +70,7 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
cancel()
if err != nil {
result.Close()
clientLogger.Warn("Failed to dial rpc endpoint", append(fields, zap.Error(err))...)
clientLogger.Warn("failed to dial rpc endpoint", append(fields, zap.Error(err))...)
return nil, merrors.Internal(fmt.Sprintf("rpc dial failed for %s: %s", name, err.Error()))
}
client := ethclient.NewClient(rpcCli)
@@ -79,7 +78,7 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
eth: client,
rpc: rpcCli,
}
clientLogger.Info("RPC client ready", fields...)
clientLogger.Info("rpc client ready", fields...)
}
if len(result.clients) == 0 {
@@ -95,12 +94,12 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
// Client returns a prepared client for the given network name.
func (c *Clients) Client(network string) (*ethclient.Client, error) {
if c == nil {
return nil, merrors.Internal("RPC clients not initialised")
return nil, merrors.Internal("rpc clients not initialised")
}
name := strings.ToLower(strings.TrimSpace(network))
entry, ok := c.clients[name]
if !ok || entry.eth == nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("RPC client not configured for network %s", name))
return nil, merrors.InvalidArgument(fmt.Sprintf("rpc client not configured for network %s", name))
}
return entry.eth, nil
}
@@ -130,7 +129,7 @@ func (c *Clients) Close() {
entry.eth.Close()
}
if c.logger != nil {
c.logger.Info("RPC client closed", zap.String("network", name))
c.logger.Info("rpc client closed", zap.String("network", name))
}
}
}
@@ -156,15 +155,16 @@ func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
fields := []zap.Field{
zap.String("network", l.network),
zap.String("rpc_endpoint", l.endpoint),
}
if len(reqBody) > 0 {
fields = append(fields, zap.String("rpc_request", truncate(string(reqBody), 2048)))
}
l.logger.Debug("RPC request", fields...)
l.logger.Debug("rpc request", fields...)
resp, err := l.base.RoundTrip(req)
if err != nil {
l.logger.Warn("RPC http request failed", append(fields, zap.Error(err))...)
l.logger.Warn("rpc http request failed", append(fields, zap.Error(err))...)
return nil, err
}
@@ -175,19 +175,11 @@ func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
respFields := append(fields,
zap.Int("status_code", resp.StatusCode),
)
if contentType := strings.TrimSpace(resp.Header.Get("Content-Type")); contentType != "" {
respFields = append(respFields, zap.String("content_type", contentType))
}
if len(bodyBytes) > 0 {
respFields = append(respFields, zap.String("rpc_response", truncate(string(bodyBytes), 2048)))
}
l.logger.Debug("RPC response", respFields...)
if resp.StatusCode >= 400 {
l.logger.Warn("RPC response error", respFields...)
} else if len(bodyBytes) == 0 {
l.logger.Warn("RPC response empty body", respFields...)
} else if len(bodyBytes) > 0 && !json.Valid(bodyBytes) {
l.logger.Warn("RPC response invalid JSON", respFields...)
}
return resp, nil

View File

@@ -119,15 +119,6 @@ func TransferStatusToProto(status model.TransferStatus) chainv1.TransferStatus {
}
}
// NativeCurrency returns the canonical native token symbol for a network.
func NativeCurrency(network Network) string {
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
if currency == "" {
currency = strings.ToUpper(strings.TrimSpace(network.Name))
}
return currency
}
// Network describes a supported blockchain network and known token contracts.
type Network struct {
Name string

View File

@@ -24,7 +24,7 @@ func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, n
defer cancel()
if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil {
s.logger.Warn("Failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
s.logger.Warn("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
}
}(transferRef, sourceWalletRef, network)
}
@@ -57,23 +57,6 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
return err
}
sourceAddress, err := chainDriver.NormalizeAddress(sourceWallet.DepositAddress)
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
return err
}
if chainDriver.Name() == "tron" && sourceAddress == destinationAddress {
s.logger.Info("Self transfer detected; skipping submission",
zap.String("transfer_ref", transferRef),
zap.String("wallet_ref", sourceWalletRef),
zap.String("network", network.Name),
)
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", ""); err != nil {
s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err))
}
return nil
}
txHash, err := chainDriver.SubmitTransfer(ctx, driverDeps, network, transfer, sourceWallet, destinationAddress)
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")

View File

@@ -99,49 +99,24 @@ func (w *Wallets) Create(ctx context.Context, wallet *model.ManagedWallet) (*mod
if strings.TrimSpace(wallet.IdempotencyKey) == "" {
return nil, merrors.InvalidArgument("walletsStore: empty idempotencyKey")
}
fields := []zap.Field{
zap.String("wallet_ref", wallet.WalletRef),
zap.String("idempotency_key", wallet.IdempotencyKey),
}
if wallet.OrganizationRef != "" {
fields = append(fields, zap.String("organization_ref", wallet.OrganizationRef))
}
if wallet.OwnerRef != "" {
fields = append(fields, zap.String("owner_ref", wallet.OwnerRef))
}
if wallet.Network != "" {
fields = append(fields, zap.String("network", wallet.Network))
}
if wallet.TokenSymbol != "" {
fields = append(fields, zap.String("token_symbol", wallet.TokenSymbol))
}
if err := w.walletRepo.Insert(ctx, wallet, repository.Filter("idempotencyKey", wallet.IdempotencyKey)); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
w.logger.Debug("wallet already exists", fields...)
w.logger.Debug("wallet already exists", zap.String("wallet_ref", wallet.WalletRef), zap.String("idempotency_key", wallet.IdempotencyKey))
return wallet, nil
}
w.logger.Warn("wallet create failed", append(fields, zap.Error(err))...)
return nil, err
}
w.logger.Debug("wallet created", fields...)
w.logger.Debug("wallet created", zap.String("wallet_ref", wallet.WalletRef))
return wallet, nil
}
func (w *Wallets) Get(ctx context.Context, walletID string) (*model.ManagedWallet, error) {
walletID = strings.TrimSpace(walletID)
if walletID == "" {
func (w *Wallets) Get(ctx context.Context, walletRef string) (*model.ManagedWallet, error) {
walletRef = strings.TrimSpace(walletRef)
if walletRef == "" {
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
}
fields := []zap.Field{
zap.String("wallet_id", walletID),
}
wallet := &model.ManagedWallet{}
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), wallet); err != nil {
if errors.Is(err, merrors.ErrNoData) {
w.logger.Debug("wallet not found", fields...)
} else {
w.logger.Warn("wallet lookup failed", append(fields, zap.Error(err))...)
}
if err := w.walletRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), wallet); err != nil {
return nil, err
}
return wallet, nil
@@ -149,38 +124,29 @@ func (w *Wallets) Get(ctx context.Context, walletID string) (*model.ManagedWalle
func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error) {
query := repository.Query()
fields := make([]zap.Field, 0, 6)
if org := strings.TrimSpace(filter.OrganizationRef); org != "" {
query = query.Filter(repository.Field("organizationRef"), org)
fields = append(fields, zap.String("organization_ref", org))
}
if owner := strings.TrimSpace(filter.OwnerRef); owner != "" {
query = query.Filter(repository.Field("ownerRef"), owner)
fields = append(fields, zap.String("owner_ref", owner))
}
if network := strings.TrimSpace(filter.Network); network != "" {
normalized := strings.ToLower(network)
query = query.Filter(repository.Field("network"), normalized)
fields = append(fields, zap.String("network", normalized))
query = query.Filter(repository.Field("network"), strings.ToLower(network))
}
if token := strings.TrimSpace(filter.TokenSymbol); token != "" {
normalized := strings.ToUpper(token)
query = query.Filter(repository.Field("tokenSymbol"), normalized)
fields = append(fields, zap.String("token_symbol", normalized))
query = query.Filter(repository.Field("tokenSymbol"), strings.ToUpper(token))
}
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
query = query.Comparison(repository.IDField(), builder.Gt, oid)
fields = append(fields, zap.String("cursor", cursor))
} else {
w.logger.Warn("ignoring invalid wallet cursor", zap.String("cursor", cursor), zap.Error(err))
}
}
limit := sanitizeWalletLimit(filter.Limit)
fields = append(fields, zap.Int64("limit", limit))
fetchLimit := limit + 1
query = query.Sort(repository.IDField(), true).Limit(&fetchLimit)
@@ -194,10 +160,8 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*
return nil
}
listErr := w.walletRepo.FindManyByFilter(ctx, query, decoder)
if listErr != nil && !errors.Is(listErr, merrors.ErrNoData) {
w.logger.Warn("wallet list failed", append(fields, zap.Error(listErr))...)
return nil, listErr
if err := w.walletRepo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
return nil, err
}
nextCursor := ""
@@ -207,21 +171,10 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*
wallets = wallets[:len(wallets)-1]
}
result := &model.ManagedWalletList{
return &model.ManagedWalletList{
Items: wallets,
NextCursor: nextCursor,
}
fields = append(fields,
zap.Int("count", len(result.Items)),
zap.String("next_cursor", result.NextCursor),
)
if errors.Is(listErr, merrors.ErrNoData) {
w.logger.Debug("wallet list empty", fields...)
} else {
w.logger.Debug("wallet list fetched", fields...)
}
return result, nil
}, nil
}
func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance) error {
@@ -235,7 +188,6 @@ func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance)
if balance.CalculatedAt.IsZero() {
balance.CalculatedAt = time.Now().UTC()
}
fields := []zap.Field{zap.String("wallet_ref", balance.WalletRef)}
existing := &model.WalletBalance{}
err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", balance.WalletRef), existing)
@@ -246,40 +198,28 @@ func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance)
existing.PendingOutbound = balance.PendingOutbound
existing.CalculatedAt = balance.CalculatedAt
if err := w.balanceRepo.Update(ctx, existing); err != nil {
w.logger.Warn("wallet balance update failed", append(fields, zap.Error(err))...)
return err
}
w.logger.Debug("wallet balance updated", fields...)
return nil
case errors.Is(err, merrors.ErrNoData):
if err := w.balanceRepo.Insert(ctx, balance, repository.Filter("walletRef", balance.WalletRef)); err != nil {
w.logger.Warn("wallet balance create failed", append(fields, zap.Error(err))...)
return err
}
w.logger.Debug("wallet balance created", fields...)
return nil
default:
w.logger.Warn("wallet balance lookup failed", append(fields, zap.Error(err))...)
return err
}
}
func (w *Wallets) GetBalance(ctx context.Context, walletID string) (*model.WalletBalance, error) {
walletID = strings.TrimSpace(walletID)
if walletID == "" {
func (w *Wallets) GetBalance(ctx context.Context, walletRef string) (*model.WalletBalance, error) {
walletRef = strings.TrimSpace(walletRef)
if walletRef == "" {
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
}
fields := []zap.Field{zap.String("wallet_ref", walletID)}
balance := &model.WalletBalance{}
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), balance); err != nil {
if errors.Is(err, merrors.ErrNoData) {
w.logger.Debug("wallet balance not found", fields...)
} else {
w.logger.Warn("wallet balance lookup failed", append(fields, zap.Error(err))...)
}
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), balance); err != nil {
return nil, err
}
w.logger.Debug("wallet balance fetched", fields...)
return balance, nil
}

View File

@@ -26,7 +26,7 @@ monetix:
base_url_env: MONETIX_BASE_URL
project_id_env: MONETIX_PROJECT_ID
secret_key_env: MONETIX_SECRET_KEY
allowed_currencies: ["RUB"]
allowed_currencies: ["USD", "EUR"]
require_customer_address: false
request_timeout_seconds: 15
status_success: "success"

View File

@@ -51,12 +51,6 @@ gateway:
call_timeout_seconds: 3
insecure: true
mntx:
address: "sendico_mntx_gateway:50075"
dial_timeout_seconds: 5
call_timeout_seconds: 3
insecure: true
oracle:
address: "sendico_fx_oracle:50051"
dial_timeout_seconds: 5
@@ -66,7 +60,7 @@ oracle:
card_gateways:
monetix:
funding_address: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF"
fee_wallet_ref: "694c124ed76f9f811ac57133"
fee_wallet_ref: "694c124fd76f9f811ac57134"
fee_ledger_accounts:
monetix: "ledger:fees:monetix"

View File

@@ -9,7 +9,6 @@ import (
oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client"
mntxclient "github.com/tech/sendico/gateway/mntx/client"
ledgerclient "github.com/tech/sendico/ledger/client"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
"github.com/tech/sendico/payments/orchestrator/storage"
@@ -37,7 +36,6 @@ type Imp struct {
feesConn *grpc.ClientConn
ledgerClient ledgerclient.Client
gatewayClient chainclient.Client
mntxClient mntxclient.Client
oracleClient oracleclient.Client
}
@@ -46,7 +44,6 @@ type config struct {
Fees clientConfig `yaml:"fees"`
Ledger clientConfig `yaml:"ledger"`
Gateway clientConfig `yaml:"gateway"`
Mntx clientConfig `yaml:"mntx"`
Oracle clientConfig `yaml:"oracle"`
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
@@ -108,9 +105,6 @@ func (i *Imp) Shutdown() {
if i.gatewayClient != nil {
_ = i.gatewayClient.Close()
}
if i.mntxClient != nil {
_ = i.mntxClient.Close()
}
if i.oracleClient != nil {
_ = i.oracleClient.Close()
}
@@ -145,11 +139,6 @@ func (i *Imp) Start() error {
i.gatewayClient = gatewayClient
}
mntxClient := i.initMntxClient(cfg.Mntx)
if mntxClient != nil {
i.mntxClient = mntxClient
}
oracleClient := i.initOracleClient(cfg.Oracle)
if oracleClient != nil {
i.oracleClient = oracleClient
@@ -166,9 +155,6 @@ func (i *Imp) Start() error {
if gatewayClient != nil {
opts = append(opts, orchestrator.WithChainGatewayClient(gatewayClient))
}
if mntxClient != nil {
opts = append(opts, orchestrator.WithMntxGateway(mntxClient))
}
if oracleClient != nil {
opts = append(opts, orchestrator.WithOracleClient(oracleClient))
}
@@ -206,11 +192,11 @@ func (i *Imp) initFeesClient(cfg clientConfig) (feesv1.FeeEngineClient, *grpc.Cl
conn, err := grpc.DialContext(dialCtx, addr, grpc.WithTransportCredentials(creds))
if err != nil {
i.logger.Warn("Failed to connect to fees service", zap.String("address", addr), zap.Error(err))
i.logger.Warn("failed to connect to fees service", zap.String("address", addr), zap.Error(err))
return nil, nil
}
i.logger.Info("Connected to fees service", zap.String("address", addr))
i.logger.Info("connected to fees service", zap.String("address", addr))
return feesv1.NewFeeEngineClient(conn), conn
}
@@ -230,10 +216,10 @@ func (i *Imp) initLedgerClient(cfg clientConfig) ledgerclient.Client {
Insecure: cfg.InsecureTransport,
})
if err != nil {
i.logger.Warn("Failed to connect to ledger service", zap.String("address", addr), zap.Error(err))
i.logger.Warn("failed to connect to ledger service", zap.String("address", addr), zap.Error(err))
return nil
}
i.logger.Info("Connected to ledger service", zap.String("address", addr))
i.logger.Info("connected to ledger service", zap.String("address", addr))
return client
}
@@ -260,28 +246,6 @@ func (i *Imp) initGatewayClient(cfg clientConfig) chainclient.Client {
return client
}
func (i *Imp) initMntxClient(cfg clientConfig) mntxclient.Client {
addr := cfg.address()
if addr == "" {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
defer cancel()
client, err := mntxclient.New(ctx, mntxclient.Config{
Address: addr,
DialTimeout: cfg.dialTimeout(),
CallTimeout: cfg.callTimeout(),
})
if err != nil {
i.logger.Warn("Failed to connect to mntx gateway service", zap.String("address", addr), zap.Error(err))
return nil
}
i.logger.Info("Connected to mntx gateway service", zap.String("address", addr))
return client
}
func (i *Imp) initOracleClient(cfg clientConfig) oracleclient.Client {
addr := cfg.address()
if addr == "" {
@@ -298,10 +262,10 @@ func (i *Imp) initOracleClient(cfg clientConfig) oracleclient.Client {
Insecure: cfg.InsecureTransport,
})
if err != nil {
i.logger.Warn("Failed to connect to oracle service", zap.String("address", addr), zap.Error(err))
i.logger.Warn("failed to connect to oracle service", zap.String("address", addr), zap.Error(err))
return nil
}
i.logger.Info("Connected to oracle service", zap.String("address", addr))
i.logger.Info("connected to oracle service", zap.String("address", addr))
return client
}

View File

@@ -13,7 +13,7 @@ import (
type paymentEngine interface {
EnsureRepository(ctx context.Context) error
BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error)
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error)
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error)
ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error
Repository() storage.Repository
}
@@ -30,7 +30,7 @@ func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef stri
return e.svc.buildPaymentQuote(ctx, orgRef, req)
}
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) {
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
return e.svc.resolvePaymentQuote(ctx, in)
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
@@ -62,13 +61,7 @@ func (h *quotePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.Q
if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info(
"Stored payment quote",
zap.String("quote_ref", quoteRef),
mzap.ObjRef("org_ref", orgID),
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
zap.String("kind", intent.GetKind().String()),
)
h.logger.Info("stored payment quote", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
}
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
@@ -86,7 +79,7 @@ func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgID, orgRef, err := validateMetaAndOrgRef(req.GetMeta())
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
@@ -108,7 +101,7 @@ func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.
Intent: intent,
PreviewOnly: req.GetPreviewOnly(),
}
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgID, quoteReq)
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, quoteReq)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
@@ -139,14 +132,11 @@ func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(orgRef)
record.SetOrganizationRef(orgID)
if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("Stored payment quotes",
zap.String("quote_ref", quoteRef), mzap.ObjRef("org_ref", orgRef),
zap.String("idempotency_key", baseKey), zap.Int("quote_count", len(quotes)),
)
h.logger.Info("stored payment quotes", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
}
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
@@ -168,7 +158,7 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
_, orgRef, err := validateMetaAndOrgRef(req.GetMeta())
_, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
@@ -185,7 +175,7 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
record, err := quotesStore.GetByRef(ctx, orgRef, quoteRef)
record, err := quotesStore.GetByRef(ctx, orgID, quoteRef)
if err != nil {
if errors.Is(err, storage.ErrQuoteNotFound) {
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
@@ -223,14 +213,14 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
quoteProto.QuoteRef = quoteRef
perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents))
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgRef, perKey); err == nil && existing != nil {
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, perKey); err == nil && existing != nil {
payments = append(payments, toProtoPayment(existing))
continue
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
entity := newPayment(orgRef, intentProto, perKey, req.GetMetadata(), quoteProto)
entity := newPayment(orgID, intentProto, perKey, req.GetMetadata(), quoteProto)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
@@ -245,13 +235,6 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
payments = append(payments, toProtoPayment(entity))
}
h.logger.Info(
"Payments initiated",
mzap.ObjRef("org_ref", orgRef),
zap.String("quote_ref", quoteRef),
zap.String("idempotency_key", idempotencyKey),
zap.Int("payment_count", len(payments)),
)
return gsresponse.Success(&orchestratorv1.InitiatePaymentsResponse{Payments: payments})
}
@@ -272,31 +255,13 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intent := req.GetIntent()
quoteRef := strings.TrimSpace(req.GetQuoteRef())
hasIntent := intent != nil
hasQuote := quoteRef != ""
switch {
case !hasIntent && !hasQuote:
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent or quote_ref is required"))
case hasIntent && hasQuote:
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent and quote_ref are mutually exclusive"))
}
if hasIntent {
if err := requireNonNilIntent(intent); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := requireNonNilIntent(intent); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Debug(
"Initiate payment request accepted",
zap.String("org_ref", orgID.Hex()),
zap.String("idempotency_key", idempotencyKey),
zap.String("quote_ref", quoteRef),
zap.Bool("has_intent", hasIntent),
)
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
@@ -304,24 +269,18 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
}
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
h.logger.Debug(
"idempotent payment request reused",
zap.String("payment_ref", existing.PaymentRef),
zap.String("org_ref", orgID.Hex()),
zap.String("idempotency_key", idempotencyKey),
zap.String("quote_ref", quoteRef),
)
h.logger.Debug("idempotent payment request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)})
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteSnapshot, resolvedIntent, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
quoteSnapshot, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
OrgRef: orgRef,
OrgID: orgID,
Meta: req.GetMeta(),
Intent: intent,
QuoteRef: quoteRef,
QuoteRef: req.GetQuoteRef(),
IdempotencyKey: req.GetIdempotencyKey(),
})
if err != nil {
@@ -342,17 +301,8 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
if quoteSnapshot == nil {
quoteSnapshot = &orchestratorv1.PaymentQuote{}
}
if err := requireNonNilIntent(resolvedIntent); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Debug(
"Payment quote resolved",
zap.String("org_ref", orgID.Hex()),
zap.String("quote_ref", quoteRef),
zap.Bool("quote_ref_used", quoteRef != ""),
)
entity := newPayment(orgID, resolvedIntent, idempotencyKey, req.GetMetadata(), quoteSnapshot)
entity := newPayment(orgID, intent, idempotencyKey, req.GetMetadata(), quoteSnapshot)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
@@ -365,14 +315,7 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info(
"Payment initiated",
zap.String("payment_ref", entity.PaymentRef),
zap.String("org_ref", orgID.Hex()),
zap.String("kind", resolvedIntent.GetKind().String()),
zap.String("quote_ref", quoteSnapshot.GetQuoteRef()),
zap.String("idempotency_key", idempotencyKey),
)
h.logger.Info("payment initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()), zap.String("kind", intent.GetKind().String()))
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
Payment: toProtoPayment(entity),
})
@@ -412,7 +355,7 @@ func (h *cancelPaymentCommand) Execute(ctx context.Context, req *orchestratorv1.
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("Payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex()))
h.logger.Info("payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex()))
return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)})
}
@@ -453,7 +396,7 @@ func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestrat
}
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
h.logger.Debug("Idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
h.logger.Debug("idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
@@ -496,7 +439,7 @@ func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestrat
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("Conversion initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()))
h.logger.Info("conversion initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
Conversion: toProtoPayment(entity),
})

View File

@@ -103,40 +103,33 @@ type quoteResolutionError struct {
func (e quoteResolutionError) Error() string { return e.err.Error() }
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) {
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
if ref := strings.TrimSpace(in.QuoteRef); ref != "" {
quotesStore, err := ensureQuotesStore(s.storage)
if err != nil {
return nil, nil, err
return nil, err
}
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
if err != nil {
if errors.Is(err, storage.ErrQuoteNotFound) {
return nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
return nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
}
return nil, nil, err
return nil, err
}
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
return nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
return nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
}
intent, err := recordIntentFromQuote(record)
if err != nil {
return nil, nil, err
if !proto.Equal(protoIntentFromModel(record.Intent), in.Intent) {
return nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
}
if in.Intent != nil && !proto.Equal(intent, in.Intent) {
return nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
}
quote, err := recordQuoteFromQuote(record)
if err != nil {
return nil, nil, err
quote := modelQuoteToProto(record.Quote)
if quote == nil {
return nil, merrors.InvalidArgument("stored quote is empty")
}
quote.QuoteRef = ref
return quote, intent, nil
return quote, nil
}
if in.Intent == nil {
return nil, nil, merrors.InvalidArgument("intent is required")
}
req := &orchestratorv1.QuotePaymentRequest{
Meta: in.Meta,
IdempotencyKey: in.IdempotencyKey,
@@ -145,41 +138,9 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp
}
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
if err != nil {
return nil, nil, err
return nil, err
}
return quote, in.Intent, nil
}
func recordIntentFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.PaymentIntent, error) {
if record == nil {
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
}
if len(record.Intents) > 0 {
if len(record.Intents) != 1 {
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
}
return protoIntentFromModel(record.Intents[0]), nil
}
if record.Intent.Amount == nil && (record.Intent.Kind == "" || record.Intent.Kind == model.PaymentKindUnspecified) {
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
}
return protoIntentFromModel(record.Intent), nil
}
func recordQuoteFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.PaymentQuote, error) {
if record == nil {
return nil, merrors.InvalidArgument("stored quote is empty")
}
if record.Quote != nil {
return modelQuoteToProto(record.Quote), nil
}
if len(record.Quotes) > 0 {
if len(record.Quotes) != 1 {
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
}
return modelQuoteToProto(record.Quotes[0]), nil
}
return nil, merrors.InvalidArgument("stored quote is empty")
return quote, nil
}
func newPayment(orgID primitive.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.PaymentQuote) *model.Payment {

View File

@@ -73,7 +73,7 @@ func TestResolvePaymentQuote_NotFound(t *testing.T) {
storage: stubRepo{quotes: &helperQuotesStore{}},
clock: clockpkg.NewSystem(),
}
_, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
OrgRef: org.Hex(),
OrgID: org,
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
@@ -98,7 +98,7 @@ func TestResolvePaymentQuote_Expired(t *testing.T) {
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
clock: clockpkg.NewSystem(),
}
_, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
OrgRef: org.Hex(),
OrgID: org,
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
@@ -110,35 +110,6 @@ func TestResolvePaymentQuote_Expired(t *testing.T) {
}
}
func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) {
org := primitive.NewObjectID()
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
record := &model.PaymentQuoteRecord{
QuoteRef: "q1",
Intent: intentFromProto(intent),
Quote: &model.PaymentQuoteSnapshot{},
}
svc := &Service{
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
clock: clockpkg.NewSystem(),
}
quote, resolvedIntent, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
OrgRef: org.Hex(),
OrgID: org,
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
QuoteRef: "q1",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if quote == nil || quote.GetQuoteRef() != "q1" {
t.Fatalf("expected quote_ref q1, got %#v", quote)
}
if resolvedIntent == nil || resolvedIntent.GetAmount().GetAmount() != "1" {
t.Fatalf("expected resolved intent with amount, got %#v", resolvedIntent)
}
}
func TestInitiatePaymentIdempotency(t *testing.T) {
logger := mloggerfactory.NewLogger(false)
org := primitive.NewObjectID()
@@ -169,42 +140,6 @@ func TestInitiatePaymentIdempotency(t *testing.T) {
}
}
func TestInitiatePaymentByQuoteRef(t *testing.T) {
logger := mloggerfactory.NewLogger(false)
org := primitive.NewObjectID()
store := newHelperPaymentStore()
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
record := &model.PaymentQuoteRecord{
QuoteRef: "q1",
Intent: intentFromProto(intent),
Quote: &model.PaymentQuoteSnapshot{},
}
svc := NewService(logger, stubRepo{
payments: store,
quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}},
}, WithClock(clockpkg.NewSystem()))
svc.ensureHandlers()
req := &orchestratorv1.InitiatePaymentRequest{
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
QuoteRef: "q1",
IdempotencyKey: "k1",
}
resp, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background())
if err != nil {
t.Fatalf("initiate by quote_ref failed: %v", err)
}
if resp == nil || resp.GetPayment() == nil {
t.Fatalf("expected payment response")
}
if resp.GetPayment().GetIntent().GetAmount().GetAmount() != "1" {
t.Fatalf("expected intent amount to be resolved from quote")
}
if resp.GetPayment().GetLastQuote().GetQuoteRef() != "q1" {
t.Fatalf("expected last quote_ref to be set from stored quote")
}
}
// --- test doubles ---
type stubRepo struct {

View File

@@ -8,7 +8,6 @@ import (
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
@@ -21,7 +20,7 @@ import (
func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, token *sresponse.TokenData, expectQuote bool) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for payment initiation", zap.Error(err), mutil.PLog(a.oph, r))
a.logger.Warn("Failed to parse organization reference for payment initiation", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
@@ -77,7 +76,7 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to
resp, err := a.client.InitiatePayment(ctx, req)
if err != nil {
a.logger.Warn("Failed to initiate payment", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
a.logger.Warn("Failed to initiate payment", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
return response.Auto(a.logger, a.Name(), err)
}

View File

@@ -1,20 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/api/responses/base.dart';
import 'package:pshared/api/responses/token.dart';
import 'package:pshared/data/dto/payment/payment.dart';
part 'payment.g.dart';
@JsonSerializable(explicitToJson: true)
class PaymentResponse extends BaseAuthorizedResponse {
final PaymentDTO payment;
const PaymentResponse({required super.accessToken, required this.payment});
factory PaymentResponse.fromJson(Map<String, dynamic> json) => _$PaymentResponseFromJson(json);
@override
Map<String, dynamic> toJson() => _$PaymentResponseToJson(this);
}

View File

@@ -3,11 +3,14 @@ import 'package:flutter/foundation.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
class PaymentFlowProvider extends ChangeNotifier {
PaymentType _selectedType;
PaymentMethodData? _manualPaymentData;
MethodMap _availableTypes = {};
Recipient? _recipient;
PaymentFlowProvider({
required PaymentType initialType,
@@ -15,57 +18,40 @@ class PaymentFlowProvider extends ChangeNotifier {
PaymentType get selectedType => _selectedType;
PaymentMethodData? get manualPaymentData => _manualPaymentData;
Recipient? get recipient => _recipient;
void sync({
bool get hasRecipient => _recipient != null;
MethodMap get availableTypes => hasRecipient
? _availableTypes
: {for (final type in PaymentType.values) type: null};
PaymentMethodData? get selectedPaymentData =>
hasRecipient ? _availableTypes[_selectedType] : _manualPaymentData;
void syncWith({
required Recipient? recipient,
required MethodMap availableTypes,
required PaymentMethodsProvider methodsProvider,
PaymentType? preferredType,
}) {
final resolvedType = _resolveSelectedType(
recipient: recipient,
availableTypes: availableTypes,
preferredType: preferredType,
);
var hasChanges = false;
if (resolvedType != _selectedType) {
_selectedType = resolvedType;
hasChanges = true;
}
if (recipient != null && _manualPaymentData != null) {
_manualPaymentData = null;
hasChanges = true;
}
if (hasChanges) notifyListeners();
}
}) =>
_applyState(
recipient: recipient,
availableTypes: methodsProvider.availableTypesForRecipient(recipient),
preferredType: preferredType,
forceResetManualData: false,
);
void reset({
required Recipient? recipient,
required MethodMap availableTypes,
required PaymentMethodsProvider methodsProvider,
PaymentType? preferredType,
}) {
final resolvedType = _resolveSelectedType(
recipient: recipient,
availableTypes: availableTypes,
preferredType: preferredType,
);
var hasChanges = false;
if (resolvedType != _selectedType) {
_selectedType = resolvedType;
hasChanges = true;
}
if (_manualPaymentData != null) {
_manualPaymentData = null;
hasChanges = true;
}
if (hasChanges) notifyListeners();
}
}) =>
_applyState(
recipient: recipient,
availableTypes: methodsProvider.availableTypesForRecipient(recipient),
preferredType: preferredType,
forceResetManualData: true,
);
void selectType(PaymentType type, {bool resetManualData = false}) {
if (_selectedType == type && (!resetManualData || _manualPaymentData == null)) {
@@ -107,4 +93,41 @@ class PaymentFlowProvider extends ChangeNotifier {
return availableTypes.keys.first;
}
void _applyState({
required Recipient? recipient,
required MethodMap availableTypes,
required PaymentType? preferredType,
required bool forceResetManualData,
}) {
final resolvedType = _resolveSelectedType(
recipient: recipient,
availableTypes: availableTypes,
preferredType: preferredType,
);
var hasChanges = false;
if (_recipient != recipient) {
_recipient = recipient;
hasChanges = true;
}
if (!mapEquals(_availableTypes, availableTypes)) {
_availableTypes = availableTypes;
hasChanges = true;
}
if (resolvedType != _selectedType) {
_selectedType = resolvedType;
hasChanges = true;
}
if ((recipient != null || forceResetManualData) && _manualPaymentData != null) {
_manualPaymentData = null;
hasChanges = true;
}
if (hasChanges) notifyListeners();
}
}

View File

@@ -1,64 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/payment/quotation.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/payment/service.dart';
class PaymentProvider extends ChangeNotifier {
late OrganizationsProvider _organization;
late QuotationProvider _quotation;
Resource<Payment> _payment = Resource(data: null, isLoading: false, error: null);
bool _isLoaded = false;
void update(OrganizationsProvider organization, QuotationProvider quotation) {
_quotation = quotation;
_organization = organization;
}
Payment? get payment => _payment.data;
bool get isLoading => _payment.isLoading;
Exception? get error => _payment.error;
bool get isReady => _isLoaded && !_payment.isLoading && _payment.error == null;
void _setResource(Resource<Payment> payment) {
_payment = payment;
notifyListeners();
}
Future<Payment?> pay({String? idempotencyKey, Map<String, String>? metadata}) async {
if (!_organization.isOrganizationSet) throw StateError('Organization is not set');
if (!_quotation.isReady) throw StateError('Quotation is not ready');
final quoteRef = _quotation.quotation?.quoteRef;
if (quoteRef == null || quoteRef.isEmpty) {
throw StateError('Quotation reference is not set');
}
_setResource(_payment.copyWith(isLoading: true, error: null));
try {
final response = await PaymentService.pay(
_organization.current.id,
quoteRef,
idempotencyKey: idempotencyKey,
metadata: metadata,
);
_isLoaded = true;
_setResource(_payment.copyWith(data: response, isLoading: false, error: null));
} catch (e) {
_setResource(_payment.copyWith(
data: null,
error: e is Exception ? e : Exception(e.toString()),
isLoading: false,
));
}
return _payment.data;
}
void reset() {
_setResource(Resource(data: null, isLoading: false, error: null));
_isLoaded = false;
}
}

View File

@@ -1,7 +1,5 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:uuid/uuid.dart';
import 'package:pshared/api/requests/payment/quote.dart';
@@ -20,7 +18,6 @@ import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/payment/quotation.dart';
import 'package:pshared/utils/currency.dart';
@@ -36,12 +33,10 @@ class QuotationProvider extends ChangeNotifier {
PaymentAmountProvider payment,
WalletsProvider wallets,
PaymentFlowProvider flow,
PaymentMethodsProvider methods,
) {
_organizations = venue;
final t = flow.selectedType;
final method = methods.methods.firstWhereOrNull((m) => m.type == t);
if ((wallets.selectedWallet != null) && (method != null)) {
final destination = flow.selectedPaymentData;
if ((wallets.selectedWallet != null) && (destination != null)) {
getQuotation(PaymentIntent(
kind: PaymentKind.payout,
amount: Money(
@@ -49,7 +44,7 @@ class QuotationProvider extends ChangeNotifier {
// TODO: adapt to possible other sources
currency: currencyCodeToString(wallets.selectedWallet!.currency),
),
destination: method.data,
destination: destination,
source: ManagedWalletPaymentMethod(
managedWalletRef: wallets.selectedWallet!.id,
),

View File

@@ -5,6 +5,8 @@ import 'package:pshared/models/describable.dart';
import 'package:pshared/models/organization/bound.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/type.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/models/permissions/bound.dart';
import 'package:pshared/models/storable.dart';
import 'package:pshared/provider/organizations.dart';
@@ -20,6 +22,24 @@ class PaymentMethodsProvider extends GenericProvider<PaymentMethod> {
List<PaymentMethod> get methods => List<PaymentMethod>.unmodifiable(items.toList()..sort((a, b) => a.storable.createdAt.compareTo(b.storable.createdAt)));
List<PaymentMethod> methodsForRecipient(Recipient? recipient) {
if (recipient == null || !isReady) return [];
return methods
.where((method) => !method.isArchived && method.recipientRef == recipient.id)
.toList();
}
MethodMap availableTypesForRecipient(Recipient? recipient) => {
for (final method in methodsForRecipient(recipient)) method.type: method.data,
};
PaymentMethod? findMethodByType({
required PaymentType type,
required Recipient? recipient,
}) =>
methodsForRecipient(recipient).firstWhereOrNull((method) => method.type == type);
void updateProviders(OrganizationsProvider organizations, RecipientsProvider recipients) {
if (recipients.currentObject != null) loadMethods(organizations, recipients.currentObject?.id);
}

View File

@@ -1,36 +0,0 @@
import 'package:logging/logging.dart';
import 'package:uuid/uuid.dart';
import 'package:pshared/api/requests/payment/initiate.dart';
import 'package:pshared/api/responses/payment/payment.dart';
import 'package:pshared/data/mapper/payment/payment_response.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/service/authorization/service.dart';
import 'package:pshared/service/services.dart';
class PaymentService {
static final _logger = Logger('service.payment');
static const String _objectType = Services.payments;
static Future<Payment> pay(
String organizationRef,
String quotationRef, {
String? idempotencyKey,
Map<String, String>? metadata,
}) async {
_logger.fine('Executing payment for quotation $quotationRef in $organizationRef');
final request = InitiatePaymentRequest(
idempotencyKey: idempotencyKey ?? Uuid().v4(),
quoteRef: quotationRef,
metadata: metadata,
);
final response = await AuthorizationService.getPOSTResponse(
_objectType,
'/by-quote/$organizationRef',
request.toJson(),
);
return PaymentResponse.fromJson(response).payment.toDomain();
}
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/payment/quotation.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/pages/dashboard/payouts/form.dart';
class PaymentFromWrappingWidget extends StatelessWidget {
const PaymentFromWrappingWidget({super.key});
@override
Widget build(BuildContext context) => MultiProvider(
providers: [
ChangeNotifierProvider(
create: (_) => PaymentAmountProvider(),
),
ChangeNotifierProxyProvider4<OrganizationsProvider, PaymentAmountProvider, WalletsProvider, PaymentFlowProvider, QuotationProvider>(
create: (_) => QuotationProvider(),
update: (context, orgnization, payment, wallet, flow, provider) => provider!..update(orgnization, payment, wallet, flow),
),
],
child: const PaymentFormWidget(),
);
}

View File

@@ -4,21 +4,17 @@ import 'package:collection/collection.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/type.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/payment/provider.dart';
import 'package:pshared/provider/payment/quotation.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/payment_methods/payment_page/body.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/pages/payment_methods/payment_page/body.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/services/posthog.dart';
@@ -68,20 +64,29 @@ class _PaymentPageState extends State<PaymentPage> {
context.read<RecipientsProvider>().setQuery(query);
}
void _handleRecipientSelected(BuildContext context, Recipient recipient) {
void _handleRecipientSelected(Recipient recipient) {
final recipientProvider = context.read<RecipientsProvider>();
final methodsProvider = context.read<PaymentMethodsProvider>();
final flowProvider = context.read<PaymentFlowProvider>();
recipientProvider.setCurrentObject(recipient.id);
flowProvider.reset(
recipient: recipient,
methodsProvider: methodsProvider,
preferredType: widget.initialPaymentType,
);
_clearSearchField();
}
void _handleRecipientCleared(BuildContext context) {
void _handleRecipientCleared() {
final recipientProvider = context.read<RecipientsProvider>();
final methodsProvider = context.read<PaymentMethodsProvider>();
final flowProvider = context.read<PaymentFlowProvider>();
recipientProvider.setCurrentObject(null);
context.read<PaymentFlowProvider>().reset(
flowProvider.reset(
recipient: null,
availableTypes: _availablePaymentTypes(null, methodsProvider),
methodsProvider: methodsProvider,
preferredType: widget.initialPaymentType,
);
_clearSearchField();
@@ -93,13 +98,9 @@ class _PaymentPageState extends State<PaymentPage> {
context.read<RecipientsProvider>().setQuery('');
}
void _handleSendPayment(BuildContext context) {
if (context.read<QuotationProvider>().isReady) {
context.read<PaymentProvider>().pay();
PosthogService.paymentInitiated(
method: context.read<PaymentFlowProvider>().selectedType,
);
}
void _handleSendPayment() {
// TODO: Handle Payment logic
PosthogService.paymentInitiated(method: context.read<PaymentFlowProvider>().selectedType);
}
@override
@@ -107,51 +108,35 @@ class _PaymentPageState extends State<PaymentPage> {
final methodsProvider = context.watch<PaymentMethodsProvider>();
final recipientProvider = context.watch<RecipientsProvider>();
final recipient = recipientProvider.currentObject;
final availableTypes = _availablePaymentTypes(recipient, methodsProvider);
return MultiProvider(
providers: [
ChangeNotifierProxyProvider2<RecipientsProvider, PaymentMethodsProvider, PaymentFlowProvider>(
create: (_) => PaymentFlowProvider(
initialType: widget.initialPaymentType ?? PaymentType.bankAccount,
),
update: (_, recipients, methods, flow) {
final currentRecipient = recipients.currentObject;
flow!.sync(
recipient: currentRecipient,
availableTypes: _availablePaymentTypes(currentRecipient, methods),
preferredType: currentRecipient != null ? widget.initialPaymentType : null,
);
return flow;
},
),
ChangeNotifierProvider(
create: (_) => PaymentAmountProvider(),
),
ChangeNotifierProxyProvider5<OrganizationsProvider, PaymentAmountProvider, WalletsProvider, PaymentFlowProvider, PaymentMethodsProvider, QuotationProvider>(
create: (_) => QuotationProvider(),
update: (_, organization, payment, wallet, flow, methods, provider) => provider!..update(organization, payment, wallet, flow, methods),
),
ChangeNotifierProxyProvider2<OrganizationsProvider, QuotationProvider, PaymentProvider>(
create: (_) => PaymentProvider(),
update: (_, organization, quotation, provider) => provider!..update(organization, quotation),
),
],
child: Builder(
builder: (innerContext) => PaymentPageBody(
onBack: widget.onBack,
fallbackDestination: widget.fallbackDestination,
recipient: recipient,
recipientProvider: recipientProvider,
methodsProvider: methodsProvider,
availablePaymentTypes: availableTypes,
searchController: _searchController,
searchFocusNode: _searchFocusNode,
onSearchChanged: _handleSearchChanged,
onRecipientSelected: (selected) => _handleRecipientSelected(innerContext, selected),
onRecipientCleared: () => _handleRecipientCleared(innerContext),
onSend: () => _handleSendPayment(innerContext),
),
return ChangeNotifierProxyProvider2<RecipientsProvider, PaymentMethodsProvider, PaymentFlowProvider>(
create: (_) => PaymentFlowProvider(
initialType: widget.initialPaymentType ?? PaymentType.bankAccount,
),
update: (_, recipients, methods, flow) {
final provider = flow ?? PaymentFlowProvider(
initialType: widget.initialPaymentType ?? PaymentType.bankAccount,
);
final currentRecipient = recipients.currentObject;
provider.syncWith(
recipient: currentRecipient,
methodsProvider: methods,
preferredType: currentRecipient != null ? widget.initialPaymentType : null,
);
return provider;
},
child: PaymentPageBody(
onBack: widget.onBack,
fallbackDestination: widget.fallbackDestination,
recipient: recipient,
recipientProvider: recipientProvider,
methodsProvider: methodsProvider,
searchController: _searchController,
searchFocusNode: _searchFocusNode,
onSearchChanged: _handleSearchChanged,
onRecipientSelected: _handleRecipientSelected,
onRecipientCleared: _handleRecipientCleared,
onSend: _handleSendPayment,
),
);
}
@@ -166,21 +151,6 @@ class _PaymentPageState extends State<PaymentPage> {
}
}
MethodMap _availablePaymentTypes(
Recipient? recipient,
PaymentMethodsProvider methodsProvider,
) {
if (recipient == null || !methodsProvider.isReady) return {};
final methodsForRecipient = methodsProvider.methods.where(
(method) => !method.isArchived && method.recipientRef == recipient.id,
);
return {
for (final method in methodsForRecipient) method.type: method.data,
};
}
PaymentMethod? _getPaymentMethodForWallet(
Wallet wallet,
PaymentMethodsProvider methodsProvider,

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart';
@@ -17,7 +16,6 @@ class PaymentPageBody extends StatelessWidget {
final Recipient? recipient;
final RecipientsProvider recipientProvider;
final PaymentMethodsProvider methodsProvider;
final MethodMap availablePaymentTypes;
final PayoutDestination fallbackDestination;
final TextEditingController searchController;
final FocusNode searchFocusNode;
@@ -32,7 +30,6 @@ class PaymentPageBody extends StatelessWidget {
required this.recipient,
required this.recipientProvider,
required this.methodsProvider,
required this.availablePaymentTypes,
required this.fallbackDestination,
required this.searchController,
required this.searchFocusNode,
@@ -61,7 +58,6 @@ class PaymentPageBody extends StatelessWidget {
recipient: recipient,
recipientProvider: recipientProvider,
methodsProvider: methodsProvider,
availablePaymentTypes: availablePaymentTypes,
fallbackDestination: fallbackDestination,
searchController: searchController,
searchFocusNode: searchFocusNode,

View File

@@ -1,12 +1,8 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pweb/pages/payment_methods/payment_page/back_button.dart';
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
@@ -27,7 +23,6 @@ class PaymentPageContent extends StatelessWidget {
final Recipient? recipient;
final RecipientsProvider recipientProvider;
final PaymentMethodsProvider methodsProvider;
final MethodMap availablePaymentTypes;
final PayoutDestination fallbackDestination;
final TextEditingController searchController;
final FocusNode searchFocusNode;
@@ -42,7 +37,6 @@ class PaymentPageContent extends StatelessWidget {
required this.recipient,
required this.recipientProvider,
required this.methodsProvider,
required this.availablePaymentTypes,
required this.fallbackDestination,
required this.searchController,
required this.searchFocusNode,
@@ -55,7 +49,6 @@ class PaymentPageContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
final dimensions = AppDimensions();
final flowProvider = context.watch<PaymentFlowProvider>();
final loc = AppLocalizations.of(context)!;
return Align(
@@ -98,12 +91,7 @@ class PaymentPageContent extends StatelessWidget {
onRecipientCleared: onRecipientCleared,
),
SizedBox(height: dimensions.paddingXLarge),
PaymentInfoSection(
dimensions: dimensions,
flowProvider: flowProvider,
recipient: recipient,
availableTypes: availablePaymentTypes,
),
PaymentInfoSection(dimensions: dimensions),
SizedBox(height: dimensions.paddingLarge),
const PaymentFormWidget(),
SizedBox(height: dimensions.paddingXXXLarge),

View File

@@ -1,14 +1,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pweb/pages/dashboard/payouts/form.dart';
import 'package:pweb/pages/dashboard/payouts/widget.dart';
import 'package:pweb/pages/payment_methods/payment_page/back_button.dart';
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart';
@@ -27,7 +23,6 @@ class PaymentPageContent extends StatelessWidget {
final Recipient? recipient;
final RecipientsProvider recipientProvider;
final PaymentMethodsProvider methodsProvider;
final MethodMap availablePaymentTypes;
final PayoutDestination fallbackDestination;
final TextEditingController searchController;
final FocusNode searchFocusNode;
@@ -42,7 +37,6 @@ class PaymentPageContent extends StatelessWidget {
required this.recipient,
required this.recipientProvider,
required this.methodsProvider,
required this.availablePaymentTypes,
required this.fallbackDestination,
required this.searchController,
required this.searchFocusNode,
@@ -55,7 +49,6 @@ class PaymentPageContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
final dimensions = AppDimensions();
final flowProvider = context.watch<PaymentFlowProvider>();
final loc = AppLocalizations.of(context)!;
return Align(
@@ -98,14 +91,9 @@ class PaymentPageContent extends StatelessWidget {
onRecipientCleared: onRecipientCleared,
),
SizedBox(height: dimensions.paddingXLarge),
PaymentInfoSection(
dimensions: dimensions,
flowProvider: flowProvider,
recipient: recipient,
availableTypes: availablePaymentTypes,
),
PaymentInfoSection(dimensions: dimensions),
SizedBox(height: dimensions.paddingLarge),
const PaymentFormWidget(),
const PaymentFromWrappingWidget(),
SizedBox(height: dimensions.paddingXXXLarge),
SendButton(onPressed: onSend),
SizedBox(height: dimensions.paddingLarge),

View File

@@ -1,8 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pweb/pages/payment_methods/form.dart';
@@ -15,25 +14,18 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentInfoSection extends StatelessWidget {
final AppDimensions dimensions;
final MethodMap availableTypes;
final PaymentFlowProvider flowProvider;
final Recipient? recipient;
const PaymentInfoSection({
super.key,
required this.dimensions,
required this.availableTypes,
required this.flowProvider,
required this.recipient,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final hasRecipient = recipient != null;
final MethodMap resolvedAvailableTypes = hasRecipient
? availableTypes
: {for (final type in PaymentType.values) type: null};
final flowProvider = context.watch<PaymentFlowProvider>();
final hasRecipient = flowProvider.hasRecipient;
final MethodMap resolvedAvailableTypes = flowProvider.availableTypes;
if (hasRecipient && resolvedAvailableTypes.isEmpty) {
return Text(loc.recipientNoPaymentDetails);
@@ -62,7 +54,7 @@ class PaymentInfoSection extends StatelessWidget {
flowProvider.setManualPaymentData(data);
}
},
initialData: hasRecipient ? resolvedAvailableTypes[selectedType] : flowProvider.manualPaymentData,
initialData: flowProvider.selectedPaymentData,
isEditable: !hasRecipient,
),
],

View File

@@ -0,0 +1,210 @@
import 'package:amplitude_flutter/amplitude.dart';
import 'package:amplitude_flutter/configuration.dart';
import 'package:amplitude_flutter/constants.dart' as amp;
import 'package:amplitude_flutter/events/base_event.dart';
import 'package:flutter/widgets.dart';
import 'package:pshared/models/account/account.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/status.dart';
import 'package:pshared/models/recipient/type.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
class AmplitudeService {
static late Amplitude _analytics;
static Amplitude _amp() => _analytics;
static Future<void> initialize() async {
_analytics = Amplitude(Configuration(
apiKey: '12345', //TODO define through App Contants
serverZone: amp.ServerZone.eu, //TODO define through App Contants
));
await _analytics.isBuilt;
}
static Future<void> identify(Account account) async =>
_amp().setUserId(account.id);
static Future<void> login(Account account) async =>
_logEvent(
'login',
userProperties: {
// 'email': account.email, TODO Add email into account
'locale': account.locale,
},
);
static Future<void> logout() async => _logEvent("logout");
static Future<void> pageOpened(PayoutDestination page, {String? path, String? uiSource}) async {
return _logEvent("pageOpened", eventProperties: {
"page": page,
if (path != null) "path": path,
if (uiSource != null) "uiSource": uiSource,
});
}
//TODO Add when registration is ready. User properties {user_id, registration_date, has_wallet (true/false), wallet_balance (should concider loggin it as: 0 / <100 / 100500 / 500+), preferred_method (Wallet/Card/Bank/IBAN), total_transactions, total_amount, last_payout_date, last_login_date , marketing_source}
// static Future<void> registrationStarted(String method, String country) async =>
// _logEvent("registrationStarted", eventProperties: {"method": method, "country": country});
// static Future<void> registrationCompleted(String method, String country) async =>
// _logEvent("registrationCompleted", eventProperties: {"method": method, "country": country});
static Future<void> pageNotFound(String url) async =>
_logEvent("pageNotFound", eventProperties: {"url": url});
static Future<void> localeChanged(Locale locale) async =>
_logEvent("localeChanged", eventProperties: {"locale": locale.toString()});
static Future<void> localeMatched(String locale, bool haveRequested) async => //DO we need it?
_logEvent("localeMatched", eventProperties: {
"locale": locale,
"have_requested_locale": haveRequested
});
static Future<void> recipientAddStarted() async =>
_logEvent("recipientAddStarted");
static Future<void> recipientAddCompleted(
RecipientType type,
RecipientStatus status,
Set<PaymentType> methods,
) async {
_logEvent(
"recipientAddCompleted",
eventProperties: {
"methods": methods.map((m) => m.name).toList(),
"type": type.name,
"status": status.name,
},
);
}
static Future<void> _paymentEvent(
String evt,
double amount,
double fee,
bool payerCoversFee,
PaymentType source,
PaymentType recpientPaymentMethod, {
String? message,
String? errorType,
Map<String, dynamic>? extraProps,
}) async {
final props = {
"amount": amount,
"fee": fee,
"feeCoveredBy": payerCoversFee ? 'payer' : 'recipient',
"source": source,
"recipient_method": recpientPaymentMethod,
if (message != null) "message": message,
if (errorType != null) "error_type": errorType,
if (extraProps != null) ...extraProps,
};
return _logEvent(evt, eventProperties: props);
}
static Future<void> paymentPrepared(double amount, double fee,
bool payerCoversFee, PaymentType source, PaymentType recpientPaymentMethod) async =>
_paymentEvent("paymentPrepared", amount, fee, payerCoversFee, source, recpientPaymentMethod);
//TODO Rework paymentStarted (do i need all those properties or is the event enough? Mb properties should be passed at paymentPrepared)
static Future<void> paymentStarted(double amount, double fee,
bool payerCoversFee, PaymentType source, PaymentType recpientPaymentMethod) async =>
_paymentEvent("paymentStarted", amount, fee, payerCoversFee, source, recpientPaymentMethod);
static Future<void> paymentFailed(double amount, double fee, bool payerCoversFee,
PaymentType source, PaymentType recpientPaymentMethod, String errorType, String message) async =>
_paymentEvent("paymentFailed", amount, fee, payerCoversFee, source, recpientPaymentMethod,
errorType: errorType, message: message);
static Future<void> paymentError(double amount, double fee, bool payerCoversFee,
PaymentType source,PaymentType recpientPaymentMethod, String message) async =>
_paymentEvent("paymentError", amount, fee, payerCoversFee, source, recpientPaymentMethod,
message: message);
static Future<void> paymentSuccess({
required double amount,
required double fee,
required bool payerCoversFee,
required PaymentType source,
required PaymentType recpientPaymentMethod,
required String transactionId,
String? comment,
required int durationMs,
}) async {
return _paymentEvent(
"paymentSuccess",
amount,
fee,
payerCoversFee,
source,
recpientPaymentMethod,
message: comment,
extraProps: {
"transaction_id": transactionId,
"duration_ms": durationMs, //How do i calculate duration here?
"\$revenue": amount, //How do i calculate revenue here?
"\$revenueType": "payment", //Do we need to get revenue type?
},
);
}
//TODO add when support is ready
// static Future<void> supportOpened(String fromPage, String trigger) async =>
// _logEvent("supportOpened", eventProperties: {"from_page": fromPage, "trigger": trigger});
// static Future<void> supportMessageSent(String category, bool resolved) async =>
// _logEvent("supportMessageSent", eventProperties: {"category": category, "resolved": resolved});
static Future<void> walletTopUp(double amount, PaymentType method) async =>
_logEvent("walletTopUp", eventProperties: {"amount": amount, "method": method});
//TODO Decide do we need uiElementClicked or pageOpened is enough?
static Future<void> uiElementClicked(String elementName, String page, String uiSource) async =>
_logEvent("uiElementClicked", eventProperties: {
"element_name": elementName,
"page": page,
"uiSource": uiSource
});
static final Map<String, int> _stepStartTimes = {};
//TODO Consider it as part of payment flow or registration flow or adding recipient and rework accordingly
static Future<void> stepStarted(String stepName, {String? context}) async {
_stepStartTimes[stepName] = DateTime.now().millisecondsSinceEpoch;
return _logEvent("stepStarted", eventProperties: {
"step_name": stepName,
if (context != null) "context": context,
});
}
static Future<void> stepCompleted(String stepName, bool success) async {
final now = DateTime.now().millisecondsSinceEpoch;
final start = _stepStartTimes[stepName] ?? now;
final duration = now - start;
return _logEvent("stepCompleted", eventProperties: {
"step_name": stepName,
"duration_ms": duration,
"success": success
});
}
static Future<void> _logEvent(
String eventType, {
Map<String, dynamic>? eventProperties,
Map<String, dynamic>? userProperties,
}) async {
final event = BaseEvent(
eventType,
eventProperties: eventProperties,
userProperties: userProperties,
);
_amp().track(event);
print(event.toString()); //TODO delete when everything is ready
}
}