1 Commits

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

View File

@@ -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{

View File

@@ -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 = &copyWallet
}
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{

View File

@@ -25,7 +25,7 @@ func NewSubmitTransfer(deps Deps) *submitTransferCommand {
func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.SubmitTransferRequest) gsresponse.Responder[chainv1.SubmitTransferResponse] { 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)
} }

View File

@@ -95,7 +95,7 @@ func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network sh
} }
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) { 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),

View File

@@ -95,7 +95,7 @@ func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network sh
} }
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) { 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),

View File

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

View File

@@ -10,7 +10,6 @@ import (
"github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum"
"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 {

View File

@@ -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)

View File

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

View File

@@ -81,12 +81,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
client, err := o.clients.Client(network.Name) 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()),

View File

@@ -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

View File

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

View File

@@ -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(), "")

View File

@@ -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
} }

View File

@@ -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"

View File

@@ -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))
} }
}() }()

View File

@@ -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")
}
}

View File

@@ -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)
} }

View File

@@ -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")
}
}

View File

@@ -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})
} }

View File

@@ -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)
})
}
}

View File

@@ -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))
} }
} }

View File

@@ -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())
}
}

View File

@@ -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)
})
}
}

View File

@@ -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")
}

View File

@@ -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})
} }

View File

@@ -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))
} }
} }

View File

@@ -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
} }

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}
})
}
}

View File

@@ -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))
} }
} }

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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"

View File

@@ -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
} }

View File

@@ -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)
} }

View File

@@ -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),
}) })

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)
} }

View File

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

View File

@@ -3,11 +3,14 @@ import 'package:flutter/foundation.dart';
import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/models/payment/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();
}
} }

View File

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

View File

@@ -1,7 +1,5 @@
import 'package:flutter/material.dart'; import 'package: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,
), ),

View File

@@ -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);
} }

View File

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

View File

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

View File

@@ -4,21 +4,17 @@ import 'package:collection/collection.dart';
import 'package:provider/provider.dart'; import 'package: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,

View File

@@ -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,

View File

@@ -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),

View File

@@ -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),

View File

@@ -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,
), ),
], ],

View File

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