Compare commits
26 Commits
SEND017
...
34a565d86d
| Author | SHA1 | Date | |
|---|---|---|---|
| 34a565d86d | |||
|
|
171d90b3f7 | ||
| 5191336a49 | |||
|
|
48f64a722d | ||
| bde453d106 | |||
|
|
3bb33b8895 | ||
| 8ee092089f | |||
|
|
eca3d0d62e | ||
| aba743406a | |||
|
|
deb29efde3 | ||
| 6995afc47d | |||
|
|
7b645a3bbe | ||
| 0ddd92b88b | |||
|
|
6151e3d3a5 | ||
| af7abbb095 | |||
|
|
71be1ef9f0 | ||
| 3df358d865 | |||
|
|
c6b2ba486b | ||
| d324e455cc | |||
|
|
8c87e5534e | ||
| bcb3e9e647 | |||
|
|
43f26143df | ||
| ed6e6bf1ba | |||
|
|
2d38b974ba | ||
|
|
610296b301 | ||
|
|
fcc68c8380 |
@@ -17,21 +17,21 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
|
||||
managedRef := strings.TrimSpace(dest.GetManagedWalletRef())
|
||||
external := strings.TrimSpace(dest.GetExternalAddress())
|
||||
if managedRef != "" && external != "" {
|
||||
deps.Logger.Warn("both managed and external destination provided")
|
||||
deps.Logger.Warn("Both managed and external destination provided")
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination must be managed_wallet_ref or external_address")
|
||||
}
|
||||
if managedRef != "" {
|
||||
wallet, err := deps.Storage.Wallets().Get(ctx, managedRef)
|
||||
if err != nil {
|
||||
deps.Logger.Warn("destination wallet lookup failed", zap.Error(err), zap.String("managed_wallet_ref", managedRef))
|
||||
deps.Logger.Warn("Destination wallet lookup failed", zap.Error(err), zap.String("managed_wallet_ref", managedRef))
|
||||
return model.TransferDestination{}, err
|
||||
}
|
||||
if !strings.EqualFold(wallet.Network, source.Network) {
|
||||
deps.Logger.Warn("destination wallet network mismatch", zap.String("source_network", source.Network), zap.String("dest_network", wallet.Network))
|
||||
deps.Logger.Warn("Destination wallet network mismatch", zap.String("source_network", source.Network), zap.String("dest_network", wallet.Network))
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch")
|
||||
}
|
||||
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||
deps.Logger.Warn("destination wallet missing deposit address", zap.String("managed_wallet_ref", managedRef))
|
||||
deps.Logger.Warn("Destination wallet missing deposit address", zap.String("managed_wallet_ref", managedRef))
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet missing deposit address")
|
||||
}
|
||||
return model.TransferDestination{
|
||||
@@ -40,21 +40,21 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
|
||||
}, nil
|
||||
}
|
||||
if external == "" {
|
||||
deps.Logger.Warn("destination external address missing")
|
||||
deps.Logger.Warn("Destination external address missing")
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
||||
}
|
||||
if deps.Drivers == nil {
|
||||
deps.Logger.Warn("chain drivers missing", zap.String("network", source.Network))
|
||||
deps.Logger.Warn("Chain drivers missing", zap.String("network", source.Network))
|
||||
return model.TransferDestination{}, merrors.Internal("chain drivers not configured")
|
||||
}
|
||||
chainDriver, err := deps.Drivers.Driver(source.Network)
|
||||
if err != nil {
|
||||
deps.Logger.Warn("unsupported chain driver", zap.String("network", source.Network), zap.Error(err))
|
||||
deps.Logger.Warn("Unsupported chain driver", zap.String("network", source.Network), zap.Error(err))
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("unsupported chain for wallet")
|
||||
}
|
||||
normalized, err := chainDriver.NormalizeAddress(external)
|
||||
if err != nil {
|
||||
deps.Logger.Warn("invalid external address", zap.Error(err))
|
||||
deps.Logger.Warn("Invalid external address", zap.Error(err))
|
||||
return model.TransferDestination{}, err
|
||||
}
|
||||
return model.TransferDestination{
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
@@ -23,11 +24,11 @@ func NewEstimateTransfer(deps Deps) *estimateTransferFeeCommand {
|
||||
|
||||
func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) gsresponse.Responder[chainv1.EstimateTransferFeeResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("nil request")
|
||||
c.deps.Logger.Warn("Empty request received")
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
|
||||
}
|
||||
|
||||
@@ -45,58 +46,67 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
|
||||
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
|
||||
c.deps.Logger.Warn("Source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
||||
c.deps.Logger.Warn("Storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||
networkCfg, ok := c.deps.Networks.Network(networkKey)
|
||||
if !ok {
|
||||
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
||||
c.deps.Logger.Warn("Unsupported chain", zap.String("network", networkKey))
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||
}
|
||||
if c.deps.Drivers == nil {
|
||||
c.deps.Logger.Warn("chain drivers missing", zap.String("network", networkKey))
|
||||
c.deps.Logger.Warn("Chain drivers missing", zap.String("network", networkKey))
|
||||
return gsresponse.Internal[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
|
||||
}
|
||||
chainDriver, err := c.deps.Drivers.Driver(networkKey)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("unsupported chain driver", zap.String("network", networkKey), zap.Error(err))
|
||||
c.deps.Logger.Warn("Unsupported chain driver", zap.String("network", networkKey), zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||
}
|
||||
|
||||
dest, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
|
||||
c.deps.Logger.Warn("Destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
|
||||
return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("invalid destination", zap.Error(err))
|
||||
c.deps.Logger.Warn("Invalid destination", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
destinationAddress, err := destinationAddress(ctx, c.deps, chainDriver, dest)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("failed to resolve destination address", zap.Error(err))
|
||||
c.deps.Logger.Warn("Failed to resolve destination address", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
walletForFee := sourceWallet
|
||||
nativeCurrency := shared.NativeCurrency(networkCfg)
|
||||
if nativeCurrency != "" && strings.EqualFold(nativeCurrency, amount.GetCurrency()) {
|
||||
copyWallet := *sourceWallet
|
||||
copyWallet.ContractAddress = ""
|
||||
copyWallet.TokenSymbol = nativeCurrency
|
||||
walletForFee = ©Wallet
|
||||
}
|
||||
|
||||
driverDeps := driver.Deps{
|
||||
Logger: c.deps.Logger,
|
||||
Registry: c.deps.Networks,
|
||||
RPCTimeout: c.deps.RPCTimeout,
|
||||
}
|
||||
feeMoney, err := chainDriver.EstimateFee(ctx, driverDeps, networkCfg, sourceWallet, destinationAddress, amount)
|
||||
feeMoney, err := chainDriver.EstimateFee(ctx, driverDeps, networkCfg, walletForFee, destinationAddress, amount)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("fee estimation failed", zap.Error(err))
|
||||
c.deps.Logger.Warn("Fee estimation failed", zap.Error(err))
|
||||
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
contextLabel := "erc20_transfer"
|
||||
if strings.TrimSpace(sourceWallet.ContractAddress) == "" {
|
||||
if strings.TrimSpace(walletForFee.ContractAddress) == "" {
|
||||
contextLabel = "native_transfer"
|
||||
}
|
||||
resp := &chainv1.EstimateTransferFeeResponse{
|
||||
|
||||
@@ -25,7 +25,7 @@ func NewSubmitTransfer(deps Deps) *submitTransferCommand {
|
||||
|
||||
func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.SubmitTransferRequest) gsresponse.Responder[chainv1.SubmitTransferResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
@@ -35,84 +35,92 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
|
||||
|
||||
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
if idempotencyKey == "" {
|
||||
c.deps.Logger.Warn("missing idempotency key")
|
||||
c.deps.Logger.Warn("Missing idempotency key")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||
}
|
||||
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||
if organizationRef == "" {
|
||||
c.deps.Logger.Warn("missing organization ref")
|
||||
c.deps.Logger.Warn("mMssing organization ref")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||
}
|
||||
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
||||
if sourceWalletRef == "" {
|
||||
c.deps.Logger.Warn("missing source wallet ref")
|
||||
c.deps.Logger.Warn("Missing source wallet ref")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
|
||||
}
|
||||
amount := req.GetAmount()
|
||||
if amount == nil {
|
||||
c.deps.Logger.Warn("missing amount")
|
||||
c.deps.Logger.Warn("Missing amount")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
|
||||
}
|
||||
amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
|
||||
if amountCurrency == "" {
|
||||
c.deps.Logger.Warn("missing amount currency")
|
||||
c.deps.Logger.Warn("Missing amount currency")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.currency is required"))
|
||||
}
|
||||
amountValue := strings.TrimSpace(amount.GetAmount())
|
||||
if amountValue == "" {
|
||||
c.deps.Logger.Warn("missing amount value")
|
||||
c.deps.Logger.Warn("Missing amount value")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.amount is required"))
|
||||
}
|
||||
|
||||
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
|
||||
c.deps.Logger.Warn("Source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
||||
c.deps.Logger.Warn("Storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if !strings.EqualFold(sourceWallet.OrganizationRef, organizationRef) {
|
||||
c.deps.Logger.Warn("organization mismatch", zap.String("wallet_org", sourceWallet.OrganizationRef), zap.String("req_org", organizationRef))
|
||||
c.deps.Logger.Warn("Organization mismatch", zap.String("wallet_org", sourceWallet.OrganizationRef), zap.String("req_org", organizationRef))
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
|
||||
}
|
||||
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||
networkCfg, ok := c.deps.Networks.Network(networkKey)
|
||||
if !ok {
|
||||
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
||||
c.deps.Logger.Warn("Unsupported chain", zap.String("network", networkKey))
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||
}
|
||||
|
||||
destination, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
|
||||
c.deps.Logger.Warn("Destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
|
||||
return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("invalid destination", zap.Error(err))
|
||||
c.deps.Logger.Warn("Invalid destination", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
fees, feeSum, err := convertFees(req.GetFees(), amountCurrency)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("fee conversion failed", zap.Error(err))
|
||||
c.deps.Logger.Warn("Fee conversion failed", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
amountDec, err := decimal.NewFromString(amountValue)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("invalid amount", zap.Error(err))
|
||||
c.deps.Logger.Warn("Invalid amount", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("invalid amount"))
|
||||
}
|
||||
netDec := amountDec.Sub(feeSum)
|
||||
if netDec.IsNegative() {
|
||||
c.deps.Logger.Warn("fees exceed amount", zap.String("amount", amountValue), zap.String("fee_sum", feeSum.String()))
|
||||
c.deps.Logger.Warn("Fees exceed amount", zap.String("amount", amountValue), zap.String("fee_sum", feeSum.String()))
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("fees exceed amount"))
|
||||
}
|
||||
|
||||
netAmount := shared.CloneMoney(amount)
|
||||
netAmount.Amount = netDec.String()
|
||||
|
||||
effectiveTokenSymbol := sourceWallet.TokenSymbol
|
||||
effectiveContractAddress := sourceWallet.ContractAddress
|
||||
nativeCurrency := shared.NativeCurrency(networkCfg)
|
||||
if nativeCurrency != "" && strings.EqualFold(nativeCurrency, amountCurrency) {
|
||||
effectiveTokenSymbol = nativeCurrency
|
||||
effectiveContractAddress = ""
|
||||
}
|
||||
|
||||
transfer := &model.Transfer{
|
||||
IdempotencyKey: idempotencyKey,
|
||||
TransferRef: shared.GenerateTransferRef(),
|
||||
@@ -120,8 +128,8 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
|
||||
SourceWalletRef: sourceWalletRef,
|
||||
Destination: destination,
|
||||
Network: sourceWallet.Network,
|
||||
TokenSymbol: sourceWallet.TokenSymbol,
|
||||
ContractAddress: sourceWallet.ContractAddress,
|
||||
TokenSymbol: effectiveTokenSymbol,
|
||||
ContractAddress: effectiveContractAddress,
|
||||
RequestedAmount: shared.CloneMoney(amount),
|
||||
NetAmount: netAmount,
|
||||
Fees: fees,
|
||||
@@ -133,10 +141,10 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
|
||||
saved, err := c.deps.Storage.Transfers().Create(ctx, transfer)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
c.deps.Logger.Debug("transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey))
|
||||
c.deps.Logger.Debug("Transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey))
|
||||
return gsresponse.Success(&chainv1.SubmitTransferResponse{Transfer: toProtoTransfer(saved)})
|
||||
}
|
||||
c.deps.Logger.Warn("storage create failed", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
|
||||
c.deps.Logger.Warn("Storage create failed", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
|
||||
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network sh
|
||||
}
|
||||
|
||||
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||
d.logger.Debug("estimate fee request",
|
||||
d.logger.Debug("Estimate fee request",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("destination", destination),
|
||||
@@ -104,13 +104,13 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
|
||||
if err != nil {
|
||||
d.logger.Warn("estimate fee failed",
|
||||
d.logger.Warn("Estimate fee failed",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("estimate fee result",
|
||||
d.logger.Debug("Estimate fee result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
|
||||
@@ -95,7 +95,7 @@ func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network sh
|
||||
}
|
||||
|
||||
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||
d.logger.Debug("estimate fee request",
|
||||
d.logger.Debug("Estimate fee request",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("destination", destination),
|
||||
@@ -104,13 +104,13 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
|
||||
if err != nil {
|
||||
d.logger.Warn("estimate fee failed",
|
||||
d.logger.Warn("Estimate fee failed",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("estimate fee result",
|
||||
d.logger.Debug("Estimate fee result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package evm
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ethereum/go-ethereum"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTronEstimateCallUsesData(t *testing.T) {
|
||||
from := common.HexToAddress("0xfa89b4d534bdeb2713d4ffd893e79d6535fb58f8")
|
||||
to := common.HexToAddress("0xa614f803b6fd780986a42c78ec9c7f77e6ded13c")
|
||||
callMsg := ethereum.CallMsg{
|
||||
From: from,
|
||||
To: &to,
|
||||
GasPrice: big.NewInt(100),
|
||||
Data: []byte{0xa9, 0x05, 0x9c, 0xbb},
|
||||
}
|
||||
|
||||
call := tronEstimateCall(callMsg)
|
||||
|
||||
require.Equal(t, strings.ToLower(from.Hex()), call["from"])
|
||||
require.Equal(t, strings.ToLower(to.Hex()), call["to"])
|
||||
require.Equal(t, "0x64", call["gasPrice"])
|
||||
require.Equal(t, "0xa9059cbb", call["data"])
|
||||
_, hasInput := call["input"]
|
||||
require.False(t, hasInput)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/ethereum/go-ethereum"
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/common/hexutil"
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/ethereum/go-ethereum/rpc"
|
||||
"github.com/shopspring/decimal"
|
||||
@@ -95,7 +96,7 @@ func parseBaseUnitAmount(amount string) (*big.Int, error) {
|
||||
|
||||
// Balance fetches ERC20 token balance for the provided address.
|
||||
func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) {
|
||||
logger := deps.Logger
|
||||
logger := deps.Logger.Named("evm")
|
||||
registry := deps.Registry
|
||||
|
||||
if registry == nil {
|
||||
@@ -175,7 +176,7 @@ func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wall
|
||||
|
||||
// NativeBalance fetches native token balance for the provided address.
|
||||
func NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) {
|
||||
logger := deps.Logger
|
||||
logger := deps.Logger.Named("evm")
|
||||
registry := deps.Registry
|
||||
|
||||
if registry == nil {
|
||||
@@ -233,7 +234,7 @@ func NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network
|
||||
|
||||
// EstimateFee estimates ERC20 transfer fees for the given parameters.
|
||||
func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, fromAddress, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||
logger := deps.Logger
|
||||
logger := deps.Logger.Named("evm")
|
||||
registry := deps.Registry
|
||||
|
||||
if registry == nil {
|
||||
@@ -259,10 +260,12 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
|
||||
|
||||
client, err := registry.Client(network.Name)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to resolve client", zap.Error(err), zap.String("network_name", network.Name))
|
||||
return nil, err
|
||||
}
|
||||
rpcClient, err := registry.RPCClient(network.Name)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to resolve RPC client", zap.Error(err), zap.String("network_name", network.Name))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -280,10 +283,12 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
|
||||
if contract == "" {
|
||||
amountBase, err := parseBaseUnitAmount(amount.GetAmount())
|
||||
if err != nil {
|
||||
logger.Warn("Failed to parse base unit amount", zap.Error(err), zap.String("amount", amount.GetAmount()))
|
||||
return nil, err
|
||||
}
|
||||
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to suggest gas price", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
|
||||
}
|
||||
callMsg := ethereum.CallMsg{
|
||||
@@ -292,8 +297,9 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
|
||||
GasPrice: gasPrice,
|
||||
Value: amountBase,
|
||||
}
|
||||
gasLimit, err := client.EstimateGas(timeoutCtx, callMsg)
|
||||
gasLimit, err := estimateGas(timeoutCtx, network, client, rpcClient, callMsg)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to estimate gas", zap.Error(err), zap.Any("call_mesasge", callMsg))
|
||||
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
|
||||
}
|
||||
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
|
||||
@@ -304,6 +310,7 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
|
||||
}, nil
|
||||
}
|
||||
if !common.IsHexAddress(contract) {
|
||||
logger.Warn("Failed to validate contract", zap.String("contract", contract))
|
||||
return nil, merrors.InvalidArgument("invalid token contract address")
|
||||
}
|
||||
|
||||
@@ -322,11 +329,13 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
|
||||
|
||||
input, err := erc20ABI.Pack("transfer", toAddr, amountBase)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to encode transfer call", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to encode transfer call: " + err.Error())
|
||||
}
|
||||
|
||||
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to suggest gas price", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -336,8 +345,9 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
|
||||
GasPrice: gasPrice,
|
||||
Data: input,
|
||||
}
|
||||
gasLimit, err := client.EstimateGas(timeoutCtx, callMsg)
|
||||
gasLimit, err := estimateGas(timeoutCtx, network, client, rpcClient, callMsg)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to estimate gas", zap.Error(err), zap.Any("call_message", callMsg))
|
||||
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -352,7 +362,7 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
|
||||
|
||||
// SubmitTransfer submits an ERC20 transfer on an EVM-compatible chain.
|
||||
func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, fromAddress, destination string) (string, error) {
|
||||
logger := deps.Logger
|
||||
logger := deps.Logger.Named("evm")
|
||||
registry := deps.Registry
|
||||
|
||||
if deps.KeyManager == nil {
|
||||
@@ -384,7 +394,7 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
|
||||
return "", executorInvalid("invalid destination address " + destination)
|
||||
}
|
||||
|
||||
logger.Info("submitting transfer",
|
||||
logger.Info("Submitting transfer",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("source_wallet_ref", source.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
@@ -393,12 +403,12 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
|
||||
|
||||
client, err := registry.Client(network.Name)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to initialise rpc client", zap.Error(err), zap.String("network", network.Name))
|
||||
logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", network.Name))
|
||||
return "", err
|
||||
}
|
||||
rpcClient, err := registry.RPCClient(network.Name)
|
||||
if err != nil {
|
||||
logger.Warn("failed to initialise rpc client", zap.String("network", network.Name))
|
||||
logger.Warn("Failed to initialise RPC client", zap.String("network", network.Name))
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -448,7 +458,7 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
|
||||
GasPrice: gasPrice,
|
||||
Value: amountInt,
|
||||
}
|
||||
gasLimit, err := client.EstimateGas(ctx, callMsg)
|
||||
gasLimit, err := estimateGas(ctx, network, client, rpcClient, callMsg)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to estimate gas", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
@@ -498,7 +508,7 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
|
||||
GasPrice: gasPrice,
|
||||
Data: input,
|
||||
}
|
||||
gasLimit, err := client.EstimateGas(ctx, callMsg)
|
||||
gasLimit, err := estimateGas(ctx, network, client, rpcClient, callMsg)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to estimate gas", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
@@ -537,7 +547,7 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
|
||||
|
||||
// AwaitConfirmation waits for the transaction receipt.
|
||||
func AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||
logger := deps.Logger
|
||||
logger := deps.Logger.Named("evm")
|
||||
registry := deps.Registry
|
||||
|
||||
if strings.TrimSpace(txHash) == "" {
|
||||
@@ -652,6 +662,63 @@ func erc20Decimals(ctx context.Context, client *rpc.Client, token common.Address
|
||||
return val, nil
|
||||
}
|
||||
|
||||
type gasEstimator interface {
|
||||
EstimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error)
|
||||
}
|
||||
|
||||
func estimateGas(ctx context.Context, network shared.Network, client gasEstimator, rpcClient *rpc.Client, callMsg ethereum.CallMsg) (uint64, error) {
|
||||
if isTronNetwork(network) {
|
||||
if rpcClient == nil {
|
||||
return 0, merrors.Internal("rpc client not initialised")
|
||||
}
|
||||
return estimateGasTron(ctx, rpcClient, callMsg)
|
||||
}
|
||||
return client.EstimateGas(ctx, callMsg)
|
||||
}
|
||||
|
||||
func estimateGasTron(ctx context.Context, rpcClient *rpc.Client, callMsg ethereum.CallMsg) (uint64, error) {
|
||||
call := tronEstimateCall(callMsg)
|
||||
var hexResp string
|
||||
if err := rpcClient.CallContext(ctx, &hexResp, "eth_estimateGas", call); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
val, err := shared.DecodeHexBig(hexResp)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if val == nil {
|
||||
return 0, merrors.Internal("failed to decode gas estimate")
|
||||
}
|
||||
return val.Uint64(), nil
|
||||
}
|
||||
|
||||
func tronEstimateCall(callMsg ethereum.CallMsg) map[string]string {
|
||||
call := make(map[string]string)
|
||||
if callMsg.From != (common.Address{}) {
|
||||
call["from"] = strings.ToLower(callMsg.From.Hex())
|
||||
}
|
||||
if callMsg.To != nil {
|
||||
call["to"] = strings.ToLower(callMsg.To.Hex())
|
||||
}
|
||||
if callMsg.Gas > 0 {
|
||||
call["gas"] = hexutil.EncodeUint64(callMsg.Gas)
|
||||
}
|
||||
if callMsg.GasPrice != nil {
|
||||
call["gasPrice"] = hexutil.EncodeBig(callMsg.GasPrice)
|
||||
}
|
||||
if callMsg.Value != nil {
|
||||
call["value"] = hexutil.EncodeBig(callMsg.Value)
|
||||
}
|
||||
if len(callMsg.Data) > 0 {
|
||||
call["data"] = hexutil.Encode(callMsg.Data)
|
||||
}
|
||||
return call
|
||||
}
|
||||
|
||||
func isTronNetwork(network shared.Network) bool {
|
||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(network.Name)), "tron")
|
||||
}
|
||||
|
||||
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
||||
value, err := decimal.NewFromString(strings.TrimSpace(amount))
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package tron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||
@@ -113,6 +114,9 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
|
||||
if wallet == nil {
|
||||
return nil, merrors.InvalidArgument("wallet is required")
|
||||
}
|
||||
if amount == nil {
|
||||
return nil, merrors.InvalidArgument("amount is required")
|
||||
}
|
||||
d.logger.Debug("Estimate fee request",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
@@ -134,6 +138,12 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
if rpcFrom == rpcTo {
|
||||
return &moneyv1.Money{
|
||||
Currency: nativeCurrency(network),
|
||||
Amount: "0",
|
||||
}, nil
|
||||
}
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, rpcFrom, rpcTo, amount)
|
||||
@@ -141,6 +151,10 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
|
||||
d.logger.Warn("Estimate fee failed", zap.Error(err),
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("from_address", wallet.DepositAddress),
|
||||
zap.String("from_rpc", rpcFrom),
|
||||
zap.String("to_address", destination),
|
||||
zap.String("to_rpc", rpcTo),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("Estimate fee result",
|
||||
@@ -220,4 +234,12 @@ func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, networ
|
||||
return receipt, err
|
||||
}
|
||||
|
||||
func nativeCurrency(network shared.Network) string {
|
||||
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
||||
if currency == "" {
|
||||
currency = strings.ToUpper(strings.TrimSpace(network.Name))
|
||||
}
|
||||
return currency
|
||||
}
|
||||
|
||||
var _ driver.Driver = (*Driver)(nil)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestEstimateFeeSelfTransferReturnsZero(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
d := New(logger)
|
||||
wallet := &model.ManagedWallet{
|
||||
WalletRef: "wallet_ref",
|
||||
DepositAddress: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF",
|
||||
}
|
||||
network := shared.Network{
|
||||
Name: "tron_mainnet",
|
||||
NativeToken: "TRX",
|
||||
}
|
||||
amount := &moneyv1.Money{Currency: "TRX", Amount: "1000000"}
|
||||
|
||||
fee, err := d.EstimateFee(context.Background(), driver.Deps{}, network, wallet, wallet.DepositAddress, amount)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, fee)
|
||||
require.Equal(t, "TRX", fee.GetCurrency())
|
||||
require.Equal(t, "0", fee.GetAmount())
|
||||
}
|
||||
@@ -81,12 +81,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
|
||||
client, err := o.clients.Client(network.Name)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to initialise rpc client", zap.Error(err), zap.String("network", network.Name))
|
||||
o.logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", network.Name))
|
||||
return "", err
|
||||
}
|
||||
rpcClient, err := o.clients.RPCClient(network.Name)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to initialise rpc client",
|
||||
o.logger.Warn("Failed to initialise RPC client",
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
@@ -101,7 +101,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
|
||||
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to fetch nonce", zap.Error(err),
|
||||
o.logger.Warn("Failed to fetch nonce", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("wallet_ref", source.WalletRef),
|
||||
)
|
||||
@@ -110,7 +110,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
|
||||
gasPrice, err := client.SuggestGasPrice(ctx)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to suggest gas price",
|
||||
o.logger.Warn("Failed to suggest gas price",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
@@ -124,12 +124,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
chainID := new(big.Int).SetUint64(network.ChainID)
|
||||
|
||||
if strings.TrimSpace(transfer.ContractAddress) == "" {
|
||||
o.logger.Warn("native token transfer requested but not supported", zap.String("transfer_ref", transfer.TransferRef))
|
||||
o.logger.Warn("Native token transfer requested but not supported", zap.String("transfer_ref", transfer.TransferRef))
|
||||
return "", merrors.NotImplemented("executor: native token transfers not yet supported")
|
||||
}
|
||||
|
||||
if !common.IsHexAddress(transfer.ContractAddress) {
|
||||
o.logger.Warn("invalid token contract address",
|
||||
o.logger.Warn("Invalid token contract address",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("contract", transfer.ContractAddress),
|
||||
)
|
||||
@@ -139,7 +139,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
|
||||
decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to read token decimals", zap.Error(err),
|
||||
o.logger.Warn("Failed to read token decimals", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("contract", transfer.ContractAddress),
|
||||
)
|
||||
@@ -148,12 +148,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
|
||||
amount := transfer.NetAmount
|
||||
if amount == nil || strings.TrimSpace(amount.Amount) == "" {
|
||||
o.logger.Warn("transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
|
||||
o.logger.Warn("Transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
|
||||
return "", executorInvalid("transfer missing net amount")
|
||||
}
|
||||
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to convert amount to base units", zap.Error(err),
|
||||
o.logger.Warn("Failed to convert amount to base units", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("amount", amount.Amount),
|
||||
)
|
||||
@@ -177,7 +177,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
}
|
||||
gasLimit, err := client.EstimateGas(ctx, callMsg)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to estimate gas",
|
||||
o.logger.Warn("Failed to estimate gas",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.Error(err),
|
||||
)
|
||||
@@ -188,7 +188,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
|
||||
signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to sign transaction", zap.Error(err),
|
||||
o.logger.Warn("Failed to sign transaction", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("wallet_ref", source.WalletRef),
|
||||
)
|
||||
@@ -196,14 +196,14 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
}
|
||||
|
||||
if err := client.SendTransaction(ctx, signedTx); err != nil {
|
||||
o.logger.Warn("failed to send transaction", zap.Error(err),
|
||||
o.logger.Warn("Failed to send transaction", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
)
|
||||
return "", executorInternal("failed to send transaction", err)
|
||||
}
|
||||
|
||||
txHash = signedTx.Hash().Hex()
|
||||
o.logger.Info("transaction submitted",
|
||||
o.logger.Info("Transaction submitted",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
@@ -214,12 +214,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
|
||||
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||
if strings.TrimSpace(txHash) == "" {
|
||||
o.logger.Warn("missing transaction hash for confirmation", zap.String("network", network.Name))
|
||||
o.logger.Warn("Missing transaction hash for confirmation", zap.String("network", network.Name))
|
||||
return nil, executorInvalid("tx hash is required")
|
||||
}
|
||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||
if rpcURL == "" {
|
||||
o.logger.Warn("network rpc url missing while awaiting confirmation", zap.String("tx_hash", txHash))
|
||||
o.logger.Warn("Network RPC url missing while awaiting confirmation", zap.String("tx_hash", txHash))
|
||||
return nil, executorInvalid("network rpc url is not configured")
|
||||
}
|
||||
|
||||
@@ -238,27 +238,27 @@ func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.
|
||||
if errors.Is(err, ethereum.NotFound) {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
o.logger.Debug("transaction not yet mined",
|
||||
o.logger.Debug("Transaction not yet mined",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
o.logger.Warn("context cancelled while awaiting confirmation",
|
||||
o.logger.Warn("Context cancelled while awaiting confirmation",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
o.logger.Warn("failed to fetch transaction receipt",
|
||||
o.logger.Warn("Failed to fetch transaction receipt",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, executorInternal("failed to fetch transaction receipt", err)
|
||||
}
|
||||
o.logger.Info("transaction confirmed",
|
||||
o.logger.Info("Transaction confirmed",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||
|
||||
@@ -2,6 +2,7 @@ package rpcclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -70,7 +71,7 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
|
||||
cancel()
|
||||
if err != nil {
|
||||
result.Close()
|
||||
clientLogger.Warn("failed to dial rpc endpoint", append(fields, zap.Error(err))...)
|
||||
clientLogger.Warn("Failed to dial rpc endpoint", append(fields, zap.Error(err))...)
|
||||
return nil, merrors.Internal(fmt.Sprintf("rpc dial failed for %s: %s", name, err.Error()))
|
||||
}
|
||||
client := ethclient.NewClient(rpcCli)
|
||||
@@ -78,7 +79,7 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
|
||||
eth: client,
|
||||
rpc: rpcCli,
|
||||
}
|
||||
clientLogger.Info("rpc client ready", fields...)
|
||||
clientLogger.Info("RPC client ready", fields...)
|
||||
}
|
||||
|
||||
if len(result.clients) == 0 {
|
||||
@@ -94,12 +95,12 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
|
||||
// Client returns a prepared client for the given network name.
|
||||
func (c *Clients) Client(network string) (*ethclient.Client, error) {
|
||||
if c == nil {
|
||||
return nil, merrors.Internal("rpc clients not initialised")
|
||||
return nil, merrors.Internal("RPC clients not initialised")
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(network))
|
||||
entry, ok := c.clients[name]
|
||||
if !ok || entry.eth == nil {
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("rpc client not configured for network %s", name))
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("RPC client not configured for network %s", name))
|
||||
}
|
||||
return entry.eth, nil
|
||||
}
|
||||
@@ -129,7 +130,7 @@ func (c *Clients) Close() {
|
||||
entry.eth.Close()
|
||||
}
|
||||
if c.logger != nil {
|
||||
c.logger.Info("rpc client closed", zap.String("network", name))
|
||||
c.logger.Info("RPC client closed", zap.String("network", name))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,16 +156,15 @@ func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.String("network", l.network),
|
||||
zap.String("rpc_endpoint", l.endpoint),
|
||||
}
|
||||
if len(reqBody) > 0 {
|
||||
fields = append(fields, zap.String("rpc_request", truncate(string(reqBody), 2048)))
|
||||
}
|
||||
l.logger.Debug("rpc request", fields...)
|
||||
l.logger.Debug("RPC request", fields...)
|
||||
|
||||
resp, err := l.base.RoundTrip(req)
|
||||
if err != nil {
|
||||
l.logger.Warn("rpc http request failed", append(fields, zap.Error(err))...)
|
||||
l.logger.Warn("RPC http request failed", append(fields, zap.Error(err))...)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -175,11 +175,19 @@ func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
|
||||
respFields := append(fields,
|
||||
zap.Int("status_code", resp.StatusCode),
|
||||
)
|
||||
if contentType := strings.TrimSpace(resp.Header.Get("Content-Type")); contentType != "" {
|
||||
respFields = append(respFields, zap.String("content_type", contentType))
|
||||
}
|
||||
if len(bodyBytes) > 0 {
|
||||
respFields = append(respFields, zap.String("rpc_response", truncate(string(bodyBytes), 2048)))
|
||||
}
|
||||
l.logger.Debug("RPC response", respFields...)
|
||||
if resp.StatusCode >= 400 {
|
||||
l.logger.Warn("RPC response error", respFields...)
|
||||
} else if len(bodyBytes) == 0 {
|
||||
l.logger.Warn("RPC response empty body", respFields...)
|
||||
} else if len(bodyBytes) > 0 && !json.Valid(bodyBytes) {
|
||||
l.logger.Warn("RPC response invalid JSON", respFields...)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
|
||||
@@ -119,6 +119,15 @@ func TransferStatusToProto(status model.TransferStatus) chainv1.TransferStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// NativeCurrency returns the canonical native token symbol for a network.
|
||||
func NativeCurrency(network Network) string {
|
||||
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
||||
if currency == "" {
|
||||
currency = strings.ToUpper(strings.TrimSpace(network.Name))
|
||||
}
|
||||
return currency
|
||||
}
|
||||
|
||||
// Network describes a supported blockchain network and known token contracts.
|
||||
type Network struct {
|
||||
Name string
|
||||
|
||||
@@ -24,7 +24,7 @@ func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, n
|
||||
defer cancel()
|
||||
|
||||
if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil {
|
||||
s.logger.Warn("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
|
||||
s.logger.Warn("Failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
|
||||
}
|
||||
}(transferRef, sourceWalletRef, network)
|
||||
}
|
||||
@@ -57,6 +57,23 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
|
||||
return err
|
||||
}
|
||||
|
||||
sourceAddress, err := chainDriver.NormalizeAddress(sourceWallet.DepositAddress)
|
||||
if err != nil {
|
||||
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||
return err
|
||||
}
|
||||
if chainDriver.Name() == "tron" && sourceAddress == destinationAddress {
|
||||
s.logger.Info("Self transfer detected; skipping submission",
|
||||
zap.String("transfer_ref", transferRef),
|
||||
zap.String("wallet_ref", sourceWalletRef),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", ""); err != nil {
|
||||
s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
txHash, err := chainDriver.SubmitTransfer(ctx, driverDeps, network, transfer, sourceWallet, destinationAddress)
|
||||
if err != nil {
|
||||
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||
|
||||
@@ -99,24 +99,49 @@ func (w *Wallets) Create(ctx context.Context, wallet *model.ManagedWallet) (*mod
|
||||
if strings.TrimSpace(wallet.IdempotencyKey) == "" {
|
||||
return nil, merrors.InvalidArgument("walletsStore: empty idempotencyKey")
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("idempotency_key", wallet.IdempotencyKey),
|
||||
}
|
||||
if wallet.OrganizationRef != "" {
|
||||
fields = append(fields, zap.String("organization_ref", wallet.OrganizationRef))
|
||||
}
|
||||
if wallet.OwnerRef != "" {
|
||||
fields = append(fields, zap.String("owner_ref", wallet.OwnerRef))
|
||||
}
|
||||
if wallet.Network != "" {
|
||||
fields = append(fields, zap.String("network", wallet.Network))
|
||||
}
|
||||
if wallet.TokenSymbol != "" {
|
||||
fields = append(fields, zap.String("token_symbol", wallet.TokenSymbol))
|
||||
}
|
||||
if err := w.walletRepo.Insert(ctx, wallet, repository.Filter("idempotencyKey", wallet.IdempotencyKey)); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
w.logger.Debug("wallet already exists", zap.String("wallet_ref", wallet.WalletRef), zap.String("idempotency_key", wallet.IdempotencyKey))
|
||||
w.logger.Debug("wallet already exists", fields...)
|
||||
return wallet, nil
|
||||
}
|
||||
w.logger.Warn("wallet create failed", append(fields, zap.Error(err))...)
|
||||
return nil, err
|
||||
}
|
||||
w.logger.Debug("wallet created", zap.String("wallet_ref", wallet.WalletRef))
|
||||
w.logger.Debug("wallet created", fields...)
|
||||
return wallet, nil
|
||||
}
|
||||
|
||||
func (w *Wallets) Get(ctx context.Context, walletRef string) (*model.ManagedWallet, error) {
|
||||
walletRef = strings.TrimSpace(walletRef)
|
||||
if walletRef == "" {
|
||||
func (w *Wallets) Get(ctx context.Context, walletID string) (*model.ManagedWallet, error) {
|
||||
walletID = strings.TrimSpace(walletID)
|
||||
if walletID == "" {
|
||||
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("wallet_id", walletID),
|
||||
}
|
||||
wallet := &model.ManagedWallet{}
|
||||
if err := w.walletRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), wallet); err != nil {
|
||||
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), wallet); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
w.logger.Debug("wallet not found", fields...)
|
||||
} else {
|
||||
w.logger.Warn("wallet lookup failed", append(fields, zap.Error(err))...)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return wallet, nil
|
||||
@@ -124,29 +149,38 @@ func (w *Wallets) Get(ctx context.Context, walletRef string) (*model.ManagedWall
|
||||
|
||||
func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error) {
|
||||
query := repository.Query()
|
||||
fields := make([]zap.Field, 0, 6)
|
||||
|
||||
if org := strings.TrimSpace(filter.OrganizationRef); org != "" {
|
||||
query = query.Filter(repository.Field("organizationRef"), org)
|
||||
fields = append(fields, zap.String("organization_ref", org))
|
||||
}
|
||||
if owner := strings.TrimSpace(filter.OwnerRef); owner != "" {
|
||||
query = query.Filter(repository.Field("ownerRef"), owner)
|
||||
fields = append(fields, zap.String("owner_ref", owner))
|
||||
}
|
||||
if network := strings.TrimSpace(filter.Network); network != "" {
|
||||
query = query.Filter(repository.Field("network"), strings.ToLower(network))
|
||||
normalized := strings.ToLower(network)
|
||||
query = query.Filter(repository.Field("network"), normalized)
|
||||
fields = append(fields, zap.String("network", normalized))
|
||||
}
|
||||
if token := strings.TrimSpace(filter.TokenSymbol); token != "" {
|
||||
query = query.Filter(repository.Field("tokenSymbol"), strings.ToUpper(token))
|
||||
normalized := 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 oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
|
||||
query = query.Comparison(repository.IDField(), builder.Gt, oid)
|
||||
fields = append(fields, zap.String("cursor", cursor))
|
||||
} else {
|
||||
w.logger.Warn("ignoring invalid wallet cursor", zap.String("cursor", cursor), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
limit := sanitizeWalletLimit(filter.Limit)
|
||||
fields = append(fields, zap.Int64("limit", limit))
|
||||
fetchLimit := limit + 1
|
||||
query = query.Sort(repository.IDField(), true).Limit(&fetchLimit)
|
||||
|
||||
@@ -160,8 +194,10 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := w.walletRepo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, err
|
||||
listErr := w.walletRepo.FindManyByFilter(ctx, query, decoder)
|
||||
if listErr != nil && !errors.Is(listErr, merrors.ErrNoData) {
|
||||
w.logger.Warn("wallet list failed", append(fields, zap.Error(listErr))...)
|
||||
return nil, listErr
|
||||
}
|
||||
|
||||
nextCursor := ""
|
||||
@@ -171,10 +207,21 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*
|
||||
wallets = wallets[:len(wallets)-1]
|
||||
}
|
||||
|
||||
return &model.ManagedWalletList{
|
||||
result := &model.ManagedWalletList{
|
||||
Items: wallets,
|
||||
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 {
|
||||
@@ -188,6 +235,7 @@ func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance)
|
||||
if balance.CalculatedAt.IsZero() {
|
||||
balance.CalculatedAt = time.Now().UTC()
|
||||
}
|
||||
fields := []zap.Field{zap.String("wallet_ref", balance.WalletRef)}
|
||||
|
||||
existing := &model.WalletBalance{}
|
||||
err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", balance.WalletRef), existing)
|
||||
@@ -198,28 +246,40 @@ func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance)
|
||||
existing.PendingOutbound = balance.PendingOutbound
|
||||
existing.CalculatedAt = balance.CalculatedAt
|
||||
if err := w.balanceRepo.Update(ctx, existing); err != nil {
|
||||
w.logger.Warn("wallet balance update failed", append(fields, zap.Error(err))...)
|
||||
return err
|
||||
}
|
||||
w.logger.Debug("wallet balance updated", fields...)
|
||||
return nil
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
if err := w.balanceRepo.Insert(ctx, balance, repository.Filter("walletRef", balance.WalletRef)); err != nil {
|
||||
w.logger.Warn("wallet balance create failed", append(fields, zap.Error(err))...)
|
||||
return err
|
||||
}
|
||||
w.logger.Debug("wallet balance created", fields...)
|
||||
return nil
|
||||
default:
|
||||
w.logger.Warn("wallet balance lookup failed", append(fields, zap.Error(err))...)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Wallets) GetBalance(ctx context.Context, walletRef string) (*model.WalletBalance, error) {
|
||||
walletRef = strings.TrimSpace(walletRef)
|
||||
if walletRef == "" {
|
||||
func (w *Wallets) GetBalance(ctx context.Context, walletID string) (*model.WalletBalance, error) {
|
||||
walletID = strings.TrimSpace(walletID)
|
||||
if walletID == "" {
|
||||
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
|
||||
}
|
||||
fields := []zap.Field{zap.String("wallet_ref", walletID)}
|
||||
balance := &model.WalletBalance{}
|
||||
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), balance); err != nil {
|
||||
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), balance); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
w.logger.Debug("wallet balance not found", fields...)
|
||||
} else {
|
||||
w.logger.Warn("wallet balance lookup failed", append(fields, zap.Error(err))...)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
w.logger.Debug("wallet balance fetched", fields...)
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ monetix:
|
||||
base_url_env: MONETIX_BASE_URL
|
||||
project_id_env: MONETIX_PROJECT_ID
|
||||
secret_key_env: MONETIX_SECRET_KEY
|
||||
allowed_currencies: ["USD", "EUR"]
|
||||
allowed_currencies: ["RUB"]
|
||||
require_customer_address: false
|
||||
request_timeout_seconds: 15
|
||||
status_success: "success"
|
||||
|
||||
@@ -95,22 +95,49 @@ func (i *Imp) Shutdown() {
|
||||
}
|
||||
|
||||
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()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
i.logger.Error("Failed to resolve Monetix configuration", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
callbackCfg, err := i.resolveCallbackConfig(cfg.HTTP.Callback)
|
||||
if err != nil {
|
||||
i.logger.Error("Failed to resolve callback configuration", zap.Error(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) {
|
||||
svc := mntxservice.NewService(logger,
|
||||
mntxservice.WithProducer(producer),
|
||||
@@ -137,7 +164,7 @@ func (i *Imp) Start() error {
|
||||
func (i *Imp) loadConfig() (*config, error) {
|
||||
data, err := os.ReadFile(i.file)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -145,7 +172,7 @@ func (i *Imp) loadConfig() (*config, error) {
|
||||
Config: &grpcapp.Config{},
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -245,7 +272,7 @@ func (i *Imp) resolveCallbackConfig(cfg callbackConfig) (callbackRuntimeConfig,
|
||||
}
|
||||
_, block, err := net.ParseCIDR(clean)
|
||||
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
|
||||
}
|
||||
cidrs = append(cidrs, block)
|
||||
@@ -270,20 +297,36 @@ func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRunt
|
||||
|
||||
router := chi.NewRouter()
|
||||
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) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, cfg.MaxBodyBytes))
|
||||
if err != nil {
|
||||
log.Warn("Callback body read failed", zap.Error(err))
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
status, err := svc.ProcessMonetixCallback(r.Context(), body)
|
||||
if err != nil {
|
||||
log.Warn("Callback processing failed", zap.Error(err), zap.Int("status", status))
|
||||
http.Error(w, err.Error(), status)
|
||||
return
|
||||
}
|
||||
log.Debug("Callback processed", zap.Int("status", status))
|
||||
w.WriteHeader(status)
|
||||
})
|
||||
|
||||
@@ -301,7 +344,7 @@ func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRunt
|
||||
|
||||
go func() {
|
||||
if err := server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
i.logger.Error("Monetix callback server stopped with error", zap.Error(err))
|
||||
i.logger.Warn("Monetix callback server stopped with error", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
52
api/gateway/mntx/internal/server/internal/serverimp_test.go
Normal file
52
api/gateway/mntx/internal/server/internal/serverimp_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package serverimp
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientIPFromRequest(t *testing.T) {
|
||||
req := &http.Request{
|
||||
Header: http.Header{"X-Forwarded-For": []string{"1.2.3.4, 5.6.7.8"}},
|
||||
RemoteAddr: "9.8.7.6:1234",
|
||||
}
|
||||
ip := clientIPFromRequest(req)
|
||||
if ip == nil || ip.String() != "1.2.3.4" {
|
||||
t.Fatalf("expected forwarded ip, got %v", ip)
|
||||
}
|
||||
|
||||
req = &http.Request{RemoteAddr: "9.8.7.6:1234"}
|
||||
ip = clientIPFromRequest(req)
|
||||
if ip == nil || ip.String() != "9.8.7.6" {
|
||||
t.Fatalf("expected remote addr ip, got %v", ip)
|
||||
}
|
||||
|
||||
req = &http.Request{RemoteAddr: "invalid"}
|
||||
ip = clientIPFromRequest(req)
|
||||
if ip != nil {
|
||||
t.Fatalf("expected nil ip, got %v", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAllowed(t *testing.T) {
|
||||
_, cidr, err := net.ParseCIDR("10.0.0.0/8")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse cidr: %v", err)
|
||||
}
|
||||
|
||||
allowedReq := &http.Request{RemoteAddr: "10.1.2.3:1234"}
|
||||
if !clientAllowed(allowedReq, []*net.IPNet{cidr}) {
|
||||
t.Fatalf("expected allowed request")
|
||||
}
|
||||
|
||||
deniedReq := &http.Request{RemoteAddr: "8.8.8.8:1234"}
|
||||
if clientAllowed(deniedReq, []*net.IPNet{cidr}) {
|
||||
t.Fatalf("expected denied request")
|
||||
}
|
||||
|
||||
openReq := &http.Request{RemoteAddr: "8.8.8.8:1234"}
|
||||
if !clientAllowed(openReq, nil) {
|
||||
t.Fatalf("expected allow when no cidrs are configured")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -66,9 +67,12 @@ type monetixCallback struct {
|
||||
|
||||
// ProcessMonetixCallback ingests Monetix provider callbacks and updates payout state.
|
||||
func (s *Service) ProcessMonetixCallback(ctx context.Context, payload []byte) (int, error) {
|
||||
log := s.logger.Named("callback")
|
||||
if s.card == nil {
|
||||
log.Warn("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)
|
||||
}
|
||||
|
||||
|
||||
130
api/gateway/mntx/internal/service/gateway/callback_test.go
Normal file
130
api/gateway/mntx/internal/service/gateway/callback_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
type fixedClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (f fixedClock) Now() time.Time {
|
||||
return f.now
|
||||
}
|
||||
|
||||
func baseCallback() monetixCallback {
|
||||
cb := monetixCallback{
|
||||
ProjectID: 42,
|
||||
}
|
||||
cb.Payment.ID = "payout-1"
|
||||
cb.Payment.Status = "success"
|
||||
cb.Payment.Sum.Amount = 5000
|
||||
cb.Payment.Sum.Currency = "usd"
|
||||
cb.Customer.ID = "cust-1"
|
||||
cb.Operation.Status = "success"
|
||||
cb.Operation.Code = ""
|
||||
cb.Operation.Message = "ok"
|
||||
cb.Operation.RequestID = "req-1"
|
||||
cb.Operation.Provider.PaymentID = "prov-1"
|
||||
return cb
|
||||
}
|
||||
|
||||
func TestMapCallbackToState_StatusMapping(t *testing.T) {
|
||||
now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
cfg := monetix.DefaultConfig()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
paymentStatus string
|
||||
operationStatus string
|
||||
code string
|
||||
expectedStatus mntxv1.PayoutStatus
|
||||
expectedOutcome string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
paymentStatus: "success",
|
||||
operationStatus: "success",
|
||||
code: "0",
|
||||
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED,
|
||||
expectedOutcome: monetix.OutcomeSuccess,
|
||||
},
|
||||
{
|
||||
name: "processing",
|
||||
paymentStatus: "processing",
|
||||
operationStatus: "success",
|
||||
code: "",
|
||||
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
|
||||
expectedOutcome: monetix.OutcomeProcessing,
|
||||
},
|
||||
{
|
||||
name: "decline",
|
||||
paymentStatus: "failed",
|
||||
operationStatus: "failed",
|
||||
code: "1",
|
||||
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED,
|
||||
expectedOutcome: monetix.OutcomeDecline,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cb := baseCallback()
|
||||
cb.Payment.Status = tc.paymentStatus
|
||||
cb.Operation.Status = tc.operationStatus
|
||||
cb.Operation.Code = tc.code
|
||||
|
||||
state, outcome := mapCallbackToState(fixedClock{now: now}, cfg, cb)
|
||||
if state.Status != tc.expectedStatus {
|
||||
t.Fatalf("expected status %v, got %v", tc.expectedStatus, state.Status)
|
||||
}
|
||||
if outcome != tc.expectedOutcome {
|
||||
t.Fatalf("expected outcome %q, got %q", tc.expectedOutcome, outcome)
|
||||
}
|
||||
if state.Currency != "USD" {
|
||||
t.Fatalf("expected currency USD, got %q", state.Currency)
|
||||
}
|
||||
if !state.UpdatedAt.AsTime().Equal(now) {
|
||||
t.Fatalf("expected updated_at %v, got %v", now, state.UpdatedAt.AsTime())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackProviderPaymentID(t *testing.T) {
|
||||
cb := baseCallback()
|
||||
if got := fallbackProviderPaymentID(cb); got != "prov-1" {
|
||||
t.Fatalf("expected provider payment id, got %q", got)
|
||||
}
|
||||
cb.Operation.Provider.PaymentID = ""
|
||||
if got := fallbackProviderPaymentID(cb); got != "req-1" {
|
||||
t.Fatalf("expected request id fallback, got %q", got)
|
||||
}
|
||||
cb.Operation.RequestID = ""
|
||||
if got := fallbackProviderPaymentID(cb); got != "payout-1" {
|
||||
t.Fatalf("expected payment id fallback, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCallbackSignature(t *testing.T) {
|
||||
secret := "secret"
|
||||
cb := baseCallback()
|
||||
|
||||
sig, err := monetix.SignPayload(cb, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign payload: %v", err)
|
||||
}
|
||||
cb.Signature = sig
|
||||
if err := verifyCallbackSignature(cb, secret); err != nil {
|
||||
t.Fatalf("expected valid signature, got %v", err)
|
||||
}
|
||||
|
||||
cb.Signature = "invalid"
|
||||
if err := verifyCallbackSignature(cb, secret); err == nil {
|
||||
t.Fatalf("expected signature mismatch error")
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
@@ -17,14 +18,24 @@ func (s *Service) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRe
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Warn("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)
|
||||
if err != nil {
|
||||
log.Warn("Card payout submission failed", zap.Error(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)
|
||||
}
|
||||
|
||||
@@ -33,14 +44,24 @@ func (s *Service) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTok
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Warn("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)
|
||||
if err != nil {
|
||||
log.Warn("Card token payout submission failed", zap.Error(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)
|
||||
}
|
||||
|
||||
@@ -49,14 +70,22 @@ func (s *Service) CreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeR
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Warn("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)
|
||||
if err != nil {
|
||||
log.Warn("Card tokenization failed", zap.Error(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)
|
||||
}
|
||||
|
||||
@@ -65,14 +94,19 @@ func (s *Service) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPa
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Warn("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())
|
||||
if err != nil {
|
||||
log.Warn("Card payout status lookup failed", zap.Error(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})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
func TestValidateCardPayoutRequest_Valid(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardPayoutRequest()
|
||||
if err := validateCardPayoutRequest(req, cfg); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCardPayoutRequest_Errors(t *testing.T) {
|
||||
baseCfg := testMonetixConfig()
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*mntxv1.CardPayoutRequest)
|
||||
config func(monetix.Config) monetix.Config
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "missing_payout_id",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.PayoutId = "" },
|
||||
expected: "missing_payout_id",
|
||||
},
|
||||
{
|
||||
name: "missing_customer_id",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerId = "" },
|
||||
expected: "missing_customer_id",
|
||||
},
|
||||
{
|
||||
name: "missing_customer_ip",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerIp = "" },
|
||||
expected: "missing_customer_ip",
|
||||
},
|
||||
{
|
||||
name: "invalid_amount",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.AmountMinor = 0 },
|
||||
expected: "invalid_amount",
|
||||
},
|
||||
{
|
||||
name: "missing_currency",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.Currency = "" },
|
||||
expected: "missing_currency",
|
||||
},
|
||||
{
|
||||
name: "unsupported_currency",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.Currency = "EUR" },
|
||||
config: func(cfg monetix.Config) monetix.Config {
|
||||
cfg.AllowedCurrencies = []string{"USD"}
|
||||
return cfg
|
||||
},
|
||||
expected: "unsupported_currency",
|
||||
},
|
||||
{
|
||||
name: "missing_card_pan",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardPan = "" },
|
||||
expected: "missing_card_pan",
|
||||
},
|
||||
{
|
||||
name: "missing_card_holder",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardHolder = "" },
|
||||
expected: "missing_card_holder",
|
||||
},
|
||||
{
|
||||
name: "invalid_expiry_month",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardExpMonth = 13 },
|
||||
expected: "invalid_expiry_month",
|
||||
},
|
||||
{
|
||||
name: "invalid_expiry_year",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardExpYear = 0 },
|
||||
expected: "invalid_expiry_year",
|
||||
},
|
||||
{
|
||||
name: "missing_customer_country_when_required",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerCountry = "" },
|
||||
config: func(cfg monetix.Config) monetix.Config {
|
||||
cfg.RequireCustomerAddress = true
|
||||
return cfg
|
||||
},
|
||||
expected: "missing_customer_country",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := validCardPayoutRequest()
|
||||
tc.mutate(req)
|
||||
cfg := baseCfg
|
||||
if tc.config != nil {
|
||||
cfg = tc.config(cfg)
|
||||
}
|
||||
err := validateCardPayoutRequest(req, cfg)
|
||||
requireReason(t, err, tc.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -45,14 +45,20 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
if p == nil {
|
||||
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) == "" {
|
||||
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")
|
||||
}
|
||||
|
||||
req = sanitizeCardPayoutRequest(req)
|
||||
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("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
@@ -65,7 +71,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
projectID = p.config.ProjectID
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -95,7 +101,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
state.ProviderMessage = err.Error()
|
||||
state.UpdatedAt = timestamppb.New(p.clock.Now())
|
||||
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("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
@@ -122,6 +128,13 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
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
|
||||
}
|
||||
|
||||
@@ -129,14 +142,20 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
if p == nil {
|
||||
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) == "" {
|
||||
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")
|
||||
}
|
||||
|
||||
req = sanitizeCardTokenPayoutRequest(req)
|
||||
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("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
@@ -149,7 +168,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
projectID = p.config.ProjectID
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -179,7 +198,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
state.ProviderMessage = err.Error()
|
||||
state.UpdatedAt = timestamppb.New(p.clock.Now())
|
||||
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("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
@@ -206,6 +225,13 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
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
|
||||
}
|
||||
|
||||
@@ -213,9 +239,13 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
||||
if p == nil {
|
||||
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)
|
||||
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("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
@@ -228,7 +258,7 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
||||
projectID = p.config.ProjectID
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -238,7 +268,7 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
||||
apiReq := buildCardTokenizeRequest(projectID, req, cardInput)
|
||||
result, err := client.CreateCardTokenization(ctx, apiReq)
|
||||
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("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
@@ -258,6 +288,12 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
||||
resp.ExpiryYear = result.ExpiryYear
|
||||
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
|
||||
}
|
||||
|
||||
@@ -267,16 +303,18 @@ func (p *cardPayoutProcessor) Status(_ context.Context, payoutID string) (*mntxv
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(payoutID)
|
||||
p.logger.Info("Card payout status requested", zap.String("payout_id", 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")
|
||||
}
|
||||
|
||||
state, ok := p.store.Get(id)
|
||||
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")
|
||||
}
|
||||
p.logger.Info("Card payout status resolved", zap.String("payout_id", state.GetPayoutId()), zap.String("status", state.GetStatus().String()))
|
||||
return state, nil
|
||||
}
|
||||
|
||||
@@ -284,18 +322,19 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
|
||||
if p == nil {
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
var cb monetixCallback
|
||||
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
|
||||
}
|
||||
|
||||
@@ -318,7 +357,7 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
|
||||
p.emitCardPayoutEvent(state)
|
||||
monetix.ObserveCallback(statusLabel)
|
||||
|
||||
p.logger.Info("Monetix payout callback processed",
|
||||
p.logger.Debug("Monetix payout callback processed",
|
||||
zap.String("payout_id", state.GetPayoutId()),
|
||||
zap.String("status", statusLabel),
|
||||
zap.String("provider_code", state.GetProviderCode()),
|
||||
@@ -337,16 +376,16 @@ func (p *cardPayoutProcessor) emitCardPayoutEvent(state *mntxv1.CardPayoutState)
|
||||
event := &mntxv1.CardPayoutStatusChangedEvent{Payout: state}
|
||||
payload, err := protojson.Marshal(event)
|
||||
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
|
||||
}
|
||||
|
||||
env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, nm.NAUpdated))
|
||||
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
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
149
api/gateway/mntx/internal/service/gateway/card_processor_test.go
Normal file
149
api/gateway/mntx/internal/service/gateway/card_processor_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
func TestValidateCardTokenizeRequest_ValidTopLevel(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardTokenizeRequest()
|
||||
if _, err := validateCardTokenizeRequest(req, cfg); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCardTokenizeRequest_ValidNestedCard(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardTokenizeRequest()
|
||||
req.Card = &mntxv1.CardDetails{
|
||||
Pan: "4111111111111111",
|
||||
ExpMonth: req.CardExpMonth,
|
||||
ExpYear: req.CardExpYear,
|
||||
CardHolder: req.CardHolder,
|
||||
Cvv: req.CardCvv,
|
||||
}
|
||||
req.CardPan = ""
|
||||
req.CardExpMonth = 0
|
||||
req.CardExpYear = 0
|
||||
req.CardHolder = ""
|
||||
req.CardCvv = ""
|
||||
|
||||
if _, err := validateCardTokenizeRequest(req, cfg); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCardTokenizeRequest_Expired(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardTokenizeRequest()
|
||||
now := time.Now().UTC()
|
||||
req.CardExpMonth = uint32(now.Month())
|
||||
req.CardExpYear = uint32(now.Year() - 1)
|
||||
|
||||
_, err := validateCardTokenizeRequest(req, cfg)
|
||||
requireReason(t, err, "expired_card")
|
||||
}
|
||||
|
||||
func TestValidateCardTokenizeRequest_MissingCvv(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardTokenizeRequest()
|
||||
req.CardCvv = ""
|
||||
|
||||
_, err := validateCardTokenizeRequest(req, cfg)
|
||||
requireReason(t, err, "missing_cvv")
|
||||
}
|
||||
|
||||
func TestValidateCardTokenizeRequest_MissingCardPan(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardTokenizeRequest()
|
||||
req.CardPan = ""
|
||||
|
||||
_, err := validateCardTokenizeRequest(req, cfg)
|
||||
requireReason(t, err, "missing_card_pan")
|
||||
}
|
||||
|
||||
func TestValidateCardTokenizeRequest_AddressRequired(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
cfg.RequireCustomerAddress = true
|
||||
req := validCardTokenizeRequest()
|
||||
req.CustomerCountry = ""
|
||||
|
||||
_, err := validateCardTokenizeRequest(req, cfg)
|
||||
requireReason(t, err, "missing_customer_country")
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
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) {
|
||||
@@ -17,14 +18,19 @@ func (s *Service) GetPayout(ctx context.Context, req *mntxv1.GetPayoutRequest) (
|
||||
|
||||
func (s *Service) handleGetPayout(_ context.Context, req *mntxv1.GetPayoutRequest) gsresponse.Responder[mntxv1.GetPayoutResponse] {
|
||||
ref := strings.TrimSpace(req.GetPayoutRef())
|
||||
log := s.logger.Named("payout")
|
||||
log.Info("Get payout request received", zap.String("payout_ref", 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"))
|
||||
}
|
||||
|
||||
payout, ok := s.store.Get(ref)
|
||||
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)))
|
||||
}
|
||||
|
||||
log.Info("Payout retrieved", zap.String("payout_ref", ref), zap.String("status", payout.GetStatus().String()))
|
||||
return gsresponse.Success(&mntxv1.GetPayoutResponse{Payout: payout})
|
||||
}
|
||||
|
||||
@@ -22,8 +22,17 @@ func (s *Service) SubmitPayout(ctx context.Context, req *mntxv1.SubmitPayoutRequ
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
log.Warn("Submit payout validation failed", zap.Error(err))
|
||||
return gsresponse.Auto[mntxv1.SubmitPayoutResponse](s.logger, mservice.MntxGateway, err)
|
||||
}
|
||||
|
||||
@@ -31,6 +40,7 @@ func (s *Service) handleSubmitPayout(_ context.Context, req *mntxv1.SubmitPayout
|
||||
s.emitEvent(payout, nm.NAPending)
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -79,6 +89,7 @@ func (s *Service) buildPayout(req *mntxv1.SubmitPayoutRequest) (*mntxv1.Payout,
|
||||
}
|
||||
|
||||
func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure string) {
|
||||
log := s.logger.Named("payout")
|
||||
outcome := clonePayout(original)
|
||||
if outcome == nil {
|
||||
return
|
||||
@@ -95,6 +106,7 @@ func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure strin
|
||||
observePayoutError(simulatedFailure, outcome.Amount)
|
||||
s.store.Save(outcome)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -102,6 +114,7 @@ func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure strin
|
||||
observePayoutSuccess(outcome.Amount)
|
||||
s.store.Save(outcome)
|
||||
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) {
|
||||
@@ -111,18 +124,18 @@ func (s *Service) emitEvent(payout *mntxv1.Payout, action nm.NotificationAction)
|
||||
|
||||
payload, err := protojson.Marshal(&mntxv1.PayoutStatusChangedEvent{Payout: payout})
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to marshal payout event", zapError(err))
|
||||
s.logger.Warn("Failed to marshal payout event", zapError(err))
|
||||
return
|
||||
}
|
||||
|
||||
env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, action))
|
||||
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
|
||||
}
|
||||
|
||||
if err := s.producer.SendMessage(env); err != nil {
|
||||
s.logger.Warn("failed to publish payout event", zapError(err))
|
||||
s.logger.Warn("Failed to publish payout event", zapError(err))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
@@ -97,9 +98,19 @@ 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) {
|
||||
log := svc.logger.Named("rpc")
|
||||
log.Info("RPC request started", zap.String("method", method))
|
||||
|
||||
start := svc.clock.Now()
|
||||
resp, err := gsresponse.Unary(svc.logger, mservice.MntxGateway, handler)(ctx, req)
|
||||
observeRPC(method, err, svc.clock.Now().Sub(start))
|
||||
duration := 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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
func requireReason(t *testing.T, err error, reason string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument error, got %v", err)
|
||||
}
|
||||
reasoned, ok := err.(payoutFailure)
|
||||
if !ok {
|
||||
t.Fatalf("expected payout failure reason, got %T", err)
|
||||
}
|
||||
if reasoned.Reason() != reason {
|
||||
t.Fatalf("expected reason %q, got %q", reason, reasoned.Reason())
|
||||
}
|
||||
}
|
||||
|
||||
func testMonetixConfig() monetix.Config {
|
||||
return monetix.Config{
|
||||
AllowedCurrencies: []string{"RUB", "USD"},
|
||||
}
|
||||
}
|
||||
|
||||
func validCardPayoutRequest() *mntxv1.CardPayoutRequest {
|
||||
return &mntxv1.CardPayoutRequest{
|
||||
PayoutId: "payout-1",
|
||||
CustomerId: "cust-1",
|
||||
CustomerFirstName: "Jane",
|
||||
CustomerLastName: "Doe",
|
||||
CustomerIp: "203.0.113.10",
|
||||
AmountMinor: 1500,
|
||||
Currency: "RUB",
|
||||
CardPan: "4111111111111111",
|
||||
CardHolder: "JANE DOE",
|
||||
CardExpMonth: 12,
|
||||
CardExpYear: 2035,
|
||||
}
|
||||
}
|
||||
|
||||
func validCardTokenPayoutRequest() *mntxv1.CardTokenPayoutRequest {
|
||||
return &mntxv1.CardTokenPayoutRequest{
|
||||
PayoutId: "payout-1",
|
||||
CustomerId: "cust-1",
|
||||
CustomerFirstName: "Jane",
|
||||
CustomerLastName: "Doe",
|
||||
CustomerIp: "203.0.113.11",
|
||||
AmountMinor: 2500,
|
||||
Currency: "USD",
|
||||
CardToken: "tok_123",
|
||||
}
|
||||
}
|
||||
|
||||
func validCardTokenizeRequest() *mntxv1.CardTokenizeRequest {
|
||||
month, year := futureExpiry()
|
||||
return &mntxv1.CardTokenizeRequest{
|
||||
RequestId: "req-1",
|
||||
CustomerId: "cust-1",
|
||||
CustomerFirstName: "Jane",
|
||||
CustomerLastName: "Doe",
|
||||
CustomerIp: "203.0.113.12",
|
||||
CardPan: "4111111111111111",
|
||||
CardHolder: "JANE DOE",
|
||||
CardCvv: "123",
|
||||
CardExpMonth: month,
|
||||
CardExpYear: year,
|
||||
}
|
||||
}
|
||||
|
||||
func futureExpiry() (uint32, uint32) {
|
||||
now := time.Now().UTC()
|
||||
return uint32(now.Month()), uint32(now.Year() + 1)
|
||||
}
|
||||
@@ -2,10 +2,6 @@ package monetix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
@@ -45,21 +41,3 @@ func (c *Client) CreateCardTokenPayout(ctx context.Context, req CardTokenPayoutR
|
||||
func (c *Client) CreateCardTokenization(ctx context.Context, req CardTokenizeRequest) (*TokenizationResult, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
23
api/gateway/mntx/internal/service/monetix/mask_test.go
Normal file
23
api/gateway/mntx/internal/service/monetix/mask_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package monetix
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMaskPAN(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{input: "1234", expected: "****"},
|
||||
{input: "1234567890", expected: "12******90"},
|
||||
{input: "1234567890123456", expected: "123456******3456"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
got := MaskPAN(tc.input)
|
||||
if got != tc.expected {
|
||||
t.Fatalf("expected %q, got %q", tc.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func (c *Client) sendCardPayout(ctx context.Context, req CardPayoutRequest) (*Ca
|
||||
maskedPAN := MaskPAN(req.Card.PAN)
|
||||
return c.send(ctx, &req, "/v2/payment/card/payout",
|
||||
func() {
|
||||
c.logger.Info("dispatching Monetix card payout",
|
||||
c.logger.Info("Dispatching Monetix card payout",
|
||||
zap.String("payout_id", req.General.PaymentID),
|
||||
zap.Int64("amount_minor", req.Payment.Amount),
|
||||
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) {
|
||||
return c.send(ctx, &req, "/v2/payment/card/payout/token",
|
||||
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.Int64("amount_minor", req.Payment.Amount),
|
||||
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("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("masked_pan", MaskPAN(req.Card.PAN)),
|
||||
)
|
||||
@@ -111,7 +111,7 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest)
|
||||
duration := time.Since(start)
|
||||
if err != nil {
|
||||
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())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -133,7 +133,7 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest)
|
||||
var apiResp APIResponse
|
||||
if len(body) > 0 {
|
||||
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 {
|
||||
var tokenData struct {
|
||||
Token string `json:"token"`
|
||||
@@ -245,7 +245,7 @@ func (c *Client) send(ctx context.Context, req any, path string, dispatchLog fun
|
||||
var apiResp APIResponse
|
||||
if len(body) > 0 {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
128
api/gateway/mntx/internal/service/monetix/sender_test.go
Normal file
128
api/gateway/mntx/internal/service/monetix/sender_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
112
api/gateway/mntx/internal/service/monetix/signature.go
Normal file
112
api/gateway/mntx/internal/service/monetix/signature.go
Normal file
@@ -0,0 +1,112 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
148
api/gateway/mntx/internal/service/monetix/signature_test.go
Normal file
148
api/gateway/mntx/internal/service/monetix/signature_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package monetix
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSignaturePayloadString_Example(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"general": map[string]any{
|
||||
"project_id": 3254,
|
||||
"payment_id": "id_38202316",
|
||||
"signature": "<ignored>",
|
||||
},
|
||||
"customer": map[string]any{
|
||||
"id": "585741",
|
||||
"email": "johndoe@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"address": "Downing str., 23",
|
||||
"identify": map[string]any{
|
||||
"doc_number": "54122312544",
|
||||
},
|
||||
"ip_address": "198.51.100.47",
|
||||
},
|
||||
"payment": map[string]any{
|
||||
"amount": 10800,
|
||||
"currency": "USD",
|
||||
"description": "Computer keyboards",
|
||||
},
|
||||
"receipt_data": map[string]any{
|
||||
"positions": []any{
|
||||
map[string]any{
|
||||
"quantity": "10",
|
||||
"amount": "108",
|
||||
"description": "Computer keyboard",
|
||||
},
|
||||
},
|
||||
},
|
||||
"return_url": map[string]any{
|
||||
"success": "https://paymentpage.example.com/complete-redirect?id=success",
|
||||
"decline": "https://paymentpage.example.com/complete-redirect?id=decline",
|
||||
},
|
||||
}
|
||||
|
||||
got, err := signaturePayloadString(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build signature string: %v", err)
|
||||
}
|
||||
|
||||
expected := "customer:address:Downing str., 23;customer:email:johndoe@example.com;customer:first_name:John;customer:id:585741;customer:identify:doc_number:54122312544;customer:ip_address:198.51.100.47;customer:last_name:Doe;general:payment_id:id_38202316;general:project_id:3254;payment:amount:10800;payment:currency:USD;payment:description:Computer keyboards;receipt_data:positions:0:amount:108;receipt_data:positions:0:description:Computer keyboard;receipt_data:positions:0:quantity:10;return_url:decline:https://paymentpage.example.com/complete-redirect?id=decline;return_url:success:https://paymentpage.example.com/complete-redirect?id=success"
|
||||
if got != expected {
|
||||
t.Fatalf("unexpected signature string\nexpected: %s\ngot: %s", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignPayload_Example(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"general": map[string]any{
|
||||
"project_id": 3254,
|
||||
"payment_id": "id_38202316",
|
||||
"signature": "<ignored>",
|
||||
},
|
||||
"customer": map[string]any{
|
||||
"id": "585741",
|
||||
"email": "johndoe@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"address": "Downing str., 23",
|
||||
"identify": map[string]any{
|
||||
"doc_number": "54122312544",
|
||||
},
|
||||
"ip_address": "198.51.100.47",
|
||||
},
|
||||
"payment": map[string]any{
|
||||
"amount": 10800,
|
||||
"currency": "USD",
|
||||
"description": "Computer keyboards",
|
||||
},
|
||||
"receipt_data": map[string]any{
|
||||
"positions": []any{
|
||||
map[string]any{
|
||||
"quantity": "10",
|
||||
"amount": "108",
|
||||
"description": "Computer keyboard",
|
||||
},
|
||||
},
|
||||
},
|
||||
"return_url": map[string]any{
|
||||
"success": "https://paymentpage.example.com/complete-redirect?id=success",
|
||||
"decline": "https://paymentpage.example.com/complete-redirect?id=decline",
|
||||
},
|
||||
}
|
||||
|
||||
got, err := SignPayload(payload, "secret")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign payload: %v", err)
|
||||
}
|
||||
expected := "lagSnuspAn+F6XkmQISqwtBg0PsiTy62fF9x33TM+278mnufIDZyi1yP0BQALuCxyikkIxIMbodBn2F8hMdRwA=="
|
||||
if got != expected {
|
||||
t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignaturePayloadString_BooleansAndArrays(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"flag": true,
|
||||
"false_flag": false,
|
||||
"empty": "",
|
||||
"zero": 0,
|
||||
"nested": map[string]any{
|
||||
"list": []any{},
|
||||
"items": []any{"alpha", "beta"},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := signaturePayloadString(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build signature string: %v", err)
|
||||
}
|
||||
|
||||
expected := "empty:;false_flag:0;flag:1;nested:items:0:alpha;nested:items:1:beta;zero:0"
|
||||
if got != expected {
|
||||
t.Fatalf("unexpected signature string\nexpected: %s\ngot: %s", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignPayload_EthEstimateGasExample(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "eth_estimateGas",
|
||||
"params": []any{
|
||||
map[string]any{
|
||||
"from": "0xfa89b4d534bdeb2713d4ffd893e79d6535fb58f8",
|
||||
"to": "0x44162e39eefd9296231e772663a92e72958e182f",
|
||||
"gasPrice": "0x64",
|
||||
"data": "0xa9059cbb00000000000000000000000044162e39eefd9296231e772663a92e72958e182f00000000000000000000000000000000000000000000000000000000000f4240",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := SignPayload(payload, "1")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign payload: %v", err)
|
||||
}
|
||||
expected := "C4WbSvXKSMyX8yLamQcUe/Nzr6nSt9m3HYn4jHSyA7yi/FaTiqk0r8BlfIzfxSCoDaRgrSd82ihgZW+DxELhdQ=="
|
||||
if got != expected {
|
||||
t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got)
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,12 @@ gateway:
|
||||
call_timeout_seconds: 3
|
||||
insecure: true
|
||||
|
||||
mntx:
|
||||
address: "sendico_mntx_gateway:50075"
|
||||
dial_timeout_seconds: 5
|
||||
call_timeout_seconds: 3
|
||||
insecure: true
|
||||
|
||||
oracle:
|
||||
address: "sendico_fx_oracle:50051"
|
||||
dial_timeout_seconds: 5
|
||||
@@ -60,7 +66,7 @@ oracle:
|
||||
card_gateways:
|
||||
monetix:
|
||||
funding_address: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF"
|
||||
fee_wallet_ref: "694c124fd76f9f811ac57134"
|
||||
fee_wallet_ref: "694c124ed76f9f811ac57133"
|
||||
|
||||
fee_ledger_accounts:
|
||||
monetix: "ledger:fees:monetix"
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||
mntxclient "github.com/tech/sendico/gateway/mntx/client"
|
||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||
@@ -36,6 +37,7 @@ type Imp struct {
|
||||
feesConn *grpc.ClientConn
|
||||
ledgerClient ledgerclient.Client
|
||||
gatewayClient chainclient.Client
|
||||
mntxClient mntxclient.Client
|
||||
oracleClient oracleclient.Client
|
||||
}
|
||||
|
||||
@@ -44,6 +46,7 @@ type config struct {
|
||||
Fees clientConfig `yaml:"fees"`
|
||||
Ledger clientConfig `yaml:"ledger"`
|
||||
Gateway clientConfig `yaml:"gateway"`
|
||||
Mntx clientConfig `yaml:"mntx"`
|
||||
Oracle clientConfig `yaml:"oracle"`
|
||||
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
|
||||
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
|
||||
@@ -105,6 +108,9 @@ func (i *Imp) Shutdown() {
|
||||
if i.gatewayClient != nil {
|
||||
_ = i.gatewayClient.Close()
|
||||
}
|
||||
if i.mntxClient != nil {
|
||||
_ = i.mntxClient.Close()
|
||||
}
|
||||
if i.oracleClient != nil {
|
||||
_ = i.oracleClient.Close()
|
||||
}
|
||||
@@ -139,6 +145,11 @@ func (i *Imp) Start() error {
|
||||
i.gatewayClient = gatewayClient
|
||||
}
|
||||
|
||||
mntxClient := i.initMntxClient(cfg.Mntx)
|
||||
if mntxClient != nil {
|
||||
i.mntxClient = mntxClient
|
||||
}
|
||||
|
||||
oracleClient := i.initOracleClient(cfg.Oracle)
|
||||
if oracleClient != nil {
|
||||
i.oracleClient = oracleClient
|
||||
@@ -155,6 +166,9 @@ func (i *Imp) Start() error {
|
||||
if gatewayClient != nil {
|
||||
opts = append(opts, orchestrator.WithChainGatewayClient(gatewayClient))
|
||||
}
|
||||
if mntxClient != nil {
|
||||
opts = append(opts, orchestrator.WithMntxGateway(mntxClient))
|
||||
}
|
||||
if oracleClient != nil {
|
||||
opts = append(opts, orchestrator.WithOracleClient(oracleClient))
|
||||
}
|
||||
@@ -192,11 +206,11 @@ func (i *Imp) initFeesClient(cfg clientConfig) (feesv1.FeeEngineClient, *grpc.Cl
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, addr, grpc.WithTransportCredentials(creds))
|
||||
if err != nil {
|
||||
i.logger.Warn("failed to connect to fees service", zap.String("address", addr), zap.Error(err))
|
||||
i.logger.Warn("Failed to connect to fees service", zap.String("address", addr), zap.Error(err))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
i.logger.Info("connected to fees service", zap.String("address", addr))
|
||||
i.logger.Info("Connected to fees service", zap.String("address", addr))
|
||||
return feesv1.NewFeeEngineClient(conn), conn
|
||||
}
|
||||
|
||||
@@ -216,10 +230,10 @@ func (i *Imp) initLedgerClient(cfg clientConfig) ledgerclient.Client {
|
||||
Insecure: cfg.InsecureTransport,
|
||||
})
|
||||
if err != nil {
|
||||
i.logger.Warn("failed to connect to ledger service", zap.String("address", addr), zap.Error(err))
|
||||
i.logger.Warn("Failed to connect to ledger service", zap.String("address", addr), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
i.logger.Info("connected to ledger service", zap.String("address", addr))
|
||||
i.logger.Info("Connected to ledger service", zap.String("address", addr))
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -246,6 +260,28 @@ func (i *Imp) initGatewayClient(cfg clientConfig) chainclient.Client {
|
||||
return client
|
||||
}
|
||||
|
||||
func (i *Imp) initMntxClient(cfg clientConfig) mntxclient.Client {
|
||||
addr := cfg.address()
|
||||
if addr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
|
||||
defer cancel()
|
||||
|
||||
client, err := mntxclient.New(ctx, mntxclient.Config{
|
||||
Address: addr,
|
||||
DialTimeout: cfg.dialTimeout(),
|
||||
CallTimeout: cfg.callTimeout(),
|
||||
})
|
||||
if err != nil {
|
||||
i.logger.Warn("Failed to connect to mntx gateway service", zap.String("address", addr), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
i.logger.Info("Connected to mntx gateway service", zap.String("address", addr))
|
||||
return client
|
||||
}
|
||||
|
||||
func (i *Imp) initOracleClient(cfg clientConfig) oracleclient.Client {
|
||||
addr := cfg.address()
|
||||
if addr == "" {
|
||||
@@ -262,10 +298,10 @@ func (i *Imp) initOracleClient(cfg clientConfig) oracleclient.Client {
|
||||
Insecure: cfg.InsecureTransport,
|
||||
})
|
||||
if err != nil {
|
||||
i.logger.Warn("failed to connect to oracle service", zap.String("address", addr), zap.Error(err))
|
||||
i.logger.Warn("Failed to connect to oracle service", zap.String("address", addr), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
i.logger.Info("connected to oracle service", zap.String("address", addr))
|
||||
i.logger.Info("Connected to oracle service", zap.String("address", addr))
|
||||
return client
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
type paymentEngine interface {
|
||||
EnsureRepository(ctx context.Context) error
|
||||
BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error)
|
||||
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error)
|
||||
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error)
|
||||
ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error
|
||||
Repository() storage.Repository
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef stri
|
||||
return e.svc.buildPaymentQuote(ctx, orgRef, req)
|
||||
}
|
||||
|
||||
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
|
||||
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) {
|
||||
return e.svc.resolvePaymentQuote(ctx, in)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
@@ -61,7 +62,13 @@ func (h *quotePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.Q
|
||||
if err := quotesStore.Create(ctx, record); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Info("stored payment quote", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
|
||||
h.logger.Info(
|
||||
"Stored payment quote",
|
||||
zap.String("quote_ref", quoteRef),
|
||||
mzap.ObjRef("org_ref", orgID),
|
||||
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
|
||||
zap.String("kind", intent.GetKind().String()),
|
||||
)
|
||||
}
|
||||
|
||||
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
|
||||
@@ -79,7 +86,7 @@ func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
orgID, orgRef, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
@@ -101,7 +108,7 @@ func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.
|
||||
Intent: intent,
|
||||
PreviewOnly: req.GetPreviewOnly(),
|
||||
}
|
||||
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, quoteReq)
|
||||
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgID, quoteReq)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
@@ -132,11 +139,14 @@ func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
record.SetID(primitive.NewObjectID())
|
||||
record.SetOrganizationRef(orgID)
|
||||
record.SetOrganizationRef(orgRef)
|
||||
if err := quotesStore.Create(ctx, record); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Info("stored payment quotes", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
|
||||
h.logger.Info("Stored payment quotes",
|
||||
zap.String("quote_ref", quoteRef), mzap.ObjRef("org_ref", orgRef),
|
||||
zap.String("idempotency_key", baseKey), zap.Int("quote_count", len(quotes)),
|
||||
)
|
||||
}
|
||||
|
||||
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
|
||||
@@ -158,7 +168,7 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
_, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
_, orgRef, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
@@ -175,7 +185,7 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
|
||||
if err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
record, err := quotesStore.GetByRef(ctx, orgID, quoteRef)
|
||||
record, err := quotesStore.GetByRef(ctx, orgRef, quoteRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrQuoteNotFound) {
|
||||
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
|
||||
@@ -213,14 +223,14 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
|
||||
quoteProto.QuoteRef = quoteRef
|
||||
|
||||
perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents))
|
||||
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, perKey); err == nil && existing != nil {
|
||||
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgRef, perKey); err == nil && existing != nil {
|
||||
payments = append(payments, toProtoPayment(existing))
|
||||
continue
|
||||
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
entity := newPayment(orgID, intentProto, perKey, req.GetMetadata(), quoteProto)
|
||||
entity := newPayment(orgRef, intentProto, perKey, req.GetMetadata(), quoteProto)
|
||||
if err = store.Create(ctx, entity); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicatePayment) {
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
|
||||
@@ -235,6 +245,13 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
|
||||
payments = append(payments, toProtoPayment(entity))
|
||||
}
|
||||
|
||||
h.logger.Info(
|
||||
"Payments initiated",
|
||||
mzap.ObjRef("org_ref", orgRef),
|
||||
zap.String("quote_ref", quoteRef),
|
||||
zap.String("idempotency_key", idempotencyKey),
|
||||
zap.Int("payment_count", len(payments)),
|
||||
)
|
||||
return gsresponse.Success(&orchestratorv1.InitiatePaymentsResponse{Payments: payments})
|
||||
}
|
||||
|
||||
@@ -255,13 +272,31 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
intent := req.GetIntent()
|
||||
if err := requireNonNilIntent(intent); err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
quoteRef := strings.TrimSpace(req.GetQuoteRef())
|
||||
hasIntent := intent != nil
|
||||
hasQuote := quoteRef != ""
|
||||
switch {
|
||||
case !hasIntent && !hasQuote:
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent or quote_ref is required"))
|
||||
case hasIntent && hasQuote:
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent and quote_ref are mutually exclusive"))
|
||||
}
|
||||
if hasIntent {
|
||||
if err := requireNonNilIntent(intent); err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
}
|
||||
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Debug(
|
||||
"Initiate payment request accepted",
|
||||
zap.String("org_ref", orgID.Hex()),
|
||||
zap.String("idempotency_key", idempotencyKey),
|
||||
zap.String("quote_ref", quoteRef),
|
||||
zap.Bool("has_intent", hasIntent),
|
||||
)
|
||||
|
||||
store, err := ensurePaymentsStore(h.engine.Repository())
|
||||
if err != nil {
|
||||
@@ -269,18 +304,24 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
|
||||
}
|
||||
|
||||
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
|
||||
h.logger.Debug("idempotent payment request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
|
||||
h.logger.Debug(
|
||||
"idempotent payment request reused",
|
||||
zap.String("payment_ref", existing.PaymentRef),
|
||||
zap.String("org_ref", orgID.Hex()),
|
||||
zap.String("idempotency_key", idempotencyKey),
|
||||
zap.String("quote_ref", quoteRef),
|
||||
)
|
||||
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)})
|
||||
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
quoteSnapshot, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
|
||||
quoteSnapshot, resolvedIntent, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
|
||||
OrgRef: orgRef,
|
||||
OrgID: orgID,
|
||||
Meta: req.GetMeta(),
|
||||
Intent: intent,
|
||||
QuoteRef: req.GetQuoteRef(),
|
||||
QuoteRef: quoteRef,
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -301,8 +342,17 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
|
||||
if quoteSnapshot == nil {
|
||||
quoteSnapshot = &orchestratorv1.PaymentQuote{}
|
||||
}
|
||||
if err := requireNonNilIntent(resolvedIntent); err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Debug(
|
||||
"Payment quote resolved",
|
||||
zap.String("org_ref", orgID.Hex()),
|
||||
zap.String("quote_ref", quoteRef),
|
||||
zap.Bool("quote_ref_used", quoteRef != ""),
|
||||
)
|
||||
|
||||
entity := newPayment(orgID, intent, idempotencyKey, req.GetMetadata(), quoteSnapshot)
|
||||
entity := newPayment(orgID, resolvedIntent, idempotencyKey, req.GetMetadata(), quoteSnapshot)
|
||||
|
||||
if err = store.Create(ctx, entity); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicatePayment) {
|
||||
@@ -315,7 +365,14 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
h.logger.Info("payment initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()), zap.String("kind", intent.GetKind().String()))
|
||||
h.logger.Info(
|
||||
"Payment initiated",
|
||||
zap.String("payment_ref", entity.PaymentRef),
|
||||
zap.String("org_ref", orgID.Hex()),
|
||||
zap.String("kind", resolvedIntent.GetKind().String()),
|
||||
zap.String("quote_ref", quoteSnapshot.GetQuoteRef()),
|
||||
zap.String("idempotency_key", idempotencyKey),
|
||||
)
|
||||
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
|
||||
Payment: toProtoPayment(entity),
|
||||
})
|
||||
@@ -355,7 +412,7 @@ func (h *cancelPaymentCommand) Execute(ctx context.Context, req *orchestratorv1.
|
||||
if err := store.Update(ctx, payment); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Info("payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex()))
|
||||
h.logger.Info("Payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex()))
|
||||
return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)})
|
||||
}
|
||||
|
||||
@@ -396,7 +453,7 @@ func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestrat
|
||||
}
|
||||
|
||||
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
|
||||
h.logger.Debug("idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
|
||||
h.logger.Debug("Idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
|
||||
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
|
||||
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
@@ -439,7 +496,7 @@ func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestrat
|
||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
h.logger.Info("conversion initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()))
|
||||
h.logger.Info("Conversion initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()))
|
||||
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
|
||||
Conversion: toProtoPayment(entity),
|
||||
})
|
||||
|
||||
@@ -103,33 +103,40 @@ type quoteResolutionError struct {
|
||||
|
||||
func (e quoteResolutionError) Error() string { return e.err.Error() }
|
||||
|
||||
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
|
||||
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) {
|
||||
if ref := strings.TrimSpace(in.QuoteRef); ref != "" {
|
||||
quotesStore, err := ensureQuotesStore(s.storage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrQuoteNotFound) {
|
||||
return nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
|
||||
return nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
|
||||
}
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
|
||||
return nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
|
||||
return nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
|
||||
}
|
||||
if !proto.Equal(protoIntentFromModel(record.Intent), in.Intent) {
|
||||
return nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
|
||||
intent, err := recordIntentFromQuote(record)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
quote := modelQuoteToProto(record.Quote)
|
||||
if quote == nil {
|
||||
return nil, merrors.InvalidArgument("stored quote is empty")
|
||||
if in.Intent != nil && !proto.Equal(intent, in.Intent) {
|
||||
return nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
|
||||
}
|
||||
quote, err := recordQuoteFromQuote(record)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
quote.QuoteRef = ref
|
||||
return quote, nil
|
||||
return quote, intent, nil
|
||||
}
|
||||
|
||||
if in.Intent == nil {
|
||||
return nil, nil, merrors.InvalidArgument("intent is required")
|
||||
}
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: in.Meta,
|
||||
IdempotencyKey: in.IdempotencyKey,
|
||||
@@ -138,9 +145,41 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp
|
||||
}
|
||||
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return quote, nil
|
||||
return quote, in.Intent, nil
|
||||
}
|
||||
|
||||
func recordIntentFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.PaymentIntent, error) {
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
if len(record.Intents) > 0 {
|
||||
if len(record.Intents) != 1 {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
return protoIntentFromModel(record.Intents[0]), nil
|
||||
}
|
||||
if record.Intent.Amount == nil && (record.Intent.Kind == "" || record.Intent.Kind == model.PaymentKindUnspecified) {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
return protoIntentFromModel(record.Intent), nil
|
||||
}
|
||||
|
||||
func recordQuoteFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.PaymentQuote, error) {
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("stored quote is empty")
|
||||
}
|
||||
if record.Quote != nil {
|
||||
return modelQuoteToProto(record.Quote), nil
|
||||
}
|
||||
if len(record.Quotes) > 0 {
|
||||
if len(record.Quotes) != 1 {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
return modelQuoteToProto(record.Quotes[0]), nil
|
||||
}
|
||||
return nil, merrors.InvalidArgument("stored quote is empty")
|
||||
}
|
||||
|
||||
func newPayment(orgID primitive.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.PaymentQuote) *model.Payment {
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestResolvePaymentQuote_NotFound(t *testing.T) {
|
||||
storage: stubRepo{quotes: &helperQuotesStore{}},
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
_, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
@@ -98,7 +98,7 @@ func TestResolvePaymentQuote_Expired(t *testing.T) {
|
||||
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
_, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
@@ -110,6 +110,35 @@ func TestResolvePaymentQuote_Expired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) {
|
||||
org := primitive.NewObjectID()
|
||||
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: "q1",
|
||||
Intent: intentFromProto(intent),
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
}
|
||||
svc := &Service{
|
||||
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
quote, resolvedIntent, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
QuoteRef: "q1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if quote == nil || quote.GetQuoteRef() != "q1" {
|
||||
t.Fatalf("expected quote_ref q1, got %#v", quote)
|
||||
}
|
||||
if resolvedIntent == nil || resolvedIntent.GetAmount().GetAmount() != "1" {
|
||||
t.Fatalf("expected resolved intent with amount, got %#v", resolvedIntent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentIdempotency(t *testing.T) {
|
||||
logger := mloggerfactory.NewLogger(false)
|
||||
org := primitive.NewObjectID()
|
||||
@@ -140,6 +169,42 @@ func TestInitiatePaymentIdempotency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentByQuoteRef(t *testing.T) {
|
||||
logger := mloggerfactory.NewLogger(false)
|
||||
org := primitive.NewObjectID()
|
||||
store := newHelperPaymentStore()
|
||||
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: "q1",
|
||||
Intent: intentFromProto(intent),
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
}
|
||||
svc := NewService(logger, stubRepo{
|
||||
payments: store,
|
||||
quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}},
|
||||
}, WithClock(clockpkg.NewSystem()))
|
||||
svc.ensureHandlers()
|
||||
|
||||
req := &orchestratorv1.InitiatePaymentRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
QuoteRef: "q1",
|
||||
IdempotencyKey: "k1",
|
||||
}
|
||||
resp, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("initiate by quote_ref failed: %v", err)
|
||||
}
|
||||
if resp == nil || resp.GetPayment() == nil {
|
||||
t.Fatalf("expected payment response")
|
||||
}
|
||||
if resp.GetPayment().GetIntent().GetAmount().GetAmount() != "1" {
|
||||
t.Fatalf("expected intent amount to be resolved from quote")
|
||||
}
|
||||
if resp.GetPayment().GetLastQuote().GetQuoteRef() != "q1" {
|
||||
t.Fatalf("expected last quote_ref to be set from stored quote")
|
||||
}
|
||||
}
|
||||
|
||||
// --- test doubles ---
|
||||
|
||||
type stubRepo struct {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
@@ -20,7 +21,7 @@ import (
|
||||
func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, token *sresponse.TokenData, expectQuote bool) http.HandlerFunc {
|
||||
orgRef, err := a.oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to parse organization reference for payment initiation", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
|
||||
a.logger.Warn("Failed to parse organization reference for payment initiation", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
|
||||
}
|
||||
|
||||
@@ -76,7 +77,7 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to
|
||||
|
||||
resp, err := a.client.InitiatePayment(ctx, req)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to initiate payment", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
|
||||
a.logger.Warn("Failed to initiate payment", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
|
||||
20
frontend/pshared/lib/api/responses/payment/payment.dart
Normal file
20
frontend/pshared/lib/api/responses/payment/payment.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
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);
|
||||
}
|
||||
64
frontend/pshared/lib/provider/payment/provider.dart
Normal file
64
frontend/pshared/lib/provider/payment/provider.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
36
frontend/pshared/lib/service/payment/service.dart
Normal file
36
frontend/pshared/lib/service/payment/service.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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:pshared/provider/recipient/pmethods.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(),
|
||||
),
|
||||
ChangeNotifierProxyProvider5<OrganizationsProvider, PaymentAmountProvider, WalletsProvider, PaymentFlowProvider, PaymentMethodsProvider, QuotationProvider>(
|
||||
create: (_) => QuotationProvider(),
|
||||
update: (context, orgnization, payment, wallet, flow, methods, provider) => provider!..update(orgnization, payment, wallet, flow, methods),
|
||||
),
|
||||
],
|
||||
child: const PaymentFormWidget(),
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,11 @@ import 'package:pshared/models/payment/methods/data.dart';
|
||||
import 'package:pshared/models/payment/methods/type.dart';
|
||||
import 'package:pshared/models/payment/type.dart';
|
||||
import 'package:pshared/models/recipient/recipient.dart';
|
||||
import 'package:pshared/provider/organizations.dart';
|
||||
import 'package:pshared/provider/payment/amount.dart';
|
||||
import 'package:pshared/provider/payment/flow.dart';
|
||||
import 'package:pshared/provider/payment/provider.dart';
|
||||
import 'package:pshared/provider/payment/quotation.dart';
|
||||
import 'package:pshared/provider/recipient/pmethods.dart';
|
||||
import 'package:pshared/provider/recipient/provider.dart';
|
||||
|
||||
@@ -38,16 +42,12 @@ class PaymentPage extends StatefulWidget {
|
||||
class _PaymentPageState extends State<PaymentPage> {
|
||||
late final TextEditingController _searchController;
|
||||
late final FocusNode _searchFocusNode;
|
||||
late final PaymentFlowProvider _flowProvider;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController = TextEditingController();
|
||||
_searchFocusNode = FocusNode();
|
||||
_flowProvider = PaymentFlowProvider(
|
||||
initialType: widget.initialPaymentType ?? PaymentType.bankAccount,
|
||||
);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _initializePaymentPage());
|
||||
}
|
||||
@@ -56,45 +56,30 @@ class _PaymentPageState extends State<PaymentPage> {
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_searchFocusNode.dispose();
|
||||
_flowProvider.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initializePaymentPage() {
|
||||
final methodsProvider = context.read<PaymentMethodsProvider>();
|
||||
_handleWalletAutoSelection(methodsProvider);
|
||||
|
||||
final recipient = context.read<RecipientsProvider>().currentObject;
|
||||
_syncFlowProvider(
|
||||
recipient: recipient,
|
||||
methodsProvider: methodsProvider,
|
||||
preferredType: widget.initialPaymentType,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSearchChanged(String query) {
|
||||
context.read<RecipientsProvider>().setQuery(query);
|
||||
}
|
||||
|
||||
void _handleRecipientSelected(Recipient recipient) {
|
||||
void _handleRecipientSelected(BuildContext context, Recipient recipient) {
|
||||
final recipientProvider = context.read<RecipientsProvider>();
|
||||
final methodsProvider = context.read<PaymentMethodsProvider>();
|
||||
|
||||
recipientProvider.setCurrentObject(recipient.id);
|
||||
_flowProvider.reset(
|
||||
recipient: recipient,
|
||||
availableTypes: _availablePaymentTypes(recipient, methodsProvider),
|
||||
preferredType: widget.initialPaymentType,
|
||||
);
|
||||
_clearSearchField();
|
||||
}
|
||||
|
||||
void _handleRecipientCleared() {
|
||||
void _handleRecipientCleared(BuildContext context) {
|
||||
final recipientProvider = context.read<RecipientsProvider>();
|
||||
final methodsProvider = context.read<PaymentMethodsProvider>();
|
||||
|
||||
recipientProvider.setCurrentObject(null);
|
||||
_flowProvider.reset(
|
||||
context.read<PaymentFlowProvider>().reset(
|
||||
recipient: null,
|
||||
availableTypes: _availablePaymentTypes(null, methodsProvider),
|
||||
preferredType: widget.initialPaymentType,
|
||||
@@ -108,9 +93,13 @@ class _PaymentPageState extends State<PaymentPage> {
|
||||
context.read<RecipientsProvider>().setQuery('');
|
||||
}
|
||||
|
||||
void _handleSendPayment() {
|
||||
// TODO: Handle Payment logic
|
||||
PosthogService.paymentInitiated(method: _flowProvider.selectedType);
|
||||
void _handleSendPayment(BuildContext context) {
|
||||
if (context.read<QuotationProvider>().isReady) {
|
||||
context.read<PaymentProvider>().pay();
|
||||
PosthogService.paymentInitiated(
|
||||
method: context.read<PaymentFlowProvider>().selectedType,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -120,27 +109,49 @@ class _PaymentPageState extends State<PaymentPage> {
|
||||
final recipient = recipientProvider.currentObject;
|
||||
final availableTypes = _availablePaymentTypes(recipient, methodsProvider);
|
||||
|
||||
_syncFlowProvider(
|
||||
recipient: recipient,
|
||||
methodsProvider: methodsProvider,
|
||||
preferredType: recipient != null ? widget.initialPaymentType : null,
|
||||
);
|
||||
|
||||
return ChangeNotifierProvider.value(
|
||||
value: _flowProvider,
|
||||
child: PaymentPageBody(
|
||||
onBack: widget.onBack,
|
||||
fallbackDestination: widget.fallbackDestination,
|
||||
recipient: recipient,
|
||||
recipientProvider: recipientProvider,
|
||||
methodsProvider: methodsProvider,
|
||||
availablePaymentTypes: availableTypes,
|
||||
searchController: _searchController,
|
||||
searchFocusNode: _searchFocusNode,
|
||||
onSearchChanged: _handleSearchChanged,
|
||||
onRecipientSelected: _handleRecipientSelected,
|
||||
onRecipientCleared: _handleRecipientCleared,
|
||||
onSend: _handleSendPayment,
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProxyProvider2<RecipientsProvider, PaymentMethodsProvider, PaymentFlowProvider>(
|
||||
create: (_) => PaymentFlowProvider(
|
||||
initialType: widget.initialPaymentType ?? PaymentType.bankAccount,
|
||||
),
|
||||
update: (_, recipients, methods, flow) {
|
||||
final currentRecipient = recipients.currentObject;
|
||||
flow!.sync(
|
||||
recipient: currentRecipient,
|
||||
availableTypes: _availablePaymentTypes(currentRecipient, methods),
|
||||
preferredType: currentRecipient != null ? widget.initialPaymentType : null,
|
||||
);
|
||||
return flow;
|
||||
},
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => PaymentAmountProvider(),
|
||||
),
|
||||
ChangeNotifierProxyProvider5<OrganizationsProvider, PaymentAmountProvider, WalletsProvider, PaymentFlowProvider, PaymentMethodsProvider, QuotationProvider>(
|
||||
create: (_) => QuotationProvider(),
|
||||
update: (_, organization, payment, wallet, flow, methods, provider) => provider!..update(organization, payment, wallet, flow, methods),
|
||||
),
|
||||
ChangeNotifierProxyProvider2<OrganizationsProvider, QuotationProvider, PaymentProvider>(
|
||||
create: (_) => PaymentProvider(),
|
||||
update: (_, organization, quotation, provider) => provider!..update(organization, quotation),
|
||||
),
|
||||
],
|
||||
child: Builder(
|
||||
builder: (innerContext) => PaymentPageBody(
|
||||
onBack: widget.onBack,
|
||||
fallbackDestination: widget.fallbackDestination,
|
||||
recipient: recipient,
|
||||
recipientProvider: recipientProvider,
|
||||
methodsProvider: methodsProvider,
|
||||
availablePaymentTypes: availableTypes,
|
||||
searchController: _searchController,
|
||||
searchFocusNode: _searchFocusNode,
|
||||
onSearchChanged: _handleSearchChanged,
|
||||
onRecipientSelected: (selected) => _handleRecipientSelected(innerContext, selected),
|
||||
onRecipientCleared: () => _handleRecipientCleared(innerContext),
|
||||
onSend: () => _handleSendPayment(innerContext),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -155,18 +166,6 @@ class _PaymentPageState extends State<PaymentPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _syncFlowProvider({
|
||||
required Recipient? recipient,
|
||||
required PaymentMethodsProvider methodsProvider,
|
||||
PaymentType? preferredType,
|
||||
}) {
|
||||
_flowProvider.sync(
|
||||
recipient: recipient,
|
||||
availableTypes: _availablePaymentTypes(recipient, methodsProvider),
|
||||
preferredType: preferredType,
|
||||
);
|
||||
}
|
||||
|
||||
MethodMap _availablePaymentTypes(
|
||||
Recipient? recipient,
|
||||
PaymentMethodsProvider methodsProvider,
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:pshared/provider/recipient/pmethods.dart';
|
||||
import 'package:pshared/provider/recipient/provider.dart';
|
||||
import 'package:pshared/provider/payment/flow.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/payouts/widget.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/form.dart';
|
||||
import 'package:pweb/pages/payment_methods/payment_page/back_button.dart';
|
||||
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
|
||||
import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart';
|
||||
@@ -105,7 +105,7 @@ class PaymentPageContent extends StatelessWidget {
|
||||
availableTypes: availablePaymentTypes,
|
||||
),
|
||||
SizedBox(height: dimensions.paddingLarge),
|
||||
const PaymentFromWrappingWidget(),
|
||||
const PaymentFormWidget(),
|
||||
SizedBox(height: dimensions.paddingXXXLarge),
|
||||
SendButton(onPressed: onSend),
|
||||
SizedBox(height: dimensions.paddingLarge),
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import 'package:amplitude_flutter/amplitude.dart';
|
||||
import 'package:amplitude_flutter/configuration.dart';
|
||||
import 'package:amplitude_flutter/constants.dart' as amp;
|
||||
import 'package:amplitude_flutter/events/base_event.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:pshared/models/account/account.dart';
|
||||
import 'package:pshared/models/payment/type.dart';
|
||||
import 'package:pshared/models/recipient/status.dart';
|
||||
import 'package:pshared/models/recipient/type.dart';
|
||||
import 'package:pweb/widgets/sidebar/destinations.dart';
|
||||
|
||||
|
||||
class AmplitudeService {
|
||||
static late Amplitude _analytics;
|
||||
|
||||
static Amplitude _amp() => _analytics;
|
||||
|
||||
static Future<void> initialize() async {
|
||||
_analytics = Amplitude(Configuration(
|
||||
apiKey: '12345', //TODO define through App Contants
|
||||
serverZone: amp.ServerZone.eu, //TODO define through App Contants
|
||||
));
|
||||
await _analytics.isBuilt;
|
||||
}
|
||||
|
||||
static Future<void> identify(Account account) async =>
|
||||
_amp().setUserId(account.id);
|
||||
|
||||
|
||||
static Future<void> login(Account account) async =>
|
||||
_logEvent(
|
||||
'login',
|
||||
userProperties: {
|
||||
// 'email': account.email, TODO Add email into account
|
||||
'locale': account.locale,
|
||||
},
|
||||
);
|
||||
|
||||
static Future<void> logout() async => _logEvent("logout");
|
||||
|
||||
static Future<void> pageOpened(PayoutDestination page, {String? path, String? uiSource}) async {
|
||||
return _logEvent("pageOpened", eventProperties: {
|
||||
"page": page,
|
||||
if (path != null) "path": path,
|
||||
if (uiSource != null) "uiSource": uiSource,
|
||||
});
|
||||
}
|
||||
|
||||
//TODO Add when registration is ready. User properties {user_id, registration_date, has_wallet (true/false), wallet_balance (should concider loggin it as: 0 / <100 / 100–500 / 500+), preferred_method (Wallet/Card/Bank/IBAN), total_transactions, total_amount, last_payout_date, last_login_date , marketing_source}
|
||||
|
||||
// static Future<void> registrationStarted(String method, String country) async =>
|
||||
// _logEvent("registrationStarted", eventProperties: {"method": method, "country": country});
|
||||
|
||||
// static Future<void> registrationCompleted(String method, String country) async =>
|
||||
// _logEvent("registrationCompleted", eventProperties: {"method": method, "country": country});
|
||||
|
||||
static Future<void> pageNotFound(String url) async =>
|
||||
_logEvent("pageNotFound", eventProperties: {"url": url});
|
||||
|
||||
static Future<void> localeChanged(Locale locale) async =>
|
||||
_logEvent("localeChanged", eventProperties: {"locale": locale.toString()});
|
||||
|
||||
static Future<void> localeMatched(String locale, bool haveRequested) async => //DO we need it?
|
||||
_logEvent("localeMatched", eventProperties: {
|
||||
"locale": locale,
|
||||
"have_requested_locale": haveRequested
|
||||
});
|
||||
|
||||
static Future<void> recipientAddStarted() async =>
|
||||
_logEvent("recipientAddStarted");
|
||||
|
||||
static Future<void> recipientAddCompleted(
|
||||
RecipientType type,
|
||||
RecipientStatus status,
|
||||
Set<PaymentType> methods,
|
||||
) async {
|
||||
_logEvent(
|
||||
"recipientAddCompleted",
|
||||
eventProperties: {
|
||||
"methods": methods.map((m) => m.name).toList(),
|
||||
"type": type.name,
|
||||
"status": status.name,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _paymentEvent(
|
||||
String evt,
|
||||
double amount,
|
||||
double fee,
|
||||
bool payerCoversFee,
|
||||
PaymentType source,
|
||||
PaymentType recpientPaymentMethod, {
|
||||
String? message,
|
||||
String? errorType,
|
||||
Map<String, dynamic>? extraProps,
|
||||
}) async {
|
||||
final props = {
|
||||
"amount": amount,
|
||||
"fee": fee,
|
||||
"feeCoveredBy": payerCoversFee ? 'payer' : 'recipient',
|
||||
"source": source,
|
||||
"recipient_method": recpientPaymentMethod,
|
||||
if (message != null) "message": message,
|
||||
if (errorType != null) "error_type": errorType,
|
||||
if (extraProps != null) ...extraProps,
|
||||
};
|
||||
return _logEvent(evt, eventProperties: props);
|
||||
}
|
||||
|
||||
static Future<void> paymentPrepared(double amount, double fee,
|
||||
bool payerCoversFee, PaymentType source, PaymentType recpientPaymentMethod) async =>
|
||||
_paymentEvent("paymentPrepared", amount, fee, payerCoversFee, source, recpientPaymentMethod);
|
||||
//TODO Rework paymentStarted (do i need all those properties or is the event enough? Mb properties should be passed at paymentPrepared)
|
||||
static Future<void> paymentStarted(double amount, double fee,
|
||||
bool payerCoversFee, PaymentType source, PaymentType recpientPaymentMethod) async =>
|
||||
_paymentEvent("paymentStarted", amount, fee, payerCoversFee, source, recpientPaymentMethod);
|
||||
|
||||
static Future<void> paymentFailed(double amount, double fee, bool payerCoversFee,
|
||||
PaymentType source, PaymentType recpientPaymentMethod, String errorType, String message) async =>
|
||||
_paymentEvent("paymentFailed", amount, fee, payerCoversFee, source, recpientPaymentMethod,
|
||||
errorType: errorType, message: message);
|
||||
|
||||
static Future<void> paymentError(double amount, double fee, bool payerCoversFee,
|
||||
PaymentType source,PaymentType recpientPaymentMethod, String message) async =>
|
||||
_paymentEvent("paymentError", amount, fee, payerCoversFee, source, recpientPaymentMethod,
|
||||
message: message);
|
||||
|
||||
static Future<void> paymentSuccess({
|
||||
required double amount,
|
||||
required double fee,
|
||||
required bool payerCoversFee,
|
||||
required PaymentType source,
|
||||
required PaymentType recpientPaymentMethod,
|
||||
required String transactionId,
|
||||
String? comment,
|
||||
required int durationMs,
|
||||
}) async {
|
||||
return _paymentEvent(
|
||||
"paymentSuccess",
|
||||
amount,
|
||||
fee,
|
||||
payerCoversFee,
|
||||
source,
|
||||
recpientPaymentMethod,
|
||||
message: comment,
|
||||
extraProps: {
|
||||
"transaction_id": transactionId,
|
||||
"duration_ms": durationMs, //How do i calculate duration here?
|
||||
"\$revenue": amount, //How do i calculate revenue here?
|
||||
"\$revenueType": "payment", //Do we need to get revenue type?
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
//TODO add when support is ready
|
||||
// static Future<void> supportOpened(String fromPage, String trigger) async =>
|
||||
// _logEvent("supportOpened", eventProperties: {"from_page": fromPage, "trigger": trigger});
|
||||
|
||||
// static Future<void> supportMessageSent(String category, bool resolved) async =>
|
||||
// _logEvent("supportMessageSent", eventProperties: {"category": category, "resolved": resolved});
|
||||
|
||||
|
||||
static Future<void> walletTopUp(double amount, PaymentType method) async =>
|
||||
_logEvent("walletTopUp", eventProperties: {"amount": amount, "method": method});
|
||||
|
||||
|
||||
//TODO Decide do we need uiElementClicked or pageOpened is enough?
|
||||
static Future<void> uiElementClicked(String elementName, String page, String uiSource) async =>
|
||||
_logEvent("uiElementClicked", eventProperties: {
|
||||
"element_name": elementName,
|
||||
"page": page,
|
||||
"uiSource": uiSource
|
||||
});
|
||||
|
||||
static final Map<String, int> _stepStartTimes = {};
|
||||
//TODO Consider it as part of payment flow or registration flow or adding recipient and rework accordingly
|
||||
static Future<void> stepStarted(String stepName, {String? context}) async {
|
||||
_stepStartTimes[stepName] = DateTime.now().millisecondsSinceEpoch;
|
||||
return _logEvent("stepStarted", eventProperties: {
|
||||
"step_name": stepName,
|
||||
if (context != null) "context": context,
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> stepCompleted(String stepName, bool success) async {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final start = _stepStartTimes[stepName] ?? now;
|
||||
final duration = now - start;
|
||||
return _logEvent("stepCompleted", eventProperties: {
|
||||
"step_name": stepName,
|
||||
"duration_ms": duration,
|
||||
"success": success
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> _logEvent(
|
||||
String eventType, {
|
||||
Map<String, dynamic>? eventProperties,
|
||||
Map<String, dynamic>? userProperties,
|
||||
}) async {
|
||||
final event = BaseEvent(
|
||||
eventType,
|
||||
eventProperties: eventProperties,
|
||||
userProperties: userProperties,
|
||||
);
|
||||
_amp().track(event);
|
||||
print(event.toString()); //TODO delete when everything is ready
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user