Compare commits
1 Commits
34a565d86d
...
SEND017
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ddd7718c2 |
@@ -17,21 +17,21 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
|
|||||||
managedRef := strings.TrimSpace(dest.GetManagedWalletRef())
|
managedRef := strings.TrimSpace(dest.GetManagedWalletRef())
|
||||||
external := strings.TrimSpace(dest.GetExternalAddress())
|
external := strings.TrimSpace(dest.GetExternalAddress())
|
||||||
if managedRef != "" && external != "" {
|
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")
|
return model.TransferDestination{}, merrors.InvalidArgument("destination must be managed_wallet_ref or external_address")
|
||||||
}
|
}
|
||||||
if managedRef != "" {
|
if managedRef != "" {
|
||||||
wallet, err := deps.Storage.Wallets().Get(ctx, managedRef)
|
wallet, err := deps.Storage.Wallets().Get(ctx, managedRef)
|
||||||
if err != nil {
|
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
|
return model.TransferDestination{}, err
|
||||||
}
|
}
|
||||||
if !strings.EqualFold(wallet.Network, source.Network) {
|
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")
|
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
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{}, merrors.InvalidArgument("destination wallet missing deposit address")
|
||||||
}
|
}
|
||||||
return model.TransferDestination{
|
return model.TransferDestination{
|
||||||
@@ -40,21 +40,21 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
if external == "" {
|
if external == "" {
|
||||||
deps.Logger.Warn("Destination external address missing")
|
deps.Logger.Warn("destination external address missing")
|
||||||
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
||||||
}
|
}
|
||||||
if deps.Drivers == nil {
|
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")
|
return model.TransferDestination{}, merrors.Internal("chain drivers not configured")
|
||||||
}
|
}
|
||||||
chainDriver, err := deps.Drivers.Driver(source.Network)
|
chainDriver, err := deps.Drivers.Driver(source.Network)
|
||||||
if err != nil {
|
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")
|
return model.TransferDestination{}, merrors.InvalidArgument("unsupported chain for wallet")
|
||||||
}
|
}
|
||||||
normalized, err := chainDriver.NormalizeAddress(external)
|
normalized, err := chainDriver.NormalizeAddress(external)
|
||||||
if err != nil {
|
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{}, err
|
||||||
}
|
}
|
||||||
return model.TransferDestination{
|
return model.TransferDestination{
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
"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/api/routers/gsresponse"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"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] {
|
func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) gsresponse.Responder[chainv1.EstimateTransferFeeResponse] {
|
||||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
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)
|
return gsresponse.Unavailable[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
if req == nil {
|
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"))
|
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)
|
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
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)
|
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)
|
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||||
networkCfg, ok := c.deps.Networks.Network(networkKey)
|
networkCfg, ok := c.deps.Networks.Network(networkKey)
|
||||||
if !ok {
|
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"))
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||||
}
|
}
|
||||||
if c.deps.Drivers == nil {
|
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"))
|
return gsresponse.Internal[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
|
||||||
}
|
}
|
||||||
chainDriver, err := c.deps.Drivers.Driver(networkKey)
|
chainDriver, err := c.deps.Drivers.Driver(networkKey)
|
||||||
if err != nil {
|
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"))
|
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)
|
dest, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
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)
|
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)
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destinationAddress, err := destinationAddress(ctx, c.deps, chainDriver, dest)
|
destinationAddress, err := destinationAddress(ctx, c.deps, chainDriver, dest)
|
||||||
if err != nil {
|
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)
|
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 = ©Wallet
|
|
||||||
}
|
|
||||||
|
|
||||||
driverDeps := driver.Deps{
|
driverDeps := driver.Deps{
|
||||||
Logger: c.deps.Logger,
|
Logger: c.deps.Logger,
|
||||||
Registry: c.deps.Networks,
|
Registry: c.deps.Networks,
|
||||||
RPCTimeout: c.deps.RPCTimeout,
|
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 {
|
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)
|
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
contextLabel := "erc20_transfer"
|
contextLabel := "erc20_transfer"
|
||||||
if strings.TrimSpace(walletForFee.ContractAddress) == "" {
|
if strings.TrimSpace(sourceWallet.ContractAddress) == "" {
|
||||||
contextLabel = "native_transfer"
|
contextLabel = "native_transfer"
|
||||||
}
|
}
|
||||||
resp := &chainv1.EstimateTransferFeeResponse{
|
resp := &chainv1.EstimateTransferFeeResponse{
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func NewSubmitTransfer(deps Deps) *submitTransferCommand {
|
|||||||
|
|
||||||
func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.SubmitTransferRequest) gsresponse.Responder[chainv1.SubmitTransferResponse] {
|
func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.SubmitTransferRequest) gsresponse.Responder[chainv1.SubmitTransferResponse] {
|
||||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
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)
|
return gsresponse.Unavailable[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
if req == nil {
|
if req == nil {
|
||||||
@@ -35,92 +35,84 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
|
|||||||
|
|
||||||
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||||
if idempotencyKey == "" {
|
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"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||||
}
|
}
|
||||||
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||||
if organizationRef == "" {
|
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"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||||
}
|
}
|
||||||
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
||||||
if sourceWalletRef == "" {
|
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"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
|
||||||
}
|
}
|
||||||
amount := req.GetAmount()
|
amount := req.GetAmount()
|
||||||
if amount == nil {
|
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"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
|
||||||
}
|
}
|
||||||
amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
|
amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
|
||||||
if amountCurrency == "" {
|
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"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.currency is required"))
|
||||||
}
|
}
|
||||||
amountValue := strings.TrimSpace(amount.GetAmount())
|
amountValue := strings.TrimSpace(amount.GetAmount())
|
||||||
if amountValue == "" {
|
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"))
|
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)
|
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
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)
|
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)
|
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
if !strings.EqualFold(sourceWallet.OrganizationRef, organizationRef) {
|
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"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
|
||||||
}
|
}
|
||||||
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||||
networkCfg, ok := c.deps.Networks.Network(networkKey)
|
networkCfg, ok := c.deps.Networks.Network(networkKey)
|
||||||
if !ok {
|
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"))
|
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)
|
destination, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
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)
|
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)
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fees, feeSum, err := convertFees(req.GetFees(), amountCurrency)
|
fees, feeSum, err := convertFees(req.GetFees(), amountCurrency)
|
||||||
if err != nil {
|
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)
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
amountDec, err := decimal.NewFromString(amountValue)
|
amountDec, err := decimal.NewFromString(amountValue)
|
||||||
if err != nil {
|
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"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("invalid amount"))
|
||||||
}
|
}
|
||||||
netDec := amountDec.Sub(feeSum)
|
netDec := amountDec.Sub(feeSum)
|
||||||
if netDec.IsNegative() {
|
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"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("fees exceed amount"))
|
||||||
}
|
}
|
||||||
|
|
||||||
netAmount := shared.CloneMoney(amount)
|
netAmount := shared.CloneMoney(amount)
|
||||||
netAmount.Amount = netDec.String()
|
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{
|
transfer := &model.Transfer{
|
||||||
IdempotencyKey: idempotencyKey,
|
IdempotencyKey: idempotencyKey,
|
||||||
TransferRef: shared.GenerateTransferRef(),
|
TransferRef: shared.GenerateTransferRef(),
|
||||||
@@ -128,8 +120,8 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
|
|||||||
SourceWalletRef: sourceWalletRef,
|
SourceWalletRef: sourceWalletRef,
|
||||||
Destination: destination,
|
Destination: destination,
|
||||||
Network: sourceWallet.Network,
|
Network: sourceWallet.Network,
|
||||||
TokenSymbol: effectiveTokenSymbol,
|
TokenSymbol: sourceWallet.TokenSymbol,
|
||||||
ContractAddress: effectiveContractAddress,
|
ContractAddress: sourceWallet.ContractAddress,
|
||||||
RequestedAmount: shared.CloneMoney(amount),
|
RequestedAmount: shared.CloneMoney(amount),
|
||||||
NetAmount: netAmount,
|
NetAmount: netAmount,
|
||||||
Fees: fees,
|
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)
|
saved, err := c.deps.Storage.Transfers().Create(ctx, transfer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
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)})
|
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)
|
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
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("wallet_ref", wallet.WalletRef),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
zap.String("destination", destination),
|
zap.String("destination", destination),
|
||||||
@@ -104,13 +104,13 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
|
|||||||
driverDeps.Logger = d.logger
|
driverDeps.Logger = d.logger
|
||||||
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
|
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
d.logger.Warn("Estimate fee failed",
|
d.logger.Warn("estimate fee failed",
|
||||||
zap.String("wallet_ref", wallet.WalletRef),
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
} else if result != nil {
|
} else if result != nil {
|
||||||
d.logger.Debug("Estimate fee result",
|
d.logger.Debug("estimate fee result",
|
||||||
zap.String("wallet_ref", wallet.WalletRef),
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
zap.String("amount", result.Amount),
|
zap.String("amount", result.Amount),
|
||||||
|
|||||||
@@ -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) {
|
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("wallet_ref", wallet.WalletRef),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
zap.String("destination", destination),
|
zap.String("destination", destination),
|
||||||
@@ -104,13 +104,13 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
|
|||||||
driverDeps.Logger = d.logger
|
driverDeps.Logger = d.logger
|
||||||
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
|
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
d.logger.Warn("Estimate fee failed",
|
d.logger.Warn("estimate fee failed",
|
||||||
zap.String("wallet_ref", wallet.WalletRef),
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
} else if result != nil {
|
} else if result != nil {
|
||||||
d.logger.Debug("Estimate fee result",
|
d.logger.Debug("estimate fee result",
|
||||||
zap.String("wallet_ref", wallet.WalletRef),
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
zap.String("amount", result.Amount),
|
zap.String("amount", result.Amount),
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"github.com/ethereum/go-ethereum"
|
"github.com/ethereum/go-ethereum"
|
||||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"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/core/types"
|
||||||
"github.com/ethereum/go-ethereum/rpc"
|
"github.com/ethereum/go-ethereum/rpc"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
@@ -96,7 +95,7 @@ func parseBaseUnitAmount(amount string) (*big.Int, error) {
|
|||||||
|
|
||||||
// Balance fetches ERC20 token balance for the provided address.
|
// 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) {
|
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
|
registry := deps.Registry
|
||||||
|
|
||||||
if registry == nil {
|
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.
|
// 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) {
|
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
|
registry := deps.Registry
|
||||||
|
|
||||||
if registry == nil {
|
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.
|
// 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) {
|
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
|
registry := deps.Registry
|
||||||
|
|
||||||
if registry == nil {
|
if registry == nil {
|
||||||
@@ -260,12 +259,10 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
|
|||||||
|
|
||||||
client, err := registry.Client(network.Name)
|
client, err := registry.Client(network.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("Failed to resolve client", zap.Error(err), zap.String("network_name", network.Name))
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rpcClient, err := registry.RPCClient(network.Name)
|
rpcClient, err := registry.RPCClient(network.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("Failed to resolve RPC client", zap.Error(err), zap.String("network_name", network.Name))
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,12 +280,10 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
|
|||||||
if contract == "" {
|
if contract == "" {
|
||||||
amountBase, err := parseBaseUnitAmount(amount.GetAmount())
|
amountBase, err := parseBaseUnitAmount(amount.GetAmount())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("Failed to parse base unit amount", zap.Error(err), zap.String("amount", amount.GetAmount()))
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
|
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("Failed to suggest gas price", zap.Error(err))
|
|
||||||
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
|
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
|
||||||
}
|
}
|
||||||
callMsg := ethereum.CallMsg{
|
callMsg := ethereum.CallMsg{
|
||||||
@@ -297,9 +292,8 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
|
|||||||
GasPrice: gasPrice,
|
GasPrice: gasPrice,
|
||||||
Value: amountBase,
|
Value: amountBase,
|
||||||
}
|
}
|
||||||
gasLimit, err := estimateGas(timeoutCtx, network, client, rpcClient, callMsg)
|
gasLimit, err := client.EstimateGas(timeoutCtx, callMsg)
|
||||||
if err != nil {
|
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())
|
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
|
||||||
}
|
}
|
||||||
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
|
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
|
}, nil
|
||||||
}
|
}
|
||||||
if !common.IsHexAddress(contract) {
|
if !common.IsHexAddress(contract) {
|
||||||
logger.Warn("Failed to validate contract", zap.String("contract", contract))
|
|
||||||
return nil, merrors.InvalidArgument("invalid token contract address")
|
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)
|
input, err := erc20ABI.Pack("transfer", toAddr, amountBase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("Failed to encode transfer call", zap.Error(err))
|
|
||||||
return nil, merrors.Internal("failed to encode transfer call: " + err.Error())
|
return nil, merrors.Internal("failed to encode transfer call: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
|
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("Failed to suggest gas price", zap.Error(err))
|
|
||||||
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
|
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,
|
GasPrice: gasPrice,
|
||||||
Data: input,
|
Data: input,
|
||||||
}
|
}
|
||||||
gasLimit, err := estimateGas(timeoutCtx, network, client, rpcClient, callMsg)
|
gasLimit, err := client.EstimateGas(timeoutCtx, callMsg)
|
||||||
if err != nil {
|
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())
|
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.
|
// 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) {
|
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
|
registry := deps.Registry
|
||||||
|
|
||||||
if deps.KeyManager == nil {
|
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)
|
return "", executorInvalid("invalid destination address " + destination)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("Submitting transfer",
|
logger.Info("submitting transfer",
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("source_wallet_ref", source.WalletRef),
|
zap.String("source_wallet_ref", source.WalletRef),
|
||||||
zap.String("network", network.Name),
|
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)
|
client, err := registry.Client(network.Name)
|
||||||
if err != nil {
|
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
|
return "", err
|
||||||
}
|
}
|
||||||
rpcClient, err := registry.RPCClient(network.Name)
|
rpcClient, err := registry.RPCClient(network.Name)
|
||||||
if err != nil {
|
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
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +448,7 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
|
|||||||
GasPrice: gasPrice,
|
GasPrice: gasPrice,
|
||||||
Value: amountInt,
|
Value: amountInt,
|
||||||
}
|
}
|
||||||
gasLimit, err := estimateGas(ctx, network, client, rpcClient, callMsg)
|
gasLimit, err := client.EstimateGas(ctx, callMsg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("Failed to estimate gas", zap.Error(err),
|
logger.Warn("Failed to estimate gas", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
@@ -508,7 +498,7 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
|
|||||||
GasPrice: gasPrice,
|
GasPrice: gasPrice,
|
||||||
Data: input,
|
Data: input,
|
||||||
}
|
}
|
||||||
gasLimit, err := estimateGas(ctx, network, client, rpcClient, callMsg)
|
gasLimit, err := client.EstimateGas(ctx, callMsg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("Failed to estimate gas", zap.Error(err),
|
logger.Warn("Failed to estimate gas", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
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.
|
// AwaitConfirmation waits for the transaction receipt.
|
||||||
func AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
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
|
registry := deps.Registry
|
||||||
|
|
||||||
if strings.TrimSpace(txHash) == "" {
|
if strings.TrimSpace(txHash) == "" {
|
||||||
@@ -662,63 +652,6 @@ func erc20Decimals(ctx context.Context, client *rpc.Client, token common.Address
|
|||||||
return val, nil
|
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) {
|
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
||||||
value, err := decimal.NewFromString(strings.TrimSpace(amount))
|
value, err := decimal.NewFromString(strings.TrimSpace(amount))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package tron
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum/core/types"
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
"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 {
|
if wallet == nil {
|
||||||
return nil, merrors.InvalidArgument("wallet is required")
|
return nil, merrors.InvalidArgument("wallet is required")
|
||||||
}
|
}
|
||||||
if amount == nil {
|
|
||||||
return nil, merrors.InvalidArgument("amount is required")
|
|
||||||
}
|
|
||||||
d.logger.Debug("Estimate fee request",
|
d.logger.Debug("Estimate fee request",
|
||||||
zap.String("wallet_ref", wallet.WalletRef),
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
@@ -138,12 +134,6 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
|
|||||||
)
|
)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if rpcFrom == rpcTo {
|
|
||||||
return &moneyv1.Money{
|
|
||||||
Currency: nativeCurrency(network),
|
|
||||||
Amount: "0",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
driverDeps := deps
|
driverDeps := deps
|
||||||
driverDeps.Logger = d.logger
|
driverDeps.Logger = d.logger
|
||||||
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, rpcFrom, rpcTo, amount)
|
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),
|
d.logger.Warn("Estimate fee failed", zap.Error(err),
|
||||||
zap.String("wallet_ref", wallet.WalletRef),
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
zap.String("network", network.Name),
|
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 {
|
} else if result != nil {
|
||||||
d.logger.Debug("Estimate fee result",
|
d.logger.Debug("Estimate fee result",
|
||||||
@@ -234,12 +220,4 @@ func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, networ
|
|||||||
return receipt, err
|
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)
|
var _ driver.Driver = (*Driver)(nil)
|
||||||
|
|||||||
@@ -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())
|
|
||||||
}
|
|
||||||
@@ -81,12 +81,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
|
|
||||||
client, err := o.clients.Client(network.Name)
|
client, err := o.clients.Client(network.Name)
|
||||||
if err != nil {
|
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
|
return "", err
|
||||||
}
|
}
|
||||||
rpcClient, err := o.clients.RPCClient(network.Name)
|
rpcClient, err := o.clients.RPCClient(network.Name)
|
||||||
if err != nil {
|
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.String("network", network.Name),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
@@ -101,7 +101,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
|
|
||||||
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
||||||
if err != nil {
|
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("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("wallet_ref", source.WalletRef),
|
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)
|
gasPrice, err := client.SuggestGasPrice(ctx)
|
||||||
if err != nil {
|
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("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
@@ -124,12 +124,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
chainID := new(big.Int).SetUint64(network.ChainID)
|
chainID := new(big.Int).SetUint64(network.ChainID)
|
||||||
|
|
||||||
if strings.TrimSpace(transfer.ContractAddress) == "" {
|
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")
|
return "", merrors.NotImplemented("executor: native token transfers not yet supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !common.IsHexAddress(transfer.ContractAddress) {
|
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("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("contract", transfer.ContractAddress),
|
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)
|
decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress)
|
||||||
if err != nil {
|
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("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("contract", transfer.ContractAddress),
|
zap.String("contract", transfer.ContractAddress),
|
||||||
)
|
)
|
||||||
@@ -148,12 +148,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
|
|
||||||
amount := transfer.NetAmount
|
amount := transfer.NetAmount
|
||||||
if amount == nil || strings.TrimSpace(amount.Amount) == "" {
|
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")
|
return "", executorInvalid("transfer missing net amount")
|
||||||
}
|
}
|
||||||
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
||||||
if err != nil {
|
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("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("amount", amount.Amount),
|
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)
|
gasLimit, err := client.EstimateGas(ctx, callMsg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("Failed to estimate gas",
|
o.logger.Warn("failed to estimate gas",
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.Error(err),
|
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)
|
signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
||||||
if err != nil {
|
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("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("wallet_ref", source.WalletRef),
|
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 {
|
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),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
)
|
)
|
||||||
return "", executorInternal("failed to send transaction", err)
|
return "", executorInternal("failed to send transaction", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
txHash = signedTx.Hash().Hex()
|
txHash = signedTx.Hash().Hex()
|
||||||
o.logger.Info("Transaction submitted",
|
o.logger.Info("transaction submitted",
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("tx_hash", txHash),
|
zap.String("tx_hash", txHash),
|
||||||
zap.String("network", network.Name),
|
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) {
|
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||||
if strings.TrimSpace(txHash) == "" {
|
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")
|
return nil, executorInvalid("tx hash is required")
|
||||||
}
|
}
|
||||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||||
if 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")
|
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) {
|
if errors.Is(err, ethereum.NotFound) {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
o.logger.Debug("Transaction not yet mined",
|
o.logger.Debug("transaction not yet mined",
|
||||||
zap.String("tx_hash", txHash),
|
zap.String("tx_hash", txHash),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
case <-ctx.Done():
|
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("tx_hash", txHash),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
)
|
)
|
||||||
return nil, ctx.Err()
|
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("tx_hash", txHash),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
return nil, executorInternal("failed to fetch transaction receipt", 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("tx_hash", txHash),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package rpcclient
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -71,7 +70,7 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
|
|||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Close()
|
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()))
|
return nil, merrors.Internal(fmt.Sprintf("rpc dial failed for %s: %s", name, err.Error()))
|
||||||
}
|
}
|
||||||
client := ethclient.NewClient(rpcCli)
|
client := ethclient.NewClient(rpcCli)
|
||||||
@@ -79,7 +78,7 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
|
|||||||
eth: client,
|
eth: client,
|
||||||
rpc: rpcCli,
|
rpc: rpcCli,
|
||||||
}
|
}
|
||||||
clientLogger.Info("RPC client ready", fields...)
|
clientLogger.Info("rpc client ready", fields...)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.clients) == 0 {
|
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.
|
// Client returns a prepared client for the given network name.
|
||||||
func (c *Clients) Client(network string) (*ethclient.Client, error) {
|
func (c *Clients) Client(network string) (*ethclient.Client, error) {
|
||||||
if c == nil {
|
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))
|
name := strings.ToLower(strings.TrimSpace(network))
|
||||||
entry, ok := c.clients[name]
|
entry, ok := c.clients[name]
|
||||||
if !ok || entry.eth == nil {
|
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
|
return entry.eth, nil
|
||||||
}
|
}
|
||||||
@@ -130,7 +129,7 @@ func (c *Clients) Close() {
|
|||||||
entry.eth.Close()
|
entry.eth.Close()
|
||||||
}
|
}
|
||||||
if c.logger != nil {
|
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{
|
fields := []zap.Field{
|
||||||
zap.String("network", l.network),
|
zap.String("network", l.network),
|
||||||
|
zap.String("rpc_endpoint", l.endpoint),
|
||||||
}
|
}
|
||||||
if len(reqBody) > 0 {
|
if len(reqBody) > 0 {
|
||||||
fields = append(fields, zap.String("rpc_request", truncate(string(reqBody), 2048)))
|
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)
|
resp, err := l.base.RoundTrip(req)
|
||||||
if err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,19 +175,11 @@ func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
|
|||||||
respFields := append(fields,
|
respFields := append(fields,
|
||||||
zap.Int("status_code", resp.StatusCode),
|
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 {
|
if len(bodyBytes) > 0 {
|
||||||
respFields = append(respFields, zap.String("rpc_response", truncate(string(bodyBytes), 2048)))
|
respFields = append(respFields, zap.String("rpc_response", truncate(string(bodyBytes), 2048)))
|
||||||
}
|
}
|
||||||
l.logger.Debug("RPC response", respFields...)
|
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
l.logger.Warn("RPC response error", respFields...)
|
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
|
return resp, nil
|
||||||
|
|||||||
@@ -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.
|
// Network describes a supported blockchain network and known token contracts.
|
||||||
type Network struct {
|
type Network struct {
|
||||||
Name string
|
Name string
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, n
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil {
|
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)
|
}(transferRef, sourceWalletRef, network)
|
||||||
}
|
}
|
||||||
@@ -57,23 +57,6 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
|
|||||||
return err
|
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)
|
txHash, err := chainDriver.SubmitTransfer(ctx, driverDeps, network, transfer, sourceWallet, destinationAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||||
|
|||||||
@@ -99,49 +99,24 @@ func (w *Wallets) Create(ctx context.Context, wallet *model.ManagedWallet) (*mod
|
|||||||
if strings.TrimSpace(wallet.IdempotencyKey) == "" {
|
if strings.TrimSpace(wallet.IdempotencyKey) == "" {
|
||||||
return nil, merrors.InvalidArgument("walletsStore: empty 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 err := w.walletRepo.Insert(ctx, wallet, repository.Filter("idempotencyKey", wallet.IdempotencyKey)); err != nil {
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
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
|
return wallet, nil
|
||||||
}
|
}
|
||||||
w.logger.Warn("wallet create failed", append(fields, zap.Error(err))...)
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
w.logger.Debug("wallet created", fields...)
|
w.logger.Debug("wallet created", zap.String("wallet_ref", wallet.WalletRef))
|
||||||
return wallet, nil
|
return wallet, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Wallets) Get(ctx context.Context, walletID string) (*model.ManagedWallet, error) {
|
func (w *Wallets) Get(ctx context.Context, walletRef string) (*model.ManagedWallet, error) {
|
||||||
walletID = strings.TrimSpace(walletID)
|
walletRef = strings.TrimSpace(walletRef)
|
||||||
if walletID == "" {
|
if walletRef == "" {
|
||||||
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
|
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
|
||||||
}
|
}
|
||||||
fields := []zap.Field{
|
|
||||||
zap.String("wallet_id", walletID),
|
|
||||||
}
|
|
||||||
wallet := &model.ManagedWallet{}
|
wallet := &model.ManagedWallet{}
|
||||||
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), wallet); err != nil {
|
if err := w.walletRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), 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))...)
|
|
||||||
}
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return wallet, nil
|
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) {
|
func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error) {
|
||||||
query := repository.Query()
|
query := repository.Query()
|
||||||
fields := make([]zap.Field, 0, 6)
|
|
||||||
|
|
||||||
if org := strings.TrimSpace(filter.OrganizationRef); org != "" {
|
if org := strings.TrimSpace(filter.OrganizationRef); org != "" {
|
||||||
query = query.Filter(repository.Field("organizationRef"), org)
|
query = query.Filter(repository.Field("organizationRef"), org)
|
||||||
fields = append(fields, zap.String("organization_ref", org))
|
|
||||||
}
|
}
|
||||||
if owner := strings.TrimSpace(filter.OwnerRef); owner != "" {
|
if owner := strings.TrimSpace(filter.OwnerRef); owner != "" {
|
||||||
query = query.Filter(repository.Field("ownerRef"), owner)
|
query = query.Filter(repository.Field("ownerRef"), owner)
|
||||||
fields = append(fields, zap.String("owner_ref", owner))
|
|
||||||
}
|
}
|
||||||
if network := strings.TrimSpace(filter.Network); network != "" {
|
if network := strings.TrimSpace(filter.Network); network != "" {
|
||||||
normalized := strings.ToLower(network)
|
query = query.Filter(repository.Field("network"), strings.ToLower(network))
|
||||||
query = query.Filter(repository.Field("network"), normalized)
|
|
||||||
fields = append(fields, zap.String("network", normalized))
|
|
||||||
}
|
}
|
||||||
if token := strings.TrimSpace(filter.TokenSymbol); token != "" {
|
if token := strings.TrimSpace(filter.TokenSymbol); token != "" {
|
||||||
normalized := strings.ToUpper(token)
|
query = query.Filter(repository.Field("tokenSymbol"), strings.ToUpper(token))
|
||||||
query = query.Filter(repository.Field("tokenSymbol"), normalized)
|
|
||||||
fields = append(fields, zap.String("token_symbol", normalized))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
|
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
|
||||||
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
|
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
|
||||||
query = query.Comparison(repository.IDField(), builder.Gt, oid)
|
query = query.Comparison(repository.IDField(), builder.Gt, oid)
|
||||||
fields = append(fields, zap.String("cursor", cursor))
|
|
||||||
} else {
|
} else {
|
||||||
w.logger.Warn("ignoring invalid wallet cursor", zap.String("cursor", cursor), zap.Error(err))
|
w.logger.Warn("ignoring invalid wallet cursor", zap.String("cursor", cursor), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
limit := sanitizeWalletLimit(filter.Limit)
|
limit := sanitizeWalletLimit(filter.Limit)
|
||||||
fields = append(fields, zap.Int64("limit", limit))
|
|
||||||
fetchLimit := limit + 1
|
fetchLimit := limit + 1
|
||||||
query = query.Sort(repository.IDField(), true).Limit(&fetchLimit)
|
query = query.Sort(repository.IDField(), true).Limit(&fetchLimit)
|
||||||
|
|
||||||
@@ -194,10 +160,8 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
listErr := w.walletRepo.FindManyByFilter(ctx, query, decoder)
|
if err := w.walletRepo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||||
if listErr != nil && !errors.Is(listErr, merrors.ErrNoData) {
|
return nil, err
|
||||||
w.logger.Warn("wallet list failed", append(fields, zap.Error(listErr))...)
|
|
||||||
return nil, listErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nextCursor := ""
|
nextCursor := ""
|
||||||
@@ -207,21 +171,10 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*
|
|||||||
wallets = wallets[:len(wallets)-1]
|
wallets = wallets[:len(wallets)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &model.ManagedWalletList{
|
return &model.ManagedWalletList{
|
||||||
Items: wallets,
|
Items: wallets,
|
||||||
NextCursor: nextCursor,
|
NextCursor: nextCursor,
|
||||||
}
|
}, nil
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance) error {
|
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() {
|
if balance.CalculatedAt.IsZero() {
|
||||||
balance.CalculatedAt = time.Now().UTC()
|
balance.CalculatedAt = time.Now().UTC()
|
||||||
}
|
}
|
||||||
fields := []zap.Field{zap.String("wallet_ref", balance.WalletRef)}
|
|
||||||
|
|
||||||
existing := &model.WalletBalance{}
|
existing := &model.WalletBalance{}
|
||||||
err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", balance.WalletRef), existing)
|
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.PendingOutbound = balance.PendingOutbound
|
||||||
existing.CalculatedAt = balance.CalculatedAt
|
existing.CalculatedAt = balance.CalculatedAt
|
||||||
if err := w.balanceRepo.Update(ctx, existing); err != nil {
|
if err := w.balanceRepo.Update(ctx, existing); err != nil {
|
||||||
w.logger.Warn("wallet balance update failed", append(fields, zap.Error(err))...)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.logger.Debug("wallet balance updated", fields...)
|
|
||||||
return nil
|
return nil
|
||||||
case errors.Is(err, merrors.ErrNoData):
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
if err := w.balanceRepo.Insert(ctx, balance, repository.Filter("walletRef", balance.WalletRef)); err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
w.logger.Debug("wallet balance created", fields...)
|
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
w.logger.Warn("wallet balance lookup failed", append(fields, zap.Error(err))...)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Wallets) GetBalance(ctx context.Context, walletID string) (*model.WalletBalance, error) {
|
func (w *Wallets) GetBalance(ctx context.Context, walletRef string) (*model.WalletBalance, error) {
|
||||||
walletID = strings.TrimSpace(walletID)
|
walletRef = strings.TrimSpace(walletRef)
|
||||||
if walletID == "" {
|
if walletRef == "" {
|
||||||
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
|
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
|
||||||
}
|
}
|
||||||
fields := []zap.Field{zap.String("wallet_ref", walletID)}
|
|
||||||
balance := &model.WalletBalance{}
|
balance := &model.WalletBalance{}
|
||||||
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), balance); err != nil {
|
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), 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))...)
|
|
||||||
}
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
w.logger.Debug("wallet balance fetched", fields...)
|
|
||||||
return balance, nil
|
return balance, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ monetix:
|
|||||||
base_url_env: MONETIX_BASE_URL
|
base_url_env: MONETIX_BASE_URL
|
||||||
project_id_env: MONETIX_PROJECT_ID
|
project_id_env: MONETIX_PROJECT_ID
|
||||||
secret_key_env: MONETIX_SECRET_KEY
|
secret_key_env: MONETIX_SECRET_KEY
|
||||||
allowed_currencies: ["RUB"]
|
allowed_currencies: ["USD", "EUR"]
|
||||||
require_customer_address: false
|
require_customer_address: false
|
||||||
request_timeout_seconds: 15
|
request_timeout_seconds: 15
|
||||||
status_success: "success"
|
status_success: "success"
|
||||||
|
|||||||
@@ -95,49 +95,22 @@ func (i *Imp) Shutdown() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (i *Imp) Start() error {
|
func (i *Imp) Start() error {
|
||||||
i.logger.Info("Starting Monetix gateway", zap.String("config_file", i.file), zap.Bool("debug", i.debug))
|
|
||||||
|
|
||||||
cfg, err := i.loadConfig()
|
cfg, err := i.loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
i.config = cfg
|
i.config = cfg
|
||||||
|
|
||||||
i.logger.Info("Configuration loaded",
|
|
||||||
zap.String("grpc_address", cfg.GRPC.Address),
|
|
||||||
zap.String("metrics_address", cfg.Metrics.Address),
|
|
||||||
)
|
|
||||||
|
|
||||||
monetixCfg, err := i.resolveMonetixConfig(cfg.Monetix)
|
monetixCfg, err := i.resolveMonetixConfig(cfg.Monetix)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
i.logger.Error("Failed to resolve Monetix configuration", zap.Error(err))
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
callbackCfg, err := i.resolveCallbackConfig(cfg.HTTP.Callback)
|
callbackCfg, err := i.resolveCallbackConfig(cfg.HTTP.Callback)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
i.logger.Error("Failed to resolve callback configuration", zap.Error(err))
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
i.logger.Info("Monetix configuration resolved",
|
|
||||||
zap.Bool("base_url_set", strings.TrimSpace(monetixCfg.BaseURL) != ""),
|
|
||||||
zap.Int64("project_id", monetixCfg.ProjectID),
|
|
||||||
zap.Bool("secret_key_set", strings.TrimSpace(monetixCfg.SecretKey) != ""),
|
|
||||||
zap.Int("allowed_currencies", len(monetixCfg.AllowedCurrencies)),
|
|
||||||
zap.Bool("require_customer_address", monetixCfg.RequireCustomerAddress),
|
|
||||||
zap.Duration("request_timeout", monetixCfg.RequestTimeout),
|
|
||||||
zap.String("status_success", monetixCfg.SuccessStatus()),
|
|
||||||
zap.String("status_processing", monetixCfg.ProcessingStatus()),
|
|
||||||
)
|
|
||||||
|
|
||||||
i.logger.Info("Callback configuration resolved",
|
|
||||||
zap.String("address", callbackCfg.Address),
|
|
||||||
zap.String("path", callbackCfg.Path),
|
|
||||||
zap.Int("allowed_cidrs", len(callbackCfg.AllowedCIDRs)),
|
|
||||||
zap.Int64("max_body_bytes", callbackCfg.MaxBodyBytes),
|
|
||||||
)
|
|
||||||
|
|
||||||
serviceFactory := func(logger mlogger.Logger, _ struct{}, producer msg.Producer) (grpcapp.Service, error) {
|
serviceFactory := func(logger mlogger.Logger, _ struct{}, producer msg.Producer) (grpcapp.Service, error) {
|
||||||
svc := mntxservice.NewService(logger,
|
svc := mntxservice.NewService(logger,
|
||||||
mntxservice.WithProducer(producer),
|
mntxservice.WithProducer(producer),
|
||||||
@@ -164,7 +137,7 @@ func (i *Imp) Start() error {
|
|||||||
func (i *Imp) loadConfig() (*config, error) {
|
func (i *Imp) loadConfig() (*config, error) {
|
||||||
data, err := os.ReadFile(i.file)
|
data, err := os.ReadFile(i.file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
i.logger.Error("could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +145,7 @@ func (i *Imp) loadConfig() (*config, error) {
|
|||||||
Config: &grpcapp.Config{},
|
Config: &grpcapp.Config{},
|
||||||
}
|
}
|
||||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
i.logger.Error("failed to parse configuration", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +245,7 @@ func (i *Imp) resolveCallbackConfig(cfg callbackConfig) (callbackRuntimeConfig,
|
|||||||
}
|
}
|
||||||
_, block, err := net.ParseCIDR(clean)
|
_, block, err := net.ParseCIDR(clean)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
i.logger.Warn("Invalid callback allowlist CIDR skipped", zap.String("cidr", clean), zap.Error(err))
|
i.logger.Warn("invalid callback allowlist CIDR skipped", zap.String("cidr", clean), zap.Error(err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cidrs = append(cidrs, block)
|
cidrs = append(cidrs, block)
|
||||||
@@ -297,36 +270,20 @@ func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRunt
|
|||||||
|
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Post(cfg.Path, func(w http.ResponseWriter, r *http.Request) {
|
router.Post(cfg.Path, func(w http.ResponseWriter, r *http.Request) {
|
||||||
log := i.logger.Named("callback_http")
|
|
||||||
log.Debug("Callback request received",
|
|
||||||
zap.String("remote_addr", strings.TrimSpace(r.RemoteAddr)),
|
|
||||||
zap.String("path", r.URL.Path),
|
|
||||||
zap.String("method", r.Method),
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(cfg.AllowedCIDRs) > 0 && !clientAllowed(r, cfg.AllowedCIDRs) {
|
if len(cfg.AllowedCIDRs) > 0 && !clientAllowed(r, cfg.AllowedCIDRs) {
|
||||||
ip := clientIPFromRequest(r)
|
|
||||||
remoteIP := ""
|
|
||||||
if ip != nil {
|
|
||||||
remoteIP = ip.String()
|
|
||||||
}
|
|
||||||
log.Warn("Callback rejected by CIDR allowlist", zap.String("remote_ip", remoteIP))
|
|
||||||
http.Error(w, "forbidden", http.StatusForbidden)
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body, err := io.ReadAll(io.LimitReader(r.Body, cfg.MaxBodyBytes))
|
body, err := io.ReadAll(io.LimitReader(r.Body, cfg.MaxBodyBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Callback body read failed", zap.Error(err))
|
|
||||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
status, err := svc.ProcessMonetixCallback(r.Context(), body)
|
status, err := svc.ProcessMonetixCallback(r.Context(), body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Callback processing failed", zap.Error(err), zap.Int("status", status))
|
|
||||||
http.Error(w, err.Error(), status)
|
http.Error(w, err.Error(), status)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Debug("Callback processed", zap.Int("status", status))
|
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -344,7 +301,7 @@ func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRunt
|
|||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
i.logger.Warn("Monetix callback server stopped with error", zap.Error(err))
|
i.logger.Error("Monetix callback server stopped with error", zap.Error(err))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
package serverimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestClientIPFromRequest(t *testing.T) {
|
|
||||||
req := &http.Request{
|
|
||||||
Header: http.Header{"X-Forwarded-For": []string{"1.2.3.4, 5.6.7.8"}},
|
|
||||||
RemoteAddr: "9.8.7.6:1234",
|
|
||||||
}
|
|
||||||
ip := clientIPFromRequest(req)
|
|
||||||
if ip == nil || ip.String() != "1.2.3.4" {
|
|
||||||
t.Fatalf("expected forwarded ip, got %v", ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
req = &http.Request{RemoteAddr: "9.8.7.6:1234"}
|
|
||||||
ip = clientIPFromRequest(req)
|
|
||||||
if ip == nil || ip.String() != "9.8.7.6" {
|
|
||||||
t.Fatalf("expected remote addr ip, got %v", ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
req = &http.Request{RemoteAddr: "invalid"}
|
|
||||||
ip = clientIPFromRequest(req)
|
|
||||||
if ip != nil {
|
|
||||||
t.Fatalf("expected nil ip, got %v", ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientAllowed(t *testing.T) {
|
|
||||||
_, cidr, err := net.ParseCIDR("10.0.0.0/8")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to parse cidr: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
allowedReq := &http.Request{RemoteAddr: "10.1.2.3:1234"}
|
|
||||||
if !clientAllowed(allowedReq, []*net.IPNet{cidr}) {
|
|
||||||
t.Fatalf("expected allowed request")
|
|
||||||
}
|
|
||||||
|
|
||||||
deniedReq := &http.Request{RemoteAddr: "8.8.8.8:1234"}
|
|
||||||
if clientAllowed(deniedReq, []*net.IPNet{cidr}) {
|
|
||||||
t.Fatalf("expected denied request")
|
|
||||||
}
|
|
||||||
|
|
||||||
openReq := &http.Request{RemoteAddr: "8.8.8.8:1234"}
|
|
||||||
if !clientAllowed(openReq, nil) {
|
|
||||||
t.Fatalf("expected allow when no cidrs are configured")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
"go.uber.org/zap"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,12 +66,9 @@ type monetixCallback struct {
|
|||||||
|
|
||||||
// ProcessMonetixCallback ingests Monetix provider callbacks and updates payout state.
|
// ProcessMonetixCallback ingests Monetix provider callbacks and updates payout state.
|
||||||
func (s *Service) ProcessMonetixCallback(ctx context.Context, payload []byte) (int, error) {
|
func (s *Service) ProcessMonetixCallback(ctx context.Context, payload []byte) (int, error) {
|
||||||
log := s.logger.Named("callback")
|
|
||||||
if s.card == nil {
|
if s.card == nil {
|
||||||
log.Warn("Card payout processor not initialised")
|
|
||||||
return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised")
|
return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised")
|
||||||
}
|
}
|
||||||
log.Debug("Callback processing requested", zap.Int("payload_bytes", len(payload)))
|
|
||||||
return s.card.ProcessCallback(ctx, payload)
|
return s.card.ProcessCallback(ctx, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fixedClock struct {
|
|
||||||
now time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f fixedClock) Now() time.Time {
|
|
||||||
return f.now
|
|
||||||
}
|
|
||||||
|
|
||||||
func baseCallback() monetixCallback {
|
|
||||||
cb := monetixCallback{
|
|
||||||
ProjectID: 42,
|
|
||||||
}
|
|
||||||
cb.Payment.ID = "payout-1"
|
|
||||||
cb.Payment.Status = "success"
|
|
||||||
cb.Payment.Sum.Amount = 5000
|
|
||||||
cb.Payment.Sum.Currency = "usd"
|
|
||||||
cb.Customer.ID = "cust-1"
|
|
||||||
cb.Operation.Status = "success"
|
|
||||||
cb.Operation.Code = ""
|
|
||||||
cb.Operation.Message = "ok"
|
|
||||||
cb.Operation.RequestID = "req-1"
|
|
||||||
cb.Operation.Provider.PaymentID = "prov-1"
|
|
||||||
return cb
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMapCallbackToState_StatusMapping(t *testing.T) {
|
|
||||||
now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
||||||
cfg := monetix.DefaultConfig()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
paymentStatus string
|
|
||||||
operationStatus string
|
|
||||||
code string
|
|
||||||
expectedStatus mntxv1.PayoutStatus
|
|
||||||
expectedOutcome string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "success",
|
|
||||||
paymentStatus: "success",
|
|
||||||
operationStatus: "success",
|
|
||||||
code: "0",
|
|
||||||
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED,
|
|
||||||
expectedOutcome: monetix.OutcomeSuccess,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "processing",
|
|
||||||
paymentStatus: "processing",
|
|
||||||
operationStatus: "success",
|
|
||||||
code: "",
|
|
||||||
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
|
|
||||||
expectedOutcome: monetix.OutcomeProcessing,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "decline",
|
|
||||||
paymentStatus: "failed",
|
|
||||||
operationStatus: "failed",
|
|
||||||
code: "1",
|
|
||||||
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED,
|
|
||||||
expectedOutcome: monetix.OutcomeDecline,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
cb := baseCallback()
|
|
||||||
cb.Payment.Status = tc.paymentStatus
|
|
||||||
cb.Operation.Status = tc.operationStatus
|
|
||||||
cb.Operation.Code = tc.code
|
|
||||||
|
|
||||||
state, outcome := mapCallbackToState(fixedClock{now: now}, cfg, cb)
|
|
||||||
if state.Status != tc.expectedStatus {
|
|
||||||
t.Fatalf("expected status %v, got %v", tc.expectedStatus, state.Status)
|
|
||||||
}
|
|
||||||
if outcome != tc.expectedOutcome {
|
|
||||||
t.Fatalf("expected outcome %q, got %q", tc.expectedOutcome, outcome)
|
|
||||||
}
|
|
||||||
if state.Currency != "USD" {
|
|
||||||
t.Fatalf("expected currency USD, got %q", state.Currency)
|
|
||||||
}
|
|
||||||
if !state.UpdatedAt.AsTime().Equal(now) {
|
|
||||||
t.Fatalf("expected updated_at %v, got %v", now, state.UpdatedAt.AsTime())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFallbackProviderPaymentID(t *testing.T) {
|
|
||||||
cb := baseCallback()
|
|
||||||
if got := fallbackProviderPaymentID(cb); got != "prov-1" {
|
|
||||||
t.Fatalf("expected provider payment id, got %q", got)
|
|
||||||
}
|
|
||||||
cb.Operation.Provider.PaymentID = ""
|
|
||||||
if got := fallbackProviderPaymentID(cb); got != "req-1" {
|
|
||||||
t.Fatalf("expected request id fallback, got %q", got)
|
|
||||||
}
|
|
||||||
cb.Operation.RequestID = ""
|
|
||||||
if got := fallbackProviderPaymentID(cb); got != "payout-1" {
|
|
||||||
t.Fatalf("expected payment id fallback, got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerifyCallbackSignature(t *testing.T) {
|
|
||||||
secret := "secret"
|
|
||||||
cb := baseCallback()
|
|
||||||
|
|
||||||
sig, err := monetix.SignPayload(cb, secret)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to sign payload: %v", err)
|
|
||||||
}
|
|
||||||
cb.Signature = sig
|
|
||||||
if err := verifyCallbackSignature(cb, secret); err != nil {
|
|
||||||
t.Fatalf("expected valid signature, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cb.Signature = "invalid"
|
|
||||||
if err := verifyCallbackSignature(cb, secret); err == nil {
|
|
||||||
t.Fatalf("expected signature mismatch error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
"go.uber.org/zap"
|
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,24 +17,14 @@ func (s *Service) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleCreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) gsresponse.Responder[mntxv1.CardPayoutResponse] {
|
func (s *Service) handleCreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) gsresponse.Responder[mntxv1.CardPayoutResponse] {
|
||||||
log := s.logger.Named("card_payout")
|
|
||||||
log.Info("Create card payout request received",
|
|
||||||
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
|
|
||||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
|
||||||
zap.Int64("amount_minor", req.GetAmountMinor()),
|
|
||||||
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
|
|
||||||
)
|
|
||||||
if s.card == nil {
|
if s.card == nil {
|
||||||
log.Warn("Card payout processor not initialised")
|
|
||||||
return gsresponse.Internal[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
return gsresponse.Internal[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := s.card.Submit(ctx, req)
|
resp, err := s.card.Submit(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Card payout submission failed", zap.Error(err))
|
|
||||||
return gsresponse.Auto[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, err)
|
return gsresponse.Auto[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, err)
|
||||||
}
|
}
|
||||||
log.Info("Card payout submission completed", zap.String("payout_id", resp.GetPayout().GetPayoutId()), zap.Bool("accepted", resp.GetAccepted()))
|
|
||||||
return gsresponse.Success(resp)
|
return gsresponse.Success(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,24 +33,14 @@ func (s *Service) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTok
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleCreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) gsresponse.Responder[mntxv1.CardTokenPayoutResponse] {
|
func (s *Service) handleCreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) gsresponse.Responder[mntxv1.CardTokenPayoutResponse] {
|
||||||
log := s.logger.Named("card_token_payout")
|
|
||||||
log.Info("Create card token payout request received",
|
|
||||||
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
|
|
||||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
|
||||||
zap.Int64("amount_minor", req.GetAmountMinor()),
|
|
||||||
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
|
|
||||||
)
|
|
||||||
if s.card == nil {
|
if s.card == nil {
|
||||||
log.Warn("Card payout processor not initialised")
|
|
||||||
return gsresponse.Internal[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
return gsresponse.Internal[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := s.card.SubmitToken(ctx, req)
|
resp, err := s.card.SubmitToken(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Card token payout submission failed", zap.Error(err))
|
|
||||||
return gsresponse.Auto[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, err)
|
return gsresponse.Auto[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, err)
|
||||||
}
|
}
|
||||||
log.Info("Card token payout submission completed", zap.String("payout_id", resp.GetPayout().GetPayoutId()), zap.Bool("accepted", resp.GetAccepted()))
|
|
||||||
return gsresponse.Success(resp)
|
return gsresponse.Success(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,22 +49,14 @@ func (s *Service) CreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeR
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleCreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeRequest) gsresponse.Responder[mntxv1.CardTokenizeResponse] {
|
func (s *Service) handleCreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeRequest) gsresponse.Responder[mntxv1.CardTokenizeResponse] {
|
||||||
log := s.logger.Named("card_tokenize")
|
|
||||||
log.Info("Create card token request received",
|
|
||||||
zap.String("request_id", strings.TrimSpace(req.GetRequestId())),
|
|
||||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
|
||||||
)
|
|
||||||
if s.card == nil {
|
if s.card == nil {
|
||||||
log.Warn("Card payout processor not initialised")
|
|
||||||
return gsresponse.Internal[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
return gsresponse.Internal[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := s.card.Tokenize(ctx, req)
|
resp, err := s.card.Tokenize(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Card tokenization failed", zap.Error(err))
|
|
||||||
return gsresponse.Auto[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, err)
|
return gsresponse.Auto[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, err)
|
||||||
}
|
}
|
||||||
log.Info("Card tokenization completed", zap.String("request_id", resp.GetRequestId()), zap.Bool("success", resp.GetSuccess()))
|
|
||||||
return gsresponse.Success(resp)
|
return gsresponse.Success(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,19 +65,14 @@ func (s *Service) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPa
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleGetCardPayoutStatus(_ context.Context, req *mntxv1.GetCardPayoutStatusRequest) gsresponse.Responder[mntxv1.GetCardPayoutStatusResponse] {
|
func (s *Service) handleGetCardPayoutStatus(_ context.Context, req *mntxv1.GetCardPayoutStatusRequest) gsresponse.Responder[mntxv1.GetCardPayoutStatusResponse] {
|
||||||
log := s.logger.Named("card_payout_status")
|
|
||||||
log.Info("Get card payout status request received", zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())))
|
|
||||||
if s.card == nil {
|
if s.card == nil {
|
||||||
log.Warn("Card payout processor not initialised")
|
|
||||||
return gsresponse.Internal[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
return gsresponse.Internal[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||||
}
|
}
|
||||||
|
|
||||||
state, err := s.card.Status(context.Background(), req.GetPayoutId())
|
state, err := s.card.Status(context.Background(), req.GetPayoutId())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Card payout status lookup failed", zap.Error(err))
|
|
||||||
return gsresponse.Auto[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, err)
|
return gsresponse.Auto[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, err)
|
||||||
}
|
}
|
||||||
log.Info("Card payout status retrieved", zap.String("payout_id", state.GetPayoutId()), zap.String("status", state.GetStatus().String()))
|
|
||||||
return gsresponse.Success(&mntxv1.GetCardPayoutStatusResponse{Payout: state})
|
return gsresponse.Success(&mntxv1.GetCardPayoutStatusResponse{Payout: state})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestValidateCardPayoutRequest_Valid(t *testing.T) {
|
|
||||||
cfg := testMonetixConfig()
|
|
||||||
req := validCardPayoutRequest()
|
|
||||||
if err := validateCardPayoutRequest(req, cfg); err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateCardPayoutRequest_Errors(t *testing.T) {
|
|
||||||
baseCfg := testMonetixConfig()
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
mutate func(*mntxv1.CardPayoutRequest)
|
|
||||||
config func(monetix.Config) monetix.Config
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "missing_payout_id",
|
|
||||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.PayoutId = "" },
|
|
||||||
expected: "missing_payout_id",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing_customer_id",
|
|
||||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerId = "" },
|
|
||||||
expected: "missing_customer_id",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing_customer_ip",
|
|
||||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerIp = "" },
|
|
||||||
expected: "missing_customer_ip",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid_amount",
|
|
||||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.AmountMinor = 0 },
|
|
||||||
expected: "invalid_amount",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing_currency",
|
|
||||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.Currency = "" },
|
|
||||||
expected: "missing_currency",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unsupported_currency",
|
|
||||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.Currency = "EUR" },
|
|
||||||
config: func(cfg monetix.Config) monetix.Config {
|
|
||||||
cfg.AllowedCurrencies = []string{"USD"}
|
|
||||||
return cfg
|
|
||||||
},
|
|
||||||
expected: "unsupported_currency",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing_card_pan",
|
|
||||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardPan = "" },
|
|
||||||
expected: "missing_card_pan",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing_card_holder",
|
|
||||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardHolder = "" },
|
|
||||||
expected: "missing_card_holder",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid_expiry_month",
|
|
||||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardExpMonth = 13 },
|
|
||||||
expected: "invalid_expiry_month",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid_expiry_year",
|
|
||||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardExpYear = 0 },
|
|
||||||
expected: "invalid_expiry_year",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing_customer_country_when_required",
|
|
||||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerCountry = "" },
|
|
||||||
config: func(cfg monetix.Config) monetix.Config {
|
|
||||||
cfg.RequireCustomerAddress = true
|
|
||||||
return cfg
|
|
||||||
},
|
|
||||||
expected: "missing_customer_country",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
req := validCardPayoutRequest()
|
|
||||||
tc.mutate(req)
|
|
||||||
cfg := baseCfg
|
|
||||||
if tc.config != nil {
|
|
||||||
cfg = tc.config(cfg)
|
|
||||||
}
|
|
||||||
err := validateCardPayoutRequest(req, cfg)
|
|
||||||
requireReason(t, err, tc.expected)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -45,20 +45,14 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
|||||||
if p == nil {
|
if p == nil {
|
||||||
return nil, merrors.Internal("card payout processor not initialised")
|
return nil, merrors.Internal("card payout processor not initialised")
|
||||||
}
|
}
|
||||||
p.logger.Info("Submitting card payout",
|
|
||||||
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
|
|
||||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
|
||||||
zap.Int64("amount_minor", req.GetAmountMinor()),
|
|
||||||
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
|
|
||||||
)
|
|
||||||
if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" {
|
if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" {
|
||||||
p.logger.Warn("Monetix configuration is incomplete for payout submission")
|
p.logger.Warn("monetix configuration is incomplete for payout submission")
|
||||||
return nil, merrors.Internal("monetix configuration is incomplete")
|
return nil, merrors.Internal("monetix configuration is incomplete")
|
||||||
}
|
}
|
||||||
|
|
||||||
req = sanitizeCardPayoutRequest(req)
|
req = sanitizeCardPayoutRequest(req)
|
||||||
if err := validateCardPayoutRequest(req, p.config); err != nil {
|
if err := validateCardPayoutRequest(req, p.config); err != nil {
|
||||||
p.logger.Warn("Card payout validation failed",
|
p.logger.Warn("card payout validation failed",
|
||||||
zap.String("payout_id", req.GetPayoutId()),
|
zap.String("payout_id", req.GetPayoutId()),
|
||||||
zap.String("customer_id", req.GetCustomerId()),
|
zap.String("customer_id", req.GetCustomerId()),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
@@ -71,7 +65,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
|||||||
projectID = p.config.ProjectID
|
projectID = p.config.ProjectID
|
||||||
}
|
}
|
||||||
if projectID == 0 {
|
if projectID == 0 {
|
||||||
p.logger.Warn("Monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId()))
|
p.logger.Warn("monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId()))
|
||||||
return nil, merrors.Internal("monetix project_id is not configured")
|
return nil, merrors.Internal("monetix project_id is not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +95,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
|||||||
state.ProviderMessage = err.Error()
|
state.ProviderMessage = err.Error()
|
||||||
state.UpdatedAt = timestamppb.New(p.clock.Now())
|
state.UpdatedAt = timestamppb.New(p.clock.Now())
|
||||||
p.store.Save(state)
|
p.store.Save(state)
|
||||||
p.logger.Warn("Monetix payout submission failed",
|
p.logger.Warn("monetix payout submission failed",
|
||||||
zap.String("payout_id", req.GetPayoutId()),
|
zap.String("payout_id", req.GetPayoutId()),
|
||||||
zap.String("customer_id", req.GetCustomerId()),
|
zap.String("customer_id", req.GetCustomerId()),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
@@ -128,13 +122,6 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
|||||||
ErrorMessage: result.ErrorMessage,
|
ErrorMessage: result.ErrorMessage,
|
||||||
}
|
}
|
||||||
|
|
||||||
p.logger.Info("Card payout submission stored",
|
|
||||||
zap.String("payout_id", state.GetPayoutId()),
|
|
||||||
zap.String("status", state.GetStatus().String()),
|
|
||||||
zap.Bool("accepted", result.Accepted),
|
|
||||||
zap.String("provider_request_id", result.ProviderRequestID),
|
|
||||||
)
|
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,20 +129,14 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
|||||||
if p == nil {
|
if p == nil {
|
||||||
return nil, merrors.Internal("card payout processor not initialised")
|
return nil, merrors.Internal("card payout processor not initialised")
|
||||||
}
|
}
|
||||||
p.logger.Info("Submitting card token payout",
|
|
||||||
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
|
|
||||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
|
||||||
zap.Int64("amount_minor", req.GetAmountMinor()),
|
|
||||||
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
|
|
||||||
)
|
|
||||||
if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" {
|
if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" {
|
||||||
p.logger.Warn("Monetix configuration is incomplete for token payout submission")
|
p.logger.Warn("monetix configuration is incomplete for token payout submission")
|
||||||
return nil, merrors.Internal("monetix configuration is incomplete")
|
return nil, merrors.Internal("monetix configuration is incomplete")
|
||||||
}
|
}
|
||||||
|
|
||||||
req = sanitizeCardTokenPayoutRequest(req)
|
req = sanitizeCardTokenPayoutRequest(req)
|
||||||
if err := validateCardTokenPayoutRequest(req, p.config); err != nil {
|
if err := validateCardTokenPayoutRequest(req, p.config); err != nil {
|
||||||
p.logger.Warn("Card token payout validation failed",
|
p.logger.Warn("card token payout validation failed",
|
||||||
zap.String("payout_id", req.GetPayoutId()),
|
zap.String("payout_id", req.GetPayoutId()),
|
||||||
zap.String("customer_id", req.GetCustomerId()),
|
zap.String("customer_id", req.GetCustomerId()),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
@@ -168,7 +149,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
|||||||
projectID = p.config.ProjectID
|
projectID = p.config.ProjectID
|
||||||
}
|
}
|
||||||
if projectID == 0 {
|
if projectID == 0 {
|
||||||
p.logger.Warn("Monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId()))
|
p.logger.Warn("monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId()))
|
||||||
return nil, merrors.Internal("monetix project_id is not configured")
|
return nil, merrors.Internal("monetix project_id is not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +179,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
|||||||
state.ProviderMessage = err.Error()
|
state.ProviderMessage = err.Error()
|
||||||
state.UpdatedAt = timestamppb.New(p.clock.Now())
|
state.UpdatedAt = timestamppb.New(p.clock.Now())
|
||||||
p.store.Save(state)
|
p.store.Save(state)
|
||||||
p.logger.Warn("Monetix token payout submission failed",
|
p.logger.Warn("monetix token payout submission failed",
|
||||||
zap.String("payout_id", req.GetPayoutId()),
|
zap.String("payout_id", req.GetPayoutId()),
|
||||||
zap.String("customer_id", req.GetCustomerId()),
|
zap.String("customer_id", req.GetCustomerId()),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
@@ -225,13 +206,6 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
|||||||
ErrorMessage: result.ErrorMessage,
|
ErrorMessage: result.ErrorMessage,
|
||||||
}
|
}
|
||||||
|
|
||||||
p.logger.Info("Card token payout submission stored",
|
|
||||||
zap.String("payout_id", state.GetPayoutId()),
|
|
||||||
zap.String("status", state.GetStatus().String()),
|
|
||||||
zap.Bool("accepted", result.Accepted),
|
|
||||||
zap.String("provider_request_id", result.ProviderRequestID),
|
|
||||||
)
|
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,13 +213,9 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
|||||||
if p == nil {
|
if p == nil {
|
||||||
return nil, merrors.Internal("card payout processor not initialised")
|
return nil, merrors.Internal("card payout processor not initialised")
|
||||||
}
|
}
|
||||||
p.logger.Info("Submitting card tokenization",
|
|
||||||
zap.String("request_id", strings.TrimSpace(req.GetRequestId())),
|
|
||||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
|
||||||
)
|
|
||||||
cardInput, err := validateCardTokenizeRequest(req, p.config)
|
cardInput, err := validateCardTokenizeRequest(req, p.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.Warn("Card tokenization validation failed",
|
p.logger.Warn("card tokenization validation failed",
|
||||||
zap.String("request_id", req.GetRequestId()),
|
zap.String("request_id", req.GetRequestId()),
|
||||||
zap.String("customer_id", req.GetCustomerId()),
|
zap.String("customer_id", req.GetCustomerId()),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
@@ -258,7 +228,7 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
|||||||
projectID = p.config.ProjectID
|
projectID = p.config.ProjectID
|
||||||
}
|
}
|
||||||
if projectID == 0 {
|
if projectID == 0 {
|
||||||
p.logger.Warn("Monetix project_id is not configured", zap.String("request_id", req.GetRequestId()))
|
p.logger.Warn("monetix project_id is not configured", zap.String("request_id", req.GetRequestId()))
|
||||||
return nil, merrors.Internal("monetix project_id is not configured")
|
return nil, merrors.Internal("monetix project_id is not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +238,7 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
|||||||
apiReq := buildCardTokenizeRequest(projectID, req, cardInput)
|
apiReq := buildCardTokenizeRequest(projectID, req, cardInput)
|
||||||
result, err := client.CreateCardTokenization(ctx, apiReq)
|
result, err := client.CreateCardTokenization(ctx, apiReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.Warn("Monetix tokenization request failed",
|
p.logger.Warn("monetix tokenization request failed",
|
||||||
zap.String("request_id", req.GetRequestId()),
|
zap.String("request_id", req.GetRequestId()),
|
||||||
zap.String("customer_id", req.GetCustomerId()),
|
zap.String("customer_id", req.GetCustomerId()),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
@@ -288,12 +258,6 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
|||||||
resp.ExpiryYear = result.ExpiryYear
|
resp.ExpiryYear = result.ExpiryYear
|
||||||
resp.CardBrand = result.CardBrand
|
resp.CardBrand = result.CardBrand
|
||||||
|
|
||||||
p.logger.Info("Card tokenization completed",
|
|
||||||
zap.String("request_id", resp.GetRequestId()),
|
|
||||||
zap.Bool("success", resp.GetSuccess()),
|
|
||||||
zap.String("provider_request_id", result.ProviderRequestID),
|
|
||||||
)
|
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,18 +267,16 @@ func (p *cardPayoutProcessor) Status(_ context.Context, payoutID string) (*mntxv
|
|||||||
}
|
}
|
||||||
|
|
||||||
id := strings.TrimSpace(payoutID)
|
id := strings.TrimSpace(payoutID)
|
||||||
p.logger.Info("Card payout status requested", zap.String("payout_id", id))
|
|
||||||
if id == "" {
|
if id == "" {
|
||||||
p.logger.Warn("Payout status requested with empty payout_id")
|
p.logger.Warn("payout status requested with empty payout_id")
|
||||||
return nil, merrors.InvalidArgument("payout_id is required", "payout_id")
|
return nil, merrors.InvalidArgument("payout_id is required", "payout_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
state, ok := p.store.Get(id)
|
state, ok := p.store.Get(id)
|
||||||
if !ok || state == nil {
|
if !ok || state == nil {
|
||||||
p.logger.Warn("Payout status not found", zap.String("payout_id", id))
|
p.logger.Warn("payout status not found", zap.String("payout_id", id))
|
||||||
return nil, merrors.NoData("payout not found")
|
return nil, merrors.NoData("payout not found")
|
||||||
}
|
}
|
||||||
p.logger.Info("Card payout status resolved", zap.String("payout_id", state.GetPayoutId()), zap.String("status", state.GetStatus().String()))
|
|
||||||
return state, nil
|
return state, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,19 +284,18 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
|
|||||||
if p == nil {
|
if p == nil {
|
||||||
return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised")
|
return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised")
|
||||||
}
|
}
|
||||||
p.logger.Debug("Processing Monetix callback", zap.Int("payload_bytes", len(payload)))
|
|
||||||
if len(payload) == 0 {
|
if len(payload) == 0 {
|
||||||
p.logger.Warn("Received empty Monetix callback payload")
|
p.logger.Warn("received empty Monetix callback payload")
|
||||||
return http.StatusBadRequest, merrors.InvalidArgument("callback body is empty")
|
return http.StatusBadRequest, merrors.InvalidArgument("callback body is empty")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(p.config.SecretKey) == "" {
|
if strings.TrimSpace(p.config.SecretKey) == "" {
|
||||||
p.logger.Warn("Monetix secret key is not configured; cannot verify callback")
|
p.logger.Warn("monetix secret key is not configured; cannot verify callback")
|
||||||
return http.StatusInternalServerError, merrors.Internal("monetix secret key is not configured")
|
return http.StatusInternalServerError, merrors.Internal("monetix secret key is not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
var cb monetixCallback
|
var cb monetixCallback
|
||||||
if err := json.Unmarshal(payload, &cb); err != nil {
|
if err := json.Unmarshal(payload, &cb); err != nil {
|
||||||
p.logger.Warn("Failed to unmarshal Monetix callback", zap.Error(err))
|
p.logger.Warn("failed to unmarshal Monetix callback", zap.Error(err))
|
||||||
return http.StatusBadRequest, err
|
return http.StatusBadRequest, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +318,7 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
|
|||||||
p.emitCardPayoutEvent(state)
|
p.emitCardPayoutEvent(state)
|
||||||
monetix.ObserveCallback(statusLabel)
|
monetix.ObserveCallback(statusLabel)
|
||||||
|
|
||||||
p.logger.Debug("Monetix payout callback processed",
|
p.logger.Info("Monetix payout callback processed",
|
||||||
zap.String("payout_id", state.GetPayoutId()),
|
zap.String("payout_id", state.GetPayoutId()),
|
||||||
zap.String("status", statusLabel),
|
zap.String("status", statusLabel),
|
||||||
zap.String("provider_code", state.GetProviderCode()),
|
zap.String("provider_code", state.GetProviderCode()),
|
||||||
@@ -376,16 +337,16 @@ func (p *cardPayoutProcessor) emitCardPayoutEvent(state *mntxv1.CardPayoutState)
|
|||||||
event := &mntxv1.CardPayoutStatusChangedEvent{Payout: state}
|
event := &mntxv1.CardPayoutStatusChangedEvent{Payout: state}
|
||||||
payload, err := protojson.Marshal(event)
|
payload, err := protojson.Marshal(event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.Warn("Failed to marshal payout callback event", zap.Error(err))
|
p.logger.Warn("failed to marshal payout callback event", zap.Error(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, nm.NAUpdated))
|
env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, nm.NAUpdated))
|
||||||
if _, err := env.Wrap(payload); err != nil {
|
if _, err := env.Wrap(payload); err != nil {
|
||||||
p.logger.Warn("Failed to wrap payout callback event payload", zap.Error(err))
|
p.logger.Warn("failed to wrap payout callback event payload", zap.Error(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := p.producer.SendMessage(env); err != nil {
|
if err := p.producer.SendMessage(env); err != nil {
|
||||||
p.logger.Warn("Failed to publish payout callback event", zap.Error(err))
|
p.logger.Warn("failed to publish payout callback event", zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
)
|
|
||||||
|
|
||||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
|
||||||
|
|
||||||
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
||||||
return f(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
type staticClock struct {
|
|
||||||
now time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s staticClock) Now() time.Time {
|
|
||||||
return s.now
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCardPayoutProcessor_Submit_Success(t *testing.T) {
|
|
||||||
cfg := monetix.Config{
|
|
||||||
BaseURL: "https://monetix.test",
|
|
||||||
SecretKey: "secret",
|
|
||||||
ProjectID: 99,
|
|
||||||
AllowedCurrencies: []string{"RUB"},
|
|
||||||
}
|
|
||||||
|
|
||||||
existingCreated := timestamppb.New(time.Date(2020, 2, 3, 0, 0, 0, 0, time.UTC))
|
|
||||||
store := newCardPayoutStore()
|
|
||||||
store.Save(&mntxv1.CardPayoutState{
|
|
||||||
PayoutId: "payout-1",
|
|
||||||
CreatedAt: existingCreated,
|
|
||||||
})
|
|
||||||
|
|
||||||
httpClient := &http.Client{
|
|
||||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
|
||||||
resp := monetix.APIResponse{}
|
|
||||||
resp.Operation.RequestID = "req-123"
|
|
||||||
body, _ := json.Marshal(resp)
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Body: io.NopCloser(bytes.NewReader(body)),
|
|
||||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
||||||
}, nil
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
|
||||||
processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: now}, store, httpClient, nil)
|
|
||||||
|
|
||||||
req := validCardPayoutRequest()
|
|
||||||
req.ProjectId = 0
|
|
||||||
|
|
||||||
resp, err := processor.Submit(context.Background(), req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if !resp.GetAccepted() {
|
|
||||||
t.Fatalf("expected accepted payout response")
|
|
||||||
}
|
|
||||||
if resp.GetPayout().GetProjectId() != cfg.ProjectID {
|
|
||||||
t.Fatalf("expected project id %d, got %d", cfg.ProjectID, resp.GetPayout().GetProjectId())
|
|
||||||
}
|
|
||||||
if resp.GetPayout().GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING {
|
|
||||||
t.Fatalf("expected pending status, got %v", resp.GetPayout().GetStatus())
|
|
||||||
}
|
|
||||||
if !resp.GetPayout().GetCreatedAt().AsTime().Equal(existingCreated.AsTime()) {
|
|
||||||
t.Fatalf("expected created_at preserved, got %v", resp.GetPayout().GetCreatedAt().AsTime())
|
|
||||||
}
|
|
||||||
|
|
||||||
stored, ok := store.Get(req.GetPayoutId())
|
|
||||||
if !ok || stored == nil {
|
|
||||||
t.Fatalf("expected payout state stored")
|
|
||||||
}
|
|
||||||
if stored.GetProviderPaymentId() == "" {
|
|
||||||
t.Fatalf("expected provider payment id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCardPayoutProcessor_Submit_MissingConfig(t *testing.T) {
|
|
||||||
cfg := monetix.Config{
|
|
||||||
AllowedCurrencies: []string{"RUB"},
|
|
||||||
}
|
|
||||||
processor := newCardPayoutProcessor(zap.NewNop(), cfg, clockpkg.NewSystem(), newCardPayoutStore(), &http.Client{}, nil)
|
|
||||||
|
|
||||||
_, err := processor.Submit(context.Background(), validCardPayoutRequest())
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected error")
|
|
||||||
}
|
|
||||||
if !errors.Is(err, merrors.ErrInternal) {
|
|
||||||
t.Fatalf("expected internal error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCardPayoutProcessor_ProcessCallback(t *testing.T) {
|
|
||||||
cfg := monetix.Config{
|
|
||||||
SecretKey: "secret",
|
|
||||||
StatusSuccess: "success",
|
|
||||||
StatusProcessing: "processing",
|
|
||||||
AllowedCurrencies: []string{"RUB"},
|
|
||||||
}
|
|
||||||
store := newCardPayoutStore()
|
|
||||||
processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)}, store, &http.Client{}, nil)
|
|
||||||
|
|
||||||
cb := baseCallback()
|
|
||||||
cb.Payment.Sum.Currency = "RUB"
|
|
||||||
cb.Signature = ""
|
|
||||||
sig, err := monetix.SignPayload(cb, cfg.SecretKey)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to sign callback: %v", err)
|
|
||||||
}
|
|
||||||
cb.Signature = sig
|
|
||||||
|
|
||||||
payload, err := json.Marshal(cb)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to marshal callback: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
status, err := processor.ProcessCallback(context.Background(), payload)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if status != http.StatusOK {
|
|
||||||
t.Fatalf("expected status ok, got %d", status)
|
|
||||||
}
|
|
||||||
|
|
||||||
state, ok := store.Get(cb.Payment.ID)
|
|
||||||
if !ok || state == nil {
|
|
||||||
t.Fatalf("expected payout state stored")
|
|
||||||
}
|
|
||||||
if state.GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED {
|
|
||||||
t.Fatalf("expected processed status, got %v", state.GetStatus())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestValidateCardTokenPayoutRequest_Valid(t *testing.T) {
|
|
||||||
cfg := testMonetixConfig()
|
|
||||||
req := validCardTokenPayoutRequest()
|
|
||||||
if err := validateCardTokenPayoutRequest(req, cfg); err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateCardTokenPayoutRequest_Errors(t *testing.T) {
|
|
||||||
baseCfg := testMonetixConfig()
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
mutate func(*mntxv1.CardTokenPayoutRequest)
|
|
||||||
config func(monetix.Config) monetix.Config
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "missing_payout_id",
|
|
||||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.PayoutId = "" },
|
|
||||||
expected: "missing_payout_id",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing_customer_id",
|
|
||||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CustomerId = "" },
|
|
||||||
expected: "missing_customer_id",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing_customer_ip",
|
|
||||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CustomerIp = "" },
|
|
||||||
expected: "missing_customer_ip",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid_amount",
|
|
||||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.AmountMinor = 0 },
|
|
||||||
expected: "invalid_amount",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing_currency",
|
|
||||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.Currency = "" },
|
|
||||||
expected: "missing_currency",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unsupported_currency",
|
|
||||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.Currency = "EUR" },
|
|
||||||
config: func(cfg monetix.Config) monetix.Config {
|
|
||||||
cfg.AllowedCurrencies = []string{"USD"}
|
|
||||||
return cfg
|
|
||||||
},
|
|
||||||
expected: "unsupported_currency",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing_card_token",
|
|
||||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CardToken = "" },
|
|
||||||
expected: "missing_card_token",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing_customer_city_when_required",
|
|
||||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) {
|
|
||||||
r.CustomerCountry = "US"
|
|
||||||
r.CustomerCity = ""
|
|
||||||
r.CustomerAddress = "Main St"
|
|
||||||
r.CustomerZip = "12345"
|
|
||||||
},
|
|
||||||
config: func(cfg monetix.Config) monetix.Config {
|
|
||||||
cfg.RequireCustomerAddress = true
|
|
||||||
return cfg
|
|
||||||
},
|
|
||||||
expected: "missing_customer_city",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
req := validCardTokenPayoutRequest()
|
|
||||||
tc.mutate(req)
|
|
||||||
cfg := baseCfg
|
|
||||||
if tc.config != nil {
|
|
||||||
cfg = tc.config(cfg)
|
|
||||||
}
|
|
||||||
err := validateCardTokenPayoutRequest(req, cfg)
|
|
||||||
requireReason(t, err, tc.expected)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestValidateCardTokenizeRequest_ValidTopLevel(t *testing.T) {
|
|
||||||
cfg := testMonetixConfig()
|
|
||||||
req := validCardTokenizeRequest()
|
|
||||||
if _, err := validateCardTokenizeRequest(req, cfg); err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateCardTokenizeRequest_ValidNestedCard(t *testing.T) {
|
|
||||||
cfg := testMonetixConfig()
|
|
||||||
req := validCardTokenizeRequest()
|
|
||||||
req.Card = &mntxv1.CardDetails{
|
|
||||||
Pan: "4111111111111111",
|
|
||||||
ExpMonth: req.CardExpMonth,
|
|
||||||
ExpYear: req.CardExpYear,
|
|
||||||
CardHolder: req.CardHolder,
|
|
||||||
Cvv: req.CardCvv,
|
|
||||||
}
|
|
||||||
req.CardPan = ""
|
|
||||||
req.CardExpMonth = 0
|
|
||||||
req.CardExpYear = 0
|
|
||||||
req.CardHolder = ""
|
|
||||||
req.CardCvv = ""
|
|
||||||
|
|
||||||
if _, err := validateCardTokenizeRequest(req, cfg); err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateCardTokenizeRequest_Expired(t *testing.T) {
|
|
||||||
cfg := testMonetixConfig()
|
|
||||||
req := validCardTokenizeRequest()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
req.CardExpMonth = uint32(now.Month())
|
|
||||||
req.CardExpYear = uint32(now.Year() - 1)
|
|
||||||
|
|
||||||
_, err := validateCardTokenizeRequest(req, cfg)
|
|
||||||
requireReason(t, err, "expired_card")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateCardTokenizeRequest_MissingCvv(t *testing.T) {
|
|
||||||
cfg := testMonetixConfig()
|
|
||||||
req := validCardTokenizeRequest()
|
|
||||||
req.CardCvv = ""
|
|
||||||
|
|
||||||
_, err := validateCardTokenizeRequest(req, cfg)
|
|
||||||
requireReason(t, err, "missing_cvv")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateCardTokenizeRequest_MissingCardPan(t *testing.T) {
|
|
||||||
cfg := testMonetixConfig()
|
|
||||||
req := validCardTokenizeRequest()
|
|
||||||
req.CardPan = ""
|
|
||||||
|
|
||||||
_, err := validateCardTokenizeRequest(req, cfg)
|
|
||||||
requireReason(t, err, "missing_card_pan")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateCardTokenizeRequest_AddressRequired(t *testing.T) {
|
|
||||||
cfg := testMonetixConfig()
|
|
||||||
cfg.RequireCustomerAddress = true
|
|
||||||
req := validCardTokenizeRequest()
|
|
||||||
req.CustomerCountry = ""
|
|
||||||
|
|
||||||
_, err := validateCardTokenizeRequest(req, cfg)
|
|
||||||
requireReason(t, err, "missing_customer_country")
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) GetPayout(ctx context.Context, req *mntxv1.GetPayoutRequest) (*mntxv1.GetPayoutResponse, error) {
|
func (s *Service) GetPayout(ctx context.Context, req *mntxv1.GetPayoutRequest) (*mntxv1.GetPayoutResponse, error) {
|
||||||
@@ -18,19 +17,14 @@ func (s *Service) GetPayout(ctx context.Context, req *mntxv1.GetPayoutRequest) (
|
|||||||
|
|
||||||
func (s *Service) handleGetPayout(_ context.Context, req *mntxv1.GetPayoutRequest) gsresponse.Responder[mntxv1.GetPayoutResponse] {
|
func (s *Service) handleGetPayout(_ context.Context, req *mntxv1.GetPayoutRequest) gsresponse.Responder[mntxv1.GetPayoutResponse] {
|
||||||
ref := strings.TrimSpace(req.GetPayoutRef())
|
ref := strings.TrimSpace(req.GetPayoutRef())
|
||||||
log := s.logger.Named("payout")
|
|
||||||
log.Info("Get payout request received", zap.String("payout_ref", ref))
|
|
||||||
if ref == "" {
|
if ref == "" {
|
||||||
log.Warn("Get payout request missing payout_ref")
|
|
||||||
return gsresponse.InvalidArgument[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.InvalidArgument("payout_ref is required", "payout_ref"))
|
return gsresponse.InvalidArgument[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.InvalidArgument("payout_ref is required", "payout_ref"))
|
||||||
}
|
}
|
||||||
|
|
||||||
payout, ok := s.store.Get(ref)
|
payout, ok := s.store.Get(ref)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Warn("Payout not found", zap.String("payout_ref", ref))
|
|
||||||
return gsresponse.NotFound[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.NoData(fmt.Sprintf("payout %s not found", ref)))
|
return gsresponse.NotFound[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.NoData(fmt.Sprintf("payout %s not found", ref)))
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Payout retrieved", zap.String("payout_ref", ref), zap.String("status", payout.GetStatus().String()))
|
|
||||||
return gsresponse.Success(&mntxv1.GetPayoutResponse{Payout: payout})
|
return gsresponse.Success(&mntxv1.GetPayoutResponse{Payout: payout})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,17 +22,8 @@ func (s *Service) SubmitPayout(ctx context.Context, req *mntxv1.SubmitPayoutRequ
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleSubmitPayout(_ context.Context, req *mntxv1.SubmitPayoutRequest) gsresponse.Responder[mntxv1.SubmitPayoutResponse] {
|
func (s *Service) handleSubmitPayout(_ context.Context, req *mntxv1.SubmitPayoutRequest) gsresponse.Responder[mntxv1.SubmitPayoutResponse] {
|
||||||
log := s.logger.Named("payout")
|
|
||||||
log.Info("Submit payout request received",
|
|
||||||
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
|
|
||||||
zap.String("organization_ref", strings.TrimSpace(req.GetOrganizationRef())),
|
|
||||||
zap.String("currency", strings.TrimSpace(req.GetAmount().GetCurrency())),
|
|
||||||
zap.String("amount", strings.TrimSpace(req.GetAmount().GetAmount())),
|
|
||||||
)
|
|
||||||
|
|
||||||
payout, err := s.buildPayout(req)
|
payout, err := s.buildPayout(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Submit payout validation failed", zap.Error(err))
|
|
||||||
return gsresponse.Auto[mntxv1.SubmitPayoutResponse](s.logger, mservice.MntxGateway, err)
|
return gsresponse.Auto[mntxv1.SubmitPayoutResponse](s.logger, mservice.MntxGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +31,6 @@ func (s *Service) handleSubmitPayout(_ context.Context, req *mntxv1.SubmitPayout
|
|||||||
s.emitEvent(payout, nm.NAPending)
|
s.emitEvent(payout, nm.NAPending)
|
||||||
go s.completePayout(payout, strings.TrimSpace(req.GetSimulatedFailureReason()))
|
go s.completePayout(payout, strings.TrimSpace(req.GetSimulatedFailureReason()))
|
||||||
|
|
||||||
log.Info("Payout accepted", zap.String("payout_ref", payout.GetPayoutRef()), zap.String("status", payout.GetStatus().String()))
|
|
||||||
return gsresponse.Success(&mntxv1.SubmitPayoutResponse{Payout: payout})
|
return gsresponse.Success(&mntxv1.SubmitPayoutResponse{Payout: payout})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +79,6 @@ func (s *Service) buildPayout(req *mntxv1.SubmitPayoutRequest) (*mntxv1.Payout,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure string) {
|
func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure string) {
|
||||||
log := s.logger.Named("payout")
|
|
||||||
outcome := clonePayout(original)
|
outcome := clonePayout(original)
|
||||||
if outcome == nil {
|
if outcome == nil {
|
||||||
return
|
return
|
||||||
@@ -106,7 +95,6 @@ func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure strin
|
|||||||
observePayoutError(simulatedFailure, outcome.Amount)
|
observePayoutError(simulatedFailure, outcome.Amount)
|
||||||
s.store.Save(outcome)
|
s.store.Save(outcome)
|
||||||
s.emitEvent(outcome, nm.NAUpdated)
|
s.emitEvent(outcome, nm.NAUpdated)
|
||||||
log.Info("Payout completed", zap.String("payout_ref", outcome.GetPayoutRef()), zap.String("status", outcome.GetStatus().String()), zap.String("failure_reason", simulatedFailure))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +102,6 @@ func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure strin
|
|||||||
observePayoutSuccess(outcome.Amount)
|
observePayoutSuccess(outcome.Amount)
|
||||||
s.store.Save(outcome)
|
s.store.Save(outcome)
|
||||||
s.emitEvent(outcome, nm.NAUpdated)
|
s.emitEvent(outcome, nm.NAUpdated)
|
||||||
log.Info("Payout completed", zap.String("payout_ref", outcome.GetPayoutRef()), zap.String("status", outcome.GetStatus().String()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) emitEvent(payout *mntxv1.Payout, action nm.NotificationAction) {
|
func (s *Service) emitEvent(payout *mntxv1.Payout, action nm.NotificationAction) {
|
||||||
@@ -124,18 +111,18 @@ func (s *Service) emitEvent(payout *mntxv1.Payout, action nm.NotificationAction)
|
|||||||
|
|
||||||
payload, err := protojson.Marshal(&mntxv1.PayoutStatusChangedEvent{Payout: payout})
|
payload, err := protojson.Marshal(&mntxv1.PayoutStatusChangedEvent{Payout: payout})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("Failed to marshal payout event", zapError(err))
|
s.logger.Warn("failed to marshal payout event", zapError(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, action))
|
env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, action))
|
||||||
if _, err := env.Wrap(payload); err != nil {
|
if _, err := env.Wrap(payload); err != nil {
|
||||||
s.logger.Warn("Failed to wrap payout event payload", zapError(err))
|
s.logger.Warn("failed to wrap payout event payload", zapError(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.producer.SendMessage(env); err != nil {
|
if err := s.producer.SendMessage(env); err != nil {
|
||||||
s.logger.Warn("Failed to publish payout event", zapError(err))
|
s.logger.Warn("failed to publish payout event", zapError(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
"go.uber.org/zap"
|
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -98,19 +97,9 @@ func (s *Service) Register(router routers.GRPC) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) {
|
func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) {
|
||||||
log := svc.logger.Named("rpc")
|
|
||||||
log.Info("RPC request started", zap.String("method", method))
|
|
||||||
|
|
||||||
start := svc.clock.Now()
|
start := svc.clock.Now()
|
||||||
resp, err := gsresponse.Unary(svc.logger, mservice.MntxGateway, handler)(ctx, req)
|
resp, err := gsresponse.Unary(svc.logger, mservice.MntxGateway, handler)(ctx, req)
|
||||||
duration := svc.clock.Now().Sub(start)
|
observeRPC(method, err, svc.clock.Now().Sub(start))
|
||||||
observeRPC(method, err, duration)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("RPC request failed", zap.String("method", method), zap.Duration("duration", duration), zap.Error(err))
|
|
||||||
} else {
|
|
||||||
log.Info("RPC request completed", zap.String("method", method), zap.Duration("duration", duration))
|
|
||||||
}
|
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func requireReason(t *testing.T, err error, reason string) {
|
|
||||||
t.Helper()
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected error")
|
|
||||||
}
|
|
||||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
|
||||||
t.Fatalf("expected invalid argument error, got %v", err)
|
|
||||||
}
|
|
||||||
reasoned, ok := err.(payoutFailure)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected payout failure reason, got %T", err)
|
|
||||||
}
|
|
||||||
if reasoned.Reason() != reason {
|
|
||||||
t.Fatalf("expected reason %q, got %q", reason, reasoned.Reason())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMonetixConfig() monetix.Config {
|
|
||||||
return monetix.Config{
|
|
||||||
AllowedCurrencies: []string{"RUB", "USD"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validCardPayoutRequest() *mntxv1.CardPayoutRequest {
|
|
||||||
return &mntxv1.CardPayoutRequest{
|
|
||||||
PayoutId: "payout-1",
|
|
||||||
CustomerId: "cust-1",
|
|
||||||
CustomerFirstName: "Jane",
|
|
||||||
CustomerLastName: "Doe",
|
|
||||||
CustomerIp: "203.0.113.10",
|
|
||||||
AmountMinor: 1500,
|
|
||||||
Currency: "RUB",
|
|
||||||
CardPan: "4111111111111111",
|
|
||||||
CardHolder: "JANE DOE",
|
|
||||||
CardExpMonth: 12,
|
|
||||||
CardExpYear: 2035,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validCardTokenPayoutRequest() *mntxv1.CardTokenPayoutRequest {
|
|
||||||
return &mntxv1.CardTokenPayoutRequest{
|
|
||||||
PayoutId: "payout-1",
|
|
||||||
CustomerId: "cust-1",
|
|
||||||
CustomerFirstName: "Jane",
|
|
||||||
CustomerLastName: "Doe",
|
|
||||||
CustomerIp: "203.0.113.11",
|
|
||||||
AmountMinor: 2500,
|
|
||||||
Currency: "USD",
|
|
||||||
CardToken: "tok_123",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validCardTokenizeRequest() *mntxv1.CardTokenizeRequest {
|
|
||||||
month, year := futureExpiry()
|
|
||||||
return &mntxv1.CardTokenizeRequest{
|
|
||||||
RequestId: "req-1",
|
|
||||||
CustomerId: "cust-1",
|
|
||||||
CustomerFirstName: "Jane",
|
|
||||||
CustomerLastName: "Doe",
|
|
||||||
CustomerIp: "203.0.113.12",
|
|
||||||
CardPan: "4111111111111111",
|
|
||||||
CardHolder: "JANE DOE",
|
|
||||||
CardCvv: "123",
|
|
||||||
CardExpMonth: month,
|
|
||||||
CardExpYear: year,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func futureExpiry() (uint32, uint32) {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
return uint32(now.Month()), uint32(now.Year() + 1)
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,10 @@ package monetix
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
@@ -41,3 +45,21 @@ func (c *Client) CreateCardTokenPayout(ctx context.Context, req CardTokenPayoutR
|
|||||||
func (c *Client) CreateCardTokenization(ctx context.Context, req CardTokenizeRequest) (*TokenizationResult, error) {
|
func (c *Client) CreateCardTokenization(ctx context.Context, req CardTokenizeRequest) (*TokenizationResult, error) {
|
||||||
return c.sendTokenization(ctx, req)
|
return c.sendTokenization(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func signPayload(payload any, secret string) (string, error) {
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
h := hmac.New(sha256.New, []byte(secret))
|
||||||
|
if _, err := h.Write(data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignPayload exposes signature calculation for callback verification.
|
||||||
|
func SignPayload(payload any, secret string) (string, error) {
|
||||||
|
return signPayload(payload, secret)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
package monetix
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestMaskPAN(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
input string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{input: "1234", expected: "****"},
|
|
||||||
{input: "1234567890", expected: "12******90"},
|
|
||||||
{input: "1234567890123456", expected: "123456******3456"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.input, func(t *testing.T) {
|
|
||||||
got := MaskPAN(tc.input)
|
|
||||||
if got != tc.expected {
|
|
||||||
t.Fatalf("expected %q, got %q", tc.expected, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,7 @@ func (c *Client) sendCardPayout(ctx context.Context, req CardPayoutRequest) (*Ca
|
|||||||
maskedPAN := MaskPAN(req.Card.PAN)
|
maskedPAN := MaskPAN(req.Card.PAN)
|
||||||
return c.send(ctx, &req, "/v2/payment/card/payout",
|
return c.send(ctx, &req, "/v2/payment/card/payout",
|
||||||
func() {
|
func() {
|
||||||
c.logger.Info("Dispatching Monetix card payout",
|
c.logger.Info("dispatching Monetix card payout",
|
||||||
zap.String("payout_id", req.General.PaymentID),
|
zap.String("payout_id", req.General.PaymentID),
|
||||||
zap.Int64("amount_minor", req.Payment.Amount),
|
zap.Int64("amount_minor", req.Payment.Amount),
|
||||||
zap.String("currency", req.Payment.Currency),
|
zap.String("currency", req.Payment.Currency),
|
||||||
@@ -47,7 +47,7 @@ func (c *Client) sendCardPayout(ctx context.Context, req CardPayoutRequest) (*Ca
|
|||||||
func (c *Client) sendCardTokenPayout(ctx context.Context, req CardTokenPayoutRequest) (*CardPayoutSendResult, error) {
|
func (c *Client) sendCardTokenPayout(ctx context.Context, req CardTokenPayoutRequest) (*CardPayoutSendResult, error) {
|
||||||
return c.send(ctx, &req, "/v2/payment/card/payout/token",
|
return c.send(ctx, &req, "/v2/payment/card/payout/token",
|
||||||
func() {
|
func() {
|
||||||
c.logger.Info("Dispatching Monetix card token payout",
|
c.logger.Info("dispatching Monetix card token payout",
|
||||||
zap.String("payout_id", req.General.PaymentID),
|
zap.String("payout_id", req.General.PaymentID),
|
||||||
zap.Int64("amount_minor", req.Payment.Amount),
|
zap.Int64("amount_minor", req.Payment.Amount),
|
||||||
zap.String("currency", req.Payment.Currency),
|
zap.String("currency", req.Payment.Currency),
|
||||||
@@ -101,7 +101,7 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest)
|
|||||||
httpReq.Header.Set("Content-Type", "application/json")
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
httpReq.Header.Set("Accept", "application/json")
|
httpReq.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
c.logger.Info("Dispatching Monetix card tokenization",
|
c.logger.Info("dispatching Monetix card tokenization",
|
||||||
zap.String("request_id", req.General.PaymentID),
|
zap.String("request_id", req.General.PaymentID),
|
||||||
zap.String("masked_pan", MaskPAN(req.Card.PAN)),
|
zap.String("masked_pan", MaskPAN(req.Card.PAN)),
|
||||||
)
|
)
|
||||||
@@ -111,7 +111,7 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest)
|
|||||||
duration := time.Since(start)
|
duration := time.Since(start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
observeRequest(outcomeNetworkError, duration)
|
observeRequest(outcomeNetworkError, duration)
|
||||||
c.logger.Warn("Monetix tokenization request failed", zap.Error(err))
|
c.logger.Warn("monetix tokenization request failed", zap.Error(err))
|
||||||
return nil, merrors.Internal("monetix tokenization request failed: " + err.Error())
|
return nil, merrors.Internal("monetix tokenization request failed: " + err.Error())
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -133,7 +133,7 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest)
|
|||||||
var apiResp APIResponse
|
var apiResp APIResponse
|
||||||
if len(body) > 0 {
|
if len(body) > 0 {
|
||||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
c.logger.Warn("Failed to decode Monetix tokenization response", zap.String("request_id", req.General.PaymentID), zap.Int("status_code", resp.StatusCode), zap.Error(err))
|
c.logger.Warn("failed to decode Monetix tokenization response", zap.String("request_id", req.General.PaymentID), zap.Int("status_code", resp.StatusCode), zap.Error(err))
|
||||||
} else {
|
} else {
|
||||||
var tokenData struct {
|
var tokenData struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
@@ -245,7 +245,7 @@ func (c *Client) send(ctx context.Context, req any, path string, dispatchLog fun
|
|||||||
var apiResp APIResponse
|
var apiResp APIResponse
|
||||||
if len(body) > 0 {
|
if len(body) > 0 {
|
||||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
c.logger.Warn("Failed to decode Monetix response", zap.Int("status_code", resp.StatusCode), zap.Error(err))
|
c.logger.Warn("failed to decode Monetix response", zap.Int("status_code", resp.StatusCode), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
package monetix
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
|
||||||
|
|
||||||
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
||||||
return f(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendCardPayout_SignsPayload(t *testing.T) {
|
|
||||||
secret := "secret"
|
|
||||||
var captured CardPayoutRequest
|
|
||||||
|
|
||||||
httpClient := &http.Client{
|
|
||||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
|
||||||
if r.URL.Path != "/v2/payment/card/payout" {
|
|
||||||
t.Fatalf("expected payout path, got %q", r.URL.Path)
|
|
||||||
}
|
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
if err := json.Unmarshal(body, &captured); err != nil {
|
|
||||||
t.Fatalf("failed to decode request: %v", err)
|
|
||||||
}
|
|
||||||
resp := APIResponse{}
|
|
||||||
resp.Operation.RequestID = "req-1"
|
|
||||||
payload, _ := json.Marshal(resp)
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Body: io.NopCloser(bytes.NewReader(payload)),
|
|
||||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
||||||
}, nil
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := Config{
|
|
||||||
BaseURL: "https://monetix.test",
|
|
||||||
SecretKey: secret,
|
|
||||||
}
|
|
||||||
client := NewClient(cfg, httpClient, zap.NewNop())
|
|
||||||
|
|
||||||
req := CardPayoutRequest{
|
|
||||||
General: General{ProjectID: 1, PaymentID: "payout-1"},
|
|
||||||
Customer: Customer{
|
|
||||||
ID: "cust-1",
|
|
||||||
FirstName: "Jane",
|
|
||||||
LastName: "Doe",
|
|
||||||
IP: "203.0.113.10",
|
|
||||||
},
|
|
||||||
Payment: Payment{Amount: 1000, Currency: "RUB"},
|
|
||||||
Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"},
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := client.CreateCardPayout(context.Background(), req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if !result.Accepted {
|
|
||||||
t.Fatalf("expected accepted response")
|
|
||||||
}
|
|
||||||
if captured.General.Signature == "" {
|
|
||||||
t.Fatalf("expected signature in request")
|
|
||||||
}
|
|
||||||
|
|
||||||
signed := captured
|
|
||||||
signed.General.Signature = ""
|
|
||||||
expectedSig, err := SignPayload(signed, secret)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to compute signature: %v", err)
|
|
||||||
}
|
|
||||||
if captured.General.Signature != expectedSig {
|
|
||||||
t.Fatalf("expected signature %q, got %q", expectedSig, captured.General.Signature)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendCardPayout_HTTPError(t *testing.T) {
|
|
||||||
httpClient := &http.Client{
|
|
||||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
|
||||||
body := `{"code":"E100","message":"denied"}`
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: http.StatusBadRequest,
|
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
|
||||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
||||||
}, nil
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := Config{
|
|
||||||
BaseURL: "https://monetix.test",
|
|
||||||
SecretKey: "secret",
|
|
||||||
}
|
|
||||||
client := NewClient(cfg, httpClient, zap.NewNop())
|
|
||||||
|
|
||||||
req := CardPayoutRequest{
|
|
||||||
General: General{ProjectID: 1, PaymentID: "payout-1"},
|
|
||||||
Customer: Customer{
|
|
||||||
ID: "cust-1",
|
|
||||||
FirstName: "Jane",
|
|
||||||
LastName: "Doe",
|
|
||||||
IP: "203.0.113.10",
|
|
||||||
},
|
|
||||||
Payment: Payment{Amount: 1000, Currency: "RUB"},
|
|
||||||
Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"},
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := client.CreateCardPayout(context.Background(), req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if result.Accepted {
|
|
||||||
t.Fatalf("expected rejected response")
|
|
||||||
}
|
|
||||||
if result.ErrorCode != "E100" {
|
|
||||||
t.Fatalf("expected error code E100, got %q", result.ErrorCode)
|
|
||||||
}
|
|
||||||
if result.ErrorMessage != "denied" {
|
|
||||||
t.Fatalf("expected error message denied, got %q", result.ErrorMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
package monetix
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha512"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func signPayload(payload any, secret string) (string, error) {
|
|
||||||
canonical, err := signaturePayloadString(payload)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
mac := hmac.New(sha512.New, []byte(secret))
|
|
||||||
if _, err := mac.Write([]byte(canonical)); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SignPayload exposes signature calculation for callback verification.
|
|
||||||
func SignPayload(payload any, secret string) (string, error) {
|
|
||||||
return signPayload(payload, secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
func signaturePayloadString(payload any) (string, error) {
|
|
||||||
data, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var root any
|
|
||||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
||||||
decoder.UseNumber()
|
|
||||||
if err := decoder.Decode(&root); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := make([]string, 0)
|
|
||||||
collectSignatureLines(nil, root, &lines)
|
|
||||||
sort.Strings(lines)
|
|
||||||
|
|
||||||
return strings.Join(lines, ";"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectSignatureLines(path []string, value any, lines *[]string) {
|
|
||||||
switch v := value.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
for key, child := range v {
|
|
||||||
if strings.EqualFold(key, "signature") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
collectSignatureLines(append(path, key), child, lines)
|
|
||||||
}
|
|
||||||
case []any:
|
|
||||||
if len(v) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for idx, child := range v {
|
|
||||||
collectSignatureLines(append(path, strconv.Itoa(idx)), child, lines)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
line := formatSignatureLine(path, v)
|
|
||||||
if line != "" {
|
|
||||||
*lines = append(*lines, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatSignatureLine(path []string, value any) string {
|
|
||||||
if len(path) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
val := signatureValueString(value)
|
|
||||||
segments := append(append([]string{}, path...), val)
|
|
||||||
return strings.Join(segments, ":")
|
|
||||||
}
|
|
||||||
|
|
||||||
func signatureValueString(value any) string {
|
|
||||||
switch v := value.(type) {
|
|
||||||
case nil:
|
|
||||||
return "null"
|
|
||||||
case string:
|
|
||||||
return v
|
|
||||||
case json.Number:
|
|
||||||
return v.String()
|
|
||||||
case bool:
|
|
||||||
if v {
|
|
||||||
return "1"
|
|
||||||
}
|
|
||||||
return "0"
|
|
||||||
case float64:
|
|
||||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
|
||||||
case float32:
|
|
||||||
return strconv.FormatFloat(float64(v), 'f', -1, 32)
|
|
||||||
case int:
|
|
||||||
return strconv.Itoa(v)
|
|
||||||
case int8, int16, int32, int64:
|
|
||||||
return fmt.Sprint(v)
|
|
||||||
case uint, uint8, uint16, uint32, uint64:
|
|
||||||
return fmt.Sprint(v)
|
|
||||||
default:
|
|
||||||
return fmt.Sprint(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
package monetix
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestSignaturePayloadString_Example(t *testing.T) {
|
|
||||||
payload := map[string]any{
|
|
||||||
"general": map[string]any{
|
|
||||||
"project_id": 3254,
|
|
||||||
"payment_id": "id_38202316",
|
|
||||||
"signature": "<ignored>",
|
|
||||||
},
|
|
||||||
"customer": map[string]any{
|
|
||||||
"id": "585741",
|
|
||||||
"email": "johndoe@example.com",
|
|
||||||
"first_name": "John",
|
|
||||||
"last_name": "Doe",
|
|
||||||
"address": "Downing str., 23",
|
|
||||||
"identify": map[string]any{
|
|
||||||
"doc_number": "54122312544",
|
|
||||||
},
|
|
||||||
"ip_address": "198.51.100.47",
|
|
||||||
},
|
|
||||||
"payment": map[string]any{
|
|
||||||
"amount": 10800,
|
|
||||||
"currency": "USD",
|
|
||||||
"description": "Computer keyboards",
|
|
||||||
},
|
|
||||||
"receipt_data": map[string]any{
|
|
||||||
"positions": []any{
|
|
||||||
map[string]any{
|
|
||||||
"quantity": "10",
|
|
||||||
"amount": "108",
|
|
||||||
"description": "Computer keyboard",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"return_url": map[string]any{
|
|
||||||
"success": "https://paymentpage.example.com/complete-redirect?id=success",
|
|
||||||
"decline": "https://paymentpage.example.com/complete-redirect?id=decline",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := signaturePayloadString(payload)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to build signature string: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := "customer:address:Downing str., 23;customer:email:johndoe@example.com;customer:first_name:John;customer:id:585741;customer:identify:doc_number:54122312544;customer:ip_address:198.51.100.47;customer:last_name:Doe;general:payment_id:id_38202316;general:project_id:3254;payment:amount:10800;payment:currency:USD;payment:description:Computer keyboards;receipt_data:positions:0:amount:108;receipt_data:positions:0:description:Computer keyboard;receipt_data:positions:0:quantity:10;return_url:decline:https://paymentpage.example.com/complete-redirect?id=decline;return_url:success:https://paymentpage.example.com/complete-redirect?id=success"
|
|
||||||
if got != expected {
|
|
||||||
t.Fatalf("unexpected signature string\nexpected: %s\ngot: %s", expected, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSignPayload_Example(t *testing.T) {
|
|
||||||
payload := map[string]any{
|
|
||||||
"general": map[string]any{
|
|
||||||
"project_id": 3254,
|
|
||||||
"payment_id": "id_38202316",
|
|
||||||
"signature": "<ignored>",
|
|
||||||
},
|
|
||||||
"customer": map[string]any{
|
|
||||||
"id": "585741",
|
|
||||||
"email": "johndoe@example.com",
|
|
||||||
"first_name": "John",
|
|
||||||
"last_name": "Doe",
|
|
||||||
"address": "Downing str., 23",
|
|
||||||
"identify": map[string]any{
|
|
||||||
"doc_number": "54122312544",
|
|
||||||
},
|
|
||||||
"ip_address": "198.51.100.47",
|
|
||||||
},
|
|
||||||
"payment": map[string]any{
|
|
||||||
"amount": 10800,
|
|
||||||
"currency": "USD",
|
|
||||||
"description": "Computer keyboards",
|
|
||||||
},
|
|
||||||
"receipt_data": map[string]any{
|
|
||||||
"positions": []any{
|
|
||||||
map[string]any{
|
|
||||||
"quantity": "10",
|
|
||||||
"amount": "108",
|
|
||||||
"description": "Computer keyboard",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"return_url": map[string]any{
|
|
||||||
"success": "https://paymentpage.example.com/complete-redirect?id=success",
|
|
||||||
"decline": "https://paymentpage.example.com/complete-redirect?id=decline",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := SignPayload(payload, "secret")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to sign payload: %v", err)
|
|
||||||
}
|
|
||||||
expected := "lagSnuspAn+F6XkmQISqwtBg0PsiTy62fF9x33TM+278mnufIDZyi1yP0BQALuCxyikkIxIMbodBn2F8hMdRwA=="
|
|
||||||
if got != expected {
|
|
||||||
t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSignaturePayloadString_BooleansAndArrays(t *testing.T) {
|
|
||||||
payload := map[string]any{
|
|
||||||
"flag": true,
|
|
||||||
"false_flag": false,
|
|
||||||
"empty": "",
|
|
||||||
"zero": 0,
|
|
||||||
"nested": map[string]any{
|
|
||||||
"list": []any{},
|
|
||||||
"items": []any{"alpha", "beta"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := signaturePayloadString(payload)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to build signature string: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := "empty:;false_flag:0;flag:1;nested:items:0:alpha;nested:items:1:beta;zero:0"
|
|
||||||
if got != expected {
|
|
||||||
t.Fatalf("unexpected signature string\nexpected: %s\ngot: %s", expected, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSignPayload_EthEstimateGasExample(t *testing.T) {
|
|
||||||
payload := map[string]any{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 3,
|
|
||||||
"method": "eth_estimateGas",
|
|
||||||
"params": []any{
|
|
||||||
map[string]any{
|
|
||||||
"from": "0xfa89b4d534bdeb2713d4ffd893e79d6535fb58f8",
|
|
||||||
"to": "0x44162e39eefd9296231e772663a92e72958e182f",
|
|
||||||
"gasPrice": "0x64",
|
|
||||||
"data": "0xa9059cbb00000000000000000000000044162e39eefd9296231e772663a92e72958e182f00000000000000000000000000000000000000000000000000000000000f4240",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := SignPayload(payload, "1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to sign payload: %v", err)
|
|
||||||
}
|
|
||||||
expected := "C4WbSvXKSMyX8yLamQcUe/Nzr6nSt9m3HYn4jHSyA7yi/FaTiqk0r8BlfIzfxSCoDaRgrSd82ihgZW+DxELhdQ=="
|
|
||||||
if got != expected {
|
|
||||||
t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -51,12 +51,6 @@ gateway:
|
|||||||
call_timeout_seconds: 3
|
call_timeout_seconds: 3
|
||||||
insecure: true
|
insecure: true
|
||||||
|
|
||||||
mntx:
|
|
||||||
address: "sendico_mntx_gateway:50075"
|
|
||||||
dial_timeout_seconds: 5
|
|
||||||
call_timeout_seconds: 3
|
|
||||||
insecure: true
|
|
||||||
|
|
||||||
oracle:
|
oracle:
|
||||||
address: "sendico_fx_oracle:50051"
|
address: "sendico_fx_oracle:50051"
|
||||||
dial_timeout_seconds: 5
|
dial_timeout_seconds: 5
|
||||||
@@ -66,7 +60,7 @@ oracle:
|
|||||||
card_gateways:
|
card_gateways:
|
||||||
monetix:
|
monetix:
|
||||||
funding_address: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF"
|
funding_address: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF"
|
||||||
fee_wallet_ref: "694c124ed76f9f811ac57133"
|
fee_wallet_ref: "694c124fd76f9f811ac57134"
|
||||||
|
|
||||||
fee_ledger_accounts:
|
fee_ledger_accounts:
|
||||||
monetix: "ledger:fees:monetix"
|
monetix: "ledger:fees:monetix"
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
|
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||||
mntxclient "github.com/tech/sendico/gateway/mntx/client"
|
|
||||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
|
||||||
"github.com/tech/sendico/payments/orchestrator/storage"
|
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||||
@@ -37,7 +36,6 @@ type Imp struct {
|
|||||||
feesConn *grpc.ClientConn
|
feesConn *grpc.ClientConn
|
||||||
ledgerClient ledgerclient.Client
|
ledgerClient ledgerclient.Client
|
||||||
gatewayClient chainclient.Client
|
gatewayClient chainclient.Client
|
||||||
mntxClient mntxclient.Client
|
|
||||||
oracleClient oracleclient.Client
|
oracleClient oracleclient.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +44,6 @@ type config struct {
|
|||||||
Fees clientConfig `yaml:"fees"`
|
Fees clientConfig `yaml:"fees"`
|
||||||
Ledger clientConfig `yaml:"ledger"`
|
Ledger clientConfig `yaml:"ledger"`
|
||||||
Gateway clientConfig `yaml:"gateway"`
|
Gateway clientConfig `yaml:"gateway"`
|
||||||
Mntx clientConfig `yaml:"mntx"`
|
|
||||||
Oracle clientConfig `yaml:"oracle"`
|
Oracle clientConfig `yaml:"oracle"`
|
||||||
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
|
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
|
||||||
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
|
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
|
||||||
@@ -108,9 +105,6 @@ func (i *Imp) Shutdown() {
|
|||||||
if i.gatewayClient != nil {
|
if i.gatewayClient != nil {
|
||||||
_ = i.gatewayClient.Close()
|
_ = i.gatewayClient.Close()
|
||||||
}
|
}
|
||||||
if i.mntxClient != nil {
|
|
||||||
_ = i.mntxClient.Close()
|
|
||||||
}
|
|
||||||
if i.oracleClient != nil {
|
if i.oracleClient != nil {
|
||||||
_ = i.oracleClient.Close()
|
_ = i.oracleClient.Close()
|
||||||
}
|
}
|
||||||
@@ -145,11 +139,6 @@ func (i *Imp) Start() error {
|
|||||||
i.gatewayClient = gatewayClient
|
i.gatewayClient = gatewayClient
|
||||||
}
|
}
|
||||||
|
|
||||||
mntxClient := i.initMntxClient(cfg.Mntx)
|
|
||||||
if mntxClient != nil {
|
|
||||||
i.mntxClient = mntxClient
|
|
||||||
}
|
|
||||||
|
|
||||||
oracleClient := i.initOracleClient(cfg.Oracle)
|
oracleClient := i.initOracleClient(cfg.Oracle)
|
||||||
if oracleClient != nil {
|
if oracleClient != nil {
|
||||||
i.oracleClient = oracleClient
|
i.oracleClient = oracleClient
|
||||||
@@ -166,9 +155,6 @@ func (i *Imp) Start() error {
|
|||||||
if gatewayClient != nil {
|
if gatewayClient != nil {
|
||||||
opts = append(opts, orchestrator.WithChainGatewayClient(gatewayClient))
|
opts = append(opts, orchestrator.WithChainGatewayClient(gatewayClient))
|
||||||
}
|
}
|
||||||
if mntxClient != nil {
|
|
||||||
opts = append(opts, orchestrator.WithMntxGateway(mntxClient))
|
|
||||||
}
|
|
||||||
if oracleClient != nil {
|
if oracleClient != nil {
|
||||||
opts = append(opts, orchestrator.WithOracleClient(oracleClient))
|
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))
|
conn, err := grpc.DialContext(dialCtx, addr, grpc.WithTransportCredentials(creds))
|
||||||
if err != nil {
|
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
|
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
|
return feesv1.NewFeeEngineClient(conn), conn
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,10 +216,10 @@ func (i *Imp) initLedgerClient(cfg clientConfig) ledgerclient.Client {
|
|||||||
Insecure: cfg.InsecureTransport,
|
Insecure: cfg.InsecureTransport,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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
|
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
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,28 +246,6 @@ func (i *Imp) initGatewayClient(cfg clientConfig) chainclient.Client {
|
|||||||
return 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 {
|
func (i *Imp) initOracleClient(cfg clientConfig) oracleclient.Client {
|
||||||
addr := cfg.address()
|
addr := cfg.address()
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
@@ -298,10 +262,10 @@ func (i *Imp) initOracleClient(cfg clientConfig) oracleclient.Client {
|
|||||||
Insecure: cfg.InsecureTransport,
|
Insecure: cfg.InsecureTransport,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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
|
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
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
type paymentEngine interface {
|
type paymentEngine interface {
|
||||||
EnsureRepository(ctx context.Context) error
|
EnsureRepository(ctx context.Context) error
|
||||||
BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, 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
|
ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error
|
||||||
Repository() storage.Repository
|
Repository() storage.Repository
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef stri
|
|||||||
return e.svc.buildPaymentQuote(ctx, orgRef, req)
|
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)
|
return e.svc.resolvePaymentQuote(ctx, in)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
|
||||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
"go.uber.org/zap"
|
"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 {
|
if err := quotesStore.Create(ctx, record); err != nil {
|
||||||
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
}
|
}
|
||||||
h.logger.Info(
|
h.logger.Info("stored payment quote", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
|
||||||
"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()),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
|
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
|
||||||
@@ -86,7 +79,7 @@ func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.
|
|||||||
if req == nil {
|
if req == nil {
|
||||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
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 {
|
if err != nil {
|
||||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
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,
|
Intent: intent,
|
||||||
PreviewOnly: req.GetPreviewOnly(),
|
PreviewOnly: req.GetPreviewOnly(),
|
||||||
}
|
}
|
||||||
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgID, quoteReq)
|
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, quoteReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
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,
|
ExpiresAt: expiresAt,
|
||||||
}
|
}
|
||||||
record.SetID(primitive.NewObjectID())
|
record.SetID(primitive.NewObjectID())
|
||||||
record.SetOrganizationRef(orgRef)
|
record.SetOrganizationRef(orgID)
|
||||||
if err := quotesStore.Create(ctx, record); err != nil {
|
if err := quotesStore.Create(ctx, record); err != nil {
|
||||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
}
|
}
|
||||||
h.logger.Info("Stored payment quotes",
|
h.logger.Info("stored payment quotes", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
|
||||||
zap.String("quote_ref", quoteRef), mzap.ObjRef("org_ref", orgRef),
|
|
||||||
zap.String("idempotency_key", baseKey), zap.Int("quote_count", len(quotes)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
|
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
|
||||||
@@ -168,7 +158,7 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
|
|||||||
if req == nil {
|
if req == nil {
|
||||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
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 {
|
if err != nil {
|
||||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
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 {
|
if err != nil {
|
||||||
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
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 err != nil {
|
||||||
if errors.Is(err, storage.ErrQuoteNotFound) {
|
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"))
|
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
|
quoteProto.QuoteRef = quoteRef
|
||||||
|
|
||||||
perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents))
|
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))
|
payments = append(payments, toProtoPayment(existing))
|
||||||
continue
|
continue
|
||||||
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
||||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
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 err = store.Create(ctx, entity); err != nil {
|
||||||
if errors.Is(err, storage.ErrDuplicatePayment) {
|
if errors.Is(err, storage.ErrDuplicatePayment) {
|
||||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
|
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))
|
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})
|
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)
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
}
|
}
|
||||||
intent := req.GetIntent()
|
intent := req.GetIntent()
|
||||||
quoteRef := strings.TrimSpace(req.GetQuoteRef())
|
if err := requireNonNilIntent(intent); err != nil {
|
||||||
hasIntent := intent != nil
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
|
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
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())
|
store, err := ensurePaymentsStore(h.engine.Repository())
|
||||||
if err != nil {
|
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 {
|
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
|
||||||
h.logger.Debug(
|
h.logger.Debug("idempotent payment request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
|
||||||
"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),
|
|
||||||
)
|
|
||||||
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)})
|
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)})
|
||||||
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
||||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
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,
|
OrgRef: orgRef,
|
||||||
OrgID: orgID,
|
OrgID: orgID,
|
||||||
Meta: req.GetMeta(),
|
Meta: req.GetMeta(),
|
||||||
Intent: intent,
|
Intent: intent,
|
||||||
QuoteRef: quoteRef,
|
QuoteRef: req.GetQuoteRef(),
|
||||||
IdempotencyKey: req.GetIdempotencyKey(),
|
IdempotencyKey: req.GetIdempotencyKey(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -342,17 +301,8 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
|
|||||||
if quoteSnapshot == nil {
|
if quoteSnapshot == nil {
|
||||||
quoteSnapshot = &orchestratorv1.PaymentQuote{}
|
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 err = store.Create(ctx, entity); err != nil {
|
||||||
if errors.Is(err, storage.ErrDuplicatePayment) {
|
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)
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Info(
|
h.logger.Info("payment initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()), zap.String("kind", intent.GetKind().String()))
|
||||||
"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),
|
|
||||||
)
|
|
||||||
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
|
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
|
||||||
Payment: toProtoPayment(entity),
|
Payment: toProtoPayment(entity),
|
||||||
})
|
})
|
||||||
@@ -412,7 +355,7 @@ func (h *cancelPaymentCommand) Execute(ctx context.Context, req *orchestratorv1.
|
|||||||
if err := store.Update(ctx, payment); err != nil {
|
if err := store.Update(ctx, payment); err != nil {
|
||||||
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
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)})
|
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 {
|
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)})
|
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
|
||||||
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
||||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
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)
|
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{
|
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
|
||||||
Conversion: toProtoPayment(entity),
|
Conversion: toProtoPayment(entity),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -103,40 +103,33 @@ type quoteResolutionError struct {
|
|||||||
|
|
||||||
func (e quoteResolutionError) Error() string { return e.err.Error() }
|
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 != "" {
|
if ref := strings.TrimSpace(in.QuoteRef); ref != "" {
|
||||||
quotesStore, err := ensureQuotesStore(s.storage)
|
quotesStore, err := ensureQuotesStore(s.storage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
|
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, storage.ErrQuoteNotFound) {
|
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) {
|
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 !proto.Equal(protoIntentFromModel(record.Intent), in.Intent) {
|
||||||
if err != nil {
|
return nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
|
||||||
return nil, nil, err
|
|
||||||
}
|
}
|
||||||
if in.Intent != nil && !proto.Equal(intent, in.Intent) {
|
quote := modelQuoteToProto(record.Quote)
|
||||||
return nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
|
if quote == nil {
|
||||||
}
|
return nil, merrors.InvalidArgument("stored quote is empty")
|
||||||
quote, err := recordQuoteFromQuote(record)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
}
|
||||||
quote.QuoteRef = ref
|
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{
|
req := &orchestratorv1.QuotePaymentRequest{
|
||||||
Meta: in.Meta,
|
Meta: in.Meta,
|
||||||
IdempotencyKey: in.IdempotencyKey,
|
IdempotencyKey: in.IdempotencyKey,
|
||||||
@@ -145,41 +138,9 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp
|
|||||||
}
|
}
|
||||||
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
|
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return quote, in.Intent, nil
|
return quote, 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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPayment(orgID primitive.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.PaymentQuote) *model.Payment {
|
func newPayment(orgID primitive.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.PaymentQuote) *model.Payment {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func TestResolvePaymentQuote_NotFound(t *testing.T) {
|
|||||||
storage: stubRepo{quotes: &helperQuotesStore{}},
|
storage: stubRepo{quotes: &helperQuotesStore{}},
|
||||||
clock: clockpkg.NewSystem(),
|
clock: clockpkg.NewSystem(),
|
||||||
}
|
}
|
||||||
_, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||||
OrgRef: org.Hex(),
|
OrgRef: org.Hex(),
|
||||||
OrgID: org,
|
OrgID: org,
|
||||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
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}}},
|
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
|
||||||
clock: clockpkg.NewSystem(),
|
clock: clockpkg.NewSystem(),
|
||||||
}
|
}
|
||||||
_, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||||
OrgRef: org.Hex(),
|
OrgRef: org.Hex(),
|
||||||
OrgID: org,
|
OrgID: org,
|
||||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
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) {
|
func TestInitiatePaymentIdempotency(t *testing.T) {
|
||||||
logger := mloggerfactory.NewLogger(false)
|
logger := mloggerfactory.NewLogger(false)
|
||||||
org := primitive.NewObjectID()
|
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 ---
|
// --- test doubles ---
|
||||||
|
|
||||||
type stubRepo struct {
|
type stubRepo struct {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"github.com/tech/sendico/pkg/api/http/response"
|
"github.com/tech/sendico/pkg/api/http/response"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/model"
|
"github.com/tech/sendico/pkg/model"
|
||||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
|
||||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
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/srequest"
|
||||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
"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 {
|
func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, token *sresponse.TokenData, expectQuote bool) http.HandlerFunc {
|
||||||
orgRef, err := a.oph.GetRef(r)
|
orgRef, err := a.oph.GetRef(r)
|
||||||
if err != nil {
|
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)
|
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)
|
resp, err := a.client.InitiatePayment(ctx, req)
|
||||||
if err != nil {
|
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)
|
return response.Auto(a.logger, a.Name(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -3,11 +3,14 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:pshared/models/payment/methods/data.dart';
|
import 'package:pshared/models/payment/methods/data.dart';
|
||||||
import 'package:pshared/models/payment/type.dart';
|
import 'package:pshared/models/payment/type.dart';
|
||||||
import 'package:pshared/models/recipient/recipient.dart';
|
import 'package:pshared/models/recipient/recipient.dart';
|
||||||
|
import 'package:pshared/provider/recipient/pmethods.dart';
|
||||||
|
|
||||||
|
|
||||||
class PaymentFlowProvider extends ChangeNotifier {
|
class PaymentFlowProvider extends ChangeNotifier {
|
||||||
PaymentType _selectedType;
|
PaymentType _selectedType;
|
||||||
PaymentMethodData? _manualPaymentData;
|
PaymentMethodData? _manualPaymentData;
|
||||||
|
MethodMap _availableTypes = {};
|
||||||
|
Recipient? _recipient;
|
||||||
|
|
||||||
PaymentFlowProvider({
|
PaymentFlowProvider({
|
||||||
required PaymentType initialType,
|
required PaymentType initialType,
|
||||||
@@ -15,57 +18,40 @@ class PaymentFlowProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
PaymentType get selectedType => _selectedType;
|
PaymentType get selectedType => _selectedType;
|
||||||
PaymentMethodData? get manualPaymentData => _manualPaymentData;
|
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 Recipient? recipient,
|
||||||
required MethodMap availableTypes,
|
required PaymentMethodsProvider methodsProvider,
|
||||||
PaymentType? preferredType,
|
PaymentType? preferredType,
|
||||||
}) {
|
}) =>
|
||||||
final resolvedType = _resolveSelectedType(
|
_applyState(
|
||||||
recipient: recipient,
|
recipient: recipient,
|
||||||
availableTypes: availableTypes,
|
availableTypes: methodsProvider.availableTypesForRecipient(recipient),
|
||||||
preferredType: preferredType,
|
preferredType: preferredType,
|
||||||
);
|
forceResetManualData: false,
|
||||||
|
);
|
||||||
var hasChanges = false;
|
|
||||||
if (resolvedType != _selectedType) {
|
|
||||||
_selectedType = resolvedType;
|
|
||||||
hasChanges = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recipient != null && _manualPaymentData != null) {
|
|
||||||
_manualPaymentData = null;
|
|
||||||
hasChanges = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasChanges) notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
void reset({
|
void reset({
|
||||||
required Recipient? recipient,
|
required Recipient? recipient,
|
||||||
required MethodMap availableTypes,
|
required PaymentMethodsProvider methodsProvider,
|
||||||
PaymentType? preferredType,
|
PaymentType? preferredType,
|
||||||
}) {
|
}) =>
|
||||||
final resolvedType = _resolveSelectedType(
|
_applyState(
|
||||||
recipient: recipient,
|
recipient: recipient,
|
||||||
availableTypes: availableTypes,
|
availableTypes: methodsProvider.availableTypesForRecipient(recipient),
|
||||||
preferredType: preferredType,
|
preferredType: preferredType,
|
||||||
);
|
forceResetManualData: true,
|
||||||
|
);
|
||||||
var hasChanges = false;
|
|
||||||
|
|
||||||
if (resolvedType != _selectedType) {
|
|
||||||
_selectedType = resolvedType;
|
|
||||||
hasChanges = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_manualPaymentData != null) {
|
|
||||||
_manualPaymentData = null;
|
|
||||||
hasChanges = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasChanges) notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
void selectType(PaymentType type, {bool resetManualData = false}) {
|
void selectType(PaymentType type, {bool resetManualData = false}) {
|
||||||
if (_selectedType == type && (!resetManualData || _manualPaymentData == null)) {
|
if (_selectedType == type && (!resetManualData || _manualPaymentData == null)) {
|
||||||
@@ -107,4 +93,41 @@ class PaymentFlowProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
return availableTypes.keys.first;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
|
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import 'package:pshared/api/requests/payment/quote.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/amount.dart';
|
||||||
import 'package:pshared/provider/payment/flow.dart';
|
import 'package:pshared/provider/payment/flow.dart';
|
||||||
import 'package:pshared/provider/payment/wallets.dart';
|
import 'package:pshared/provider/payment/wallets.dart';
|
||||||
import 'package:pshared/provider/recipient/pmethods.dart';
|
|
||||||
import 'package:pshared/provider/resource.dart';
|
import 'package:pshared/provider/resource.dart';
|
||||||
import 'package:pshared/service/payment/quotation.dart';
|
import 'package:pshared/service/payment/quotation.dart';
|
||||||
import 'package:pshared/utils/currency.dart';
|
import 'package:pshared/utils/currency.dart';
|
||||||
@@ -36,12 +33,10 @@ class QuotationProvider extends ChangeNotifier {
|
|||||||
PaymentAmountProvider payment,
|
PaymentAmountProvider payment,
|
||||||
WalletsProvider wallets,
|
WalletsProvider wallets,
|
||||||
PaymentFlowProvider flow,
|
PaymentFlowProvider flow,
|
||||||
PaymentMethodsProvider methods,
|
|
||||||
) {
|
) {
|
||||||
_organizations = venue;
|
_organizations = venue;
|
||||||
final t = flow.selectedType;
|
final destination = flow.selectedPaymentData;
|
||||||
final method = methods.methods.firstWhereOrNull((m) => m.type == t);
|
if ((wallets.selectedWallet != null) && (destination != null)) {
|
||||||
if ((wallets.selectedWallet != null) && (method != null)) {
|
|
||||||
getQuotation(PaymentIntent(
|
getQuotation(PaymentIntent(
|
||||||
kind: PaymentKind.payout,
|
kind: PaymentKind.payout,
|
||||||
amount: Money(
|
amount: Money(
|
||||||
@@ -49,7 +44,7 @@ class QuotationProvider extends ChangeNotifier {
|
|||||||
// TODO: adapt to possible other sources
|
// TODO: adapt to possible other sources
|
||||||
currency: currencyCodeToString(wallets.selectedWallet!.currency),
|
currency: currencyCodeToString(wallets.selectedWallet!.currency),
|
||||||
),
|
),
|
||||||
destination: method.data,
|
destination: destination,
|
||||||
source: ManagedWalletPaymentMethod(
|
source: ManagedWalletPaymentMethod(
|
||||||
managedWalletRef: wallets.selectedWallet!.id,
|
managedWalletRef: wallets.selectedWallet!.id,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import 'package:pshared/models/describable.dart';
|
|||||||
import 'package:pshared/models/organization/bound.dart';
|
import 'package:pshared/models/organization/bound.dart';
|
||||||
import 'package:pshared/models/payment/methods/data.dart';
|
import 'package:pshared/models/payment/methods/data.dart';
|
||||||
import 'package:pshared/models/payment/methods/type.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/permissions/bound.dart';
|
||||||
import 'package:pshared/models/storable.dart';
|
import 'package:pshared/models/storable.dart';
|
||||||
import 'package:pshared/provider/organizations.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> 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) {
|
void updateProviders(OrganizationsProvider organizations, RecipientsProvider recipients) {
|
||||||
if (recipients.currentObject != null) loadMethods(organizations, recipients.currentObject?.id);
|
if (recipients.currentObject != null) loadMethods(organizations, recipients.currentObject?.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
30
frontend/pweb/lib/pages/dashboard/payouts/widget.dart
Normal file
30
frontend/pweb/lib/pages/dashboard/payouts/widget.dart
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,21 +4,17 @@ import 'package:collection/collection.dart';
|
|||||||
|
|
||||||
import 'package:provider/provider.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/methods/type.dart';
|
||||||
import 'package:pshared/models/payment/type.dart';
|
import 'package:pshared/models/payment/type.dart';
|
||||||
import 'package:pshared/models/recipient/recipient.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/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/pmethods.dart';
|
||||||
import 'package:pshared/provider/recipient/provider.dart';
|
import 'package:pshared/provider/recipient/provider.dart';
|
||||||
|
|
||||||
import 'package:pshared/models/payment/wallet.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: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/widgets/sidebar/destinations.dart';
|
||||||
import 'package:pweb/services/posthog.dart';
|
import 'package:pweb/services/posthog.dart';
|
||||||
|
|
||||||
@@ -68,20 +64,29 @@ class _PaymentPageState extends State<PaymentPage> {
|
|||||||
context.read<RecipientsProvider>().setQuery(query);
|
context.read<RecipientsProvider>().setQuery(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleRecipientSelected(BuildContext context, Recipient recipient) {
|
void _handleRecipientSelected(Recipient recipient) {
|
||||||
final recipientProvider = context.read<RecipientsProvider>();
|
final recipientProvider = context.read<RecipientsProvider>();
|
||||||
|
final methodsProvider = context.read<PaymentMethodsProvider>();
|
||||||
|
final flowProvider = context.read<PaymentFlowProvider>();
|
||||||
|
|
||||||
recipientProvider.setCurrentObject(recipient.id);
|
recipientProvider.setCurrentObject(recipient.id);
|
||||||
|
flowProvider.reset(
|
||||||
|
recipient: recipient,
|
||||||
|
methodsProvider: methodsProvider,
|
||||||
|
preferredType: widget.initialPaymentType,
|
||||||
|
);
|
||||||
_clearSearchField();
|
_clearSearchField();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleRecipientCleared(BuildContext context) {
|
void _handleRecipientCleared() {
|
||||||
final recipientProvider = context.read<RecipientsProvider>();
|
final recipientProvider = context.read<RecipientsProvider>();
|
||||||
final methodsProvider = context.read<PaymentMethodsProvider>();
|
final methodsProvider = context.read<PaymentMethodsProvider>();
|
||||||
|
final flowProvider = context.read<PaymentFlowProvider>();
|
||||||
|
|
||||||
recipientProvider.setCurrentObject(null);
|
recipientProvider.setCurrentObject(null);
|
||||||
context.read<PaymentFlowProvider>().reset(
|
flowProvider.reset(
|
||||||
recipient: null,
|
recipient: null,
|
||||||
availableTypes: _availablePaymentTypes(null, methodsProvider),
|
methodsProvider: methodsProvider,
|
||||||
preferredType: widget.initialPaymentType,
|
preferredType: widget.initialPaymentType,
|
||||||
);
|
);
|
||||||
_clearSearchField();
|
_clearSearchField();
|
||||||
@@ -93,13 +98,9 @@ class _PaymentPageState extends State<PaymentPage> {
|
|||||||
context.read<RecipientsProvider>().setQuery('');
|
context.read<RecipientsProvider>().setQuery('');
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleSendPayment(BuildContext context) {
|
void _handleSendPayment() {
|
||||||
if (context.read<QuotationProvider>().isReady) {
|
// TODO: Handle Payment logic
|
||||||
context.read<PaymentProvider>().pay();
|
PosthogService.paymentInitiated(method: context.read<PaymentFlowProvider>().selectedType);
|
||||||
PosthogService.paymentInitiated(
|
|
||||||
method: context.read<PaymentFlowProvider>().selectedType,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -107,51 +108,35 @@ class _PaymentPageState extends State<PaymentPage> {
|
|||||||
final methodsProvider = context.watch<PaymentMethodsProvider>();
|
final methodsProvider = context.watch<PaymentMethodsProvider>();
|
||||||
final recipientProvider = context.watch<RecipientsProvider>();
|
final recipientProvider = context.watch<RecipientsProvider>();
|
||||||
final recipient = recipientProvider.currentObject;
|
final recipient = recipientProvider.currentObject;
|
||||||
final availableTypes = _availablePaymentTypes(recipient, methodsProvider);
|
|
||||||
|
|
||||||
return MultiProvider(
|
return ChangeNotifierProxyProvider2<RecipientsProvider, PaymentMethodsProvider, PaymentFlowProvider>(
|
||||||
providers: [
|
create: (_) => PaymentFlowProvider(
|
||||||
ChangeNotifierProxyProvider2<RecipientsProvider, PaymentMethodsProvider, PaymentFlowProvider>(
|
initialType: widget.initialPaymentType ?? PaymentType.bankAccount,
|
||||||
create: (_) => PaymentFlowProvider(
|
),
|
||||||
initialType: widget.initialPaymentType ?? PaymentType.bankAccount,
|
update: (_, recipients, methods, flow) {
|
||||||
),
|
final provider = flow ?? PaymentFlowProvider(
|
||||||
update: (_, recipients, methods, flow) {
|
initialType: widget.initialPaymentType ?? PaymentType.bankAccount,
|
||||||
final currentRecipient = recipients.currentObject;
|
);
|
||||||
flow!.sync(
|
final currentRecipient = recipients.currentObject;
|
||||||
recipient: currentRecipient,
|
provider.syncWith(
|
||||||
availableTypes: _availablePaymentTypes(currentRecipient, methods),
|
recipient: currentRecipient,
|
||||||
preferredType: currentRecipient != null ? widget.initialPaymentType : null,
|
methodsProvider: methods,
|
||||||
);
|
preferredType: currentRecipient != null ? widget.initialPaymentType : null,
|
||||||
return flow;
|
);
|
||||||
},
|
return provider;
|
||||||
),
|
},
|
||||||
ChangeNotifierProvider(
|
child: PaymentPageBody(
|
||||||
create: (_) => PaymentAmountProvider(),
|
onBack: widget.onBack,
|
||||||
),
|
fallbackDestination: widget.fallbackDestination,
|
||||||
ChangeNotifierProxyProvider5<OrganizationsProvider, PaymentAmountProvider, WalletsProvider, PaymentFlowProvider, PaymentMethodsProvider, QuotationProvider>(
|
recipient: recipient,
|
||||||
create: (_) => QuotationProvider(),
|
recipientProvider: recipientProvider,
|
||||||
update: (_, organization, payment, wallet, flow, methods, provider) => provider!..update(organization, payment, wallet, flow, methods),
|
methodsProvider: methodsProvider,
|
||||||
),
|
searchController: _searchController,
|
||||||
ChangeNotifierProxyProvider2<OrganizationsProvider, QuotationProvider, PaymentProvider>(
|
searchFocusNode: _searchFocusNode,
|
||||||
create: (_) => PaymentProvider(),
|
onSearchChanged: _handleSearchChanged,
|
||||||
update: (_, organization, quotation, provider) => provider!..update(organization, quotation),
|
onRecipientSelected: _handleRecipientSelected,
|
||||||
),
|
onRecipientCleared: _handleRecipientCleared,
|
||||||
],
|
onSend: _handleSendPayment,
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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(
|
PaymentMethod? _getPaymentMethodForWallet(
|
||||||
Wallet wallet,
|
Wallet wallet,
|
||||||
PaymentMethodsProvider methodsProvider,
|
PaymentMethodsProvider methodsProvider,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:pshared/models/payment/methods/data.dart';
|
|
||||||
import 'package:pshared/models/recipient/recipient.dart';
|
import 'package:pshared/models/recipient/recipient.dart';
|
||||||
import 'package:pshared/provider/recipient/pmethods.dart';
|
import 'package:pshared/provider/recipient/pmethods.dart';
|
||||||
import 'package:pshared/provider/recipient/provider.dart';
|
import 'package:pshared/provider/recipient/provider.dart';
|
||||||
@@ -17,7 +16,6 @@ class PaymentPageBody extends StatelessWidget {
|
|||||||
final Recipient? recipient;
|
final Recipient? recipient;
|
||||||
final RecipientsProvider recipientProvider;
|
final RecipientsProvider recipientProvider;
|
||||||
final PaymentMethodsProvider methodsProvider;
|
final PaymentMethodsProvider methodsProvider;
|
||||||
final MethodMap availablePaymentTypes;
|
|
||||||
final PayoutDestination fallbackDestination;
|
final PayoutDestination fallbackDestination;
|
||||||
final TextEditingController searchController;
|
final TextEditingController searchController;
|
||||||
final FocusNode searchFocusNode;
|
final FocusNode searchFocusNode;
|
||||||
@@ -32,7 +30,6 @@ class PaymentPageBody extends StatelessWidget {
|
|||||||
required this.recipient,
|
required this.recipient,
|
||||||
required this.recipientProvider,
|
required this.recipientProvider,
|
||||||
required this.methodsProvider,
|
required this.methodsProvider,
|
||||||
required this.availablePaymentTypes,
|
|
||||||
required this.fallbackDestination,
|
required this.fallbackDestination,
|
||||||
required this.searchController,
|
required this.searchController,
|
||||||
required this.searchFocusNode,
|
required this.searchFocusNode,
|
||||||
@@ -61,7 +58,6 @@ class PaymentPageBody extends StatelessWidget {
|
|||||||
recipient: recipient,
|
recipient: recipient,
|
||||||
recipientProvider: recipientProvider,
|
recipientProvider: recipientProvider,
|
||||||
methodsProvider: methodsProvider,
|
methodsProvider: methodsProvider,
|
||||||
availablePaymentTypes: availablePaymentTypes,
|
|
||||||
fallbackDestination: fallbackDestination,
|
fallbackDestination: fallbackDestination,
|
||||||
searchController: searchController,
|
searchController: searchController,
|
||||||
searchFocusNode: searchFocusNode,
|
searchFocusNode: searchFocusNode,
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/models/recipient/recipient.dart';
|
||||||
import 'package:pshared/provider/recipient/pmethods.dart';
|
import 'package:pshared/provider/recipient/pmethods.dart';
|
||||||
import 'package:pshared/provider/recipient/provider.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/back_button.dart';
|
||||||
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
|
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
|
||||||
@@ -27,7 +23,6 @@ class PaymentPageContent extends StatelessWidget {
|
|||||||
final Recipient? recipient;
|
final Recipient? recipient;
|
||||||
final RecipientsProvider recipientProvider;
|
final RecipientsProvider recipientProvider;
|
||||||
final PaymentMethodsProvider methodsProvider;
|
final PaymentMethodsProvider methodsProvider;
|
||||||
final MethodMap availablePaymentTypes;
|
|
||||||
final PayoutDestination fallbackDestination;
|
final PayoutDestination fallbackDestination;
|
||||||
final TextEditingController searchController;
|
final TextEditingController searchController;
|
||||||
final FocusNode searchFocusNode;
|
final FocusNode searchFocusNode;
|
||||||
@@ -42,7 +37,6 @@ class PaymentPageContent extends StatelessWidget {
|
|||||||
required this.recipient,
|
required this.recipient,
|
||||||
required this.recipientProvider,
|
required this.recipientProvider,
|
||||||
required this.methodsProvider,
|
required this.methodsProvider,
|
||||||
required this.availablePaymentTypes,
|
|
||||||
required this.fallbackDestination,
|
required this.fallbackDestination,
|
||||||
required this.searchController,
|
required this.searchController,
|
||||||
required this.searchFocusNode,
|
required this.searchFocusNode,
|
||||||
@@ -55,7 +49,6 @@ class PaymentPageContent extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final dimensions = AppDimensions();
|
final dimensions = AppDimensions();
|
||||||
final flowProvider = context.watch<PaymentFlowProvider>();
|
|
||||||
final loc = AppLocalizations.of(context)!;
|
final loc = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
return Align(
|
return Align(
|
||||||
@@ -98,12 +91,7 @@ class PaymentPageContent extends StatelessWidget {
|
|||||||
onRecipientCleared: onRecipientCleared,
|
onRecipientCleared: onRecipientCleared,
|
||||||
),
|
),
|
||||||
SizedBox(height: dimensions.paddingXLarge),
|
SizedBox(height: dimensions.paddingXLarge),
|
||||||
PaymentInfoSection(
|
PaymentInfoSection(dimensions: dimensions),
|
||||||
dimensions: dimensions,
|
|
||||||
flowProvider: flowProvider,
|
|
||||||
recipient: recipient,
|
|
||||||
availableTypes: availablePaymentTypes,
|
|
||||||
),
|
|
||||||
SizedBox(height: dimensions.paddingLarge),
|
SizedBox(height: dimensions.paddingLarge),
|
||||||
const PaymentFormWidget(),
|
const PaymentFormWidget(),
|
||||||
SizedBox(height: dimensions.paddingXXXLarge),
|
SizedBox(height: dimensions.paddingXXXLarge),
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/models/recipient/recipient.dart';
|
||||||
import 'package:pshared/provider/recipient/pmethods.dart';
|
import 'package:pshared/provider/recipient/pmethods.dart';
|
||||||
import 'package:pshared/provider/recipient/provider.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/back_button.dart';
|
||||||
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
|
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
|
||||||
import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart';
|
import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart';
|
||||||
@@ -27,7 +23,6 @@ class PaymentPageContent extends StatelessWidget {
|
|||||||
final Recipient? recipient;
|
final Recipient? recipient;
|
||||||
final RecipientsProvider recipientProvider;
|
final RecipientsProvider recipientProvider;
|
||||||
final PaymentMethodsProvider methodsProvider;
|
final PaymentMethodsProvider methodsProvider;
|
||||||
final MethodMap availablePaymentTypes;
|
|
||||||
final PayoutDestination fallbackDestination;
|
final PayoutDestination fallbackDestination;
|
||||||
final TextEditingController searchController;
|
final TextEditingController searchController;
|
||||||
final FocusNode searchFocusNode;
|
final FocusNode searchFocusNode;
|
||||||
@@ -42,7 +37,6 @@ class PaymentPageContent extends StatelessWidget {
|
|||||||
required this.recipient,
|
required this.recipient,
|
||||||
required this.recipientProvider,
|
required this.recipientProvider,
|
||||||
required this.methodsProvider,
|
required this.methodsProvider,
|
||||||
required this.availablePaymentTypes,
|
|
||||||
required this.fallbackDestination,
|
required this.fallbackDestination,
|
||||||
required this.searchController,
|
required this.searchController,
|
||||||
required this.searchFocusNode,
|
required this.searchFocusNode,
|
||||||
@@ -55,7 +49,6 @@ class PaymentPageContent extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final dimensions = AppDimensions();
|
final dimensions = AppDimensions();
|
||||||
final flowProvider = context.watch<PaymentFlowProvider>();
|
|
||||||
final loc = AppLocalizations.of(context)!;
|
final loc = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
return Align(
|
return Align(
|
||||||
@@ -98,14 +91,9 @@ class PaymentPageContent extends StatelessWidget {
|
|||||||
onRecipientCleared: onRecipientCleared,
|
onRecipientCleared: onRecipientCleared,
|
||||||
),
|
),
|
||||||
SizedBox(height: dimensions.paddingXLarge),
|
SizedBox(height: dimensions.paddingXLarge),
|
||||||
PaymentInfoSection(
|
PaymentInfoSection(dimensions: dimensions),
|
||||||
dimensions: dimensions,
|
|
||||||
flowProvider: flowProvider,
|
|
||||||
recipient: recipient,
|
|
||||||
availableTypes: availablePaymentTypes,
|
|
||||||
),
|
|
||||||
SizedBox(height: dimensions.paddingLarge),
|
SizedBox(height: dimensions.paddingLarge),
|
||||||
const PaymentFormWidget(),
|
const PaymentFromWrappingWidget(),
|
||||||
SizedBox(height: dimensions.paddingXXXLarge),
|
SizedBox(height: dimensions.paddingXXXLarge),
|
||||||
SendButton(onPressed: onSend),
|
SendButton(onPressed: onSend),
|
||||||
SizedBox(height: dimensions.paddingLarge),
|
SizedBox(height: dimensions.paddingLarge),
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:pshared/models/payment/methods/data.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:pshared/provider/payment/flow.dart';
|
||||||
|
|
||||||
import 'package:pweb/pages/payment_methods/form.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 {
|
class PaymentInfoSection extends StatelessWidget {
|
||||||
final AppDimensions dimensions;
|
final AppDimensions dimensions;
|
||||||
final MethodMap availableTypes;
|
|
||||||
final PaymentFlowProvider flowProvider;
|
|
||||||
final Recipient? recipient;
|
|
||||||
|
|
||||||
const PaymentInfoSection({
|
const PaymentInfoSection({
|
||||||
super.key,
|
super.key,
|
||||||
required this.dimensions,
|
required this.dimensions,
|
||||||
required this.availableTypes,
|
|
||||||
required this.flowProvider,
|
|
||||||
required this.recipient,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final loc = AppLocalizations.of(context)!;
|
final loc = AppLocalizations.of(context)!;
|
||||||
final hasRecipient = recipient != null;
|
final flowProvider = context.watch<PaymentFlowProvider>();
|
||||||
final MethodMap resolvedAvailableTypes = hasRecipient
|
final hasRecipient = flowProvider.hasRecipient;
|
||||||
? availableTypes
|
final MethodMap resolvedAvailableTypes = flowProvider.availableTypes;
|
||||||
: {for (final type in PaymentType.values) type: null};
|
|
||||||
|
|
||||||
if (hasRecipient && resolvedAvailableTypes.isEmpty) {
|
if (hasRecipient && resolvedAvailableTypes.isEmpty) {
|
||||||
return Text(loc.recipientNoPaymentDetails);
|
return Text(loc.recipientNoPaymentDetails);
|
||||||
@@ -62,7 +54,7 @@ class PaymentInfoSection extends StatelessWidget {
|
|||||||
flowProvider.setManualPaymentData(data);
|
flowProvider.setManualPaymentData(data);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
initialData: hasRecipient ? resolvedAvailableTypes[selectedType] : flowProvider.manualPaymentData,
|
initialData: flowProvider.selectedPaymentData,
|
||||||
isEditable: !hasRecipient,
|
isEditable: !hasRecipient,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
210
frontend/pweb/lib/services/amplitude.dart
Normal file
210
frontend/pweb/lib/services/amplitude.dart
Normal 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 / 100–500 / 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user