10 Commits

Author SHA1 Message Date
3df358d865 Merge pull request 'fixed trx source' (#177) from gas-176 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline is pending
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 was successful
Reviewed-on: #177
2025-12-25 19:36:33 +00:00
Stephan D
c6b2ba486b fixed trx source 2025-12-25 20:35:57 +01:00
d324e455cc Merge pull request 'fixed self sending TRX issue' (#175) from gas-171 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db 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/payments_orchestrator Pipeline failed
ci/woodpecker/push/notification Pipeline failed
Reviewed-on: #175
2025-12-25 18:54:31 +00:00
Stephan D
8c87e5534e fixed self sending TRX issue 2025-12-25 19:54:01 +01:00
bcb3e9e647 Merge pull request 'added mntx client to payment orchestration' (#172) from mntx-170 into main
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff 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: #172
2025-12-25 17:23:58 +00:00
Stephan D
43f26143df added mntx client to payment orchestration 2025-12-25 18:23:08 +01:00
ed6e6bf1ba Merge pull request 'payment button connected + supported payment by quote reference' (#168) from pay-167 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db 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: #168
2025-12-25 16:24:55 +00:00
Stephan D
2d38b974ba improved logging 2025-12-25 17:01:35 +01:00
Stephan D
610296b301 improved logging 2025-12-25 17:01:05 +01:00
Stephan D
fcc68c8380 payment button connected 2025-12-25 16:56:35 +01:00
22 changed files with 534 additions and 351 deletions

View File

@@ -6,6 +6,7 @@ import (
"strings" "strings"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver" "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
@@ -84,19 +85,28 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
walletForFee := sourceWallet
nativeCurrency := shared.NativeCurrency(networkCfg)
if nativeCurrency != "" && strings.EqualFold(nativeCurrency, amount.GetCurrency()) {
copyWallet := *sourceWallet
copyWallet.ContractAddress = ""
copyWallet.TokenSymbol = nativeCurrency
walletForFee = &copyWallet
}
driverDeps := driver.Deps{ driverDeps := driver.Deps{
Logger: c.deps.Logger, Logger: c.deps.Logger,
Registry: c.deps.Networks, Registry: c.deps.Networks,
RPCTimeout: c.deps.RPCTimeout, RPCTimeout: c.deps.RPCTimeout,
} }
feeMoney, err := chainDriver.EstimateFee(ctx, driverDeps, networkCfg, sourceWallet, destinationAddress, amount) feeMoney, err := chainDriver.EstimateFee(ctx, driverDeps, networkCfg, walletForFee, destinationAddress, amount)
if err != nil { if err != nil {
c.deps.Logger.Warn("fee estimation failed", zap.Error(err)) c.deps.Logger.Warn("fee estimation failed", zap.Error(err))
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
contextLabel := "erc20_transfer" contextLabel := "erc20_transfer"
if strings.TrimSpace(sourceWallet.ContractAddress) == "" { if strings.TrimSpace(walletForFee.ContractAddress) == "" {
contextLabel = "native_transfer" contextLabel = "native_transfer"
} }
resp := &chainv1.EstimateTransferFeeResponse{ resp := &chainv1.EstimateTransferFeeResponse{

View File

@@ -113,6 +113,14 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
netAmount := shared.CloneMoney(amount) netAmount := shared.CloneMoney(amount)
netAmount.Amount = netDec.String() netAmount.Amount = netDec.String()
effectiveTokenSymbol := sourceWallet.TokenSymbol
effectiveContractAddress := sourceWallet.ContractAddress
nativeCurrency := shared.NativeCurrency(networkCfg)
if nativeCurrency != "" && strings.EqualFold(nativeCurrency, amountCurrency) {
effectiveTokenSymbol = nativeCurrency
effectiveContractAddress = ""
}
transfer := &model.Transfer{ transfer := &model.Transfer{
IdempotencyKey: idempotencyKey, IdempotencyKey: idempotencyKey,
TransferRef: shared.GenerateTransferRef(), TransferRef: shared.GenerateTransferRef(),
@@ -120,8 +128,8 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
SourceWalletRef: sourceWalletRef, SourceWalletRef: sourceWalletRef,
Destination: destination, Destination: destination,
Network: sourceWallet.Network, Network: sourceWallet.Network,
TokenSymbol: sourceWallet.TokenSymbol, TokenSymbol: effectiveTokenSymbol,
ContractAddress: sourceWallet.ContractAddress, ContractAddress: effectiveContractAddress,
RequestedAmount: shared.CloneMoney(amount), RequestedAmount: shared.CloneMoney(amount),
NetAmount: netAmount, NetAmount: netAmount,
Fees: fees, Fees: fees,

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ package rpcclient
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -175,11 +176,16 @@ func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
respFields := append(fields, respFields := append(fields,
zap.Int("status_code", resp.StatusCode), zap.Int("status_code", resp.StatusCode),
) )
if contentType := strings.TrimSpace(resp.Header.Get("Content-Type")); contentType != "" {
respFields = append(respFields, zap.String("content_type", contentType))
}
if len(bodyBytes) > 0 { if len(bodyBytes) > 0 {
respFields = append(respFields, zap.String("rpc_response", truncate(string(bodyBytes), 2048))) respFields = append(respFields, zap.String("rpc_response", truncate(string(bodyBytes), 2048)))
} }
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
l.logger.Warn("RPC response error", respFields...) l.logger.Warn("RPC response error", respFields...)
} else if len(bodyBytes) > 0 && !json.Valid(bodyBytes) {
l.logger.Warn("RPC response invalid JSON", respFields...)
} }
return resp, nil return resp, nil

View File

@@ -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. // Network describes a supported blockchain network and known token contracts.
type Network struct { type Network struct {
Name string Name string

View File

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

View File

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

View File

@@ -51,6 +51,12 @@ gateway:
call_timeout_seconds: 3 call_timeout_seconds: 3
insecure: true insecure: true
mntx:
address: "sendico_mntx_gateway:50075"
dial_timeout_seconds: 5
call_timeout_seconds: 3
insecure: true
oracle: oracle:
address: "sendico_fx_oracle:50051" address: "sendico_fx_oracle:50051"
dial_timeout_seconds: 5 dial_timeout_seconds: 5

View File

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

View File

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

View File

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

View File

@@ -103,33 +103,40 @@ type quoteResolutionError struct {
func (e quoteResolutionError) Error() string { return e.err.Error() } func (e quoteResolutionError) Error() string { return e.err.Error() }
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) { func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) {
if ref := strings.TrimSpace(in.QuoteRef); ref != "" { if ref := strings.TrimSpace(in.QuoteRef); ref != "" {
quotesStore, err := ensureQuotesStore(s.storage) quotesStore, err := ensureQuotesStore(s.storage)
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref) record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
if err != nil { if err != nil {
if errors.Is(err, storage.ErrQuoteNotFound) { if errors.Is(err, storage.ErrQuoteNotFound) {
return nil, 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) { 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) { intent, err := recordIntentFromQuote(record)
return nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")} if err != nil {
return nil, nil, err
} }
quote := modelQuoteToProto(record.Quote) if in.Intent != nil && !proto.Equal(intent, in.Intent) {
if quote == nil { return nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
return nil, merrors.InvalidArgument("stored quote is empty") }
quote, err := recordQuoteFromQuote(record)
if err != nil {
return nil, nil, err
} }
quote.QuoteRef = ref quote.QuoteRef = ref
return quote, nil return quote, intent, nil
} }
if in.Intent == nil {
return nil, nil, merrors.InvalidArgument("intent is required")
}
req := &orchestratorv1.QuotePaymentRequest{ req := &orchestratorv1.QuotePaymentRequest{
Meta: in.Meta, Meta: in.Meta,
IdempotencyKey: in.IdempotencyKey, IdempotencyKey: in.IdempotencyKey,
@@ -138,9 +145,41 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp
} }
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req) quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
if err != nil { if err != nil {
return nil, 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 { func newPayment(orgID primitive.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.PaymentQuote) *model.Payment {

View File

@@ -73,7 +73,7 @@ func TestResolvePaymentQuote_NotFound(t *testing.T) {
storage: stubRepo{quotes: &helperQuotesStore{}}, storage: stubRepo{quotes: &helperQuotesStore{}},
clock: clockpkg.NewSystem(), clock: clockpkg.NewSystem(),
} }
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
OrgRef: org.Hex(), OrgRef: org.Hex(),
OrgID: org, OrgID: org,
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
@@ -98,7 +98,7 @@ func TestResolvePaymentQuote_Expired(t *testing.T) {
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}}, storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
clock: clockpkg.NewSystem(), clock: clockpkg.NewSystem(),
} }
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
OrgRef: org.Hex(), OrgRef: org.Hex(),
OrgID: org, OrgID: org,
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
@@ -110,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) { func TestInitiatePaymentIdempotency(t *testing.T) {
logger := mloggerfactory.NewLogger(false) logger := mloggerfactory.NewLogger(false)
org := primitive.NewObjectID() 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 --- // --- test doubles ---
type stubRepo struct { type stubRepo struct {

View File

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

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

View 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;
}
}

View 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();
}
}

View File

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

View File

@@ -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/methods/type.dart';
import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/payment/provider.dart';
import 'package:pshared/provider/payment/quotation.dart';
import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
@@ -38,16 +42,12 @@ class PaymentPage extends StatefulWidget {
class _PaymentPageState extends State<PaymentPage> { class _PaymentPageState extends State<PaymentPage> {
late final TextEditingController _searchController; late final TextEditingController _searchController;
late final FocusNode _searchFocusNode; late final FocusNode _searchFocusNode;
late final PaymentFlowProvider _flowProvider;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_searchController = TextEditingController(); _searchController = TextEditingController();
_searchFocusNode = FocusNode(); _searchFocusNode = FocusNode();
_flowProvider = PaymentFlowProvider(
initialType: widget.initialPaymentType ?? PaymentType.bankAccount,
);
WidgetsBinding.instance.addPostFrameCallback((_) => _initializePaymentPage()); WidgetsBinding.instance.addPostFrameCallback((_) => _initializePaymentPage());
} }
@@ -56,45 +56,30 @@ class _PaymentPageState extends State<PaymentPage> {
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
_searchFocusNode.dispose(); _searchFocusNode.dispose();
_flowProvider.dispose();
super.dispose(); super.dispose();
} }
void _initializePaymentPage() { void _initializePaymentPage() {
final methodsProvider = context.read<PaymentMethodsProvider>(); final methodsProvider = context.read<PaymentMethodsProvider>();
_handleWalletAutoSelection(methodsProvider); _handleWalletAutoSelection(methodsProvider);
final recipient = context.read<RecipientsProvider>().currentObject;
_syncFlowProvider(
recipient: recipient,
methodsProvider: methodsProvider,
preferredType: widget.initialPaymentType,
);
} }
void _handleSearchChanged(String query) { void _handleSearchChanged(String query) {
context.read<RecipientsProvider>().setQuery(query); context.read<RecipientsProvider>().setQuery(query);
} }
void _handleRecipientSelected(Recipient recipient) { void _handleRecipientSelected(BuildContext context, Recipient recipient) {
final recipientProvider = context.read<RecipientsProvider>(); final recipientProvider = context.read<RecipientsProvider>();
final methodsProvider = context.read<PaymentMethodsProvider>();
recipientProvider.setCurrentObject(recipient.id); recipientProvider.setCurrentObject(recipient.id);
_flowProvider.reset(
recipient: recipient,
availableTypes: _availablePaymentTypes(recipient, methodsProvider),
preferredType: widget.initialPaymentType,
);
_clearSearchField(); _clearSearchField();
} }
void _handleRecipientCleared() { void _handleRecipientCleared(BuildContext context) {
final recipientProvider = context.read<RecipientsProvider>(); final recipientProvider = context.read<RecipientsProvider>();
final methodsProvider = context.read<PaymentMethodsProvider>(); final methodsProvider = context.read<PaymentMethodsProvider>();
recipientProvider.setCurrentObject(null); recipientProvider.setCurrentObject(null);
_flowProvider.reset( context.read<PaymentFlowProvider>().reset(
recipient: null, recipient: null,
availableTypes: _availablePaymentTypes(null, methodsProvider), availableTypes: _availablePaymentTypes(null, methodsProvider),
preferredType: widget.initialPaymentType, preferredType: widget.initialPaymentType,
@@ -108,9 +93,13 @@ class _PaymentPageState extends State<PaymentPage> {
context.read<RecipientsProvider>().setQuery(''); context.read<RecipientsProvider>().setQuery('');
} }
void _handleSendPayment() { void _handleSendPayment(BuildContext context) {
// TODO: Handle Payment logic if (context.read<QuotationProvider>().isReady) {
PosthogService.paymentInitiated(method: _flowProvider.selectedType); context.read<PaymentProvider>().pay();
PosthogService.paymentInitiated(
method: context.read<PaymentFlowProvider>().selectedType,
);
}
} }
@override @override
@@ -120,27 +109,49 @@ class _PaymentPageState extends State<PaymentPage> {
final recipient = recipientProvider.currentObject; final recipient = recipientProvider.currentObject;
final availableTypes = _availablePaymentTypes(recipient, methodsProvider); final availableTypes = _availablePaymentTypes(recipient, methodsProvider);
_syncFlowProvider( return MultiProvider(
recipient: recipient, providers: [
methodsProvider: methodsProvider, ChangeNotifierProxyProvider2<RecipientsProvider, PaymentMethodsProvider, PaymentFlowProvider>(
preferredType: recipient != null ? widget.initialPaymentType : null, create: (_) => PaymentFlowProvider(
); initialType: widget.initialPaymentType ?? PaymentType.bankAccount,
),
return ChangeNotifierProvider.value( update: (_, recipients, methods, flow) {
value: _flowProvider, final currentRecipient = recipients.currentObject;
child: PaymentPageBody( flow!.sync(
onBack: widget.onBack, recipient: currentRecipient,
fallbackDestination: widget.fallbackDestination, availableTypes: _availablePaymentTypes(currentRecipient, methods),
recipient: recipient, preferredType: currentRecipient != null ? widget.initialPaymentType : null,
recipientProvider: recipientProvider, );
methodsProvider: methodsProvider, return flow;
availablePaymentTypes: availableTypes, },
searchController: _searchController, ),
searchFocusNode: _searchFocusNode, ChangeNotifierProvider(
onSearchChanged: _handleSearchChanged, create: (_) => PaymentAmountProvider(),
onRecipientSelected: _handleRecipientSelected, ),
onRecipientCleared: _handleRecipientCleared, ChangeNotifierProxyProvider5<OrganizationsProvider, PaymentAmountProvider, WalletsProvider, PaymentFlowProvider, PaymentMethodsProvider, QuotationProvider>(
onSend: _handleSendPayment, 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( MethodMap _availablePaymentTypes(
Recipient? recipient, Recipient? recipient,
PaymentMethodsProvider methodsProvider, PaymentMethodsProvider methodsProvider,

View File

@@ -8,7 +8,7 @@ import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/payment/flow.dart'; import 'package: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/back_button.dart';
import 'package:pweb/pages/payment_methods/payment_page/header.dart'; import 'package:pweb/pages/payment_methods/payment_page/header.dart';
import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart'; import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart';
@@ -105,7 +105,7 @@ class PaymentPageContent extends StatelessWidget {
availableTypes: availablePaymentTypes, availableTypes: availablePaymentTypes,
), ),
SizedBox(height: dimensions.paddingLarge), SizedBox(height: dimensions.paddingLarge),
const PaymentFromWrappingWidget(), const PaymentFormWidget(),
SizedBox(height: dimensions.paddingXXXLarge), SizedBox(height: dimensions.paddingXXXLarge),
SendButton(onPressed: onSend), SendButton(onPressed: onSend),
SizedBox(height: dimensions.paddingLarge), SizedBox(height: dimensions.paddingLarge),

View File

@@ -1,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 / 100500 / 500+), preferred_method (Wallet/Card/Bank/IBAN), total_transactions, total_amount, last_payout_date, last_login_date , marketing_source}
// static Future<void> registrationStarted(String method, String country) async =>
// _logEvent("registrationStarted", eventProperties: {"method": method, "country": country});
// static Future<void> registrationCompleted(String method, String country) async =>
// _logEvent("registrationCompleted", eventProperties: {"method": method, "country": country});
static Future<void> pageNotFound(String url) async =>
_logEvent("pageNotFound", eventProperties: {"url": url});
static Future<void> localeChanged(Locale locale) async =>
_logEvent("localeChanged", eventProperties: {"locale": locale.toString()});
static Future<void> localeMatched(String locale, bool haveRequested) async => //DO we need it?
_logEvent("localeMatched", eventProperties: {
"locale": locale,
"have_requested_locale": haveRequested
});
static Future<void> recipientAddStarted() async =>
_logEvent("recipientAddStarted");
static Future<void> recipientAddCompleted(
RecipientType type,
RecipientStatus status,
Set<PaymentType> methods,
) async {
_logEvent(
"recipientAddCompleted",
eventProperties: {
"methods": methods.map((m) => m.name).toList(),
"type": type.name,
"status": status.name,
},
);
}
static Future<void> _paymentEvent(
String evt,
double amount,
double fee,
bool payerCoversFee,
PaymentType source,
PaymentType recpientPaymentMethod, {
String? message,
String? errorType,
Map<String, dynamic>? extraProps,
}) async {
final props = {
"amount": amount,
"fee": fee,
"feeCoveredBy": payerCoversFee ? 'payer' : 'recipient',
"source": source,
"recipient_method": recpientPaymentMethod,
if (message != null) "message": message,
if (errorType != null) "error_type": errorType,
if (extraProps != null) ...extraProps,
};
return _logEvent(evt, eventProperties: props);
}
static Future<void> paymentPrepared(double amount, double fee,
bool payerCoversFee, PaymentType source, PaymentType recpientPaymentMethod) async =>
_paymentEvent("paymentPrepared", amount, fee, payerCoversFee, source, recpientPaymentMethod);
//TODO Rework paymentStarted (do i need all those properties or is the event enough? Mb properties should be passed at paymentPrepared)
static Future<void> paymentStarted(double amount, double fee,
bool payerCoversFee, PaymentType source, PaymentType recpientPaymentMethod) async =>
_paymentEvent("paymentStarted", amount, fee, payerCoversFee, source, recpientPaymentMethod);
static Future<void> paymentFailed(double amount, double fee, bool payerCoversFee,
PaymentType source, PaymentType recpientPaymentMethod, String errorType, String message) async =>
_paymentEvent("paymentFailed", amount, fee, payerCoversFee, source, recpientPaymentMethod,
errorType: errorType, message: message);
static Future<void> paymentError(double amount, double fee, bool payerCoversFee,
PaymentType source,PaymentType recpientPaymentMethod, String message) async =>
_paymentEvent("paymentError", amount, fee, payerCoversFee, source, recpientPaymentMethod,
message: message);
static Future<void> paymentSuccess({
required double amount,
required double fee,
required bool payerCoversFee,
required PaymentType source,
required PaymentType recpientPaymentMethod,
required String transactionId,
String? comment,
required int durationMs,
}) async {
return _paymentEvent(
"paymentSuccess",
amount,
fee,
payerCoversFee,
source,
recpientPaymentMethod,
message: comment,
extraProps: {
"transaction_id": transactionId,
"duration_ms": durationMs, //How do i calculate duration here?
"\$revenue": amount, //How do i calculate revenue here?
"\$revenueType": "payment", //Do we need to get revenue type?
},
);
}
//TODO add when support is ready
// static Future<void> supportOpened(String fromPage, String trigger) async =>
// _logEvent("supportOpened", eventProperties: {"from_page": fromPage, "trigger": trigger});
// static Future<void> supportMessageSent(String category, bool resolved) async =>
// _logEvent("supportMessageSent", eventProperties: {"category": category, "resolved": resolved});
static Future<void> walletTopUp(double amount, PaymentType method) async =>
_logEvent("walletTopUp", eventProperties: {"amount": amount, "method": method});
//TODO Decide do we need uiElementClicked or pageOpened is enough?
static Future<void> uiElementClicked(String elementName, String page, String uiSource) async =>
_logEvent("uiElementClicked", eventProperties: {
"element_name": elementName,
"page": page,
"uiSource": uiSource
});
static final Map<String, int> _stepStartTimes = {};
//TODO Consider it as part of payment flow or registration flow or adding recipient and rework accordingly
static Future<void> stepStarted(String stepName, {String? context}) async {
_stepStartTimes[stepName] = DateTime.now().millisecondsSinceEpoch;
return _logEvent("stepStarted", eventProperties: {
"step_name": stepName,
if (context != null) "context": context,
});
}
static Future<void> stepCompleted(String stepName, bool success) async {
final now = DateTime.now().millisecondsSinceEpoch;
final start = _stepStartTimes[stepName] ?? now;
final duration = now - start;
return _logEvent("stepCompleted", eventProperties: {
"step_name": stepName,
"duration_ms": duration,
"success": success
});
}
static Future<void> _logEvent(
String eventType, {
Map<String, dynamic>? eventProperties,
Map<String, dynamic>? userProperties,
}) async {
final event = BaseEvent(
eventType,
eventProperties: eventProperties,
userProperties: userProperties,
);
_amp().track(event);
print(event.toString()); //TODO delete when everything is ready
}
}