13 Commits

Author SHA1 Message Date
Arseni
ad3d44f137 Fixes for build 2025-12-26 19:26:19 +03:00
Arseni
f339630115 Moved all the payment data preparation logic from the paymentFlowProvider to the payment and walletproviders 2025-12-26 15:11:47 +03:00
Arseni
75d5a512cd Removed manual syncWith/reset calls and added an update in PaymentFlowProvider 2025-12-26 14:37:45 +03:00
Arseni
1811571f80 Fixed search field in payment page and cleaned up paymentFlow 2025-12-26 13:29:51 +03:00
Arseni
edfdef5211 Fixed search field in payment page and cleaned up payment flow 2025-12-26 11:19:49 +03:00
5191336a49 Merge pull request 'extended logging + wallet referencing improved' (#186) from tron-185 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #186
2025-12-26 00:31:34 +00:00
Stephan D
48f64a722d extended logging + wallet referencing improved 2025-12-26 01:31:15 +01:00
bde453d106 Merge pull request 'fixed wallet fetcher + removed excessive logging' (#184) from tron-183 into main
Some checks failed
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/chain_gateway Pipeline is pending
ci/woodpecker/push/db Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #184
2025-12-26 00:22:41 +00:00
Stephan D
3bb33b8895 fixed wallet fetcher 2025-12-26 01:21:16 +01:00
8ee092089f Merge pull request 'replaced evm function for tron' (#182) from tron-182 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/ledger Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
Reviewed-on: #182
2025-12-25 23:53:46 +00:00
Stephan D
eca3d0d62e replaced evm function for tron 2025-12-26 00:53:25 +01:00
aba743406a Merge pull request 'temp extended request logging' (#181) from log-181 into main
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #181
2025-12-25 21:36:19 +00:00
Stephan D
deb29efde3 temp extended request logging 2025-12-25 22:31:00 +01:00
29 changed files with 703 additions and 336 deletions

View File

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

View File

@@ -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,78 +35,78 @@ 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"))
}
@@ -141,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)
}

View File

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

View File

@@ -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"
@@ -296,7 +297,7 @@ 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())
@@ -344,9 +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.Error(err), zap.Any("call_message", callMsg))
logger.Warn("Failed to estimate gas", zap.Error(err), zap.Any("call_message", callMsg))
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
}
@@ -457,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),
@@ -507,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),
@@ -661,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 {

View File

@@ -181,6 +181,7 @@ func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
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 {

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
import 'package:json_annotation/json_annotation.dart';
part 'customer.g.dart';
@JsonSerializable()
class CustomerDTO {
final String id;
@JsonKey(name: 'first_name')
final String? firstName;
@JsonKey(name: 'middle_name')
final String? middleName;
@JsonKey(name: 'last_name')
final String? lastName;
final String? ip;
final String? zip;
final String? country;
final String? state;
final String? city;
final String? address;
const CustomerDTO({
required this.id,
this.firstName,
this.middleName,
this.lastName,
this.ip,
this.zip,
this.country,
this.state,
this.city,
this.address,
});
factory CustomerDTO.fromJson(Map<String, dynamic> json) => _$CustomerDTOFromJson(json);
Map<String, dynamic> toJson() => _$CustomerDTOToJson(this);
}

View File

@@ -1,6 +1,7 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/endpoint.dart';
import 'package:pshared/data/dto/payment/intent/customer.dart';
import 'package:pshared/data/dto/payment/intent/fx.dart';
import 'package:pshared/data/dto/payment/money.dart';
@@ -20,6 +21,7 @@ class PaymentIntentDTO {
final String? settlementMode;
final Map<String, String>? attributes;
final CustomerDTO? customer;
const PaymentIntentDTO({
this.kind,
@@ -29,6 +31,7 @@ class PaymentIntentDTO {
this.fx,
this.settlementMode,
this.attributes,
this.customer,
});
factory PaymentIntentDTO.fromJson(Map<String, dynamic> json) => _$PaymentIntentDTOFromJson(json);

View File

@@ -0,0 +1,33 @@
import 'package:pshared/data/dto/payment/intent/customer.dart';
import 'package:pshared/models/payment/customer.dart';
extension CustomerMapper on Customer {
CustomerDTO toDTO() => CustomerDTO(
id: id,
firstName: firstName,
middleName: middleName,
lastName: lastName,
ip: ip,
zip: zip,
country: country,
state: state,
city: city,
address: address,
);
}
extension CustomerDTOMapper on CustomerDTO {
Customer toDomain() => Customer(
id: id,
firstName: firstName,
middleName: middleName,
lastName: lastName,
ip: ip,
zip: zip,
country: country,
state: state,
city: city,
address: address,
);
}

View File

@@ -1,30 +1,34 @@
import 'package:pshared/data/dto/payment/intent/payment.dart';
import 'package:pshared/data/mapper/payment/payment.dart';
import 'package:pshared/data/mapper/payment/enums.dart';
import 'package:pshared/data/mapper/payment/intent/customer.dart';
import 'package:pshared/data/mapper/payment/intent/fx.dart';
import 'package:pshared/data/mapper/payment/money.dart';
import 'package:pshared/models/payment/intent.dart';
extension PaymentIntentMapper on PaymentIntent {
PaymentIntentDTO toDTO() => PaymentIntentDTO(
kind: paymentKindToValue(kind),
source: source?.toDTO(),
destination: destination?.toDTO(),
amount: amount?.toDTO(),
fx: fx?.toDTO(),
settlementMode: settlementModeToValue(settlementMode),
attributes: attributes,
);
kind: paymentKindToValue(kind),
source: source?.toDTO(),
destination: destination?.toDTO(),
amount: amount?.toDTO(),
fx: fx?.toDTO(),
settlementMode: settlementModeToValue(settlementMode),
attributes: attributes,
customer: customer?.toDTO(),
);
}
extension PaymentIntentDTOMapper on PaymentIntentDTO {
PaymentIntent toDomain() => PaymentIntent(
kind: paymentKindFromValue(kind),
source: source?.toDomain(),
destination: destination?.toDomain(),
amount: amount?.toDomain(),
fx: fx?.toDomain(),
settlementMode: settlementModeFromValue(settlementMode),
attributes: attributes,
);
kind: paymentKindFromValue(kind),
source: source?.toDomain(),
destination: destination?.toDomain(),
amount: amount?.toDomain(),
fx: fx?.toDomain(),
settlementMode: settlementModeFromValue(settlementMode),
attributes: attributes,
customer: customer?.toDomain(),
);
}

View File

@@ -0,0 +1,25 @@
class Customer {
final String id;
final String? firstName;
final String? middleName;
final String? lastName;
final String? ip;
final String? zip;
final String? country;
final String? state;
final String? city;
final String? address;
const Customer({
required this.id,
this.firstName,
this.middleName,
this.lastName,
this.ip,
this.zip,
this.country,
this.state,
this.city,
this.address,
});
}

View File

@@ -1,5 +1,6 @@
import 'package:pshared/models/payment/fx/intent.dart';
import 'package:pshared/models/payment/kind.dart';
import 'package:pshared/models/payment/customer.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/money.dart';
import 'package:pshared/models/payment/settlement_mode.dart';
@@ -13,6 +14,7 @@ class PaymentIntent {
final FxIntent? fx;
final SettlementMode settlementMode;
final Map<String, String>? attributes;
final Customer? customer;
const PaymentIntent({
this.kind = PaymentKind.unspecified,
@@ -22,5 +24,6 @@ class PaymentIntent {
this.fx,
this.settlementMode = SettlementMode.unspecified,
this.attributes,
this.customer,
});
}

View File

@@ -1,73 +1,63 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/type.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
class PaymentFlowProvider extends ChangeNotifier {
PaymentType _selectedType;
PaymentType? _preferredType;
PaymentMethodData? _manualPaymentData;
List<PaymentMethod> _recipientMethods = [];
Recipient? _recipient;
PaymentFlowProvider({
required PaymentType initialType,
}) : _selectedType = initialType;
PaymentType? preferredType,
}) : _selectedType = initialType,
_preferredType = preferredType ?? initialType;
PaymentType get selectedType => _selectedType;
PaymentMethodData? get manualPaymentData => _manualPaymentData;
Recipient? get recipient => _recipient;
PaymentMethod? get selectedMethod => hasRecipient
? _recipientMethods.firstWhereOrNull((method) => method.type == _selectedType)
: null;
void sync({
required Recipient? recipient,
required MethodMap availableTypes,
PaymentType? preferredType,
}) {
final resolvedType = _resolveSelectedType(
recipient: recipient,
availableTypes: availableTypes,
preferredType: preferredType,
);
bool get hasRecipient => _recipient != null;
var hasChanges = false;
if (resolvedType != _selectedType) {
_selectedType = resolvedType;
hasChanges = true;
}
MethodMap get availableTypes => hasRecipient
? _buildAvailableTypes(_recipientMethods)
: {for (final type in PaymentType.values) type: null};
if (recipient != null && _manualPaymentData != null) {
_manualPaymentData = null;
hasChanges = true;
}
PaymentMethodData? get selectedPaymentData =>
hasRecipient ? selectedMethod?.data : _manualPaymentData;
if (hasChanges) notifyListeners();
}
List<PaymentMethod> get methodsForRecipient => hasRecipient
? List<PaymentMethod>.unmodifiable(_recipientMethods)
: const [];
void reset({
required Recipient? recipient,
required MethodMap availableTypes,
PaymentType? preferredType,
}) {
final resolvedType = _resolveSelectedType(
recipient: recipient,
availableTypes: availableTypes,
preferredType: preferredType,
);
var hasChanges = false;
if (resolvedType != _selectedType) {
_selectedType = resolvedType;
hasChanges = true;
}
if (_manualPaymentData != null) {
_manualPaymentData = null;
hasChanges = true;
}
if (hasChanges) notifyListeners();
}
void update(
RecipientsProvider recipientsProvider,
PaymentMethodsProvider methodsProvider,
) =>
_applyState(
recipient: recipientsProvider.currentObject,
methods: methodsProvider.methodsForRecipient(recipientsProvider.currentObject),
preferredType: _preferredType,
forceResetManualData: false,
);
void selectType(PaymentType type, {bool resetManualData = false}) {
if (hasRecipient && !availableTypes.containsKey(type)) {
return;
}
if (_selectedType == type && (!resetManualData || _manualPaymentData == null)) {
return;
}
@@ -84,6 +74,20 @@ class PaymentFlowProvider extends ChangeNotifier {
notifyListeners();
}
void setPreferredType(PaymentType? preferredType) {
if (_preferredType == preferredType) {
return;
}
_preferredType = preferredType;
_applyState(
recipient: _recipient,
methods: _recipientMethods,
preferredType: _preferredType,
forceResetManualData: false,
);
}
PaymentType _resolveSelectedType({
required Recipient? recipient,
required MethodMap availableTypes,
@@ -107,4 +111,56 @@ class PaymentFlowProvider extends ChangeNotifier {
return availableTypes.keys.first;
}
void _applyState({
required Recipient? recipient,
required List<PaymentMethod> methods,
required PaymentType? preferredType,
required bool forceResetManualData,
}) {
final availableTypes = _buildAvailableTypes(methods);
final resolvedType = _resolveSelectedType(
recipient: recipient,
availableTypes: availableTypes,
preferredType: preferredType,
);
var hasChanges = false;
if (_recipient != recipient) {
_recipient = recipient;
hasChanges = true;
}
if (!_hasSameMethods(methods)) {
_recipientMethods = methods;
hasChanges = true;
}
if (resolvedType != _selectedType) {
_selectedType = resolvedType;
hasChanges = true;
}
if ((recipient != null || forceResetManualData) && _manualPaymentData != null) {
_manualPaymentData = null;
hasChanges = true;
}
if (hasChanges) notifyListeners();
}
MethodMap _buildAvailableTypes(List<PaymentMethod> methods) => {
for (final method in methods) method.type: method.data,
};
bool _hasSameMethods(List<PaymentMethod> methods) {
if (_recipientMethods.length != methods.length) return false;
for (var i = 0; i < methods.length; i++) {
final current = _recipientMethods[i];
final next = methods[i];
if (current.id != next.id || current.updatedAt != next.updatedAt) return false;
}
return true;
}
}

View File

@@ -8,18 +8,22 @@ import 'package:pshared/api/requests/payment/quote.dart';
import 'package:pshared/data/mapper/payment/intent/payment.dart';
import 'package:pshared/models/asset.dart';
import 'package:pshared/models/payment/currency_pair.dart';
import 'package:pshared/models/payment/customer.dart';
import 'package:pshared/models/payment/fx/intent.dart';
import 'package:pshared/models/payment/fx/side.dart';
import 'package:pshared/models/payment/kind.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart';
import 'package:pshared/models/payment/methods/type.dart';
import 'package:pshared/models/payment/money.dart';
import 'package:pshared/models/payment/settlement_mode.dart';
import 'package:pshared/models/payment/intent.dart';
import 'package:pshared/models/payment/quote.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/wallets.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/payment/quotation.dart';
@@ -36,12 +40,17 @@ class QuotationProvider extends ChangeNotifier {
PaymentAmountProvider payment,
WalletsProvider wallets,
PaymentFlowProvider flow,
RecipientsProvider recipients,
PaymentMethodsProvider methods,
) {
_organizations = venue;
final t = flow.selectedType;
final method = methods.methods.firstWhereOrNull((m) => m.type == t);
if ((wallets.selectedWallet != null) && (method != null)) {
final customer = _buildCustomer(
recipient: recipients.currentObject,
method: method,
);
getQuotation(PaymentIntent(
kind: PaymentKind.payout,
amount: Money(
@@ -61,6 +70,7 @@ class QuotationProvider extends ChangeNotifier {
side: FxSide.sellBaseBuyQuote,
),
settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource,
customer: customer,
));
}
}
@@ -73,6 +83,58 @@ class QuotationProvider extends ChangeNotifier {
Asset? get total => quotation == null ? null : createAsset(quotation!.debitAmount!.currency, quotation!.debitAmount!.amount);
Asset? get recipientGets => quotation == null ? null : createAsset(quotation!.expectedSettlementAmount!.currency, quotation!.expectedSettlementAmount!.amount);
Customer _buildCustomer({
required Recipient? recipient,
required PaymentMethod method,
}) {
final name = _resolveCustomerName(method, recipient);
String? firstName;
String? middleName;
String? lastName;
if (name != null && name.isNotEmpty) {
final parts = name.split(RegExp(r'\s+'));
if (parts.length == 1) {
firstName = parts.first;
} else if (parts.length == 2) {
firstName = parts.first;
lastName = parts.last;
} else {
firstName = parts.first;
lastName = parts.last;
middleName = parts.sublist(1, parts.length - 1).join(' ');
}
}
return Customer(
id: recipient?.id ?? method.recipientRef,
firstName: firstName,
middleName: middleName,
lastName: lastName,
country: method.cardData?.country,
);
}
String? _resolveCustomerName(PaymentMethod method, Recipient? recipient) {
final card = method.cardData;
if (card != null) {
return '${card.firstName} ${card.lastName}'.trim();
}
final iban = method.ibanData;
if (iban != null && iban.accountHolder.trim().isNotEmpty) {
return iban.accountHolder.trim();
}
final bank = method.bankAccountData;
if (bank != null && bank.recipientName.trim().isNotEmpty) {
return bank.recipientName.trim();
}
final recipientName = recipient?.name.trim();
return recipientName?.isNotEmpty == true ? recipientName : null;
}
void _setResource(Resource<PaymentQuote> quotation) {
_quotation = quotation;
notifyListeners();

View File

@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/wallet.dart';
@@ -38,10 +39,7 @@ class WalletsProvider with ChangeNotifier {
throw Exception('update wallet is not implemented');
}
void selectWallet(Wallet wallet) {
_selectedWallet = wallet;
notifyListeners();
}
void selectWallet(Wallet wallet) => _setSelectedWallet(wallet);
Future<void> loadWalletsWithBalances() async {
_setResource(_resource.copyWith(isLoading: true, error: null));
@@ -98,6 +96,25 @@ class WalletsProvider with ChangeNotifier {
void _setResource(Resource<List<Wallet>> newResource) {
_resource = newResource;
_selectedWallet = _resolveSelectedWallet(_selectedWallet, wallets);
notifyListeners();
}
Wallet? _resolveSelectedWallet(Wallet? current, List<Wallet> available) {
if (available.isEmpty) return null;
final currentId = current?.id;
if (currentId != null) {
final existing = available.firstWhereOrNull((wallet) => wallet.id == currentId);
if (existing != null) return existing;
}
return available.firstWhereOrNull((wallet) => !wallet.isHidden) ?? available.first;
}
void _setSelectedWallet(Wallet wallet) {
if (_selectedWallet?.id == wallet.id && _selectedWallet?.isHidden == wallet.isHidden) {
return;
}
_selectedWallet = wallet;
notifyListeners();
}
}

View File

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

View File

@@ -14,6 +14,7 @@ class RecipientsProvider extends GenericProvider<Recipient> {
RecipientFilter _selectedFilter = RecipientFilter.all;
String _query = '';
String? _previousRecipientRef;
RecipientFilter get selectedFilter => _selectedFilter;
String get query => _query;
@@ -22,6 +23,10 @@ class RecipientsProvider extends GenericProvider<Recipient> {
RecipientsProvider() : super(service: RecipientService.basicService);
Recipient? get previousRecipient => _previousRecipientRef == null
? null
: getItemByRef(_previousRecipientRef!);
List<Recipient> get filteredRecipients {
List<Recipient> filtered = recipients.where((r) {
switch (_selectedFilter) {
@@ -53,6 +58,24 @@ class RecipientsProvider extends GenericProvider<Recipient> {
notifyListeners();
}
@override
bool setCurrentObject(String? objectRef) {
final currentRef = currentObject?.id;
final didUpdate = super.setCurrentObject(objectRef);
if (didUpdate && currentRef != null && currentRef != objectRef) {
_previousRecipientRef = currentRef;
}
return didUpdate;
}
void restorePreviousRecipient() {
if (_previousRecipientRef != null) {
setCurrentObject(_previousRecipientRef);
}
}
Future<Recipient> create({
required String name,
required String email,

View File

@@ -1,5 +1,4 @@
import 'package:logging/logging.dart';
import 'package:uuid/uuid.dart';
import 'package:pshared/api/requests/payment/initiate.dart';
@@ -27,10 +26,10 @@ class PaymentService {
metadata: metadata,
);
final response = await AuthorizationService.getPOSTResponse(
_objectType,
'/by-quote/$organizationRef',
_objectType,
'/by-quote/$organizationRef',
request.toJson(),
);
return PaymentResponse.fromJson(response).payment.toDomain();
}
}
}

View File

@@ -13,15 +13,18 @@ import 'package:pshared/provider/permissions.dart';
import 'package:pshared/provider/account.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/provider.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/service/payment/wallets.dart';
import 'package:pweb/app/app.dart';
import 'package:pweb/app/timeago.dart';
import 'package:pweb/providers/carousel.dart';
import 'package:pweb/providers/mock_payment.dart';
import 'package:pweb/providers/operatioins.dart';
import 'package:pweb/providers/two_factor.dart';
import 'package:pweb/providers/upload_history.dart';
@@ -89,16 +92,31 @@ void main() async {
ChangeNotifierProvider(
create: (_) => WalletTransactionsProvider(MockWalletTransactionsService())..load(),
),
ChangeNotifierProvider(
create: (_) => MockPaymentProvider(),
ChangeNotifierProxyProvider2<RecipientsProvider, PaymentMethodsProvider, PaymentFlowProvider>(
create: (_) => PaymentFlowProvider(initialType: PaymentType.bankAccount),
update: (context, recipients, methods, provider) => provider!..update(
recipients,
methods,
),
),
ChangeNotifierProvider(
create: (_) => OperationProvider(OperationService())..loadOperations(),
),
ChangeNotifierProvider(
create: (_) => PaymentAmountProvider(),
),
ChangeNotifierProxyProvider6<OrganizationsProvider, PaymentAmountProvider, WalletsProvider, PaymentFlowProvider, RecipientsProvider, PaymentMethodsProvider, QuotationProvider>(
create: (_) => QuotationProvider(),
update: (_, organization, payment, wallet, flow, recipients, methods, provider) =>
provider!..update(organization, payment, wallet, flow, recipients, methods),
),
ChangeNotifierProxyProvider2<OrganizationsProvider, QuotationProvider, PaymentProvider>(
create: (_) => PaymentProvider(),
update: (context, organization, quotation, provider) => provider!..update(
organization,
quotation,
),
),
],
child: const PayApp(),
),

View File

@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/dashboard/payouts/form.dart';
class PaymentFromWrappingWidget extends StatelessWidget {
const PaymentFromWrappingWidget({super.key});
@override
Widget build(BuildContext context) => const PaymentFormWidget();
}

View File

@@ -1,24 +1,16 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/type.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/payment/provider.dart';
import 'package:pshared/provider/payment/quotation.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/payment_methods/payment_page/body.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/pages/payment_methods/payment_page/body.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/services/posthog.dart';
@@ -60,30 +52,23 @@ class _PaymentPageState extends State<PaymentPage> {
}
void _initializePaymentPage() {
final methodsProvider = context.read<PaymentMethodsProvider>();
_handleWalletAutoSelection(methodsProvider);
final flowProvider = context.read<PaymentFlowProvider>();
flowProvider.setPreferredType(widget.initialPaymentType);
}
void _handleSearchChanged(String query) {
context.read<RecipientsProvider>().setQuery(query);
}
void _handleRecipientSelected(BuildContext context, Recipient recipient) {
void _handleRecipientSelected(Recipient recipient) {
final recipientProvider = context.read<RecipientsProvider>();
recipientProvider.setCurrentObject(recipient.id);
_clearSearchField();
}
void _handleRecipientCleared(BuildContext context) {
void _handleRecipientCleared() {
final recipientProvider = context.read<RecipientsProvider>();
final methodsProvider = context.read<PaymentMethodsProvider>();
recipientProvider.setCurrentObject(null);
context.read<PaymentFlowProvider>().reset(
recipient: null,
availableTypes: _availablePaymentTypes(null, methodsProvider),
preferredType: widget.initialPaymentType,
);
_clearSearchField();
}
@@ -93,106 +78,42 @@ class _PaymentPageState extends State<PaymentPage> {
context.read<RecipientsProvider>().setQuery('');
}
void _handleSendPayment(BuildContext context) {
if (context.read<QuotationProvider>().isReady) {
context.read<PaymentProvider>().pay();
PosthogService.paymentInitiated(
method: context.read<PaymentFlowProvider>().selectedType,
void _handleSendPayment() {
final flowProvider = context.read<PaymentFlowProvider>();
final paymentProvider = context.read<PaymentProvider>();
if (paymentProvider.isLoading) return;
paymentProvider.pay().then((_) {
PosthogService.paymentInitiated(method: flowProvider.selectedType);
}).catchError((error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.toString())),
);
}
});
}
@override
Widget build(BuildContext context) {
final methodsProvider = context.watch<PaymentMethodsProvider>();
final recipientProvider = context.watch<RecipientsProvider>();
final recipient = recipientProvider.currentObject;
final availableTypes = _availablePaymentTypes(recipient, methodsProvider);
return MultiProvider(
providers: [
ChangeNotifierProxyProvider2<RecipientsProvider, PaymentMethodsProvider, PaymentFlowProvider>(
create: (_) => PaymentFlowProvider(
initialType: widget.initialPaymentType ?? PaymentType.bankAccount,
),
update: (_, recipients, methods, flow) {
final currentRecipient = recipients.currentObject;
flow!.sync(
recipient: currentRecipient,
availableTypes: _availablePaymentTypes(currentRecipient, methods),
preferredType: currentRecipient != null ? widget.initialPaymentType : null,
);
return flow;
},
),
ChangeNotifierProvider(
create: (_) => PaymentAmountProvider(),
),
ChangeNotifierProxyProvider5<OrganizationsProvider, PaymentAmountProvider, WalletsProvider, PaymentFlowProvider, PaymentMethodsProvider, QuotationProvider>(
create: (_) => QuotationProvider(),
update: (_, organization, payment, wallet, flow, methods, provider) => provider!..update(organization, payment, wallet, flow, methods),
),
ChangeNotifierProxyProvider2<OrganizationsProvider, QuotationProvider, PaymentProvider>(
create: (_) => PaymentProvider(),
update: (_, organization, quotation, provider) => provider!..update(organization, quotation),
),
],
child: Builder(
builder: (innerContext) => PaymentPageBody(
onBack: widget.onBack,
fallbackDestination: widget.fallbackDestination,
recipient: recipient,
recipientProvider: recipientProvider,
methodsProvider: methodsProvider,
availablePaymentTypes: availableTypes,
searchController: _searchController,
searchFocusNode: _searchFocusNode,
onSearchChanged: _handleSearchChanged,
onRecipientSelected: (selected) => _handleRecipientSelected(innerContext, selected),
onRecipientCleared: () => _handleRecipientCleared(innerContext),
onSend: () => _handleSendPayment(innerContext),
),
),
);
}
void _handleWalletAutoSelection(PaymentMethodsProvider methodsProvider) {
final wallet = context.read<WalletsProvider>().selectedWallet;
if (wallet == null) return;
final matchingMethod = _getPaymentMethodForWallet(wallet, methodsProvider);
if (matchingMethod != null) {
methodsProvider.setCurrentObject(matchingMethod.id);
}
}
MethodMap _availablePaymentTypes(
Recipient? recipient,
PaymentMethodsProvider methodsProvider,
) {
if (recipient == null || !methodsProvider.isReady) return {};
final methodsForRecipient = methodsProvider.methods.where(
(method) => !method.isArchived && method.recipientRef == recipient.id,
final recipientProvider = context.read<RecipientsProvider>();
final recipient = context.select<RecipientsProvider, Recipient?>(
(provider) => provider.currentObject,
);
return {
for (final method in methodsForRecipient) method.type: method.data,
};
}
PaymentMethod? _getPaymentMethodForWallet(
Wallet wallet,
PaymentMethodsProvider methodsProvider,
) {
if (methodsProvider.methods.isEmpty) {
return null;
}
return methodsProvider.methods.firstWhereOrNull(
(method) =>
method.type == PaymentType.wallet &&
(method.description?.contains(wallet.walletUserID) ?? false),
return PaymentPageBody(
onBack: widget.onBack,
fallbackDestination: widget.fallbackDestination,
recipient: recipient,
recipientProvider: recipientProvider,
methodsProvider: methodsProvider,
onWalletSelected: context.read<WalletsProvider>().selectWallet,
searchController: _searchController,
searchFocusNode: _searchFocusNode,
onSearchChanged: _handleSearchChanged,
onRecipientSelected: _handleRecipientSelected,
onRecipientCleared: _handleRecipientCleared,
onSend: _handleSendPayment,
);
}
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart';
@@ -17,7 +17,7 @@ class PaymentPageBody extends StatelessWidget {
final Recipient? recipient;
final RecipientsProvider recipientProvider;
final PaymentMethodsProvider methodsProvider;
final MethodMap availablePaymentTypes;
final ValueChanged<Wallet> onWalletSelected;
final PayoutDestination fallbackDestination;
final TextEditingController searchController;
final FocusNode searchFocusNode;
@@ -32,7 +32,7 @@ class PaymentPageBody extends StatelessWidget {
required this.recipient,
required this.recipientProvider,
required this.methodsProvider,
required this.availablePaymentTypes,
required this.onWalletSelected,
required this.fallbackDestination,
required this.searchController,
required this.searchFocusNode,
@@ -60,8 +60,7 @@ class PaymentPageBody extends StatelessWidget {
onBack: onBack,
recipient: recipient,
recipientProvider: recipientProvider,
methodsProvider: methodsProvider,
availablePaymentTypes: availablePaymentTypes,
onWalletSelected: onWalletSelected,
fallbackDestination: fallbackDestination,
searchController: searchController,
searchFocusNode: searchFocusNode,

View File

@@ -1,12 +1,8 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pweb/pages/payment_methods/payment_page/back_button.dart';
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
@@ -26,8 +22,7 @@ class PaymentPageContent extends StatelessWidget {
final ValueChanged<Recipient?>? onBack;
final Recipient? recipient;
final RecipientsProvider recipientProvider;
final PaymentMethodsProvider methodsProvider;
final MethodMap availablePaymentTypes;
final ValueChanged<Wallet> onWalletSelected;
final PayoutDestination fallbackDestination;
final TextEditingController searchController;
final FocusNode searchFocusNode;
@@ -41,8 +36,7 @@ class PaymentPageContent extends StatelessWidget {
required this.onBack,
required this.recipient,
required this.recipientProvider,
required this.methodsProvider,
required this.availablePaymentTypes,
required this.onWalletSelected,
required this.fallbackDestination,
required this.searchController,
required this.searchFocusNode,
@@ -55,7 +49,6 @@ class PaymentPageContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
final dimensions = AppDimensions();
final flowProvider = context.watch<PaymentFlowProvider>();
final loc = AppLocalizations.of(context)!;
return Align(
@@ -84,7 +77,7 @@ class PaymentPageContent extends StatelessWidget {
SectionTitle(loc.sourceOfFunds),
SizedBox(height: dimensions.paddingSmall),
PaymentMethodSelector(
onMethodChanged: (m) => methodsProvider.setCurrentObject(m.id),
onMethodChanged: onWalletSelected,
),
SizedBox(height: dimensions.paddingXLarge),
RecipientSection(
@@ -98,12 +91,7 @@ class PaymentPageContent extends StatelessWidget {
onRecipientCleared: onRecipientCleared,
),
SizedBox(height: dimensions.paddingXLarge),
PaymentInfoSection(
dimensions: dimensions,
flowProvider: flowProvider,
recipient: recipient,
availableTypes: availablePaymentTypes,
),
PaymentInfoSection(dimensions: dimensions),
SizedBox(height: dimensions.paddingLarge),
const PaymentFormWidget(),
SizedBox(height: dimensions.paddingXXXLarge),

View File

@@ -17,9 +17,11 @@ class PaymentMethodSelector extends StatelessWidget {
});
@override
Widget build(BuildContext context) => Consumer<WalletsProvider>(builder:(context, provider, _) => PaymentMethodDropdown(
methods: provider.wallets,
initialValue: provider.selectedWallet,
onChanged: onMethodChanged,
));
Widget build(BuildContext context) => Consumer<WalletsProvider>(
builder: (context, provider, _) => PaymentMethodDropdown(
methods: provider.wallets,
selectedMethod: provider.selectedWallet,
onChanged: onMethodChanged,
),
);
}

View File

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

View File

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

View File

@@ -45,25 +45,44 @@ class RecipientSection extends StatelessWidget {
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(loc.recipient),
SizedBox(height: dimensions.paddingSmall),
RecipientSearchField(
controller: searchController,
onChanged: onSearchChanged,
focusNode: searchFocusNode,
),
if (recipientProvider.query.isNotEmpty) ...[
SizedBox(height: dimensions.paddingMedium),
RecipientSearchResults(
dimensions: dimensions,
recipientProvider: recipientProvider,
onRecipientSelected: onRecipientSelected,
),
],
],
return AnimatedBuilder(
animation: recipientProvider,
builder: (context, _) {
final previousRecipient = recipientProvider.previousRecipient;
final hasQuery = recipientProvider.query.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(loc.recipient),
SizedBox(height: dimensions.paddingSmall),
RecipientSearchField(
controller: searchController,
onChanged: onSearchChanged,
focusNode: searchFocusNode,
),
if (previousRecipient != null) ...[
SizedBox(height: dimensions.paddingSmall),
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.undo),
title: Text(loc.back),
subtitle: Text(previousRecipient.name),
onTap: () => onRecipientSelected(previousRecipient),
),
],
if (hasQuery) ...[
SizedBox(height: dimensions.paddingMedium),
RecipientSearchResults(
dimensions: dimensions,
recipientProvider: recipientProvider,
onRecipientSelected: onRecipientSelected,
),
],
],
);
},
);
}
}
}

View File

@@ -8,40 +8,27 @@ import 'package:pweb/pages/payment_methods/icon.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentMethodDropdown extends StatefulWidget {
class PaymentMethodDropdown extends StatelessWidget {
final List<Wallet> methods;
final ValueChanged<Wallet> onChanged;
final Wallet? initialValue;
final Wallet? selectedMethod;
const PaymentMethodDropdown({
super.key,
required this.methods,
required this.onChanged,
this.initialValue,
this.selectedMethod,
});
@override
State<PaymentMethodDropdown> createState() => _PaymentMethodDropdownState();
}
class _PaymentMethodDropdownState extends State<PaymentMethodDropdown> {
late Wallet _selectedMethod;
@override
void initState() {
super.initState();
_selectedMethod = widget.initialValue ?? widget.methods.first;
}
@override
Widget build(BuildContext context) => DropdownButtonFormField<Wallet>(
dropdownColor: Theme.of(context).colorScheme.onSecondary,
initialValue: _selectedMethod,
value: _getSelectedMethod(),
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.whereGetMoney,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
items: widget.methods.map((method) => DropdownMenuItem<Wallet>(
items: methods.map((method) => DropdownMenuItem<Wallet>(
value: method,
child: Row(
children: [
@@ -53,9 +40,14 @@ class _PaymentMethodDropdownState extends State<PaymentMethodDropdown> {
)).toList(),
onChanged: (value) {
if (value != null) {
setState(() => _selectedMethod = value);
widget.onChanged(value);
onChanged(value);
}
},
);
}
Wallet? _getSelectedMethod() {
if (selectedMethod != null) return selectedMethod;
if (methods.isEmpty) return null;
return methods.first;
}
}