19 Commits

Author SHA1 Message Date
Arseni
d9a605ce21 quote requests are paused while the payout amount is being edited 2025-12-30 19:08:53 +03:00
Arseni
c3ec50c8e4 Fixes 2025-12-30 17:56:15 +03:00
Arseni
f3ad4c2d4f Added quote expiry-aware flows with auto-refresh 2025-12-29 18:38:21 +03:00
4aeb06fd31 Merge pull request 'Payments listing method' (#209) from payments-208 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: #209
2025-12-29 13:29:46 +00:00
Stephan D
d1786dc5d9 Payments listing method 2025-12-29 14:27:32 +01:00
f5bf8cf6d0 Merge pull request 'Finally Fixed search field in payment page and cleaned up payment flow' (#204) from SEND020 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/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 failed
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: #204
2025-12-26 19:35:16 +00:00
7daa4ab027 Merge pull request 'temp restrictions removal + better request callback logging' (#205) from mntx-204 into main
All checks were 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/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was 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/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
Reviewed-on: #205
2025-12-26 18:18:57 +00:00
Stephan D
6f2309669b temp restrictions removal + better request callback logging 2025-12-26 19:18:35 +01:00
Arseni
e4847cd137 Finally Fixed search field in payment page and cleaned up payment flow 2025-12-26 20:37:59 +03:00
dbd06a4162 Merge pull request 'fixed address normalizatoin' (#203) from tron-202 into main
All checks were 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/billing_fees Pipeline was successful
ci/woodpecker/push/db 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: #203
2025-12-26 16:25:01 +00:00
Stephan D
1ec6cd8386 fixed address normalizatoin 2025-12-26 17:24:43 +01:00
6daf567baf Merge pull request 'improved FX logging' (#197) from fx-196 into main
All checks were 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/billing_fees 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 was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #197
2025-12-26 14:15:01 +00:00
23a57e543d Merge pull request 'payment recipient data' (#199) from mntx-198 into main
Some checks failed
ci/woodpecker/push/chain_gateway Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/db Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
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/billing_fees Pipeline failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #199
2025-12-26 14:14:52 +00:00
Stephan D
8adfab94b5 payment recipient data 2025-12-26 15:14:31 +01:00
Stephan D
db488a31e8 improved FX logging 2025-12-26 14:25:18 +01:00
3836ff5ef3 Merge pull request 'Got rid of deprecated methods' (#191) from SEND019 into main
Some checks 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
ci/woodpecker/push/frontend Pipeline 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
Reviewed-on: #191
Reviewed-by: tech <tech.sendico@proton.me>
2025-12-26 13:09:55 +00:00
aef5c99a22 Merge pull request 'wallet search fix' (#195) from chain-190 into main
Some checks failed
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/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/billing_fees Pipeline failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #195
2025-12-26 13:09:42 +00:00
Stephan D
be7c965234 wallet search fix 2025-12-26 14:09:16 +01:00
Arseni
63448ab267 got rid of deprecated methods 2025-12-26 15:04:41 +03:00
86 changed files with 1922 additions and 668 deletions

View File

@@ -130,7 +130,7 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
logger.Warn("pair not supported", zap.String("pair", pairKey.Base+"/"+pairKey.Quote)) logger.Warn("Pair not supported", zap.String("pair", pairKey.Base+"/"+pairKey.Quote))
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported")) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported"))
default: default:
logger.Warn("GetQuote failed to load pair", zap.Error(err)) logger.Warn("GetQuote failed to load pair", zap.Error(err))
@@ -150,7 +150,7 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
logger.Warn("rate not found", zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider)) logger.Warn("Rate not found", zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err) return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err)
default: default:
logger.Warn("GetQuote failed to load rate", zap.Error(err), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider)) logger.Warn("GetQuote failed to load rate", zap.Error(err), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))

View File

@@ -50,28 +50,28 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
defer cancel() defer cancel()
if err := s.Ping(ctx); err != nil { if err := s.Ping(ctx); err != nil {
s.logger.Error("mongo ping failed during store init", zap.Error(err)) s.logger.Error("Mongo ping failed during store init", zap.Error(err))
return nil, err return nil, err
} }
ratesStore, err := store.NewRates(s.logger, db) ratesStore, err := store.NewRates(s.logger, db)
if err != nil { if err != nil {
s.logger.Error("failed to initialize rates store", zap.Error(err)) s.logger.Error("Failed to initialize rates store", zap.Error(err))
return nil, err return nil, err
} }
quotesStore, err := store.NewQuotes(s.logger, db, txFactory) quotesStore, err := store.NewQuotes(s.logger, db, txFactory)
if err != nil { if err != nil {
s.logger.Error("failed to initialize quotes store", zap.Error(err)) s.logger.Error("Failed to initialize quotes store", zap.Error(err))
return nil, err return nil, err
} }
pairsStore, err := store.NewPair(s.logger, db) pairsStore, err := store.NewPair(s.logger, db)
if err != nil { if err != nil {
s.logger.Error("failed to initialize pair store", zap.Error(err)) s.logger.Error("Failed to initialize pair store", zap.Error(err))
return nil, err return nil, err
} }
currencyStore, err := store.NewCurrency(s.logger, db) currencyStore, err := store.NewCurrency(s.logger, db)
if err != nil { if err != nil {
s.logger.Error("failed to initialize currency store", zap.Error(err)) s.logger.Error("Failed to initialize currency store", zap.Error(err))
return nil, err return nil, err
} }
@@ -80,7 +80,7 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
s.pairs = pairsStore s.pairs = pairsStore
s.currencies = currencyStore s.currencies = currencyStore
s.logger.Info("mongo storage ready") s.logger.Info("Mongo storage ready")
return s, nil return s, nil
} }

View File

@@ -29,11 +29,11 @@ func NewCurrency(logger mlogger.Logger, db *mongo.Database) (storage.CurrencySto
Unique: true, Unique: true,
} }
if err := repo.CreateIndex(index); err != nil { if err := repo.CreateIndex(index); err != nil {
logger.Error("failed to ensure currencies index", zap.Error(err)) logger.Error("Failed to ensure currencies index", zap.Error(err))
return nil, err return nil, err
} }
childLogger := logger.Named(model.CurrenciesCollection) childLogger := logger.Named(model.CurrenciesCollection)
childLogger.Debug("currency store initialised", zap.String("collection", model.CurrenciesCollection)) childLogger.Debug("Currency store initialised", zap.String("collection", model.CurrenciesCollection))
return &currencyStore{ return &currencyStore{
logger: childLogger, logger: childLogger,
@@ -43,17 +43,17 @@ func NewCurrency(logger mlogger.Logger, db *mongo.Database) (storage.CurrencySto
func (c *currencyStore) Get(ctx context.Context, code string) (*model.Currency, error) { func (c *currencyStore) Get(ctx context.Context, code string) (*model.Currency, error) {
if code == "" { if code == "" {
c.logger.Warn("attempt to fetch currency with empty code") c.logger.Warn("Attempt to fetch currency with empty code")
return nil, merrors.InvalidArgument("currencyStore: empty code") return nil, merrors.InvalidArgument("currencyStore: empty code")
} }
result := &model.Currency{} result := &model.Currency{}
if err := c.repo.FindOneByFilter(ctx, repository.Filter("code", code), result); err != nil { if err := c.repo.FindOneByFilter(ctx, repository.Filter("code", code), result); err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
c.logger.Debug("currency not found", zap.String("code", code)) c.logger.Debug("Currency not found", zap.String("code", code))
} }
return nil, err return nil, err
} }
c.logger.Debug("currency loaded", zap.String("code", code)) c.logger.Debug("Currency loaded", zap.String("code", code))
return result, nil return result, nil
} }
@@ -77,20 +77,20 @@ func (c *currencyStore) List(ctx context.Context, codes ...string) ([]*model.Cur
return nil return nil
}) })
if err != nil { if err != nil {
c.logger.Error("failed to list currencies", zap.Error(err)) c.logger.Warn("Failed to list currencies", zap.Error(err))
return nil, err return nil, err
} }
c.logger.Debug("listed currencies", zap.Int("count", len(currencies))) c.logger.Debug("Listed currencies", zap.Int("count", len(currencies)))
return currencies, nil return currencies, nil
} }
func (c *currencyStore) Upsert(ctx context.Context, currency *model.Currency) error { func (c *currencyStore) Upsert(ctx context.Context, currency *model.Currency) error {
if currency == nil { if currency == nil {
c.logger.Warn("attempt to upsert nil currency") c.logger.Warn("Attempt to upsert nil currency")
return merrors.InvalidArgument("currencyStore: nil currency") return merrors.InvalidArgument("currencyStore: nil currency")
} }
if currency.Code == "" { if currency.Code == "" {
c.logger.Warn("attempt to upsert currency with empty code") c.logger.Warn("Attempt to upsert currency with empty code")
return merrors.InvalidArgument("currencyStore: empty code") return merrors.InvalidArgument("currencyStore: empty code")
} }
@@ -98,16 +98,16 @@ func (c *currencyStore) Upsert(ctx context.Context, currency *model.Currency) er
filter := repository.Filter("code", currency.Code) filter := repository.Filter("code", currency.Code)
if err := c.repo.FindOneByFilter(ctx, filter, existing); err != nil { if err := c.repo.FindOneByFilter(ctx, filter, existing); err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
c.logger.Debug("inserting new currency", zap.String("code", currency.Code)) c.logger.Debug("Inserting new currency", zap.String("code", currency.Code))
return c.repo.Insert(ctx, currency, filter) return c.repo.Insert(ctx, currency, filter)
} }
c.logger.Error("failed to fetch currency", zap.Error(err), zap.String("code", currency.Code)) c.logger.Warn("Failed to fetch currency", zap.Error(err), zap.String("code", currency.Code))
return err return err
} }
if existing.GetID() != nil { if existing.GetID() != nil {
currency.SetID(*existing.GetID()) currency.SetID(*existing.GetID())
} }
c.logger.Debug("updating currency", zap.String("code", currency.Code)) c.logger.Debug("Updating currency", zap.String("code", currency.Code))
return c.repo.Update(ctx, currency) return c.repo.Update(ctx, currency)
} }

View File

@@ -29,10 +29,10 @@ func NewPair(logger mlogger.Logger, db *mongo.Database) (storage.PairStore, erro
Unique: true, Unique: true,
} }
if err := repo.CreateIndex(index); err != nil { if err := repo.CreateIndex(index); err != nil {
logger.Error("failed to ensure pairs index", zap.Error(err)) logger.Error("Failed to ensure pairs index", zap.Error(err))
return nil, err return nil, err
} }
logger.Debug("pair store initialised", zap.String("collection", model.PairsCollection)) logger.Debug("Pair store initialised", zap.String("collection", model.PairsCollection))
return &pairStore{ return &pairStore{
logger: logger.Named(model.PairsCollection), logger: logger.Named(model.PairsCollection),
@@ -53,16 +53,16 @@ func (p *pairStore) ListEnabled(ctx context.Context) ([]*model.Pair, error) {
return nil return nil
}) })
if err != nil { if err != nil {
p.logger.Error("failed to list enabled pairs", zap.Error(err)) p.logger.Warn("Failed to list enabled pairs", zap.Error(err))
return nil, err return nil, err
} }
p.logger.Debug("listed enabled pairs", zap.Int("count", len(pairs))) p.logger.Debug("Listed enabled pairs", zap.Int("count", len(pairs)))
return pairs, nil return pairs, nil
} }
func (p *pairStore) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) { func (p *pairStore) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
if pair.Base == "" || pair.Quote == "" { if pair.Base == "" || pair.Quote == "" {
p.logger.Warn("attempt to fetch pair with empty currency", zap.String("base", pair.Base), zap.String("quote", pair.Quote)) p.logger.Warn("Attempt to fetch pair with empty currency", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
return nil, merrors.InvalidArgument("pairStore: incomplete pair") return nil, merrors.InvalidArgument("pairStore: incomplete pair")
} }
result := &model.Pair{} result := &model.Pair{}
@@ -71,21 +71,21 @@ func (p *pairStore) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pa
Filter(repository.Field("pair").Dot("quote"), pair.Quote) Filter(repository.Field("pair").Dot("quote"), pair.Quote)
if err := p.repo.FindOneByFilter(ctx, query, result); err != nil { if err := p.repo.FindOneByFilter(ctx, query, result); err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
p.logger.Debug("pair not found", zap.String("base", pair.Base), zap.String("quote", pair.Quote)) p.logger.Debug("Pair not found", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
} }
return nil, err return nil, err
} }
p.logger.Debug("pair loaded", zap.String("base", pair.Base), zap.String("quote", pair.Quote)) p.logger.Debug("Pair loaded", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
return result, nil return result, nil
} }
func (p *pairStore) Upsert(ctx context.Context, pair *model.Pair) error { func (p *pairStore) Upsert(ctx context.Context, pair *model.Pair) error {
if pair == nil { if pair == nil {
p.logger.Warn("attempt to upsert nil pair") p.logger.Warn("Attempt to upsert nil pair")
return merrors.InvalidArgument("pairStore: nil pair") return merrors.InvalidArgument("pairStore: nil pair")
} }
if pair.Pair.Base == "" || pair.Pair.Quote == "" { if pair.Pair.Base == "" || pair.Pair.Quote == "" {
p.logger.Warn("attempt to upsert pair with empty currency", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote)) p.logger.Warn("Attempt to upsert pair with empty currency", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
return merrors.InvalidArgument("pairStore: incomplete pair") return merrors.InvalidArgument("pairStore: incomplete pair")
} }
@@ -96,16 +96,16 @@ func (p *pairStore) Upsert(ctx context.Context, pair *model.Pair) error {
err := p.repo.FindOneByFilter(ctx, query, existing) err := p.repo.FindOneByFilter(ctx, query, existing)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
p.logger.Debug("inserting new pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote)) p.logger.Debug("Inserting new pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
return p.repo.Insert(ctx, pair, query) return p.repo.Insert(ctx, pair, query)
} }
p.logger.Error("failed to fetch pair", zap.Error(err), zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote)) p.logger.Warn("Failed to fetch pair", zap.Error(err), zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
return err return err
} }
if existing.GetID() != nil { if existing.GetID() != nil {
pair.SetID(*existing.GetID()) pair.SetID(*existing.GetID())
} }
p.logger.Debug("updating pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote)) p.logger.Debug("Updating pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
return p.repo.Update(ctx, pair) return p.repo.Update(ctx, pair)
} }

View File

@@ -56,12 +56,12 @@ func NewQuotes(logger mlogger.Logger, db *mongo.Database, txFactory transaction.
for _, def := range indexes { for _, def := range indexes {
if err := repo.CreateIndex(def); err != nil { if err := repo.CreateIndex(def); err != nil {
logger.Error("failed to ensure quotes index", zap.Error(err)) logger.Error("Failed to ensure quotes index", zap.Error(err))
return nil, err return nil, err
} }
} }
childLogger := logger.Named(model.QuotesCollection) childLogger := logger.Named(model.QuotesCollection)
childLogger.Debug("quotes store initialised", zap.String("collection", model.QuotesCollection)) childLogger.Debug("Quotes store initialised", zap.String("collection", model.QuotesCollection))
return &quotesStore{ return &quotesStore{
logger: childLogger, logger: childLogger,
@@ -72,11 +72,11 @@ func NewQuotes(logger mlogger.Logger, db *mongo.Database, txFactory transaction.
func (q *quotesStore) Issue(ctx context.Context, quote *model.Quote) error { func (q *quotesStore) Issue(ctx context.Context, quote *model.Quote) error {
if quote == nil { if quote == nil {
q.logger.Warn("attempt to issue nil quote") q.logger.Warn("Attempt to issue nil quote")
return merrors.InvalidArgument("quotesStore: nil quote") return merrors.InvalidArgument("quotesStore: nil quote")
} }
if quote.QuoteRef == "" { if quote.QuoteRef == "" {
q.logger.Warn("attempt to issue quote with empty ref") q.logger.Warn("Attempt to issue quote with empty ref")
return merrors.InvalidArgument("quotesStore: empty quoteRef") return merrors.InvalidArgument("quotesStore: empty quoteRef")
} }
@@ -89,32 +89,32 @@ func (q *quotesStore) Issue(ctx context.Context, quote *model.Quote) error {
quote.ConsumedByLedgerTxnRef = "" quote.ConsumedByLedgerTxnRef = ""
quote.ConsumedAtUnixMs = nil quote.ConsumedAtUnixMs = nil
if err := q.repo.Insert(ctx, quote, repository.Filter("quoteRef", quote.QuoteRef)); err != nil { if err := q.repo.Insert(ctx, quote, repository.Filter("quoteRef", quote.QuoteRef)); err != nil {
q.logger.Error("failed to insert quote", zap.Error(err), zap.String("quote_ref", quote.QuoteRef)) q.logger.Warn("Failed to insert quote", zap.Error(err), zap.String("quote_ref", quote.QuoteRef))
return err return err
} }
q.logger.Debug("quote issued", zap.String("quote_ref", quote.QuoteRef), zap.Bool("firm", quote.Firm)) q.logger.Debug("Quote issued", zap.String("quote_ref", quote.QuoteRef), zap.Bool("firm", quote.Firm))
return nil return nil
} }
func (q *quotesStore) GetByRef(ctx context.Context, quoteRef string) (*model.Quote, error) { func (q *quotesStore) GetByRef(ctx context.Context, quoteRef string) (*model.Quote, error) {
if quoteRef == "" { if quoteRef == "" {
q.logger.Warn("attempt to fetch quote with empty ref") q.logger.Warn("Attempt to fetch quote with empty ref")
return nil, merrors.InvalidArgument("quotesStore: empty quoteRef") return nil, merrors.InvalidArgument("quotesStore: empty quoteRef")
} }
quote := &model.Quote{} quote := &model.Quote{}
if err := q.repo.FindOneByFilter(ctx, repository.Filter("quoteRef", quoteRef), quote); err != nil { if err := q.repo.FindOneByFilter(ctx, repository.Filter("quoteRef", quoteRef), quote); err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
q.logger.Debug("quote not found", zap.String("quote_ref", quoteRef)) q.logger.Debug("Quote not found", zap.String("quote_ref", quoteRef))
} }
return nil, err return nil, err
} }
q.logger.Debug("quote loaded", zap.String("quote_ref", quoteRef), zap.String("status", string(quote.Status))) q.logger.Debug("Quote loaded", zap.String("quote_ref", quoteRef), zap.String("status", string(quote.Status)))
return quote, nil return quote, nil
} }
func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string, when time.Time) (*model.Quote, error) { func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string, when time.Time) (*model.Quote, error) {
if quoteRef == "" || ledgerTxnRef == "" { if quoteRef == "" || ledgerTxnRef == "" {
q.logger.Warn("attempt to consume quote with missing identifiers", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef)) q.logger.Warn("Attempt to consume quote with missing identifiers", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
return nil, merrors.InvalidArgument("quotesStore: missing identifiers") return nil, merrors.InvalidArgument("quotesStore: missing identifiers")
} }
@@ -122,7 +122,7 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
when = time.Now() when = time.Now()
} }
q.logger.Debug("consuming quote", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef)) q.logger.Debug("Consuming quote", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
txn := q.txFactory.CreateTransaction() txn := q.txFactory.CreateTransaction()
result, err := txn.Execute(ctx, func(txCtx context.Context) (any, error) { result, err := txn.Execute(ctx, func(txCtx context.Context) (any, error) {
quote := &model.Quote{} quote := &model.Quote{}
@@ -131,7 +131,7 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
} }
if !quote.Firm { if !quote.Firm {
q.logger.Warn("quote not firm", zap.String("quote_ref", quoteRef)) q.logger.Warn("Quote not firm", zap.String("quote_ref", quoteRef))
return nil, storage.ErrQuoteNotFirm return nil, storage.ErrQuoteNotFirm
} }
@@ -140,16 +140,16 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
if err := q.repo.Update(txCtx, quote); err != nil { if err := q.repo.Update(txCtx, quote); err != nil {
return nil, err return nil, err
} }
q.logger.Info("quote expired during consume", zap.String("quote_ref", quoteRef)) q.logger.Info("Quote expired during consume", zap.String("quote_ref", quoteRef))
return nil, storage.ErrQuoteExpired return nil, storage.ErrQuoteExpired
} }
if quote.Status == model.QuoteStatusConsumed { if quote.Status == model.QuoteStatusConsumed {
if quote.ConsumedByLedgerTxnRef == ledgerTxnRef { if quote.ConsumedByLedgerTxnRef == ledgerTxnRef {
q.logger.Debug("quote already consumed by ledger", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef)) q.logger.Debug("Quote already consumed by ledger", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
return quote, nil return quote, nil
} }
q.logger.Warn("quote consumed by different ledger", zap.String("quote_ref", quoteRef), zap.String("existing_ledger_ref", quote.ConsumedByLedgerTxnRef)) q.logger.Warn("Quote consumed by different ledger", zap.String("quote_ref", quoteRef), zap.String("existing_ledger_ref", quote.ConsumedByLedgerTxnRef))
return nil, storage.ErrQuoteConsumed return nil, storage.ErrQuoteConsumed
} }
@@ -157,11 +157,11 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
if err := q.repo.Update(txCtx, quote); err != nil { if err := q.repo.Update(txCtx, quote); err != nil {
return nil, err return nil, err
} }
q.logger.Info("quote consumed", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef)) q.logger.Info("Quote consumed", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
return quote, nil return quote, nil
}) })
if err != nil { if err != nil {
q.logger.Error("quote consumption failed", zap.Error(err), zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef)) q.logger.Warn("Quote consumption failed", zap.Error(err), zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
return nil, err return nil, err
} }
quote, _ := result.(*model.Quote) quote, _ := result.(*model.Quote)
@@ -173,7 +173,7 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
func (q *quotesStore) ExpireIssuedBefore(ctx context.Context, cutoff time.Time) (int, error) { func (q *quotesStore) ExpireIssuedBefore(ctx context.Context, cutoff time.Time) (int, error) {
if cutoff.IsZero() { if cutoff.IsZero() {
q.logger.Warn("attempt to expire quotes with zero cutoff") q.logger.Warn("Attempt to expire quotes with zero cutoff")
return 0, merrors.InvalidArgument("quotesStore: cutoff time is zero") return 0, merrors.InvalidArgument("quotesStore: cutoff time is zero")
} }
@@ -188,11 +188,11 @@ func (q *quotesStore) ExpireIssuedBefore(ctx context.Context, cutoff time.Time)
updated, err := q.repo.PatchMany(ctx, filter, patch) updated, err := q.repo.PatchMany(ctx, filter, patch)
if err != nil { if err != nil {
q.logger.Error("failed to expire quotes", zap.Error(err)) q.logger.Warn("Failed to expire quotes", zap.Error(err))
return 0, err return 0, err
} }
if updated > 0 { if updated > 0 {
q.logger.Info("quotes expired", zap.Int("count", updated)) q.logger.Info("Quotes expired", zap.Int("count", updated))
} }
return updated, nil return updated, nil
} }

View File

@@ -51,11 +51,11 @@ func NewRates(logger mlogger.Logger, db *mongo.Database) (storage.RatesStore, er
for _, def := range indexes { for _, def := range indexes {
if err := repo.CreateIndex(def); err != nil { if err := repo.CreateIndex(def); err != nil {
logger.Error("failed to ensure rates index", zap.Error(err)) logger.Error("Failed to ensure rates index", zap.Error(err))
return nil, err return nil, err
} }
} }
logger.Debug("rates store initialised", zap.String("collection", model.RatesCollection)) logger.Debug("Rates store initialised", zap.String("collection", model.RatesCollection))
return &ratesStore{ return &ratesStore{
logger: logger.Named(model.RatesCollection), logger: logger.Named(model.RatesCollection),
repo: repo, repo: repo,
@@ -64,11 +64,11 @@ func NewRates(logger mlogger.Logger, db *mongo.Database) (storage.RatesStore, er
func (r *ratesStore) UpsertSnapshot(ctx context.Context, snapshot *model.RateSnapshot) error { func (r *ratesStore) UpsertSnapshot(ctx context.Context, snapshot *model.RateSnapshot) error {
if snapshot == nil { if snapshot == nil {
r.logger.Warn("attempt to upsert nil snapshot") r.logger.Warn("Attempt to upsert nil snapshot")
return merrors.InvalidArgument("ratesStore: nil snapshot") return merrors.InvalidArgument("ratesStore: nil snapshot")
} }
if snapshot.RateRef == "" { if snapshot.RateRef == "" {
r.logger.Warn("attempt to upsert snapshot with empty rate_ref") r.logger.Warn("Attempt to upsert snapshot with empty rate_ref")
return merrors.InvalidArgument("ratesStore: empty rateRef") return merrors.InvalidArgument("ratesStore: empty rateRef")
} }
@@ -82,17 +82,17 @@ func (r *ratesStore) UpsertSnapshot(ctx context.Context, snapshot *model.RateSna
err := r.repo.FindOneByFilter(ctx, filter, existing) err := r.repo.FindOneByFilter(ctx, filter, existing)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
r.logger.Debug("inserting new rate snapshot", zap.String("rate_ref", snapshot.RateRef)) r.logger.Debug("Inserting new rate snapshot", zap.String("rate_ref", snapshot.RateRef))
return r.repo.Insert(ctx, snapshot, filter) return r.repo.Insert(ctx, snapshot, filter)
} }
r.logger.Error("failed to query rate snapshot", zap.Error(err), zap.String("rate_ref", snapshot.RateRef)) r.logger.Warn("Failed to query rate snapshot", zap.Error(err), zap.String("rate_ref", snapshot.RateRef))
return err return err
} }
if existing.GetID() != nil { if existing.GetID() != nil {
snapshot.SetID(*existing.GetID()) snapshot.SetID(*existing.GetID())
} }
r.logger.Debug("updating rate snapshot", zap.String("rate_ref", snapshot.RateRef)) r.logger.Debug("Updating rate snapshot", zap.String("rate_ref", snapshot.RateRef))
return r.repo.Update(ctx, snapshot) return r.repo.Update(ctx, snapshot)
} }

View File

@@ -22,7 +22,7 @@ require (
require ( require (
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251225023818-8886bb81c549 // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect

View File

@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251225023818-8886bb81c549 h1:NERDcANvDCnspxdMEMLXOMnuITWIWrTQvvhEA8ewBBM= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b h1:g/wCbvJGhOAqfGBjWnqtD6CVsXdr3G4GCbjLR6z9kNw=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251225023818-8886bb81c549/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=

View File

@@ -45,22 +45,22 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
} }
address := strings.TrimSpace(cfg.Address) address := strings.TrimSpace(cfg.Address)
if address == "" { if address == "" {
logger.Error("vault address missing") logger.Error("Vault address missing")
return nil, merrors.InvalidArgument("vault key manager: address is required") return nil, merrors.InvalidArgument("vault key manager: address is required")
} }
tokenEnv := strings.TrimSpace(cfg.TokenEnv) tokenEnv := strings.TrimSpace(cfg.TokenEnv)
if tokenEnv == "" { if tokenEnv == "" {
logger.Error("vault token env missing") logger.Error("Vault token env missing")
return nil, merrors.InvalidArgument("vault key manager: token_env is required") return nil, merrors.InvalidArgument("vault key manager: token_env is required")
} }
token := strings.TrimSpace(os.Getenv(tokenEnv)) token := strings.TrimSpace(os.Getenv(tokenEnv))
if token == "" { if token == "" {
logger.Error("vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv)) logger.Error("Vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv))
return nil, merrors.InvalidArgument("vault key manager: token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)") return nil, merrors.InvalidArgument("vault key manager: token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)")
} }
mountPath := strings.Trim(strings.TrimSpace(cfg.MountPath), "/") mountPath := strings.Trim(strings.TrimSpace(cfg.MountPath), "/")
if mountPath == "" { if mountPath == "" {
logger.Error("vault mount path missing") logger.Error("Vault mount path missing")
return nil, merrors.InvalidArgument("vault key manager: mount_path is required") return nil, merrors.InvalidArgument("vault key manager: mount_path is required")
} }
keyPrefix := strings.Trim(strings.TrimSpace(cfg.KeyPrefix), "/") keyPrefix := strings.Trim(strings.TrimSpace(cfg.KeyPrefix), "/")
@@ -73,7 +73,7 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
client, err := api.NewClient(clientCfg) client, err := api.NewClient(clientCfg)
if err != nil { if err != nil {
logger.Error("failed to create vault client", zap.Error(err)) logger.Error("Failed to create vault client", zap.Error(err))
return nil, merrors.Internal("vault key manager: failed to create client: " + err.Error()) return nil, merrors.Internal("vault key manager: failed to create client: " + err.Error())
} }
client.SetToken(token) client.SetToken(token)
@@ -94,17 +94,17 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
// CreateManagedWalletKey creates a new managed wallet key and stores it in Vault. // CreateManagedWalletKey creates a new managed wallet key and stores it in Vault.
func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) { func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) {
if strings.TrimSpace(walletRef) == "" { if strings.TrimSpace(walletRef) == "" {
m.logger.Warn("walletRef missing for managed key creation", zap.String("network", network)) m.logger.Warn("WalletRef missing for managed key creation", zap.String("network", network))
return nil, merrors.InvalidArgument("vault key manager: walletRef is required") return nil, merrors.InvalidArgument("vault key manager: walletRef is required")
} }
if strings.TrimSpace(network) == "" { if strings.TrimSpace(network) == "" {
m.logger.Warn("network missing for managed key creation", zap.String("wallet_ref", walletRef)) m.logger.Warn("Network missing for managed key creation", zap.String("wallet_ref", walletRef))
return nil, merrors.InvalidArgument("vault key manager: network is required") return nil, merrors.InvalidArgument("vault key manager: network is required")
} }
privateKey, err := ecdsa.GenerateKey(secp256k1.S256(), rand.Reader) privateKey, err := ecdsa.GenerateKey(secp256k1.S256(), rand.Reader)
if err != nil { if err != nil {
m.logger.Warn("failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err)) m.logger.Warn("Failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
return nil, merrors.Internal("vault key manager: failed to generate key: " + err.Error()) return nil, merrors.Internal("vault key manager: failed to generate key: " + err.Error())
} }
privateKeyBytes := crypto.FromECDSA(privateKey) privateKeyBytes := crypto.FromECDSA(privateKey)
@@ -115,7 +115,7 @@ func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string,
err = m.persistKey(ctx, walletRef, network, privateKeyBytes, publicKeyBytes, address) err = m.persistKey(ctx, walletRef, network, privateKeyBytes, publicKeyBytes, address)
if err != nil { if err != nil {
m.logger.Warn("failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err)) m.logger.Warn("Failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
zeroBytes(privateKeyBytes) zeroBytes(privateKeyBytes)
zeroBytes(publicKeyBytes) zeroBytes(publicKeyBytes)
return nil, err return nil, err
@@ -123,7 +123,7 @@ func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string,
zeroBytes(privateKeyBytes) zeroBytes(privateKeyBytes)
zeroBytes(publicKeyBytes) zeroBytes(publicKeyBytes)
m.logger.Info("managed wallet key created", m.logger.Info("Managed wallet key created",
zap.String("wallet_ref", walletRef), zap.String("wallet_ref", walletRef),
zap.String("network", network), zap.String("network", network),
zap.String("address", strings.ToLower(address)), zap.String("address", strings.ToLower(address)),
@@ -158,43 +158,43 @@ func (m *Manager) buildKeyID(network, walletRef string) string {
// SignTransaction loads the key material from Vault and signs the transaction. // SignTransaction loads the key material from Vault and signs the transaction.
func (m *Manager) SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) { func (m *Manager) SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) {
if strings.TrimSpace(keyID) == "" { if strings.TrimSpace(keyID) == "" {
m.logger.Warn("signing failed: empty key id") m.logger.Warn("Signing failed: empty key id")
return nil, merrors.InvalidArgument("vault key manager: keyID is required") return nil, merrors.InvalidArgument("vault key manager: keyID is required")
} }
if tx == nil { if tx == nil {
m.logger.Warn("signing failed: nil transaction", zap.String("key_id", keyID)) m.logger.Warn("Signing failed: nil transaction", zap.String("key_id", keyID))
return nil, merrors.InvalidArgument("vault key manager: transaction is nil") return nil, merrors.InvalidArgument("vault key manager: transaction is nil")
} }
if chainID == nil { if chainID == nil {
m.logger.Warn("signing failed: nil chain id", zap.String("key_id", keyID)) m.logger.Warn("Signing failed: nil chain id", zap.String("key_id", keyID))
return nil, merrors.InvalidArgument("vault key manager: chainID is nil") return nil, merrors.InvalidArgument("vault key manager: chainID is nil")
} }
material, err := m.loadKey(ctx, keyID) material, err := m.loadKey(ctx, keyID)
if err != nil { if err != nil {
m.logger.Warn("failed to load key material", zap.String("key_id", keyID), zap.Error(err)) m.logger.Warn("Failed to load key material", zap.String("key_id", keyID), zap.Error(err))
return nil, err return nil, err
} }
keyBytes, err := hex.DecodeString(material.PrivateKey) keyBytes, err := hex.DecodeString(material.PrivateKey)
if err != nil { if err != nil {
m.logger.Warn("invalid key material", zap.String("key_id", keyID), zap.Error(err)) m.logger.Warn("Invalid key material", zap.String("key_id", keyID), zap.Error(err))
return nil, merrors.Internal("vault key manager: invalid key material: " + err.Error()) return nil, merrors.Internal("vault key manager: invalid key material: " + err.Error())
} }
defer zeroBytes(keyBytes) defer zeroBytes(keyBytes)
privateKey, err := crypto.ToECDSA(keyBytes) privateKey, err := crypto.ToECDSA(keyBytes)
if err != nil { if err != nil {
m.logger.Warn("failed to construct private key", zap.String("key_id", keyID), zap.Error(err)) m.logger.Warn("Failed to construct private key", zap.String("key_id", keyID), zap.Error(err))
return nil, merrors.Internal("vault key manager: failed to construct private key: " + err.Error()) return nil, merrors.Internal("vault key manager: failed to construct private key: " + err.Error())
} }
signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey) signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey)
if err != nil { if err != nil {
m.logger.Warn("failed to sign transaction", zap.String("key_id", keyID), zap.Error(err)) m.logger.Warn("Failed to sign transaction", zap.String("key_id", keyID), zap.Error(err))
return nil, merrors.Internal("vault key manager: failed to sign transaction: " + err.Error()) return nil, merrors.Internal("vault key manager: failed to sign transaction: " + err.Error())
} }
m.logger.Info("transaction signed with managed key", m.logger.Info("Transaction signed with managed key",
zap.String("key_id", keyID), zap.String("key_id", keyID),
zap.String("network", material.Network), zap.String("network", material.Network),
zap.String("tx_hash", signed.Hash().Hex()), zap.String("tx_hash", signed.Hash().Hex()),
@@ -213,23 +213,23 @@ func (m *Manager) loadKey(ctx context.Context, keyID string) (*keyMaterial, erro
secretPath := strings.Trim(strings.TrimPrefix(keyID, "/"), "/") secretPath := strings.Trim(strings.TrimPrefix(keyID, "/"), "/")
secret, err := m.store.Get(ctx, secretPath) secret, err := m.store.Get(ctx, secretPath)
if err != nil { if err != nil {
m.logger.Warn("failed to read secret", zap.String("path", secretPath), zap.Error(err)) m.logger.Warn("Failed to read secret", zap.String("path", secretPath), zap.Error(err))
return nil, merrors.Internal("vault key manager: failed to read secret at " + secretPath + ": " + err.Error()) return nil, merrors.Internal("vault key manager: failed to read secret at " + secretPath + ": " + err.Error())
} }
if secret == nil || secret.Data == nil { if secret == nil || secret.Data == nil {
m.logger.Warn("secret not found", zap.String("path", secretPath)) m.logger.Warn("Secret not found", zap.String("path", secretPath))
return nil, merrors.NoData("vault key manager: secret " + secretPath + " not found") return nil, merrors.NoData("vault key manager: secret " + secretPath + " not found")
} }
getString := func(key string) (string, error) { getString := func(key string) (string, error) {
val, ok := secret.Data[key] val, ok := secret.Data[key]
if !ok { if !ok {
m.logger.Warn("secret missing field", zap.String("path", secretPath), zap.String("field", key)) m.logger.Warn("Secret missing field", zap.String("path", secretPath), zap.String("field", key))
return "", merrors.Internal("vault key manager: secret " + secretPath + " missing " + key) return "", merrors.Internal("vault key manager: secret " + secretPath + " missing " + key)
} }
str, ok := val.(string) str, ok := val.(string)
if !ok || strings.TrimSpace(str) == "" { if !ok || strings.TrimSpace(str) == "" {
m.logger.Warn("secret field invalid", zap.String("path", secretPath), zap.String("field", key)) m.logger.Warn("Secret field invalid", zap.String("path", secretPath), zap.String("field", key))
return "", merrors.Internal("vault key manager: secret " + secretPath + " invalid " + key) return "", merrors.Internal("vault key manager: secret " + secretPath + " invalid " + key)
} }
return str, nil return str, nil

View File

@@ -123,12 +123,12 @@ func (i *Imp) Start() error {
cl := i.logger.Named("config") cl := i.logger.Named("config")
networkConfigs, err := resolveNetworkConfigs(cl.Named("network"), cfg.Chains) networkConfigs, err := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
if err != nil { if err != nil {
i.logger.Error("invalid chain network configuration", zap.Error(err)) i.logger.Error("Invalid chain network configuration", zap.Error(err))
return err return err
} }
rpcClients, err := rpcclient.Prepare(context.Background(), i.logger.Named("rpc"), networkConfigs) rpcClients, err := rpcclient.Prepare(context.Background(), i.logger.Named("rpc"), networkConfigs)
if err != nil { if err != nil {
i.logger.Error("failed to prepare rpc clients", zap.Error(err)) i.logger.Error("Failed to prepare rpc clients", zap.Error(err))
return err return err
} }
i.rpcClients = rpcClients i.rpcClients = rpcClients
@@ -166,7 +166,7 @@ func (i *Imp) Start() error {
func (i *Imp) loadConfig() (*config, error) { func (i *Imp) loadConfig() (*config, error) {
data, err := os.ReadFile(i.file) data, err := os.ReadFile(i.file)
if err != nil { if err != nil {
i.logger.Error("could not read configuration file", zap.String("config_file", i.file), zap.Error(err)) i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
return nil, err return nil, err
} }
@@ -174,7 +174,7 @@ func (i *Imp) loadConfig() (*config, error) {
Config: &grpcapp.Config{}, Config: &grpcapp.Config{},
} }
if err := yaml.Unmarshal(data, cfg); err != nil { if err := yaml.Unmarshal(data, cfg); err != nil {
i.logger.Error("failed to parse configuration", zap.Error(err)) i.logger.Error("Failed to parse configuration", zap.Error(err))
return nil, err return nil, err
} }
@@ -198,7 +198,7 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew
result := make([]gatewayshared.Network, 0, len(chains)) result := make([]gatewayshared.Network, 0, len(chains))
for _, chain := range chains { for _, chain := range chains {
if strings.TrimSpace(chain.Name) == "" { if strings.TrimSpace(chain.Name) == "" {
logger.Warn("skipping unnamed chain configuration") logger.Warn("Skipping unnamed chain configuration")
continue continue
} }
rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv)) rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv))
@@ -210,7 +210,7 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew
for _, token := range chain.Tokens { for _, token := range chain.Tokens {
symbol := strings.TrimSpace(token.Symbol) symbol := strings.TrimSpace(token.Symbol)
if symbol == "" { if symbol == "" {
logger.Warn("skipping token with empty symbol", zap.String("chain", chain.Name)) logger.Warn("Skipping token with empty symbol", zap.String("chain", chain.Name))
continue continue
} }
addr := strings.TrimSpace(token.Contract) addr := strings.TrimSpace(token.Contract)
@@ -220,9 +220,9 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew
} }
if addr == "" { if addr == "" {
if env != "" { if env != "" {
logger.Warn("token contract not configured", zap.String("token", symbol), zap.String("env", env), zap.String("chain", chain.Name)) logger.Warn("Token contract not configured", zap.String("token", symbol), zap.String("env", env), zap.String("chain", chain.Name))
} else { } else {
logger.Warn("token contract not configured", zap.String("token", symbol), zap.String("chain", chain.Name)) logger.Warn("Token contract not configured", zap.String("token", symbol), zap.String("chain", chain.Name))
} }
continue continue
} }
@@ -234,7 +234,7 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew
gasPolicy, err := buildGasTopUpPolicy(chain.Name, chain.GasTopUpPolicy) gasPolicy, err := buildGasTopUpPolicy(chain.Name, chain.GasTopUpPolicy)
if err != nil { if err != nil {
logger.Error("invalid gas top-up policy", zap.String("chain", chain.Name), zap.Error(err)) logger.Error("Invalid gas top-up policy", zap.String("chain", chain.Name), zap.Error(err))
return nil, err return nil, err
} }
@@ -322,13 +322,13 @@ func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewa
if address == "" { if address == "" {
if cfg.AddressEnv != "" { if cfg.AddressEnv != "" {
logger.Warn("service wallet address not configured", zap.String("env", cfg.AddressEnv)) logger.Warn("Service wallet address not configured", zap.String("env", cfg.AddressEnv))
} else { } else {
logger.Warn("service wallet address not configured", zap.String("chain", cfg.Chain)) logger.Warn("Service wallet address not configured", zap.String("chain", cfg.Chain))
} }
} }
if privateKey == "" { if privateKey == "" {
logger.Warn("service wallet private key not configured", zap.String("env", cfg.PrivateKeyEnv)) logger.Warn("Service wallet private key not configured", zap.String("env", cfg.PrivateKeyEnv))
} }
return gatewayshared.ServiceWallet{ return gatewayshared.ServiceWallet{
@@ -342,7 +342,7 @@ func resolveKeyManager(logger mlogger.Logger, cfg keymanager.Config) (keymanager
driver := strings.ToLower(strings.TrimSpace(string(cfg.Driver))) driver := strings.ToLower(strings.TrimSpace(string(cfg.Driver)))
if driver == "" { if driver == "" {
err := merrors.InvalidArgument("key management driver is not configured") err := merrors.InvalidArgument("key management driver is not configured")
logger.Error("key management driver missing") logger.Error("Key management driver missing")
return nil, err return nil, err
} }
@@ -351,19 +351,19 @@ func resolveKeyManager(logger mlogger.Logger, cfg keymanager.Config) (keymanager
settings := vaultmanager.Config{} settings := vaultmanager.Config{}
if len(cfg.Settings) > 0 { if len(cfg.Settings) > 0 {
if err := mapstructure.Decode(cfg.Settings, &settings); err != nil { if err := mapstructure.Decode(cfg.Settings, &settings); err != nil {
logger.Error("failed to decode vault key manager settings", zap.Error(err), zap.Any("settings", cfg.Settings)) logger.Error("Failed to decode vault key manager settings", zap.Error(err), zap.Any("settings", cfg.Settings))
return nil, merrors.InvalidArgument("invalid vault key manager settings: " + err.Error()) return nil, merrors.InvalidArgument("invalid vault key manager settings: " + err.Error())
} }
} }
manager, err := vaultmanager.New(logger, settings) manager, err := vaultmanager.New(logger, settings)
if err != nil { if err != nil {
logger.Error("failed to initialise vault key manager", zap.Error(err)) logger.Error("Failed to initialise vault key manager", zap.Error(err))
return nil, err return nil, err
} }
return manager, nil return manager, nil
default: default:
err := merrors.InvalidArgument("unsupported key management driver: " + driver) err := merrors.InvalidArgument("unsupported key management driver: " + driver)
logger.Error("unsupported key management driver", zap.String("driver", driver)) logger.Error("Unsupported key management driver", zap.String("driver", driver))
return nil, err return nil, err
} }
} }

View File

@@ -58,7 +58,8 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
return model.TransferDestination{}, err return model.TransferDestination{}, err
} }
return model.TransferDestination{ return model.TransferDestination{
ExternalAddress: normalized, ExternalAddress: normalized,
Memo: strings.TrimSpace(dest.GetMemo()), ExternalAddressOriginal: external,
Memo: strings.TrimSpace(dest.GetMemo()),
}, nil }, nil
} }

View File

@@ -34,12 +34,12 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef()) sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
if sourceWalletRef == "" { if sourceWalletRef == "" {
c.deps.Logger.Warn("source wallet ref missing") c.deps.Logger.Warn("Source wallet ref missing")
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required")) return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
} }
amount := req.GetAmount() amount := req.GetAmount()
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
c.deps.Logger.Warn("amount missing or incomplete") c.deps.Logger.Warn("Amount missing or incomplete")
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required")) return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
} }

View File

@@ -29,22 +29,22 @@ func NewComputeGasTopUp(deps Deps) *computeGasTopUpCommand {
func (c *computeGasTopUpCommand) Execute(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) gsresponse.Responder[chainv1.ComputeGasTopUpResponse] { func (c *computeGasTopUpCommand) Execute(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) gsresponse.Responder[chainv1.ComputeGasTopUpResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil { if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err)) c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Unavailable[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
if req == nil { if req == nil {
c.deps.Logger.Warn("nil request") c.deps.Logger.Warn("Nil request")
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required")) return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
} }
walletRef := strings.TrimSpace(req.GetWalletRef()) walletRef := strings.TrimSpace(req.GetWalletRef())
if walletRef == "" { if walletRef == "" {
c.deps.Logger.Warn("wallet ref missing") c.deps.Logger.Warn("Wallet ref missing")
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required")) return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
} }
estimatedFee := req.GetEstimatedTotalFee() estimatedFee := req.GetEstimatedTotalFee()
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" { if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
c.deps.Logger.Warn("estimated fee missing") c.deps.Logger.Warn("Estimated fee missing")
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required")) return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required"))
} }
@@ -71,37 +71,37 @@ func NewEnsureGasTopUp(deps Deps) *ensureGasTopUpCommand {
func (c *ensureGasTopUpCommand) Execute(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) gsresponse.Responder[chainv1.EnsureGasTopUpResponse] { func (c *ensureGasTopUpCommand) Execute(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) gsresponse.Responder[chainv1.EnsureGasTopUpResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil { if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err)) c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Unavailable[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
if req == nil { if req == nil {
c.deps.Logger.Warn("nil request") c.deps.Logger.Warn("Nil request")
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required")) return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
} }
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey()) idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" { if idempotencyKey == "" {
c.deps.Logger.Warn("idempotency key missing") c.deps.Logger.Warn("Idempotency key missing")
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required")) return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
} }
organizationRef := strings.TrimSpace(req.GetOrganizationRef()) organizationRef := strings.TrimSpace(req.GetOrganizationRef())
if organizationRef == "" { if organizationRef == "" {
c.deps.Logger.Warn("organization ref missing") c.deps.Logger.Warn("Organization ref missing")
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required")) return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
} }
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef()) sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
if sourceWalletRef == "" { if sourceWalletRef == "" {
c.deps.Logger.Warn("source wallet ref missing") c.deps.Logger.Warn("Source wallet ref missing")
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required")) return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
} }
targetWalletRef := strings.TrimSpace(req.GetTargetWalletRef()) targetWalletRef := strings.TrimSpace(req.GetTargetWalletRef())
if targetWalletRef == "" { if targetWalletRef == "" {
c.deps.Logger.Warn("target wallet ref missing") c.deps.Logger.Warn("Target wallet ref missing")
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("target_wallet_ref is required")) return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("target_wallet_ref is required"))
} }
estimatedFee := req.GetEstimatedTotalFee() estimatedFee := req.GetEstimatedTotalFee()
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" { if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
c.deps.Logger.Warn("estimated fee missing") c.deps.Logger.Warn("Estimated fee missing")
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required")) return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required"))
} }
@@ -268,7 +268,7 @@ func logDecision(logger mlogger.Logger, walletRef string, estimatedFee *moneyv1.
zap.String("operation_type", decision.OperationType), zap.String("operation_type", decision.OperationType),
) )
} }
logger.Info("gas top-up decision", fields...) logger.Info("Gas top-up decision", fields...)
} }
func amountString(m *moneyv1.Money) string { func amountString(m *moneyv1.Money) string {

View File

@@ -22,25 +22,25 @@ func NewGetTransfer(deps Deps) *getTransferCommand {
func (c *getTransferCommand) Execute(ctx context.Context, req *chainv1.GetTransferRequest) gsresponse.Responder[chainv1.GetTransferResponse] { func (c *getTransferCommand) Execute(ctx context.Context, req *chainv1.GetTransferRequest) gsresponse.Responder[chainv1.GetTransferResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil { if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err)) c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Unavailable[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
if req == nil { if req == nil {
c.deps.Logger.Warn("nil request") c.deps.Logger.Warn("Nil request")
return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request")) return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
} }
transferRef := strings.TrimSpace(req.GetTransferRef()) transferRef := strings.TrimSpace(req.GetTransferRef())
if transferRef == "" { if transferRef == "" {
c.deps.Logger.Warn("transfer_ref missing") c.deps.Logger.Warn("Transfer_ref missing")
return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("transfer_ref is required")) return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("transfer_ref is required"))
} }
transfer, err := c.deps.Storage.Transfers().Get(ctx, transferRef) transfer, err := c.deps.Storage.Transfers().Get(ctx, transferRef)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("not found", zap.String("transfer_ref", transferRef)) c.deps.Logger.Warn("Not found", zap.String("transfer_ref", transferRef))
return gsresponse.NotFound[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.NotFound[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("transfer_ref", transferRef)) c.deps.Logger.Warn("Storage get failed", zap.Error(err), zap.String("transfer_ref", transferRef))
return gsresponse.Auto[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
return gsresponse.Success(&chainv1.GetTransferResponse{Transfer: toProtoTransfer(transfer)}) return gsresponse.Success(&chainv1.GetTransferResponse{Transfer: toProtoTransfer(transfer)})

View File

@@ -23,7 +23,7 @@ func NewListTransfers(deps Deps) *listTransfersCommand {
func (c *listTransfersCommand) Execute(ctx context.Context, req *chainv1.ListTransfersRequest) gsresponse.Responder[chainv1.ListTransfersResponse] { func (c *listTransfersCommand) Execute(ctx context.Context, req *chainv1.ListTransfersRequest) gsresponse.Responder[chainv1.ListTransfersResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil { if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err)) c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Unavailable[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
filter := model.TransferFilter{} filter := model.TransferFilter{}
@@ -41,7 +41,7 @@ func (c *listTransfersCommand) Execute(ctx context.Context, req *chainv1.ListTra
result, err := c.deps.Storage.Transfers().List(ctx, filter) result, err := c.deps.Storage.Transfers().List(ctx, filter)
if err != nil { if err != nil {
c.deps.Logger.Warn("storage list failed", zap.Error(err)) c.deps.Logger.Warn("Storage list failed", zap.Error(err))
return gsresponse.Auto[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err)
} }

View File

@@ -29,7 +29,7 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
return gsresponse.Unavailable[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Unavailable[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
if req == nil { if req == nil {
c.deps.Logger.Warn("nil request") c.deps.Logger.Warn("Nil request")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request")) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
} }
@@ -40,7 +40,7 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
} }
organizationRef := strings.TrimSpace(req.GetOrganizationRef()) organizationRef := strings.TrimSpace(req.GetOrganizationRef())
if organizationRef == "" { if organizationRef == "" {
c.deps.Logger.Warn("mMssing organization ref") c.deps.Logger.Warn("Missing organization ref")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required")) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
} }
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef()) sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())

View File

@@ -29,41 +29,41 @@ func NewGetWalletBalance(deps Deps) *getWalletBalanceCommand {
func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetWalletBalanceRequest) gsresponse.Responder[chainv1.GetWalletBalanceResponse] { func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetWalletBalanceRequest) gsresponse.Responder[chainv1.GetWalletBalanceResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil { if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err)) c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Unavailable[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
if req == nil { if req == nil {
c.deps.Logger.Warn("nil request") c.deps.Logger.Warn("Nil request")
return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request")) return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
} }
walletRef := strings.TrimSpace(req.GetWalletRef()) walletRef := strings.TrimSpace(req.GetWalletRef())
if walletRef == "" { if walletRef == "" {
c.deps.Logger.Warn("wallet_ref missing") c.deps.Logger.Warn("Wallet_ref missing")
return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required")) return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
} }
wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef) wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("not found", zap.String("wallet_ref", walletRef)) c.deps.Logger.Warn("Not found", zap.String("wallet_ref", walletRef))
return gsresponse.NotFound[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.NotFound[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef)) c.deps.Logger.Warn("Storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
tokenBalance, nativeBalance, chainErr := OnChainWalletBalances(ctx, c.deps, wallet) tokenBalance, nativeBalance, chainErr := OnChainWalletBalances(ctx, c.deps, wallet)
if chainErr != nil { if chainErr != nil {
c.deps.Logger.Warn("on-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef)) c.deps.Logger.Warn("On-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef))
stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef) stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("cached balance not found", zap.String("wallet_ref", walletRef)) c.deps.Logger.Warn("Cached balance not found", zap.String("wallet_ref", walletRef))
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, chainErr) return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, chainErr)
} }
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
if c.isCachedBalanceStale(stored) { if c.isCachedBalanceStale(stored) {
c.deps.Logger.Warn("cached balance is stale", c.deps.Logger.Info("Cached balance is stale",
zap.String("wallet_ref", walletRef), zap.String("wallet_ref", walletRef),
zap.Time("calculated_at", stored.CalculatedAt), zap.Time("calculated_at", stored.CalculatedAt),
zap.Duration("ttl", c.cacheTTL()), zap.Duration("ttl", c.cacheTTL()),
@@ -116,7 +116,7 @@ func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, wall
record.PendingInbound = zeroMoney(currency) record.PendingInbound = zeroMoney(currency)
record.PendingOutbound = zeroMoney(currency) record.PendingOutbound = zeroMoney(currency)
if err := c.deps.Storage.Wallets().SaveBalance(ctx, record); err != nil { if err := c.deps.Storage.Wallets().SaveBalance(ctx, record); err != nil {
c.deps.Logger.Warn("failed to cache wallet balance", zap.String("wallet_ref", walletRef), zap.Error(err)) c.deps.Logger.Warn("Failed to cache wallet balance", zap.String("wallet_ref", walletRef), zap.Error(err))
} }
} }

View File

@@ -25,59 +25,59 @@ func NewCreateManagedWallet(deps Deps) *createManagedWalletCommand {
func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.CreateManagedWalletRequest) gsresponse.Responder[chainv1.CreateManagedWalletResponse] { func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.CreateManagedWalletRequest) gsresponse.Responder[chainv1.CreateManagedWalletResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil { if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err)) c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Unavailable[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
if req == nil { if req == nil {
c.deps.Logger.Warn("nil request") c.deps.Logger.Warn("Nil request")
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
} }
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey()) idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" { if idempotencyKey == "" {
c.deps.Logger.Warn("missing idempotency key") c.deps.Logger.Warn("Missing idempotency key")
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
} }
organizationRef := strings.TrimSpace(req.GetOrganizationRef()) organizationRef := strings.TrimSpace(req.GetOrganizationRef())
if organizationRef == "" { if organizationRef == "" {
c.deps.Logger.Warn("missing organization ref") c.deps.Logger.Warn("Missing organization ref")
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
} }
ownerRef := strings.TrimSpace(req.GetOwnerRef()) ownerRef := strings.TrimSpace(req.GetOwnerRef())
if ownerRef == "" { if ownerRef == "" {
c.deps.Logger.Warn("missing owner ref") c.deps.Logger.Warn("Missing owner ref")
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("owner_ref is required")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("owner_ref is required"))
} }
asset := req.GetAsset() asset := req.GetAsset()
if asset == nil { if asset == nil {
c.deps.Logger.Warn("missing asset") c.deps.Logger.Warn("Missing asset")
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset is required")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset is required"))
} }
chainKey, _ := shared.ChainKeyFromEnum(asset.GetChain()) chainKey, _ := shared.ChainKeyFromEnum(asset.GetChain())
if chainKey == "" { if chainKey == "" {
c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain())) c.deps.Logger.Warn("Unsupported chain", zap.Any("chain", asset.GetChain()))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
} }
networkCfg, ok := c.deps.Networks.Network(chainKey) networkCfg, ok := c.deps.Networks.Network(chainKey)
if !ok { if !ok {
c.deps.Logger.Warn("unsupported chain in config", zap.String("chain", chainKey)) c.deps.Logger.Warn("Unsupported chain in config", zap.String("chain", chainKey))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
} }
if c.deps.Drivers == nil { if c.deps.Drivers == nil {
c.deps.Logger.Warn("chain drivers missing", zap.String("chain", chainKey)) c.deps.Logger.Warn("Chain drivers missing", zap.String("chain", chainKey))
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured")) return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
} }
chainDriver, err := c.deps.Drivers.Driver(chainKey) chainDriver, err := c.deps.Drivers.Driver(chainKey)
if err != nil { if err != nil {
c.deps.Logger.Warn("unsupported chain driver", zap.String("chain", chainKey), zap.Error(err)) c.deps.Logger.Warn("Unsupported chain driver", zap.String("chain", chainKey), zap.Error(err))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
} }
tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol())) tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
if tokenSymbol == "" { if tokenSymbol == "" {
c.deps.Logger.Warn("missing token symbol") c.deps.Logger.Warn("Missing token symbol")
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset.token_symbol is required")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset.token_symbol is required"))
} }
contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress())) contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress()))
@@ -85,7 +85,7 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
if !strings.EqualFold(tokenSymbol, networkCfg.NativeToken) { if !strings.EqualFold(tokenSymbol, networkCfg.NativeToken) {
contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol) contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
if contractAddress == "" { if contractAddress == "" {
c.deps.Logger.Warn("unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey)) c.deps.Logger.Warn("Unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
} }
} }
@@ -93,22 +93,22 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
walletRef := shared.GenerateWalletRef() walletRef := shared.GenerateWalletRef()
if c.deps.KeyManager == nil { if c.deps.KeyManager == nil {
c.deps.Logger.Warn("key manager missing") c.deps.Logger.Warn("Key manager missing")
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager not configured")) return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager not configured"))
} }
keyInfo, err := c.deps.KeyManager.CreateManagedWalletKey(ctx, walletRef, chainKey) keyInfo, err := c.deps.KeyManager.CreateManagedWalletKey(ctx, walletRef, chainKey)
if err != nil { if err != nil {
c.deps.Logger.Warn("key manager error", zap.Error(err)) c.deps.Logger.Warn("Key manager error", zap.Error(err))
return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
if keyInfo == nil || strings.TrimSpace(keyInfo.Address) == "" { if keyInfo == nil || strings.TrimSpace(keyInfo.Address) == "" {
c.deps.Logger.Warn("key manager returned empty address") c.deps.Logger.Warn("Key manager returned empty address")
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address")) return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
} }
depositAddress, err := chainDriver.FormatAddress(keyInfo.Address) depositAddress, err := chainDriver.FormatAddress(keyInfo.Address)
if err != nil { if err != nil {
c.deps.Logger.Warn("invalid derived deposit address", zap.String("wallet_ref", walletRef), zap.Error(err)) c.deps.Logger.Warn("Invalid derived deposit address", zap.String("wallet_ref", walletRef), zap.Error(err))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
@@ -156,10 +156,10 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
created, err := c.deps.Storage.Wallets().Create(ctx, wallet) created, err := c.deps.Storage.Wallets().Create(ctx, wallet)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrDataConflict) { if errors.Is(err, merrors.ErrDataConflict) {
c.deps.Logger.Debug("wallet already exists", zap.String("wallet_ref", walletRef), zap.String("idempotency_key", idempotencyKey)) c.deps.Logger.Debug("Wallet already exists", zap.String("wallet_ref", walletRef), zap.String("idempotency_key", idempotencyKey))
return gsresponse.Success(&chainv1.CreateManagedWalletResponse{Wallet: toProtoManagedWallet(created)}) return gsresponse.Success(&chainv1.CreateManagedWalletResponse{Wallet: toProtoManagedWallet(created)})
} }
c.deps.Logger.Warn("storage create failed", zap.Error(err), zap.String("wallet_ref", walletRef)) c.deps.Logger.Warn("Storage create failed", zap.Error(err), zap.String("wallet_ref", walletRef))
return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
} }

View File

@@ -22,25 +22,25 @@ func NewGetManagedWallet(deps Deps) *getManagedWalletCommand {
func (c *getManagedWalletCommand) Execute(ctx context.Context, req *chainv1.GetManagedWalletRequest) gsresponse.Responder[chainv1.GetManagedWalletResponse] { func (c *getManagedWalletCommand) Execute(ctx context.Context, req *chainv1.GetManagedWalletRequest) gsresponse.Responder[chainv1.GetManagedWalletResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil { if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err)) c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Unavailable[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
if req == nil { if req == nil {
c.deps.Logger.Warn("nil request") c.deps.Logger.Warn("Nil request")
return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request")) return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
} }
walletRef := strings.TrimSpace(req.GetWalletRef()) walletRef := strings.TrimSpace(req.GetWalletRef())
if walletRef == "" { if walletRef == "" {
c.deps.Logger.Warn("wallet_ref missing") c.deps.Logger.Warn("Wallet_ref missing")
return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required")) return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
} }
wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef) wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("not found", zap.String("wallet_ref", walletRef)) c.deps.Logger.Warn("Not found", zap.String("wallet_ref", walletRef))
return gsresponse.NotFound[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.NotFound[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef)) c.deps.Logger.Warn("Storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
return gsresponse.Auto[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
return gsresponse.Success(&chainv1.GetManagedWalletResponse{Wallet: toProtoManagedWallet(wallet)}) return gsresponse.Success(&chainv1.GetManagedWalletResponse{Wallet: toProtoManagedWallet(wallet)})

View File

@@ -23,7 +23,7 @@ func NewListManagedWallets(deps Deps) *listManagedWalletsCommand {
func (c *listManagedWalletsCommand) Execute(ctx context.Context, req *chainv1.ListManagedWalletsRequest) gsresponse.Responder[chainv1.ListManagedWalletsResponse] { func (c *listManagedWalletsCommand) Execute(ctx context.Context, req *chainv1.ListManagedWalletsRequest) gsresponse.Responder[chainv1.ListManagedWalletsResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil { if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err)) c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Unavailable[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
filter := model.ManagedWalletFilter{} filter := model.ManagedWalletFilter{}
@@ -42,7 +42,7 @@ func (c *listManagedWalletsCommand) Execute(ctx context.Context, req *chainv1.Li
result, err := c.deps.Storage.Wallets().List(ctx, filter) result, err := c.deps.Storage.Wallets().List(ctx, filter)
if err != nil { if err != nil {
c.deps.Logger.Warn("storage list failed", zap.Error(err)) c.deps.Logger.Warn("Storage list failed", zap.Error(err))
return gsresponse.Auto[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err)
} }

View File

@@ -27,25 +27,25 @@ func (d *Driver) Name() string {
} }
func (d *Driver) FormatAddress(address string) (string, error) { func (d *Driver) FormatAddress(address string) (string, error) {
d.logger.Debug("format address", zap.String("address", address)) d.logger.Debug("Format address", zap.String("address", address))
normalized, err := evm.NormalizeAddress(address) normalized, err := evm.NormalizeAddress(address)
if err != nil { if err != nil {
d.logger.Warn("format address failed", zap.String("address", address), zap.Error(err)) d.logger.Warn("Format address failed", zap.String("address", address), zap.Error(err))
} }
return normalized, err return normalized, err
} }
func (d *Driver) NormalizeAddress(address string) (string, error) { func (d *Driver) NormalizeAddress(address string) (string, error) {
d.logger.Debug("normalize address", zap.String("address", address)) d.logger.Debug("Normalize address", zap.String("address", address))
normalized, err := evm.NormalizeAddress(address) normalized, err := evm.NormalizeAddress(address)
if err != nil { if err != nil {
d.logger.Warn("normalize address failed", zap.String("address", address), zap.Error(err)) d.logger.Warn("Normalize address failed", zap.String("address", address), zap.Error(err))
} }
return normalized, err return normalized, err
} }
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) { func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
d.logger.Debug("balance request", d.logger.Debug("Balance request",
zap.String("wallet_ref", wallet.WalletRef), zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name), zap.String("network", network.Name),
) )
@@ -53,13 +53,13 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N
driverDeps.Logger = d.logger driverDeps.Logger = d.logger
result, err := evm.Balance(ctx, driverDeps, network, wallet, wallet.DepositAddress) result, err := evm.Balance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
if err != nil { if err != nil {
d.logger.Warn("balance failed", d.logger.Warn("Balance failed",
zap.String("wallet_ref", wallet.WalletRef), zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.Error(err), zap.Error(err),
) )
} else if result != nil { } else if result != nil {
d.logger.Debug("balance result", d.logger.Debug("Balance result",
zap.String("wallet_ref", wallet.WalletRef), zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.String("amount", result.Amount), zap.String("amount", result.Amount),
@@ -70,7 +70,7 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N
} }
func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) { func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
d.logger.Debug("native balance request", d.logger.Debug("Native balance request",
zap.String("wallet_ref", wallet.WalletRef), zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name), zap.String("network", network.Name),
) )
@@ -78,13 +78,13 @@ func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network sh
driverDeps.Logger = d.logger driverDeps.Logger = d.logger
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, wallet.DepositAddress) result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
if err != nil { if err != nil {
d.logger.Warn("native balance failed", d.logger.Warn("Native balance failed",
zap.String("wallet_ref", wallet.WalletRef), zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.Error(err), zap.Error(err),
) )
} else if result != nil { } else if result != nil {
d.logger.Debug("native balance result", d.logger.Debug("Native balance result",
zap.String("wallet_ref", wallet.WalletRef), zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.String("amount", result.Amount), zap.String("amount", result.Amount),
@@ -121,7 +121,7 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
} }
func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) { func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
d.logger.Debug("submit transfer request", d.logger.Debug("Submit transfer request",
zap.String("transfer_ref", transfer.TransferRef), zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.String("destination", destination), zap.String("destination", destination),
@@ -130,13 +130,13 @@ func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network s
driverDeps.Logger = d.logger driverDeps.Logger = d.logger
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, source.DepositAddress, destination) txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, source.DepositAddress, destination)
if err != nil { if err != nil {
d.logger.Warn("submit transfer failed", d.logger.Warn("Submit transfer failed",
zap.String("transfer_ref", transfer.TransferRef), zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.Error(err), zap.Error(err),
) )
} else { } else {
d.logger.Debug("submit transfer result", d.logger.Debug("Submit transfer result",
zap.String("transfer_ref", transfer.TransferRef), zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.String("tx_hash", txHash), zap.String("tx_hash", txHash),
@@ -146,7 +146,7 @@ func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network s
} }
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) { func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
d.logger.Debug("await confirmation", d.logger.Debug("Await confirmation",
zap.String("tx_hash", txHash), zap.String("tx_hash", txHash),
zap.String("network", network.Name), zap.String("network", network.Name),
) )
@@ -154,13 +154,13 @@ func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, networ
driverDeps.Logger = d.logger driverDeps.Logger = d.logger
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash) receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
if err != nil { if err != nil {
d.logger.Warn("await confirmation failed", d.logger.Warn("Await confirmation failed",
zap.String("tx_hash", txHash), zap.String("tx_hash", txHash),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.Error(err), zap.Error(err),
) )
} else if receipt != nil { } else if receipt != nil {
d.logger.Debug("await confirmation result", d.logger.Debug("Await confirmation result",
zap.String("tx_hash", txHash), zap.String("tx_hash", txHash),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.Uint64("block_number", receipt.BlockNumber.Uint64()), zap.Uint64("block_number", receipt.BlockNumber.Uint64()),

View File

@@ -27,25 +27,25 @@ func (d *Driver) Name() string {
} }
func (d *Driver) FormatAddress(address string) (string, error) { func (d *Driver) FormatAddress(address string) (string, error) {
d.logger.Debug("format address", zap.String("address", address)) d.logger.Debug("Format address", zap.String("address", address))
normalized, err := evm.NormalizeAddress(address) normalized, err := evm.NormalizeAddress(address)
if err != nil { if err != nil {
d.logger.Warn("format address failed", zap.String("address", address), zap.Error(err)) d.logger.Warn("Format address failed", zap.String("address", address), zap.Error(err))
} }
return normalized, err return normalized, err
} }
func (d *Driver) NormalizeAddress(address string) (string, error) { func (d *Driver) NormalizeAddress(address string) (string, error) {
d.logger.Debug("normalize address", zap.String("address", address)) d.logger.Debug("Normalize address", zap.String("address", address))
normalized, err := evm.NormalizeAddress(address) normalized, err := evm.NormalizeAddress(address)
if err != nil { if err != nil {
d.logger.Warn("normalize address failed", zap.String("address", address), zap.Error(err)) d.logger.Warn("Normalize address failed", zap.String("address", address), zap.Error(err))
} }
return normalized, err return normalized, err
} }
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) { func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
d.logger.Debug("balance request", d.logger.Debug("Balance request",
zap.String("wallet_ref", wallet.WalletRef), zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name), zap.String("network", network.Name),
) )
@@ -53,13 +53,13 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N
driverDeps.Logger = d.logger driverDeps.Logger = d.logger
result, err := evm.Balance(ctx, driverDeps, network, wallet, wallet.DepositAddress) result, err := evm.Balance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
if err != nil { if err != nil {
d.logger.Warn("balance failed", d.logger.Warn("Balance failed",
zap.String("wallet_ref", wallet.WalletRef), zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.Error(err), zap.Error(err),
) )
} else if result != nil { } else if result != nil {
d.logger.Debug("balance result", d.logger.Debug("Balance result",
zap.String("wallet_ref", wallet.WalletRef), zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.String("amount", result.Amount), zap.String("amount", result.Amount),
@@ -70,7 +70,7 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N
} }
func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) { func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
d.logger.Debug("native balance request", d.logger.Debug("Native balance request",
zap.String("wallet_ref", wallet.WalletRef), zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name), zap.String("network", network.Name),
) )
@@ -78,13 +78,13 @@ func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network sh
driverDeps.Logger = d.logger driverDeps.Logger = d.logger
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, wallet.DepositAddress) result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
if err != nil { if err != nil {
d.logger.Warn("native balance failed", d.logger.Warn("Native balance failed",
zap.String("wallet_ref", wallet.WalletRef), zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.Error(err), zap.Error(err),
) )
} else if result != nil { } else if result != nil {
d.logger.Debug("native balance result", d.logger.Debug("Native balance result",
zap.String("wallet_ref", wallet.WalletRef), zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.String("amount", result.Amount), zap.String("amount", result.Amount),
@@ -121,7 +121,7 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
} }
func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) { func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
d.logger.Debug("submit transfer request", d.logger.Debug("Submit transfer request",
zap.String("transfer_ref", transfer.TransferRef), zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.String("destination", destination), zap.String("destination", destination),
@@ -130,13 +130,13 @@ func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network s
driverDeps.Logger = d.logger driverDeps.Logger = d.logger
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, source.DepositAddress, destination) txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, source.DepositAddress, destination)
if err != nil { if err != nil {
d.logger.Warn("submit transfer failed", d.logger.Warn("Submit transfer failed",
zap.String("transfer_ref", transfer.TransferRef), zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.Error(err), zap.Error(err),
) )
} else { } else {
d.logger.Debug("submit transfer result", d.logger.Debug("Submit transfer result",
zap.String("transfer_ref", transfer.TransferRef), zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.String("tx_hash", txHash), zap.String("tx_hash", txHash),
@@ -146,7 +146,7 @@ func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network s
} }
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) { func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
d.logger.Debug("await confirmation", d.logger.Debug("Await confirmation",
zap.String("tx_hash", txHash), zap.String("tx_hash", txHash),
zap.String("network", network.Name), zap.String("network", network.Name),
) )
@@ -154,13 +154,13 @@ func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, networ
driverDeps.Logger = d.logger driverDeps.Logger = d.logger
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash) receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
if err != nil { if err != nil {
d.logger.Warn("await confirmation failed", d.logger.Warn("Await confirmation failed",
zap.String("tx_hash", txHash), zap.String("tx_hash", txHash),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.Error(err), zap.Error(err),
) )
} else if receipt != nil { } else if receipt != nil {
d.logger.Debug("await confirmation result", d.logger.Debug("Await confirmation result",
zap.String("tx_hash", txHash), zap.String("tx_hash", txHash),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.Uint64("block_number", receipt.BlockNumber.Uint64()), zap.Uint64("block_number", receipt.BlockNumber.Uint64()),

View File

@@ -41,7 +41,7 @@ func (d *Driver) NormalizeAddress(address string) (string, error) {
d.logger.Debug("Normalize address", zap.String("address", address)) d.logger.Debug("Normalize address", zap.String("address", address))
normalized, err := normalizeAddress(address) normalized, err := normalizeAddress(address)
if err != nil { if err != nil {
d.logger.Warn("normalize address failed", zap.String("address", address), zap.Error(err)) d.logger.Warn("Normalize address failed", zap.String("address", address), zap.Error(err))
} }
return normalized, err return normalized, err
} }
@@ -68,7 +68,7 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N
zap.String("network", network.Name), zap.String("network", network.Name),
) )
} else if result != nil { } else if result != nil {
d.logger.Debug("balance result", d.logger.Debug("Balance result",
zap.String("wallet_ref", wallet.WalletRef), zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.String("amount", result.Amount), zap.String("amount", result.Amount),
@@ -100,7 +100,7 @@ func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network sh
zap.String("network", network.Name), zap.String("network", network.Name),
) )
} else if result != nil { } else if result != nil {
d.logger.Debug("native balance result", d.logger.Debug("Native balance result",
zap.String("wallet_ref", wallet.WalletRef), zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.String("amount", result.Amount), zap.String("amount", result.Amount),
@@ -196,7 +196,7 @@ func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network s
driverDeps.Logger = d.logger driverDeps.Logger = d.logger
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, rpcFrom, rpcTo) txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, rpcFrom, rpcTo)
if err != nil { if err != nil {
d.logger.Warn("submit transfer failed", zap.Error(err), d.logger.Warn("Submit transfer failed", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef), zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name), zap.String("network", network.Name),
) )

View File

@@ -32,7 +32,7 @@ func NewRegistry(logger mlogger.Logger, networks []shared.Network) (*Registry, e
} }
chainDriver, err := resolveDriver(logger, name) chainDriver, err := resolveDriver(logger, name)
if err != nil { if err != nil {
logger.Error("unsupported chain driver", zap.String("network", name), zap.Error(err)) logger.Error("Unsupported chain driver", zap.String("network", name), zap.Error(err))
return nil, err return nil, err
} }
result.byNetwork[name] = chainDriver result.byNetwork[name] = chainDriver
@@ -40,7 +40,7 @@ func NewRegistry(logger mlogger.Logger, networks []shared.Network) (*Registry, e
if len(result.byNetwork) == 0 { if len(result.byNetwork) == 0 {
return nil, merrors.InvalidArgument("driver registry: no supported networks configured") return nil, merrors.InvalidArgument("driver registry: no supported networks configured")
} }
logger.Info("chain drivers configured", zap.Int("count", len(result.byNetwork))) logger.Info("Chain drivers configured", zap.Int("count", len(result.byNetwork)))
return result, nil return result, nil
} }

View File

@@ -47,32 +47,32 @@ type onChainExecutor struct {
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) { func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) {
if o.keyManager == nil { if o.keyManager == nil {
o.logger.Warn("key manager not configured") o.logger.Warn("Key manager not configured")
return "", executorInternal("key manager is not configured", nil) return "", executorInternal("key manager is not configured", nil)
} }
rpcURL := strings.TrimSpace(network.RPCURL) rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" { if rpcURL == "" {
o.logger.Warn("network rpc url missing", zap.String("network", network.Name)) o.logger.Warn("Network rpc url missing", zap.String("network", network.Name))
return "", executorInvalid("network rpc url is not configured") return "", executorInvalid("network rpc url is not configured")
} }
if source == nil || transfer == nil { if source == nil || transfer == nil {
o.logger.Warn("transfer context missing") o.logger.Warn("Transfer context missing")
return "", executorInvalid("transfer context missing") return "", executorInvalid("transfer context missing")
} }
if strings.TrimSpace(source.KeyReference) == "" { if strings.TrimSpace(source.KeyReference) == "" {
o.logger.Warn("source wallet missing key reference", zap.String("wallet_ref", source.WalletRef)) o.logger.Warn("Source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
return "", executorInvalid("source wallet missing key reference") return "", executorInvalid("source wallet missing key reference")
} }
if strings.TrimSpace(source.DepositAddress) == "" { if strings.TrimSpace(source.DepositAddress) == "" {
o.logger.Warn("source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef)) o.logger.Warn("Source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
return "", executorInvalid("source wallet missing deposit address") return "", executorInvalid("source wallet missing deposit address")
} }
if !common.IsHexAddress(destinationAddress) { if !common.IsHexAddress(destinationAddress) {
o.logger.Warn("invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress)) o.logger.Warn("Invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
return "", executorInvalid("invalid destination address " + destinationAddress) return "", executorInvalid("invalid destination address " + destinationAddress)
} }
o.logger.Info("submitting transfer", o.logger.Info("Submitting transfer",
zap.String("transfer_ref", transfer.TransferRef), zap.String("transfer_ref", transfer.TransferRef),
zap.String("source_wallet_ref", source.WalletRef), zap.String("source_wallet_ref", source.WalletRef),
zap.String("network", network.Name), zap.String("network", network.Name),
@@ -162,7 +162,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
input, err := erc20ABI.Pack("transfer", destination, amountInt) input, err := erc20ABI.Pack("transfer", destination, amountInt)
if err != nil { if err != nil {
o.logger.Warn("failed to encode transfer call", o.logger.Warn("Failed to encode transfer call",
zap.String("transfer_ref", transfer.TransferRef), zap.String("transfer_ref", transfer.TransferRef),
zap.Error(err), zap.Error(err),
) )

View File

@@ -49,14 +49,14 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
if rpcURL == "" { if rpcURL == "" {
result.Close() result.Close()
err := merrors.InvalidArgument(fmt.Sprintf("rpc url not configured for network %s", name)) err := merrors.InvalidArgument(fmt.Sprintf("rpc url not configured for network %s", name))
clientLogger.Warn("rpc url missing", zap.String("network", name)) clientLogger.Warn("Rpc url missing", zap.String("network", name))
return nil, err return nil, err
} }
fields := []zap.Field{ fields := []zap.Field{
zap.String("network", name), zap.String("network", name),
} }
clientLogger.Info("initialising rpc client", fields...) clientLogger.Info("Initialising rpc client", fields...)
dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second) dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
httpClient := &http.Client{ httpClient := &http.Client{

View File

@@ -41,7 +41,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
} }
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSigning, "", ""); err != nil { if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSigning, "", ""); err != nil {
s.logger.Warn("failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err)) s.logger.Warn("Failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
} }
driverDeps := s.driverDeps() driverDeps := s.driverDeps()
@@ -81,7 +81,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
} }
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSubmitted, "", txHash); err != nil { if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSubmitted, "", txHash); err != nil {
s.logger.Warn("failed to update transfer status to submitted", zap.String("transfer_ref", transferRef), zap.Error(err)) s.logger.Warn("Failed to update transfer status to submitted", zap.String("transfer_ref", transferRef), zap.Error(err))
} }
receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
@@ -89,20 +89,20 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
receipt, err := chainDriver.AwaitConfirmation(receiptCtx, driverDeps, network, txHash) receipt, err := chainDriver.AwaitConfirmation(receiptCtx, driverDeps, network, txHash)
if err != nil { if err != nil {
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) { if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
s.logger.Warn("failed to await transfer confirmation", zap.String("transfer_ref", transferRef), zap.Error(err)) s.logger.Warn("Failed to await transfer confirmation", zap.String("transfer_ref", transferRef), zap.Error(err))
} }
return err return err
} }
if receipt != nil && receipt.Status == types.ReceiptStatusSuccessful { if receipt != nil && receipt.Status == types.ReceiptStatusSuccessful {
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", txHash); err != nil { if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", txHash); err != nil {
s.logger.Warn("failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err)) s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err))
} }
return nil return nil
} }
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, "transaction reverted", txHash); err != nil { if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, "transaction reverted", txHash); err != nil {
s.logger.Warn("failed to update transfer status to failed", zap.String("transfer_ref", transferRef), zap.Error(err)) s.logger.Warn("Failed to update transfer status to failed", zap.String("transfer_ref", transferRef), zap.Error(err))
} }
return nil return nil
} }

View File

@@ -28,9 +28,10 @@ type ServiceFee struct {
} }
type TransferDestination struct { type TransferDestination struct {
ManagedWalletRef string `bson:"managedWalletRef,omitempty" json:"managedWalletRef,omitempty"` ManagedWalletRef string `bson:"managedWalletRef,omitempty" json:"managedWalletRef,omitempty"`
ExternalAddress string `bson:"externalAddress,omitempty" json:"externalAddress,omitempty"` ExternalAddress string `bson:"externalAddress,omitempty" json:"externalAddress,omitempty"`
Memo string `bson:"memo,omitempty" json:"memo,omitempty"` ExternalAddressOriginal string `bson:"externalAddressOriginal,omitempty" json:"externalAddressOriginal,omitempty"`
Memo string `bson:"memo,omitempty" json:"memo,omitempty"`
} }
// Transfer models an on-chain transfer orchestrated by the gateway. // Transfer models an on-chain transfer orchestrated by the gateway.
@@ -85,7 +86,8 @@ func (t *Transfer) Normalize() {
t.TokenSymbol = strings.TrimSpace(strings.ToUpper(t.TokenSymbol)) t.TokenSymbol = strings.TrimSpace(strings.ToUpper(t.TokenSymbol))
t.ContractAddress = strings.TrimSpace(strings.ToLower(t.ContractAddress)) t.ContractAddress = strings.TrimSpace(strings.ToLower(t.ContractAddress))
t.Destination.ManagedWalletRef = strings.TrimSpace(t.Destination.ManagedWalletRef) t.Destination.ManagedWalletRef = strings.TrimSpace(t.Destination.ManagedWalletRef)
t.Destination.ExternalAddress = strings.TrimSpace(strings.ToLower(t.Destination.ExternalAddress)) t.Destination.ExternalAddress = normalizeWalletAddress(t.Destination.ExternalAddress)
t.Destination.ExternalAddressOriginal = strings.TrimSpace(t.Destination.ExternalAddressOriginal)
t.Destination.Memo = strings.TrimSpace(t.Destination.Memo) t.Destination.Memo = strings.TrimSpace(t.Destination.Memo)
t.ClientReference = strings.TrimSpace(t.ClientReference) t.ClientReference = strings.TrimSpace(t.ClientReference)
} }

View File

@@ -0,0 +1,50 @@
package model
import (
"strings"
"testing"
)
func TestTransferNormalizePreservesBase58ExternalAddress(t *testing.T) {
address := "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF"
transfer := &Transfer{
IdempotencyKey: "idemp",
TransferRef: "ref",
OrganizationRef: "org",
SourceWalletRef: "wallet",
Network: "tron_mainnet",
TokenSymbol: "USDT",
Destination: TransferDestination{
ExternalAddress: address,
ExternalAddressOriginal: address,
},
}
transfer.Normalize()
if transfer.Destination.ExternalAddress != address {
t.Fatalf("expected external address to preserve case, got %q", transfer.Destination.ExternalAddress)
}
if transfer.Destination.ExternalAddressOriginal != address {
t.Fatalf("expected external address original to preserve case, got %q", transfer.Destination.ExternalAddressOriginal)
}
}
func TestTransferNormalizeLowercasesHexExternalAddress(t *testing.T) {
address := "0xAABBCCDDEEFF00112233445566778899AABBCCDD"
transfer := &Transfer{
Destination: TransferDestination{
ExternalAddress: address,
ExternalAddressOriginal: address,
},
}
transfer.Normalize()
if transfer.Destination.ExternalAddress != strings.ToLower(address) {
t.Fatalf("expected hex external address to be lowercased, got %q", transfer.Destination.ExternalAddress)
}
if transfer.Destination.ExternalAddressOriginal != address {
t.Fatalf("expected external address original to preserve case, got %q", transfer.Destination.ExternalAddressOriginal)
}
}

View File

@@ -44,23 +44,23 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
defer cancel() defer cancel()
if err := result.Ping(ctx); err != nil { if err := result.Ping(ctx); err != nil {
result.logger.Error("mongo ping failed during repository initialisation", zap.Error(err)) result.logger.Error("Mongo ping failed during repository initialisation", zap.Error(err))
return nil, err return nil, err
} }
walletsStore, err := store.NewWallets(result.logger, result.db) walletsStore, err := store.NewWallets(result.logger, result.db)
if err != nil { if err != nil {
result.logger.Error("failed to initialise wallets store", zap.Error(err)) result.logger.Error("Failed to initialise wallets store", zap.Error(err))
return nil, err return nil, err
} }
transfersStore, err := store.NewTransfers(result.logger, result.db) transfersStore, err := store.NewTransfers(result.logger, result.db)
if err != nil { if err != nil {
result.logger.Error("failed to initialise transfers store", zap.Error(err)) result.logger.Error("Failed to initialise transfers store", zap.Error(err))
return nil, err return nil, err
} }
depositsStore, err := store.NewDeposits(result.logger, result.db) depositsStore, err := store.NewDeposits(result.logger, result.db)
if err != nil { if err != nil {
result.logger.Error("failed to initialise deposits store", zap.Error(err)) result.logger.Error("Failed to initialise deposits store", zap.Error(err))
return nil, err return nil, err
} }

View File

@@ -48,13 +48,13 @@ func NewDeposits(logger mlogger.Logger, db *mongo.Database) (*Deposits, error) {
} }
for _, def := range indexes { for _, def := range indexes {
if err := repo.CreateIndex(def); err != nil { if err := repo.CreateIndex(def); err != nil {
logger.Error("failed to ensure deposit index", zap.Error(err), zap.String("collection", repo.Collection())) logger.Error("Failed to ensure deposit index", zap.Error(err), zap.String("collection", repo.Collection()))
return nil, err return nil, err
} }
} }
childLogger := logger.Named("deposits") childLogger := logger.Named("deposits")
childLogger.Debug("deposits store initialised") childLogger.Debug("Deposits store initialised")
return &Deposits{logger: childLogger, repo: repo}, nil return &Deposits{logger: childLogger, repo: repo}, nil
} }

View File

@@ -53,13 +53,13 @@ func NewTransfers(logger mlogger.Logger, db *mongo.Database) (*Transfers, error)
} }
for _, def := range indexes { for _, def := range indexes {
if err := repo.CreateIndex(def); err != nil { if err := repo.CreateIndex(def); err != nil {
logger.Error("failed to ensure transfer index", zap.Error(err), zap.String("collection", repo.Collection())) logger.Error("Failed to ensure transfer index", zap.Error(err), zap.String("collection", repo.Collection()))
return nil, err return nil, err
} }
} }
childLogger := logger.Named("transfers") childLogger := logger.Named("transfers")
childLogger.Debug("transfers store initialised") childLogger.Debug("Transfers store initialised")
return &Transfers{ return &Transfers{
logger: childLogger, logger: childLogger,
@@ -89,12 +89,12 @@ func (t *Transfers) Create(ctx context.Context, transfer *model.Transfer) (*mode
} }
if err := t.repo.Insert(ctx, transfer, repository.Filter("idempotencyKey", transfer.IdempotencyKey)); err != nil { if err := t.repo.Insert(ctx, transfer, repository.Filter("idempotencyKey", transfer.IdempotencyKey)); err != nil {
if errors.Is(err, merrors.ErrDataConflict) { if errors.Is(err, merrors.ErrDataConflict) {
t.logger.Debug("transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", transfer.IdempotencyKey)) t.logger.Debug("Transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", transfer.IdempotencyKey))
return transfer, nil return transfer, nil
} }
return nil, err return nil, err
} }
t.logger.Debug("transfer created", zap.String("transfer_ref", transfer.TransferRef)) t.logger.Debug("Transfer created", zap.String("transfer_ref", transfer.TransferRef))
return transfer, nil return transfer, nil
} }
@@ -126,7 +126,7 @@ func (t *Transfers) List(ctx context.Context, filter model.TransferFilter) (*mod
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil { if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
query = query.Comparison(repository.IDField(), builder.Gt, oid) query = query.Comparison(repository.IDField(), builder.Gt, oid)
} else { } else {
t.logger.Warn("ignoring invalid transfer cursor", zap.String("cursor", cursor), zap.Error(err)) t.logger.Warn("Ignoring invalid transfer cursor", zap.String("cursor", cursor), zap.Error(err))
} }
} }

View File

@@ -56,7 +56,7 @@ func NewWallets(logger mlogger.Logger, db *mongo.Database) (*Wallets, error) {
} }
for _, def := range walletIndexes { for _, def := range walletIndexes {
if err := walletRepo.CreateIndex(def); err != nil { if err := walletRepo.CreateIndex(def); err != nil {
logger.Error("failed to ensure wallet index", zap.String("collection", walletRepo.Collection()), zap.Error(err)) logger.Error("Failed to ensure wallet index", zap.String("collection", walletRepo.Collection()), zap.Error(err))
return nil, err return nil, err
} }
} }
@@ -70,13 +70,13 @@ func NewWallets(logger mlogger.Logger, db *mongo.Database) (*Wallets, error) {
} }
for _, def := range balanceIndexes { for _, def := range balanceIndexes {
if err := balanceRepo.CreateIndex(def); err != nil { if err := balanceRepo.CreateIndex(def); err != nil {
logger.Error("failed to ensure wallet balance index", zap.String("collection", balanceRepo.Collection()), zap.Error(err)) logger.Error("Failed to ensure wallet balance index", zap.String("collection", balanceRepo.Collection()), zap.Error(err))
return nil, err return nil, err
} }
} }
childLogger := logger.Named("wallets") childLogger := logger.Named("wallets")
childLogger.Debug("wallet stores initialised") childLogger.Debug("Wallet stores initialised")
return &Wallets{ return &Wallets{
logger: childLogger, logger: childLogger,
@@ -117,13 +117,13 @@ func (w *Wallets) Create(ctx context.Context, wallet *model.ManagedWallet) (*mod
} }
if err := w.walletRepo.Insert(ctx, wallet, repository.Filter("idempotencyKey", wallet.IdempotencyKey)); err != nil { if err := w.walletRepo.Insert(ctx, wallet, repository.Filter("idempotencyKey", wallet.IdempotencyKey)); err != nil {
if errors.Is(err, merrors.ErrDataConflict) { if errors.Is(err, merrors.ErrDataConflict) {
w.logger.Debug("wallet already exists", fields...) w.logger.Debug("Wallet already exists", fields...)
return wallet, nil return wallet, nil
} }
w.logger.Warn("wallet create failed", append(fields, zap.Error(err))...) w.logger.Warn("Wallet create failed", append(fields, zap.Error(err))...)
return nil, err return nil, err
} }
w.logger.Debug("wallet created", fields...) w.logger.Debug("Wallet created", fields...)
return wallet, nil return wallet, nil
} }
@@ -136,11 +136,11 @@ func (w *Wallets) Get(ctx context.Context, walletID string) (*model.ManagedWalle
zap.String("wallet_id", walletID), zap.String("wallet_id", walletID),
} }
wallet := &model.ManagedWallet{} wallet := &model.ManagedWallet{}
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), wallet); err != nil { if err := w.walletRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), wallet); err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
w.logger.Debug("wallet not found", fields...) w.logger.Debug("Wallet not found", fields...)
} else { } else {
w.logger.Warn("wallet lookup failed", append(fields, zap.Error(err))...) w.logger.Warn("Wallet lookup failed", append(fields, zap.Error(err))...)
} }
return nil, err return nil, err
} }
@@ -175,7 +175,7 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*
query = query.Comparison(repository.IDField(), builder.Gt, oid) query = query.Comparison(repository.IDField(), builder.Gt, oid)
fields = append(fields, zap.String("cursor", cursor)) fields = append(fields, zap.String("cursor", cursor))
} else { } else {
w.logger.Warn("ignoring invalid wallet cursor", zap.String("cursor", cursor), zap.Error(err)) w.logger.Warn("Ignoring invalid wallet cursor", zap.String("cursor", cursor), zap.Error(err))
} }
} }
@@ -196,7 +196,7 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*
listErr := w.walletRepo.FindManyByFilter(ctx, query, decoder) listErr := w.walletRepo.FindManyByFilter(ctx, query, decoder)
if listErr != nil && !errors.Is(listErr, merrors.ErrNoData) { if listErr != nil && !errors.Is(listErr, merrors.ErrNoData) {
w.logger.Warn("wallet list failed", append(fields, zap.Error(listErr))...) w.logger.Warn("Wallet list failed", append(fields, zap.Error(listErr))...)
return nil, listErr return nil, listErr
} }
@@ -217,9 +217,9 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*
zap.String("next_cursor", result.NextCursor), zap.String("next_cursor", result.NextCursor),
) )
if errors.Is(listErr, merrors.ErrNoData) { if errors.Is(listErr, merrors.ErrNoData) {
w.logger.Debug("wallet list empty", fields...) w.logger.Debug("Wallet list empty", fields...)
} else { } else {
w.logger.Debug("wallet list fetched", fields...) w.logger.Debug("Wallet list fetched", fields...)
} }
return result, nil return result, nil
} }
@@ -246,20 +246,20 @@ func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance)
existing.PendingOutbound = balance.PendingOutbound existing.PendingOutbound = balance.PendingOutbound
existing.CalculatedAt = balance.CalculatedAt existing.CalculatedAt = balance.CalculatedAt
if err := w.balanceRepo.Update(ctx, existing); err != nil { if err := w.balanceRepo.Update(ctx, existing); err != nil {
w.logger.Warn("wallet balance update failed", append(fields, zap.Error(err))...) w.logger.Warn("Wallet balance update failed", append(fields, zap.Error(err))...)
return err return err
} }
w.logger.Debug("wallet balance updated", fields...) w.logger.Debug("Wallet balance updated", fields...)
return nil return nil
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
if err := w.balanceRepo.Insert(ctx, balance, repository.Filter("walletRef", balance.WalletRef)); err != nil { if err := w.balanceRepo.Insert(ctx, balance, repository.Filter("walletRef", balance.WalletRef)); err != nil {
w.logger.Warn("wallet balance create failed", append(fields, zap.Error(err))...) w.logger.Warn("Wallet balance create failed", append(fields, zap.Error(err))...)
return err return err
} }
w.logger.Debug("wallet balance created", fields...) w.logger.Debug("Wallet balance created", fields...)
return nil return nil
default: default:
w.logger.Warn("wallet balance lookup failed", append(fields, zap.Error(err))...) w.logger.Warn("Wallet balance lookup failed", append(fields, zap.Error(err))...)
return err return err
} }
} }
@@ -273,13 +273,13 @@ func (w *Wallets) GetBalance(ctx context.Context, walletID string) (*model.Walle
balance := &model.WalletBalance{} balance := &model.WalletBalance{}
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), balance); err != nil { if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), balance); err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
w.logger.Debug("wallet balance not found", fields...) w.logger.Debug("Wallet balance not found", fields...)
} else { } else {
w.logger.Warn("wallet balance lookup failed", append(fields, zap.Error(err))...) w.logger.Warn("Wallet balance lookup failed", append(fields, zap.Error(err))...)
} }
return nil, err return nil, err
} }
w.logger.Debug("wallet balance fetched", fields...) w.logger.Debug("Wallet balance fetched", fields...)
return balance, nil return balance, nil
} }

View File

@@ -357,7 +357,7 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
p.emitCardPayoutEvent(state) p.emitCardPayoutEvent(state)
monetix.ObserveCallback(statusLabel) monetix.ObserveCallback(statusLabel)
p.logger.Debug("Monetix payout callback processed", p.logger.Info("Monetix payout callback processed",
zap.String("payout_id", state.GetPayoutId()), zap.String("payout_id", state.GetPayoutId()),
zap.String("status", statusLabel), zap.String("status", statusLabel),
zap.String("provider_code", state.GetProviderCode()), zap.String("provider_code", state.GetProviderCode()),

View File

@@ -164,11 +164,3 @@ func statusLabel(err error) string {
return "error" return "error"
} }
} }
func normalizeCallbackStatus(status string) string {
status = strings.TrimSpace(status)
if status == "" {
return "unknown"
}
return strings.ToLower(status)
}

View File

@@ -313,6 +313,47 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment)
currency := strings.TrimSpace(amount.GetCurrency()) currency := strings.TrimSpace(amount.GetCurrency())
holder := strings.TrimSpace(card.Cardholder) holder := strings.TrimSpace(card.Cardholder)
meta := cloneMetadata(payment.Metadata) meta := cloneMetadata(payment.Metadata)
customer := intent.Customer
customerID := ""
customerFirstName := ""
customerMiddleName := ""
customerLastName := ""
customerIP := ""
customerZip := ""
customerCountry := ""
customerState := ""
customerCity := ""
customerAddress := ""
if customer != nil {
customerID = strings.TrimSpace(customer.ID)
customerFirstName = strings.TrimSpace(customer.FirstName)
customerMiddleName = strings.TrimSpace(customer.MiddleName)
customerLastName = strings.TrimSpace(customer.LastName)
customerIP = strings.TrimSpace(customer.IP)
customerZip = strings.TrimSpace(customer.Zip)
customerCountry = strings.TrimSpace(customer.Country)
customerState = strings.TrimSpace(customer.State)
customerCity = strings.TrimSpace(customer.City)
customerAddress = strings.TrimSpace(customer.Address)
}
if customerFirstName == "" {
customerFirstName = strings.TrimSpace(card.Cardholder)
}
if customerLastName == "" {
customerLastName = strings.TrimSpace(card.CardholderSurname)
}
if customerID == "" {
return merrors.InvalidArgument("card payout: customer id is required")
}
if customerFirstName == "" {
return merrors.InvalidArgument("card payout: customer first name is required")
}
if customerLastName == "" {
return merrors.InvalidArgument("card payout: customer last name is required")
}
if customerIP == "" {
return merrors.InvalidArgument("card payout: customer ip is required")
}
var ( var (
state *mntxv1.CardPayoutState state *mntxv1.CardPayoutState
@@ -320,13 +361,23 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment)
if token := strings.TrimSpace(card.Token); token != "" { if token := strings.TrimSpace(card.Token); token != "" {
req := &mntxv1.CardTokenPayoutRequest{ req := &mntxv1.CardTokenPayoutRequest{
PayoutId: payoutID, PayoutId: payoutID,
AmountMinor: minor, CustomerId: customerID,
Currency: currency, CustomerFirstName: customerFirstName,
CardToken: token, CustomerMiddleName: customerMiddleName,
CardHolder: holder, CustomerLastName: customerLastName,
MaskedPan: strings.TrimSpace(card.MaskedPan), CustomerIp: customerIP,
Metadata: meta, CustomerZip: customerZip,
CustomerCountry: customerCountry,
CustomerState: customerState,
CustomerCity: customerCity,
CustomerAddress: customerAddress,
AmountMinor: minor,
Currency: currency,
CardToken: token,
CardHolder: holder,
MaskedPan: strings.TrimSpace(card.MaskedPan),
Metadata: meta,
} }
resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req) resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req)
if err != nil { if err != nil {
@@ -336,14 +387,24 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment)
state = resp.GetPayout() state = resp.GetPayout()
} else if pan := strings.TrimSpace(card.Pan); pan != "" { } else if pan := strings.TrimSpace(card.Pan); pan != "" {
req := &mntxv1.CardPayoutRequest{ req := &mntxv1.CardPayoutRequest{
PayoutId: payoutID, PayoutId: payoutID,
AmountMinor: minor, CustomerId: customerID,
Currency: currency, CustomerFirstName: customerFirstName,
CardPan: pan, CustomerMiddleName: customerMiddleName,
CardExpYear: card.ExpYear, CustomerLastName: customerLastName,
CardExpMonth: card.ExpMonth, CustomerIp: customerIP,
CardHolder: holder, CustomerZip: customerZip,
Metadata: meta, CustomerCountry: customerCountry,
CustomerState: customerState,
CustomerCity: customerCity,
CustomerAddress: customerAddress,
AmountMinor: minor,
Currency: currency,
CardPan: pan,
CardExpYear: card.ExpYear,
CardExpMonth: card.ExpMonth,
CardHolder: holder,
Metadata: meta,
} }
resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req) resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req)
if err != nil { if err != nil {

View File

@@ -266,6 +266,12 @@ func TestSubmitCardPayout_UsesSettlementAmountAndTransfersFee(t *testing.T) {
}, },
}, },
Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"}, Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"},
Customer: &model.Customer{
ID: "recipient-1",
FirstName: "Stephan",
LastName: "Tester",
IP: "198.51.100.10",
},
}, },
LastQuote: &model.PaymentQuoteSnapshot{ LastQuote: &model.PaymentQuoteSnapshot{
ExpectedSettlementAmount: &moneyv1.Money{Currency: "RUB", Amount: "392.30"}, ExpectedSettlementAmount: &moneyv1.Money{Currency: "RUB", Amount: "392.30"},

View File

@@ -26,6 +26,7 @@ func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent {
FeePolicy: src.GetFeePolicy(), FeePolicy: src.GetFeePolicy(),
SettlementMode: src.GetSettlementMode(), SettlementMode: src.GetSettlementMode(),
Attributes: cloneMetadata(src.GetAttributes()), Attributes: cloneMetadata(src.GetAttributes()),
Customer: customerFromProto(src.GetCustomer()),
} }
if src.GetFx() != nil { if src.GetFx() != nil {
intent.FX = fxIntentFromProto(src.GetFx()) intent.FX = fxIntentFromProto(src.GetFx())
@@ -69,13 +70,14 @@ func endpointFromProto(src *orchestratorv1.PaymentEndpoint) model.PaymentEndpoin
if card := src.GetCard(); card != nil { if card := src.GetCard(); card != nil {
result.Type = model.EndpointTypeCard result.Type = model.EndpointTypeCard
result.Card = &model.CardEndpoint{ result.Card = &model.CardEndpoint{
Pan: strings.TrimSpace(card.GetPan()), Pan: strings.TrimSpace(card.GetPan()),
Token: strings.TrimSpace(card.GetToken()), Token: strings.TrimSpace(card.GetToken()),
Cardholder: strings.TrimSpace(card.GetCardholderName()), Cardholder: strings.TrimSpace(card.GetCardholderName()),
ExpMonth: card.GetExpMonth(), CardholderSurname: strings.TrimSpace(card.GetCardholderSurname()),
ExpYear: card.GetExpYear(), ExpMonth: card.GetExpMonth(),
Country: strings.TrimSpace(card.GetCountry()), ExpYear: card.GetExpYear(),
MaskedPan: strings.TrimSpace(card.GetMaskedPan()), Country: strings.TrimSpace(card.GetCountry()),
MaskedPan: strings.TrimSpace(card.GetMaskedPan()),
} }
return result return result
} }
@@ -161,6 +163,7 @@ func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent
FeePolicy: src.FeePolicy, FeePolicy: src.FeePolicy,
SettlementMode: src.SettlementMode, SettlementMode: src.SettlementMode,
Attributes: cloneMetadata(src.Attributes), Attributes: cloneMetadata(src.Attributes),
Customer: protoCustomerFromModel(src.Customer),
} }
if src.FX != nil { if src.FX != nil {
intent.Fx = protoFXIntentFromModel(src.FX) intent.Fx = protoFXIntentFromModel(src.FX)
@@ -168,6 +171,42 @@ func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent
return intent return intent
} }
func customerFromProto(src *orchestratorv1.Customer) *model.Customer {
if src == nil {
return nil
}
return &model.Customer{
ID: strings.TrimSpace(src.GetId()),
FirstName: strings.TrimSpace(src.GetFirstName()),
MiddleName: strings.TrimSpace(src.GetMiddleName()),
LastName: strings.TrimSpace(src.GetLastName()),
IP: strings.TrimSpace(src.GetIp()),
Zip: strings.TrimSpace(src.GetZip()),
Country: strings.TrimSpace(src.GetCountry()),
State: strings.TrimSpace(src.GetState()),
City: strings.TrimSpace(src.GetCity()),
Address: strings.TrimSpace(src.GetAddress()),
}
}
func protoCustomerFromModel(src *model.Customer) *orchestratorv1.Customer {
if src == nil {
return nil
}
return &orchestratorv1.Customer{
Id: strings.TrimSpace(src.ID),
FirstName: strings.TrimSpace(src.FirstName),
MiddleName: strings.TrimSpace(src.MiddleName),
LastName: strings.TrimSpace(src.LastName),
Ip: strings.TrimSpace(src.IP),
Zip: strings.TrimSpace(src.Zip),
Country: strings.TrimSpace(src.Country),
State: strings.TrimSpace(src.State),
City: strings.TrimSpace(src.City),
Address: strings.TrimSpace(src.Address),
}
}
func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEndpoint { func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEndpoint {
endpoint := &orchestratorv1.PaymentEndpoint{ endpoint := &orchestratorv1.PaymentEndpoint{
Metadata: cloneMetadata(src.Metadata), Metadata: cloneMetadata(src.Metadata),
@@ -204,11 +243,12 @@ func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEn
case model.EndpointTypeCard: case model.EndpointTypeCard:
if src.Card != nil { if src.Card != nil {
card := &orchestratorv1.CardEndpoint{ card := &orchestratorv1.CardEndpoint{
CardholderName: src.Card.Cardholder, CardholderName: src.Card.Cardholder,
ExpMonth: src.Card.ExpMonth, CardholderSurname: src.Card.CardholderSurname,
ExpYear: src.Card.ExpYear, ExpMonth: src.Card.ExpMonth,
Country: src.Card.Country, ExpYear: src.Card.ExpYear,
MaskedPan: src.Card.MaskedPan, Country: src.Card.Country,
MaskedPan: src.Card.MaskedPan,
} }
if pan := strings.TrimSpace(src.Card.Pan); pan != "" { if pan := strings.TrimSpace(src.Card.Pan); pan != "" {
card.Card = &orchestratorv1.CardEndpoint_Pan{Pan: pan} card.Card = &orchestratorv1.CardEndpoint_Pan{Pan: pan}

View File

@@ -11,12 +11,13 @@ func TestEndpointFromProtoCard(t *testing.T) {
protoEndpoint := &orchestratorv1.PaymentEndpoint{ protoEndpoint := &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_Card{ Endpoint: &orchestratorv1.PaymentEndpoint_Card{
Card: &orchestratorv1.CardEndpoint{ Card: &orchestratorv1.CardEndpoint{
Card: &orchestratorv1.CardEndpoint_Pan{Pan: " 411111 "}, Card: &orchestratorv1.CardEndpoint_Pan{Pan: " 411111 "},
CardholderName: " Jane Doe ", CardholderName: " Jane ",
ExpMonth: 12, CardholderSurname: " Doe ",
ExpYear: 2030, ExpMonth: 12,
Country: " US ", ExpYear: 2030,
MaskedPan: " ****1111 ", Country: " US ",
MaskedPan: " ****1111 ",
}, },
}, },
Metadata: map[string]string{"k": "v"}, Metadata: map[string]string{"k": "v"},
@@ -29,7 +30,7 @@ func TestEndpointFromProtoCard(t *testing.T) {
if modelEndpoint.Card == nil { if modelEndpoint.Card == nil {
t.Fatalf("card payload missing") t.Fatalf("card payload missing")
} }
if modelEndpoint.Card.Pan != "411111" || modelEndpoint.Card.Cardholder != "Jane Doe" || modelEndpoint.Card.Country != "US" || modelEndpoint.Card.MaskedPan != "****1111" { if modelEndpoint.Card.Pan != "411111" || modelEndpoint.Card.Cardholder != "Jane" || modelEndpoint.Card.CardholderSurname != "Doe" || modelEndpoint.Card.Country != "US" || modelEndpoint.Card.MaskedPan != "****1111" {
t.Fatalf("card payload not trimmed as expected: %#v", modelEndpoint.Card) t.Fatalf("card payload not trimmed as expected: %#v", modelEndpoint.Card)
} }
if modelEndpoint.Metadata["k"] != "v" { if modelEndpoint.Metadata["k"] != "v" {
@@ -41,12 +42,13 @@ func TestProtoEndpointFromModelCard(t *testing.T) {
modelEndpoint := model.PaymentEndpoint{ modelEndpoint := model.PaymentEndpoint{
Type: model.EndpointTypeCard, Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{ Card: &model.CardEndpoint{
Token: "tok_123", Token: "tok_123",
Cardholder: "Jane", Cardholder: "Jane",
ExpMonth: 1, CardholderSurname: "Doe",
ExpYear: 2028, ExpMonth: 1,
Country: "GB", ExpYear: 2028,
MaskedPan: "****1234", Country: "GB",
MaskedPan: "****1234",
}, },
Metadata: map[string]string{"k": "v"}, Metadata: map[string]string{"k": "v"},
} }
@@ -60,7 +62,7 @@ func TestProtoEndpointFromModelCard(t *testing.T) {
if !ok || token.Token != "tok_123" { if !ok || token.Token != "tok_123" {
t.Fatalf("expected token payload, got %T %#v", card.Card, card.Card) t.Fatalf("expected token payload, got %T %#v", card.Card, card.Card)
} }
if card.GetCardholderName() != "Jane" || card.GetCountry() != "GB" || card.GetMaskedPan() != "****1234" { if card.GetCardholderName() != "Jane" || card.GetCardholderSurname() != "Doe" || card.GetCountry() != "GB" || card.GetMaskedPan() != "****1234" {
t.Fatalf("card details mismatch: %#v", card) t.Fatalf("card details mismatch: %#v", card)
} }
if protoEndpoint.GetMetadata()["k"] != "v" { if protoEndpoint.GetMetadata()["k"] != "v" {

View File

@@ -82,13 +82,14 @@ type ExternalChainEndpoint struct {
// CardEndpoint describes a card payout destination. // CardEndpoint describes a card payout destination.
type CardEndpoint struct { type CardEndpoint struct {
Pan string `bson:"pan,omitempty" json:"pan,omitempty"` Pan string `bson:"pan,omitempty" json:"pan,omitempty"`
Token string `bson:"token,omitempty" json:"token,omitempty"` Token string `bson:"token,omitempty" json:"token,omitempty"`
Cardholder string `bson:"cardholder,omitempty" json:"cardholder,omitempty"` Cardholder string `bson:"cardholder,omitempty" json:"cardholder,omitempty"`
ExpMonth uint32 `bson:"expMonth,omitempty" json:"expMonth,omitempty"` CardholderSurname string `bson:"cardholderSurname,omitempty" json:"cardholderSurname,omitempty"`
ExpYear uint32 `bson:"expYear,omitempty" json:"expYear,omitempty"` ExpMonth uint32 `bson:"expMonth,omitempty" json:"expMonth,omitempty"`
Country string `bson:"country,omitempty" json:"country,omitempty"` ExpYear uint32 `bson:"expYear,omitempty" json:"expYear,omitempty"`
MaskedPan string `bson:"maskedPan,omitempty" json:"maskedPan,omitempty"` Country string `bson:"country,omitempty" json:"country,omitempty"`
MaskedPan string `bson:"maskedPan,omitempty" json:"maskedPan,omitempty"`
} }
// CardPayout stores gateway payout tracking info. // CardPayout stores gateway payout tracking info.
@@ -134,6 +135,21 @@ type PaymentIntent struct {
FeePolicy *feesv1.PolicyOverrides `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"` FeePolicy *feesv1.PolicyOverrides `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"`
SettlementMode orchestratorv1.SettlementMode `bson:"settlementMode,omitempty" json:"settlementMode,omitempty"` SettlementMode orchestratorv1.SettlementMode `bson:"settlementMode,omitempty" json:"settlementMode,omitempty"`
Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"` Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"`
Customer *Customer `bson:"customer,omitempty" json:"customer,omitempty"`
}
// Customer captures payer/recipient identity details for downstream processing.
type Customer struct {
ID string `bson:"id,omitempty" json:"id,omitempty"`
FirstName string `bson:"firstName,omitempty" json:"firstName,omitempty"`
MiddleName string `bson:"middleName,omitempty" json:"middleName,omitempty"`
LastName string `bson:"lastName,omitempty" json:"lastName,omitempty"`
IP string `bson:"ip,omitempty" json:"ip,omitempty"`
Zip string `bson:"zip,omitempty" json:"zip,omitempty"`
Country string `bson:"country,omitempty" json:"country,omitempty"`
State string `bson:"state,omitempty" json:"state,omitempty"`
City string `bson:"city,omitempty" json:"city,omitempty"`
Address string `bson:"address,omitempty" json:"address,omitempty"`
} }
// PaymentQuoteSnapshot stores the latest quote info. // PaymentQuoteSnapshot stores the latest quote info.
@@ -172,8 +188,8 @@ type ExecutionStep struct {
// ExecutionPlan captures the ordered list of steps to execute a payment. // ExecutionPlan captures the ordered list of steps to execute a payment.
type ExecutionPlan struct { type ExecutionPlan struct {
Steps []*ExecutionStep `bson:"steps,omitempty" json:"steps,omitempty"` Steps []*ExecutionStep `bson:"steps,omitempty" json:"steps,omitempty"`
TotalNetworkFee *moneyv1.Money `bson:"totalNetworkFee,omitempty" json:"totalNetworkFee,omitempty"` TotalNetworkFee *moneyv1.Money `bson:"totalNetworkFee,omitempty" json:"totalNetworkFee,omitempty"`
} }
// Payment persists orchestrated payment lifecycle. // Payment persists orchestrated payment lifecycle.
@@ -231,6 +247,18 @@ func (p *Payment) Normalize() {
p.Intent.Attributes[k] = strings.TrimSpace(v) p.Intent.Attributes[k] = strings.TrimSpace(v)
} }
} }
if p.Intent.Customer != nil {
p.Intent.Customer.ID = strings.TrimSpace(p.Intent.Customer.ID)
p.Intent.Customer.FirstName = strings.TrimSpace(p.Intent.Customer.FirstName)
p.Intent.Customer.MiddleName = strings.TrimSpace(p.Intent.Customer.MiddleName)
p.Intent.Customer.LastName = strings.TrimSpace(p.Intent.Customer.LastName)
p.Intent.Customer.IP = strings.TrimSpace(p.Intent.Customer.IP)
p.Intent.Customer.Zip = strings.TrimSpace(p.Intent.Customer.Zip)
p.Intent.Customer.Country = strings.TrimSpace(p.Intent.Customer.Country)
p.Intent.Customer.State = strings.TrimSpace(p.Intent.Customer.State)
p.Intent.Customer.City = strings.TrimSpace(p.Intent.Customer.City)
p.Intent.Customer.Address = strings.TrimSpace(p.Intent.Customer.Address)
}
if p.Execution != nil { if p.Execution != nil {
p.Execution.DebitEntryRef = strings.TrimSpace(p.Execution.DebitEntryRef) p.Execution.DebitEntryRef = strings.TrimSpace(p.Execution.DebitEntryRef)
p.Execution.CreditEntryRef = strings.TrimSpace(p.Execution.CreditEntryRef) p.Execution.CreditEntryRef = strings.TrimSpace(p.Execution.CreditEntryRef)
@@ -293,6 +321,7 @@ func normalizeEndpoint(ep *PaymentEndpoint) {
ep.Card.Pan = strings.TrimSpace(ep.Card.Pan) ep.Card.Pan = strings.TrimSpace(ep.Card.Pan)
ep.Card.Token = strings.TrimSpace(ep.Card.Token) ep.Card.Token = strings.TrimSpace(ep.Card.Token)
ep.Card.Cardholder = strings.TrimSpace(ep.Card.Cardholder) ep.Card.Cardholder = strings.TrimSpace(ep.Card.Cardholder)
ep.Card.CardholderSurname = strings.TrimSpace(ep.Card.CardholderSurname)
ep.Card.Country = strings.TrimSpace(ep.Card.Country) ep.Card.Country = strings.TrimSpace(ep.Card.Country)
ep.Card.MaskedPan = strings.TrimSpace(ep.Card.MaskedPan) ep.Card.MaskedPan = strings.TrimSpace(ep.Card.MaskedPan)
} }

View File

@@ -112,6 +112,20 @@ message PaymentIntent {
fees.v1.PolicyOverrides fee_policy = 7; fees.v1.PolicyOverrides fee_policy = 7;
map<string, string> attributes = 8; map<string, string> attributes = 8;
SettlementMode settlement_mode = 9; SettlementMode settlement_mode = 9;
Customer customer = 10;
}
message Customer {
string id = 1;
string first_name = 2;
string middle_name = 3;
string last_name = 4;
string ip = 5;
string zip = 6;
string country = 7;
string state = 8;
string city = 9;
string address = 10;
} }
message PaymentQuote { message PaymentQuote {

View File

@@ -0,0 +1,15 @@
package srequest
// Customer captures payer/recipient identity details for downstream processing.
type Customer struct {
ID string `json:"id,omitempty"`
FirstName string `json:"first_name,omitempty"`
MiddleName string `json:"middle_name,omitempty"`
LastName string `json:"last_name,omitempty"`
IP string `json:"ip,omitempty"`
Zip string `json:"zip,omitempty"`
Country string `json:"country,omitempty"`
State string `json:"state,omitempty"`
City string `json:"city,omitempty"`
Address string `json:"address,omitempty"`
}

View File

@@ -13,6 +13,7 @@ type PaymentIntent struct {
FX *FXIntent `json:"fx,omitempty"` FX *FXIntent `json:"fx,omitempty"`
SettlementMode SettlementMode `json:"settlement_mode,omitempty"` SettlementMode SettlementMode `json:"settlement_mode,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"` Attributes map[string]string `json:"attributes,omitempty"`
Customer *Customer `json:"customer,omitempty"`
} }
type AssetResolverStub struct{} type AssetResolverStub struct{}

View File

@@ -7,6 +7,7 @@ import (
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
) )
@@ -75,7 +76,8 @@ type paymentQuotesResponse struct {
type paymentsResponse struct { type paymentsResponse struct {
authResponse `json:",inline"` authResponse `json:",inline"`
Payments []Payment `json:"payments"` Payments []Payment `json:"payments"`
Page *paginationv1.CursorPageResponse `json:"page,omitempty"`
} }
type paymentResponse struct { type paymentResponse struct {
@@ -107,6 +109,15 @@ func PaymentsResponse(logger mlogger.Logger, payments []*orchestratorv1.Payment,
}) })
} }
// PaymentsList wraps a list of payments with refreshed access token and pagination data.
func PaymentsListResponse(logger mlogger.Logger, resp *orchestratorv1.ListPaymentsResponse, token *TokenData) http.HandlerFunc {
return response.Ok(logger, paymentsResponse{
Payments: toPayments(resp.GetPayments()),
Page: resp.GetPage(),
authResponse: authResponse{AccessToken: *token},
})
}
// Payment wraps a payment with refreshed access token. // Payment wraps a payment with refreshed access token.
func PaymentResponse(logger mlogger.Logger, payment *orchestratorv1.Payment, token *TokenData) http.HandlerFunc { func PaymentResponse(logger mlogger.Logger, payment *orchestratorv1.Payment, token *TokenData) http.HandlerFunc {
return response.Ok(logger, paymentResponse{ return response.Ok(logger, paymentResponse{

View File

@@ -0,0 +1,25 @@
package paymentapiimp
import (
"net"
"strings"
"github.com/tech/sendico/server/interface/api/srequest"
)
func applyCustomerIP(intent *srequest.PaymentIntent, remoteAddr string) {
if intent == nil {
return
}
ip := strings.TrimSpace(remoteAddr)
if ip == "" {
return
}
if host, _, err := net.SplitHostPort(ip); err == nil && host != "" {
ip = host
}
if intent.Customer == nil {
intent.Customer = &srequest.Customer{}
}
intent.Customer.IP = strings.TrimSpace(ip)
}

View File

@@ -0,0 +1,153 @@
package paymentapiimp
import (
"net/http"
"strconv"
"strings"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
const maxInt32 = int64(1<<31 - 1)
func (a *PaymentAPI) listPayments(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for payments list", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if !allowed {
a.logger.Debug("Access denied when listing payments", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
}
req := &orchestratorv1.ListPaymentsRequest{
Meta: &orchestratorv1.RequestMeta{
OrganizationRef: orgRef.Hex(),
},
}
if page, err := listPaymentsPage(r); err != nil {
return response.Auto(a.logger, a.Name(), err)
} else if page != nil {
req.Page = page
}
query := r.URL.Query()
if sourceRef := strings.TrimSpace(query.Get("source_ref")); sourceRef != "" {
req.SourceRef = sourceRef
}
if destinationRef := strings.TrimSpace(query.Get("destination_ref")); destinationRef != "" {
req.DestinationRef = destinationRef
}
if states, err := parsePaymentStateFilters(r); err != nil {
return response.Auto(a.logger, a.Name(), err)
} else if len(states) > 0 {
req.FilterStates = states
}
resp, err := a.client.ListPayments(ctx, req)
if err != nil {
a.logger.Warn("Failed to list payments", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
return response.Auto(a.logger, a.Name(), err)
}
return sresponse.PaymentsListResponse(a.logger, resp, token)
}
func listPaymentsPage(r *http.Request) (*paginationv1.CursorPageRequest, error) {
query := r.URL.Query()
cursor := strings.TrimSpace(query.Get("cursor"))
limitRaw := strings.TrimSpace(query.Get("limit"))
var limit int64
hasLimit := false
if limitRaw != "" {
parsed, err := strconv.ParseInt(limitRaw, 10, 32)
if err != nil {
return nil, merrors.InvalidArgument("invalid limit", "limit")
}
limit = parsed
hasLimit = true
}
if cursor == "" && !hasLimit {
return nil, nil
}
page := &paginationv1.CursorPageRequest{
Cursor: cursor,
}
if hasLimit {
if limit < 0 {
limit = 0
} else if limit > maxInt32 {
limit = maxInt32
}
page.Limit = int32(limit)
}
return page, nil
}
func parsePaymentStateFilters(r *http.Request) ([]orchestratorv1.PaymentState, error) {
query := r.URL.Query()
values := append([]string{}, query["state"]...)
values = append(values, query["states"]...)
values = append(values, query["filter_states"]...)
if len(values) == 0 {
return nil, nil
}
states := make([]orchestratorv1.PaymentState, 0, len(values))
for _, raw := range values {
for _, part := range strings.Split(raw, ",") {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
state, ok := paymentStateFromString(trimmed)
if !ok {
return nil, merrors.InvalidArgument("unsupported payment state: "+trimmed, "state")
}
states = append(states, state)
}
}
if len(states) == 0 {
return nil, nil
}
return states, nil
}
func paymentStateFromString(value string) (orchestratorv1.PaymentState, bool) {
upper := strings.ToUpper(strings.TrimSpace(value))
if upper == "" {
return 0, false
}
if !strings.HasPrefix(upper, "PAYMENT_STATE_") {
upper = "PAYMENT_STATE_" + upper
}
enumValue, ok := orchestratorv1.PaymentState_value[upper]
if !ok {
return 0, false
}
return orchestratorv1.PaymentState(enumValue), true
}

View File

@@ -50,6 +50,7 @@ func mapPaymentIntent(intent *srequest.PaymentIntent) (*orchestratorv1.PaymentIn
Fx: fx, Fx: fx,
SettlementMode: settlementMode, SettlementMode: settlementMode,
Attributes: copyStringMap(intent.Attributes), Attributes: copyStringMap(intent.Attributes),
Customer: mapCustomer(intent.Customer),
}, nil }, nil
} }
@@ -200,6 +201,24 @@ func mapFXIntent(fx *srequest.FXIntent) (*orchestratorv1.FXIntent, error) {
}, nil }, nil
} }
func mapCustomer(customer *srequest.Customer) *orchestratorv1.Customer {
if customer == nil {
return nil
}
return &orchestratorv1.Customer{
Id: strings.TrimSpace(customer.ID),
FirstName: strings.TrimSpace(customer.FirstName),
MiddleName: strings.TrimSpace(customer.MiddleName),
LastName: strings.TrimSpace(customer.LastName),
Ip: strings.TrimSpace(customer.IP),
Zip: strings.TrimSpace(customer.Zip),
Country: strings.TrimSpace(customer.Country),
State: strings.TrimSpace(customer.State),
City: strings.TrimSpace(customer.City),
Address: strings.TrimSpace(customer.Address),
}
}
func mapCurrencyPair(pair *srequest.CurrencyPair) *fxv1.CurrencyPair { func mapCurrencyPair(pair *srequest.CurrencyPair) *fxv1.CurrencyPair {
if pair == nil { if pair == nil {
return nil return nil

View File

@@ -59,6 +59,7 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to
var intent *orchestratorv1.PaymentIntent var intent *orchestratorv1.PaymentIntent
if payload.Intent != nil { if payload.Intent != nil {
applyCustomerIP(payload.Intent, r.RemoteAddr)
intent, err = mapPaymentIntent(payload.Intent) intent, err = mapPaymentIntent(payload.Intent)
if err != nil { if err != nil {
return response.BadPayload(a.logger, a.Name(), err) return response.BadPayload(a.logger, a.Name(), err)

View File

@@ -44,6 +44,7 @@ func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token
return response.Auto(a.logger, a.Name(), err) return response.Auto(a.logger, a.Name(), err)
} }
applyCustomerIP(&payload.Intent, r.RemoteAddr)
intent, err := mapPaymentIntent(&payload.Intent) intent, err := mapPaymentIntent(&payload.Intent)
if err != nil { if err != nil {
a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r)) a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r))
@@ -97,6 +98,7 @@ func (a *PaymentAPI) quotePayments(r *http.Request, account *model.Account, toke
intents := make([]*orchestratorv1.PaymentIntent, 0, len(payload.Intents)) intents := make([]*orchestratorv1.PaymentIntent, 0, len(payload.Intents))
for i := range payload.Intents { for i := range payload.Intents {
applyCustomerIP(&payload.Intents[i], r.RemoteAddr)
intent, err := mapPaymentIntent(&payload.Intents[i]) intent, err := mapPaymentIntent(&payload.Intents[i])
if err != nil { if err != nil {
a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r)) a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r))

View File

@@ -25,6 +25,7 @@ type paymentClient interface {
QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error)
InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error)
InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error)
Close() error Close() error
} }
@@ -72,6 +73,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/immediate"), api.Post, p.initiateImmediate) apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/immediate"), api.Post, p.initiateImmediate)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-quote"), api.Post, p.initiateByQuote) apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-quote"), api.Post, p.initiateByQuote)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-multiquote"), api.Post, p.initiatePaymentsByQuote) apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-multiquote"), api.Post, p.initiatePaymentsByQuote)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listPayments)
return p, nil return p, nil
} }

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 'payments.g.dart';
@JsonSerializable(explicitToJson: true)
class PaymentsResponse extends BaseAuthorizedResponse {
final List<PaymentDTO> payments;
const PaymentsResponse({required super.accessToken, required this.payments});
factory PaymentsResponse.fromJson(Map<String, dynamic> json) => _$PaymentsResponseFromJson(json);
@override
Map<String, dynamic> toJson() => _$PaymentsResponseToJson(this);
}

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:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/endpoint.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/intent/fx.dart';
import 'package:pshared/data/dto/payment/money.dart'; import 'package:pshared/data/dto/payment/money.dart';
@@ -20,6 +21,7 @@ class PaymentIntentDTO {
final String? settlementMode; final String? settlementMode;
final Map<String, String>? attributes; final Map<String, String>? attributes;
final CustomerDTO? customer;
const PaymentIntentDTO({ const PaymentIntentDTO({
this.kind, this.kind,
@@ -29,6 +31,7 @@ class PaymentIntentDTO {
this.fx, this.fx,
this.settlementMode, this.settlementMode,
this.attributes, this.attributes,
this.customer,
}); });
factory PaymentIntentDTO.fromJson(Map<String, dynamic> json) => _$PaymentIntentDTOFromJson(json); 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/dto/payment/intent/payment.dart';
import 'package:pshared/data/mapper/payment/payment.dart'; import 'package:pshared/data/mapper/payment/payment.dart';
import 'package:pshared/data/mapper/payment/enums.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/intent/fx.dart';
import 'package:pshared/data/mapper/payment/money.dart'; import 'package:pshared/data/mapper/payment/money.dart';
import 'package:pshared/models/payment/intent.dart'; import 'package:pshared/models/payment/intent.dart';
extension PaymentIntentMapper on PaymentIntent { extension PaymentIntentMapper on PaymentIntent {
PaymentIntentDTO toDTO() => PaymentIntentDTO( PaymentIntentDTO toDTO() => PaymentIntentDTO(
kind: paymentKindToValue(kind), kind: paymentKindToValue(kind),
source: source?.toDTO(), source: source?.toDTO(),
destination: destination?.toDTO(), destination: destination?.toDTO(),
amount: amount?.toDTO(), amount: amount?.toDTO(),
fx: fx?.toDTO(), fx: fx?.toDTO(),
settlementMode: settlementModeToValue(settlementMode), settlementMode: settlementModeToValue(settlementMode),
attributes: attributes, attributes: attributes,
); customer: customer?.toDTO(),
);
} }
extension PaymentIntentDTOMapper on PaymentIntentDTO { extension PaymentIntentDTOMapper on PaymentIntentDTO {
PaymentIntent toDomain() => PaymentIntent( PaymentIntent toDomain() => PaymentIntent(
kind: paymentKindFromValue(kind), kind: paymentKindFromValue(kind),
source: source?.toDomain(), source: source?.toDomain(),
destination: destination?.toDomain(), destination: destination?.toDomain(),
amount: amount?.toDomain(), amount: amount?.toDomain(),
fx: fx?.toDomain(), fx: fx?.toDomain(),
settlementMode: settlementModeFromValue(settlementMode), settlementMode: settlementModeFromValue(settlementMode),
attributes: attributes, attributes: attributes,
); customer: customer?.toDomain(),
);
} }

View File

@@ -2,16 +2,25 @@ import 'package:pshared/data/dto/payment/card.dart';
import 'package:pshared/data/dto/payment/card_token.dart'; import 'package:pshared/data/dto/payment/card_token.dart';
import 'package:pshared/data/dto/payment/endpoint.dart'; import 'package:pshared/data/dto/payment/endpoint.dart';
import 'package:pshared/data/dto/payment/external_chain.dart'; import 'package:pshared/data/dto/payment/external_chain.dart';
import 'package:pshared/data/dto/payment/iban.dart';
import 'package:pshared/data/dto/payment/ledger.dart'; import 'package:pshared/data/dto/payment/ledger.dart';
import 'package:pshared/data/dto/payment/managed_wallet.dart'; import 'package:pshared/data/dto/payment/managed_wallet.dart';
import 'package:pshared/data/dto/payment/russian_bank.dart';
import 'package:pshared/data/dto/payment/wallet.dart';
import 'package:pshared/data/mapper/payment/asset.dart'; import 'package:pshared/data/mapper/payment/asset.dart';
import 'package:pshared/data/mapper/payment/iban.dart';
import 'package:pshared/data/mapper/payment/type.dart'; import 'package:pshared/data/mapper/payment/type.dart';
import 'package:pshared/data/mapper/payment/russian_bank.dart';
import 'package:pshared/data/mapper/payment/wallet.dart';
import 'package:pshared/models/payment/methods/card.dart'; import 'package:pshared/models/payment/methods/card.dart';
import 'package:pshared/models/payment/methods/card_token.dart'; import 'package:pshared/models/payment/methods/card_token.dart';
import 'package:pshared/models/payment/methods/crypto_address.dart'; import 'package:pshared/models/payment/methods/crypto_address.dart';
import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/iban.dart';
import 'package:pshared/models/payment/methods/ledger.dart'; import 'package:pshared/models/payment/methods/ledger.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart'; import 'package:pshared/models/payment/methods/managed_wallet.dart';
import 'package:pshared/models/payment/methods/russian_bank.dart';
import 'package:pshared/models/payment/methods/wallet.dart';
import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/payment/type.dart';
@@ -75,8 +84,27 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData {
).toJson(), ).toJson(),
metadata: metadata, metadata: metadata,
); );
default: case PaymentType.iban:
throw UnsupportedError('Unsupported payment endpoint type: $type'); final payload = this as IbanPaymentMethod;
return PaymentEndpointDTO(
type: paymentTypeToValue(type),
data: payload.toDTO().toJson(),
metadata: metadata,
);
case PaymentType.bankAccount:
final payload = this as RussianBankAccountPaymentMethod;
return PaymentEndpointDTO(
type: paymentTypeToValue(type),
data: payload.toDTO().toJson(),
metadata: metadata,
);
case PaymentType.wallet:
final payload = this as WalletPaymentMethod;
return PaymentEndpointDTO(
type: paymentTypeToValue(type),
data: payload.toDTO().toJson(),
metadata: metadata,
);
} }
} }
} }
@@ -126,8 +154,15 @@ extension PaymentEndpointDTOMapper on PaymentEndpointDTO {
maskedPan: payload.maskedPan, maskedPan: payload.maskedPan,
metadata: metadata, metadata: metadata,
); );
default: case PaymentType.iban:
throw UnsupportedError('Unsupported payment endpoint type: ${paymentTypeFromValue(type)}'); final payload = IbanPaymentDataDTO.fromJson(data);
return payload.toDomain();
case PaymentType.bankAccount:
final payload = RussianBankAccountPaymentDataDTO.fromJson(data);
return payload.toDomain();
case PaymentType.wallet:
final payload = WalletPaymentDataDTO.fromJson(data);
return payload.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/fx/intent.dart';
import 'package:pshared/models/payment/kind.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/methods/data.dart';
import 'package:pshared/models/payment/money.dart'; import 'package:pshared/models/payment/money.dart';
import 'package:pshared/models/payment/settlement_mode.dart'; import 'package:pshared/models/payment/settlement_mode.dart';
@@ -13,6 +14,7 @@ class PaymentIntent {
final FxIntent? fx; final FxIntent? fx;
final SettlementMode settlementMode; final SettlementMode settlementMode;
final Map<String, String>? attributes; final Map<String, String>? attributes;
final Customer? customer;
const PaymentIntent({ const PaymentIntent({
this.kind = PaymentKind.unspecified, this.kind = PaymentKind.unspecified,
@@ -22,5 +24,6 @@ class PaymentIntent {
this.fx, this.fx,
this.settlementMode = SettlementMode.unspecified, this.settlementMode = SettlementMode.unspecified,
this.attributes, this.attributes,
this.customer,
}); });
} }

View File

@@ -4,9 +4,11 @@ import 'package:flutter/material.dart';
class PaymentAmountProvider with ChangeNotifier { class PaymentAmountProvider with ChangeNotifier {
double _amount = 10.0; double _amount = 10.0;
bool _payerCoversFee = true; bool _payerCoversFee = true;
bool _isEditing = false;
double get amount => _amount; double get amount => _amount;
bool get payerCoversFee => _payerCoversFee; bool get payerCoversFee => _payerCoversFee;
bool get isEditing => _isEditing;
void setAmount(double value) { void setAmount(double value) {
_amount = value; _amount = value;
@@ -17,4 +19,10 @@ class PaymentAmountProvider with ChangeNotifier {
_payerCoversFee = value; _payerCoversFee = value;
notifyListeners(); notifyListeners();
} }
void setEditing(bool value) {
if (_isEditing == value) return;
_isEditing = value;
notifyListeners();
}
} }

View File

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

@@ -31,7 +31,7 @@ class PaymentProvider extends ChangeNotifier {
Future<Payment?> pay({String? idempotencyKey, Map<String, String>? metadata}) async { Future<Payment?> pay({String? idempotencyKey, Map<String, String>? metadata}) async {
if (!_organization.isOrganizationSet) throw StateError('Organization is not set'); if (!_organization.isOrganizationSet) throw StateError('Organization is not set');
if (!_quotation.isReady) throw StateError('Quotation is not ready'); if (!_quotation.hasLiveQuote) throw StateError('Quotation is not ready');
final quoteRef = _quotation.quotation?.quoteRef; final quoteRef = _quotation.quotation?.quoteRef;
if (quoteRef == null || quoteRef.isEmpty) { if (quoteRef == null || quoteRef.isEmpty) {
throw StateError('Quotation reference is not set'); throw StateError('Quotation reference is not set');

View File

@@ -1,3 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@@ -8,18 +11,23 @@ import 'package:pshared/api/requests/payment/quote.dart';
import 'package:pshared/data/mapper/payment/intent/payment.dart'; import 'package:pshared/data/mapper/payment/intent/payment.dart';
import 'package:pshared/models/asset.dart'; import 'package:pshared/models/asset.dart';
import 'package:pshared/models/payment/currency_pair.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/intent.dart';
import 'package:pshared/models/payment/fx/side.dart'; import 'package:pshared/models/payment/fx/side.dart';
import 'package:pshared/models/payment/kind.dart'; import 'package:pshared/models/payment/kind.dart';
import 'package:pshared/models/payment/methods/managed_wallet.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/money.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/payment/settlement_mode.dart'; import 'package:pshared/models/payment/settlement_mode.dart';
import 'package:pshared/models/payment/intent.dart'; import 'package:pshared/models/payment/intent.dart';
import 'package:pshared/models/payment/quote.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/organizations.dart';
import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/payment/wallets.dart'; import 'package:pshared/provider/payment/wallets.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/resource.dart'; import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/payment/quotation.dart'; import 'package:pshared/service/payment/quotation.dart';
@@ -30,58 +38,270 @@ class QuotationProvider extends ChangeNotifier {
Resource<PaymentQuote> _quotation = Resource(data: null, isLoading: false, error: null); Resource<PaymentQuote> _quotation = Resource(data: null, isLoading: false, error: null);
late OrganizationsProvider _organizations; late OrganizationsProvider _organizations;
bool _isLoaded = false; bool _isLoaded = false;
bool _organizationAttached = false;
PaymentIntent? _pendingIntent;
String? _lastRequestSignature;
Timer? _debounceTimer;
Timer? _expirationTimer;
bool _autoRefreshEnabled = true;
bool _amountEditing = false;
static const _inputDebounce = Duration(milliseconds: 500);
static const _expiryGracePeriod = Duration(seconds: 1);
void update( void update(
OrganizationsProvider venue, OrganizationsProvider venue,
PaymentAmountProvider payment, PaymentAmountProvider payment,
WalletsProvider wallets, WalletsProvider wallets,
PaymentFlowProvider flow, PaymentFlowProvider flow,
RecipientsProvider recipients,
PaymentMethodsProvider methods, PaymentMethodsProvider methods,
) { ) {
_organizations = venue; _organizations = venue;
final t = flow.selectedType; _organizationAttached = true;
final method = methods.methods.firstWhereOrNull((m) => m.type == t); final wasEditing = _amountEditing;
if ((wallets.selectedWallet != null) && (method != null)) { _amountEditing = payment.isEditing;
getQuotation(PaymentIntent( final editingJustEnded = wasEditing && !_amountEditing;
kind: PaymentKind.payout, _pendingIntent = _buildIntent(
amount: Money( payment: payment,
amount: payment.amount.toString(), wallets: wallets,
// TODO: adapt to possible other sources flow: flow,
currency: currencyCodeToString(wallets.selectedWallet!.currency), recipients: recipients,
), methods: methods,
destination: method.data, );
source: ManagedWalletPaymentMethod(
managedWalletRef: wallets.selectedWallet!.id, if (_pendingIntent == null) {
), _reset();
fx: FxIntent( return;
pair: CurrencyPair(
base: currencyCodeToString(wallets.selectedWallet!.currency),
quote: 'RUB', // TODO: exentd target currencies
),
side: FxSide.sellBaseBuyQuote,
),
settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource,
));
} }
if (_amountEditing) {
_debounceTimer?.cancel();
return;
}
if (editingJustEnded) {
refreshNow(force: false);
return;
}
_scheduleQuotationRefresh();
} }
PaymentQuote? get quotation => _quotation.data; PaymentQuote? get quotation => hasQuoteForCurrentIntent ? _quotation.data : null;
bool get isLoading => _quotation.isLoading;
Exception? get error => _quotation.error;
bool get autoRefreshEnabled => _autoRefreshEnabled;
bool get canRequestQuote => _organizationAttached && _pendingIntent != null && _organizations.isOrganizationSet;
bool get isReady => _isLoaded && !_quotation.isLoading && _quotation.error == null; bool get _isExpired {
final remaining = timeToExpire;
return remaining != null && remaining <= Duration.zero;
}
Asset? get fee => quotation == null ? null : createAsset(quotation!.expectedFeeTotal!.currency, quotation!.expectedFeeTotal!.amount); bool get hasQuoteForCurrentIntent {
Asset? get total => quotation == null ? null : createAsset(quotation!.debitAmount!.currency, quotation!.debitAmount!.amount); if (_pendingIntent == null || _lastRequestSignature == null) return false;
Asset? get recipientGets => quotation == null ? null : createAsset(quotation!.expectedSettlementAmount!.currency, quotation!.expectedSettlementAmount!.amount); return _lastRequestSignature == _signature(_pendingIntent!);
}
bool get isReady => _isLoaded && !_isExpired && !_quotation.isLoading && _quotation.error == null;
bool get hasLiveQuote => isReady && quotation != null;
Duration? get timeToExpire {
final expiresAt = _quoteExpiry;
if (expiresAt == null) return null;
final diff = expiresAt.difference(DateTime.now().toUtc());
return diff.isNegative ? Duration.zero : diff;
}
Asset? get fee => quotation == null
? null
: createAsset(
quotation!.expectedFeeTotal!.currency,
quotation!.expectedFeeTotal!.amount,
);
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) { void _setResource(Resource<PaymentQuote> quotation) {
_quotation = quotation; _quotation = quotation;
notifyListeners(); notifyListeners();
} }
Future<PaymentQuote?> getQuotation(PaymentIntent intent) async { void refreshNow({bool force = true}) {
if (!_organizations.isOrganizationSet) throw StateError('Organization is not set'); _debounceTimer?.cancel();
if (!canRequestQuote) {
if (_pendingIntent == null) {
_reset();
}
return;
}
unawaited(_requestQuotation(_pendingIntent!, force: force));
}
void setAutoRefresh(bool enabled) {
if (!_organizationAttached) return;
if (_autoRefreshEnabled == enabled) return;
_autoRefreshEnabled = enabled;
if (_autoRefreshEnabled && (!hasLiveQuote || _isExpired) && _pendingIntent != null) {
unawaited(_requestQuotation(_pendingIntent!, force: true));
} else {
_startExpirationTimer();
notifyListeners();
}
}
@override
void dispose() {
_debounceTimer?.cancel();
_expirationTimer?.cancel();
super.dispose();
}
PaymentIntent? _buildIntent({
required PaymentAmountProvider payment,
required WalletsProvider wallets,
required PaymentFlowProvider flow,
required RecipientsProvider recipients,
required PaymentMethodsProvider methods,
}) {
if (!_organizationAttached || !_organizations.isOrganizationSet) return null;
final type = flow.selectedType;
final method = methods.methods.firstWhereOrNull((m) => m.type == type);
final wallet = wallets.selectedWallet;
if (wallet == null || method == null) return null;
final customer = _buildCustomer(
recipient: recipients.currentObject,
method: method,
);
return PaymentIntent(
kind: PaymentKind.payout,
amount: Money(
amount: payment.amount.toString(),
// TODO: adapt to possible other sources
currency: currencyCodeToString(wallet.currency),
),
destination: method.data,
source: ManagedWalletPaymentMethod(
managedWalletRef: wallet.id,
),
fx: FxIntent(
pair: CurrencyPair(
base: currencyCodeToString(wallet.currency),
quote: 'RUB', // TODO: exentd target currencies
),
side: FxSide.sellBaseBuyQuote,
),
settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource,
customer: customer,
);
}
void _scheduleQuotationRefresh() {
_debounceTimer?.cancel();
if (_pendingIntent == null) {
_reset();
return;
}
_debounceTimer = Timer(_inputDebounce, () {
unawaited(_requestQuotation(_pendingIntent!, force: false));
});
}
Future<PaymentQuote?> _requestQuotation(PaymentIntent intent, {required bool force}) async {
if (!_organizationAttached || !_organizations.isOrganizationSet) {
_reset();
return null;
}
final destinationType = intent.destination?.type;
if (destinationType == PaymentType.bankAccount) {
_setResource(
_quotation.copyWith(
data: null,
isLoading: false,
error: Exception('Unsupported payment endpoint type: $destinationType'),
),
);
return null;
}
final signature = _signature(intent);
final isSameIntent = _lastRequestSignature == signature;
if (!force && isSameIntent && hasLiveQuote) {
_startExpirationTimer();
return _quotation.data;
}
_setResource(_quotation.copyWith(isLoading: true, error: null));
try { try {
_quotation = _quotation.copyWith(isLoading: true, error: null);
final response = await QuotationService.getQuotation( final response = await QuotationService.getQuotation(
_organizations.current.id, _organizations.current.id,
QuotePaymentRequest( QuotePaymentRequest(
@@ -90,7 +310,9 @@ class QuotationProvider extends ChangeNotifier {
), ),
); );
_isLoaded = true; _isLoaded = true;
_lastRequestSignature = signature;
_setResource(_quotation.copyWith(data: response, isLoading: false, error: null)); _setResource(_quotation.copyWith(data: response, isLoading: false, error: null));
_startExpirationTimer();
} catch (e) { } catch (e) {
_setResource(_quotation.copyWith( _setResource(_quotation.copyWith(
data: null, data: null,
@@ -98,13 +320,68 @@ class QuotationProvider extends ChangeNotifier {
isLoading: false, isLoading: false,
)); ));
} }
notifyListeners();
return _quotation.data; return _quotation.data;
} }
void reset() { void _startExpirationTimer() {
_setResource(Resource(data: null, isLoading: false, error: null)); _expirationTimer?.cancel();
final remaining = timeToExpire;
if (remaining == null) return;
final triggerOffset = _autoRefreshEnabled ? _expiryGracePeriod : Duration.zero;
final duration = remaining > triggerOffset ? remaining - triggerOffset : Duration.zero;
_expirationTimer = Timer(duration, () {
if (_autoRefreshEnabled && _pendingIntent != null) {
unawaited(_requestQuotation(_pendingIntent!, force: true));
} else {
notifyListeners();
}
});
}
void _reset() {
_debounceTimer?.cancel();
_expirationTimer?.cancel();
_pendingIntent = null;
_lastRequestSignature = null;
_isLoaded = false; _isLoaded = false;
notifyListeners(); _setResource(Resource(data: null, isLoading: false, error: null));
}
DateTime? get _quoteExpiry {
final expiresAt = quotation?.fxQuote?.expiresAtUnixMs;
if (expiresAt == null) return null;
return DateTime.fromMillisecondsSinceEpoch(expiresAt, isUtc: true);
}
String _signature(PaymentIntent intent) {
try {
return jsonEncode(intent.toDTO().toJson());
} catch (_) {
return jsonEncode({
'kind': intent.kind.toString(),
'source': intent.source?.type.toString(),
'destination': intent.destination?.type.toString(),
'amount': {
'value': intent.amount?.amount,
'currency': intent.amount?.currency,
},
'fx': intent.fx == null
? null
: {
'pair': {
'base': intent.fx?.pair?.base,
'quote': intent.fx?.pair?.quote,
},
'side': intent.fx?.side.toString(),
},
'settlementMode': intent.settlementMode.toString(),
'customer': intent.customer?.id,
});
}
}
void reset() {
_reset();
} }
} }

View File

@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
@@ -38,10 +39,7 @@ class WalletsProvider with ChangeNotifier {
throw Exception('update wallet is not implemented'); throw Exception('update wallet is not implemented');
} }
void selectWallet(Wallet wallet) { void selectWallet(Wallet wallet) => _setSelectedWallet(wallet);
_selectedWallet = wallet;
notifyListeners();
}
Future<void> loadWalletsWithBalances() async { Future<void> loadWalletsWithBalances() async {
_setResource(_resource.copyWith(isLoading: true, error: null)); _setResource(_resource.copyWith(isLoading: true, error: null));
@@ -98,6 +96,25 @@ class WalletsProvider with ChangeNotifier {
void _setResource(Resource<List<Wallet>> newResource) { void _setResource(Resource<List<Wallet>> newResource) {
_resource = 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(); notifyListeners();
} }
} }

View File

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

View File

@@ -14,6 +14,7 @@ class RecipientsProvider extends GenericProvider<Recipient> {
RecipientFilter _selectedFilter = RecipientFilter.all; RecipientFilter _selectedFilter = RecipientFilter.all;
String _query = ''; String _query = '';
String? _previousRecipientRef;
RecipientFilter get selectedFilter => _selectedFilter; RecipientFilter get selectedFilter => _selectedFilter;
String get query => _query; String get query => _query;
@@ -22,6 +23,10 @@ class RecipientsProvider extends GenericProvider<Recipient> {
RecipientsProvider() : super(service: RecipientService.basicService); RecipientsProvider() : super(service: RecipientService.basicService);
Recipient? get previousRecipient => _previousRecipientRef == null
? null
: getItemByRef(_previousRecipientRef!);
List<Recipient> get filteredRecipients { List<Recipient> get filteredRecipients {
List<Recipient> filtered = recipients.where((r) { List<Recipient> filtered = recipients.where((r) {
switch (_selectedFilter) { switch (_selectedFilter) {
@@ -53,6 +58,24 @@ class RecipientsProvider extends GenericProvider<Recipient> {
notifyListeners(); 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({ Future<Recipient> create({
required String name, required String name,
required String email, required String email,

View File

@@ -4,6 +4,7 @@ import 'package:uuid/uuid.dart';
import 'package:pshared/api/requests/payment/initiate.dart'; import 'package:pshared/api/requests/payment/initiate.dart';
import 'package:pshared/api/responses/payment/payment.dart'; import 'package:pshared/api/responses/payment/payment.dart';
import 'package:pshared/api/responses/payment/payments.dart';
import 'package:pshared/data/mapper/payment/payment_response.dart'; import 'package:pshared/data/mapper/payment/payment_response.dart';
import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/service/authorization/service.dart'; import 'package:pshared/service/authorization/service.dart';
@@ -14,6 +15,40 @@ class PaymentService {
static final _logger = Logger('service.payment'); static final _logger = Logger('service.payment');
static const String _objectType = Services.payments; static const String _objectType = Services.payments;
static Future<List<Payment>> list(
String organizationRef, {
int? limit,
String? cursor,
String? sourceRef,
String? destinationRef,
List<String>? states,
}) async {
_logger.fine('Listing payments for organization $organizationRef');
final queryParams = <String, String>{};
if (limit != null) {
queryParams['limit'] = limit.toString();
}
if (cursor != null && cursor.isNotEmpty) {
queryParams['cursor'] = cursor;
}
if (sourceRef != null && sourceRef.isNotEmpty) {
queryParams['source_ref'] = sourceRef;
}
if (destinationRef != null && destinationRef.isNotEmpty) {
queryParams['destination_ref'] = destinationRef;
}
if (states != null && states.isNotEmpty) {
queryParams['state'] = states.join(',');
}
final path = '/$organizationRef';
final url = queryParams.isEmpty
? path
: Uri(path: path, queryParameters: queryParams).toString();
final response = await AuthorizationService.getGETResponse(_objectType, url);
return PaymentsResponse.fromJson(response).payments.map((payment) => payment.toDomain()).toList();
}
static Future<Payment> pay( static Future<Payment> pay(
String organizationRef, String organizationRef,
String quotationRef, { String quotationRef, {

View File

@@ -39,7 +39,7 @@
@monetixSuccess { @monetixSuccess {
path /gateway/m/success* path /gateway/m/success*
method POST method POST
remote_ip 88.218.112.16 88.218.112.16/32 88.218.113.16 88.218.113.16/32 93.179.90.141 93.179.90.128/25 93.179.90.161 93.179.91.0/24 178.57.67.47 178.57.66.128/25 178.57.67.154 178.57.67.0/24 178.57.68.244 # remote_ip 88.218.112.16 88.218.112.16/32 88.218.113.16 88.218.113.16/32 93.179.90.141 93.179.90.128/25 93.179.90.161 93.179.91.0/24 178.57.67.47 178.57.66.128/25 178.57.67.154 178.57.67.0/24 178.57.68.244
} }
handle @monetixSuccess { handle @monetixSuccess {
rewrite * /monetix/callback rewrite * /monetix/callback
@@ -50,7 +50,7 @@
@monetixFail { @monetixFail {
path /gateway/m/fail* path /gateway/m/fail*
method POST method POST
remote_ip 88.218.112.16 88.218.112.16/32 88.218.113.16 88.218.113.16/32 93.179.90.141 93.179.90.128/25 93.179.90.161 93.179.91.0/24 178.57.67.47 178.57.66.128/25 178.57.67.154 178.57.67.0/24 178.57.68.244 # remote_ip 88.218.112.16 88.218.112.16/32 88.218.113.16 88.218.113.16/32 93.179.90.141 93.179.90.128/25 93.179.90.161 93.179.91.0/24 178.57.67.47 178.57.66.128/25 178.57.67.154 178.57.67.0/24 178.57.68.244
} }
handle @monetixFail { handle @monetixFail {
rewrite * /monetix/callback rewrite * /monetix/callback

View File

@@ -383,6 +383,19 @@
"payout": "Payout", "payout": "Payout",
"sendTo": "Send Payout To", "sendTo": "Send Payout To",
"send": "Send Payout", "send": "Send Payout",
"quoteUnavailable": "Waiting for a quote...",
"quoteUpdating": "Refreshing quote...",
"quoteExpiresIn": "Quote expires in {time}",
"@quoteExpiresIn": {
"placeholders": {
"time": {
"type": "String"
}
}
},
"quoteExpired": "Quote expired, request a new one",
"quoteAutoRefresh": "Auto-refresh quote",
"quoteErrorGeneric": "Could not refresh quote, try again later",
"recipientPaysFee": "Recipient pays the fee", "recipientPaysFee": "Recipient pays the fee",
"sentAmount": "Sent amount: {amount}", "sentAmount": "Sent amount: {amount}",

View File

@@ -383,6 +383,19 @@
"payout": "Выплата", "payout": "Выплата",
"sendTo": "Отправить выплату", "sendTo": "Отправить выплату",
"send": "Отправить выплату", "send": "Отправить выплату",
"quoteUnavailable": "Ожидание котировки...",
"quoteUpdating": "Обновляем котировку...",
"quoteExpiresIn": "Котировка истекает через {time}",
"@quoteExpiresIn": {
"placeholders": {
"time": {
"type": "String"
}
}
},
"quoteExpired": "Срок котировки истек, запросите новую",
"quoteAutoRefresh": "Автообновление котировки",
"quoteErrorGeneric": "Не удалось обновить котировку, повторите позже",
"recipientPaysFee": "Получатель оплачивает комиссию", "recipientPaysFee": "Получатель оплачивает комиссию",
"sentAmount": "Отправленная сумма: {amount}", "sentAmount": "Отправленная сумма: {amount}",

View File

@@ -13,15 +13,18 @@ import 'package:pshared/provider/permissions.dart';
import 'package:pshared/provider/account.dart'; import 'package:pshared/provider/account.dart';
import 'package:pshared/provider/organizations.dart'; import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/payment/provider.dart';
import 'package:pshared/provider/payment/quotation.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/payment/wallets.dart'; import 'package:pshared/provider/payment/wallets.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/service/payment/wallets.dart'; import 'package:pshared/service/payment/wallets.dart';
import 'package:pweb/app/app.dart'; import 'package:pweb/app/app.dart';
import 'package:pweb/app/timeago.dart'; import 'package:pweb/app/timeago.dart';
import 'package:pweb/providers/carousel.dart'; import 'package:pweb/providers/carousel.dart';
import 'package:pweb/providers/mock_payment.dart';
import 'package:pweb/providers/operatioins.dart'; import 'package:pweb/providers/operatioins.dart';
import 'package:pweb/providers/two_factor.dart'; import 'package:pweb/providers/two_factor.dart';
import 'package:pweb/providers/upload_history.dart'; import 'package:pweb/providers/upload_history.dart';
@@ -89,8 +92,12 @@ void main() async {
ChangeNotifierProvider( ChangeNotifierProvider(
create: (_) => WalletTransactionsProvider(MockWalletTransactionsService())..load(), create: (_) => WalletTransactionsProvider(MockWalletTransactionsService())..load(),
), ),
ChangeNotifierProvider( ChangeNotifierProxyProvider2<RecipientsProvider, PaymentMethodsProvider, PaymentFlowProvider>(
create: (_) => MockPaymentProvider(), create: (_) => PaymentFlowProvider(initialType: PaymentType.bankAccount),
update: (context, recipients, methods, provider) => provider!..update(
recipients,
methods,
),
), ),
ChangeNotifierProvider( ChangeNotifierProvider(
@@ -99,6 +106,18 @@ void main() async {
ChangeNotifierProvider( ChangeNotifierProvider(
create: (_) => PaymentAmountProvider(), 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(), child: const PayApp(),
), ),

View File

@@ -0,0 +1 @@
enum ButtonState { enabled, disabled, loading }

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/provider/payment/quotation.dart';
import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/currency.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -17,6 +18,7 @@ class PaymentAmountWidget extends StatefulWidget {
class _PaymentAmountWidgetState extends State<PaymentAmountWidget> { class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
late final TextEditingController _controller; late final TextEditingController _controller;
late final FocusNode _focusNode;
bool _isSyncingText = false; bool _isSyncingText = false;
@override @override
@@ -24,10 +26,14 @@ class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
super.initState(); super.initState();
final initialAmount = context.read<PaymentAmountProvider>().amount; final initialAmount = context.read<PaymentAmountProvider>().amount;
_controller = TextEditingController(text: amountToString(initialAmount)); _controller = TextEditingController(text: amountToString(initialAmount));
_focusNode = FocusNode()..addListener(_handleFocusChange);
} }
@override @override
void dispose() { void dispose() {
_focusNode.removeListener(_handleFocusChange);
_focusNode.dispose();
context.read<PaymentAmountProvider>().setEditing(false);
_controller.dispose(); _controller.dispose();
super.dispose(); super.dispose();
} }
@@ -56,6 +62,16 @@ class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
} }
} }
void _handleFocusChange() {
final amountProvider = context.read<PaymentAmountProvider>();
if (_focusNode.hasFocus) {
amountProvider.setEditing(true);
return;
}
amountProvider.setEditing(false);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final amount = context.select<PaymentAmountProvider, double>((provider) => provider.amount); final amount = context.select<PaymentAmountProvider, double>((provider) => provider.amount);
@@ -63,12 +79,14 @@ class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
return TextField( return TextField(
controller: _controller, controller: _controller,
focusNode: _focusNode,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration( decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.amount, labelText: AppLocalizations.of(context)!.amount,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
onChanged: _onChanged, onChanged: _onChanged,
onEditingComplete: () => _focusNode.unfocus(),
); );
} }
} }

View File

@@ -1,24 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/type.dart';
import 'package:pshared/models/payment/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/provider.dart';
import 'package:pshared/provider/payment/quotation.dart'; import 'package:pshared/provider/payment/quotation.dart';
import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/payment_methods/payment_page/body.dart';
import 'package:pshared/provider/payment/wallets.dart'; import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/pages/payment_methods/payment_page/body.dart';
import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/services/posthog.dart'; import 'package:pweb/services/posthog.dart';
@@ -60,30 +53,23 @@ class _PaymentPageState extends State<PaymentPage> {
} }
void _initializePaymentPage() { void _initializePaymentPage() {
final methodsProvider = context.read<PaymentMethodsProvider>(); final flowProvider = context.read<PaymentFlowProvider>();
_handleWalletAutoSelection(methodsProvider); flowProvider.setPreferredType(widget.initialPaymentType);
} }
void _handleSearchChanged(String query) { void _handleSearchChanged(String query) {
context.read<RecipientsProvider>().setQuery(query); context.read<RecipientsProvider>().setQuery(query);
} }
void _handleRecipientSelected(BuildContext context, Recipient recipient) { void _handleRecipientSelected(Recipient recipient) {
final recipientProvider = context.read<RecipientsProvider>(); final recipientProvider = context.read<RecipientsProvider>();
recipientProvider.setCurrentObject(recipient.id); recipientProvider.setCurrentObject(recipient.id);
_clearSearchField(); _clearSearchField();
} }
void _handleRecipientCleared(BuildContext context) { void _handleRecipientCleared() {
final recipientProvider = context.read<RecipientsProvider>(); final recipientProvider = context.read<RecipientsProvider>();
final methodsProvider = context.read<PaymentMethodsProvider>();
recipientProvider.setCurrentObject(null); recipientProvider.setCurrentObject(null);
context.read<PaymentFlowProvider>().reset(
recipient: null,
availableTypes: _availablePaymentTypes(null, methodsProvider),
preferredType: widget.initialPaymentType,
);
_clearSearchField(); _clearSearchField();
} }
@@ -93,106 +79,50 @@ class _PaymentPageState extends State<PaymentPage> {
context.read<RecipientsProvider>().setQuery(''); context.read<RecipientsProvider>().setQuery('');
} }
void _handleSendPayment(BuildContext context) { void _handleSendPayment() {
if (context.read<QuotationProvider>().isReady) { final flowProvider = context.read<PaymentFlowProvider>();
context.read<PaymentProvider>().pay(); final paymentProvider = context.read<PaymentProvider>();
PosthogService.paymentInitiated( final quotationProvider = context.read<QuotationProvider>();
method: context.read<PaymentFlowProvider>().selectedType, if (paymentProvider.isLoading) return;
);
if (!quotationProvider.hasLiveQuote) {
if (quotationProvider.canRequestQuote) {
quotationProvider.refreshNow();
}
return;
} }
paymentProvider.pay().then((_) {
PosthogService.paymentInitiated(method: flowProvider.selectedType);
}).catchError((error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.toString())),
);
});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final methodsProvider = context.watch<PaymentMethodsProvider>(); final methodsProvider = context.watch<PaymentMethodsProvider>();
final recipientProvider = context.watch<RecipientsProvider>(); final recipientProvider = context.read<RecipientsProvider>();
final recipient = recipientProvider.currentObject; final recipient = context.select<RecipientsProvider, Recipient?>(
final availableTypes = _availablePaymentTypes(recipient, methodsProvider); (provider) => provider.currentObject,
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,
); );
return { return PaymentPageBody(
for (final method in methodsForRecipient) method.type: method.data, onBack: widget.onBack,
}; fallbackDestination: widget.fallbackDestination,
} recipient: recipient,
recipientProvider: recipientProvider,
PaymentMethod? _getPaymentMethodForWallet( methodsProvider: methodsProvider,
Wallet wallet, onWalletSelected: context.read<WalletsProvider>().selectWallet,
PaymentMethodsProvider methodsProvider, searchController: _searchController,
) { searchFocusNode: _searchFocusNode,
if (methodsProvider.methods.isEmpty) { onSearchChanged: _handleSearchChanged,
return null; onRecipientSelected: _handleRecipientSelected,
} onRecipientCleared: _handleRecipientCleared,
onSend: _handleSendPayment,
return methodsProvider.methods.firstWhereOrNull(
(method) =>
method.type == PaymentType.wallet &&
(method.description?.contains(wallet.walletUserID) ?? false),
); );
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pweb/pages/dashboard/payouts/form.dart'; import 'package:pweb/pages/dashboard/payouts/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';
import 'package:pweb/pages/payment_methods/payment_page/quote/quote_status.dart';
import 'package:pweb/pages/payment_methods/payment_page/send_button.dart'; import 'package:pweb/pages/payment_methods/payment_page/send_button.dart';
import 'package:pweb/pages/payment_methods/widgets/payment_info_section.dart'; import 'package:pweb/pages/payment_methods/widgets/payment_info_section.dart';
import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart'; import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart';
@@ -26,8 +23,7 @@ class PaymentPageContent extends StatelessWidget {
final ValueChanged<Recipient?>? onBack; final ValueChanged<Recipient?>? onBack;
final Recipient? recipient; final Recipient? recipient;
final RecipientsProvider recipientProvider; final RecipientsProvider recipientProvider;
final PaymentMethodsProvider methodsProvider; final ValueChanged<Wallet> onWalletSelected;
final MethodMap availablePaymentTypes;
final PayoutDestination fallbackDestination; final PayoutDestination fallbackDestination;
final TextEditingController searchController; final TextEditingController searchController;
final FocusNode searchFocusNode; final FocusNode searchFocusNode;
@@ -41,8 +37,7 @@ class PaymentPageContent extends StatelessWidget {
required this.onBack, required this.onBack,
required this.recipient, required this.recipient,
required this.recipientProvider, required this.recipientProvider,
required this.methodsProvider, required this.onWalletSelected,
required this.availablePaymentTypes,
required this.fallbackDestination, required this.fallbackDestination,
required this.searchController, required this.searchController,
required this.searchFocusNode, required this.searchFocusNode,
@@ -55,7 +50,6 @@ class PaymentPageContent extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dimensions = AppDimensions(); final dimensions = AppDimensions();
final flowProvider = context.watch<PaymentFlowProvider>();
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
return Align( return Align(
@@ -84,7 +78,7 @@ class PaymentPageContent extends StatelessWidget {
SectionTitle(loc.sourceOfFunds), SectionTitle(loc.sourceOfFunds),
SizedBox(height: dimensions.paddingSmall), SizedBox(height: dimensions.paddingSmall),
PaymentMethodSelector( PaymentMethodSelector(
onMethodChanged: (m) => methodsProvider.setCurrentObject(m.id), onMethodChanged: onWalletSelected,
), ),
SizedBox(height: dimensions.paddingXLarge), SizedBox(height: dimensions.paddingXLarge),
RecipientSection( RecipientSection(
@@ -98,15 +92,12 @@ class PaymentPageContent extends StatelessWidget {
onRecipientCleared: onRecipientCleared, onRecipientCleared: onRecipientCleared,
), ),
SizedBox(height: dimensions.paddingXLarge), SizedBox(height: dimensions.paddingXLarge),
PaymentInfoSection( PaymentInfoSection(dimensions: dimensions),
dimensions: dimensions,
flowProvider: flowProvider,
recipient: recipient,
availableTypes: availablePaymentTypes,
),
SizedBox(height: dimensions.paddingLarge), SizedBox(height: dimensions.paddingLarge),
const PaymentFormWidget(), const PaymentFormWidget(),
SizedBox(height: dimensions.paddingXXXLarge), SizedBox(height: dimensions.paddingLarge),
const QuoteStatus(),
SizedBox(height: dimensions.paddingXXLarge),
SendButton(onPressed: onSend), SendButton(onPressed: onSend),
SizedBox(height: dimensions.paddingLarge), SizedBox(height: dimensions.paddingLarge),
], ],

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:pweb/utils/dimensions.dart';
class QuoteStatusActions extends StatelessWidget {
final bool isLoading;
final bool canRefresh;
final bool autoRefreshEnabled;
final ValueChanged<bool>? onToggleAutoRefresh;
final VoidCallback? onRefresh;
final String autoRefreshLabel;
final String refreshLabel;
final AppDimensions dimensions;
final TextTheme theme;
const QuoteStatusActions({
super.key,
required this.isLoading,
required this.canRefresh,
required this.autoRefreshEnabled,
required this.onToggleAutoRefresh,
required this.onRefresh,
required this.autoRefreshLabel,
required this.refreshLabel,
required this.dimensions,
required this.theme,
});
@override
Widget build(BuildContext context) => Row(
children: [
Expanded(
child: SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: autoRefreshEnabled,
title: Text(autoRefreshLabel, style: theme.bodyMedium),
onChanged: onToggleAutoRefresh,
),
),
TextButton.icon(
onPressed: canRefresh ? onRefresh : null,
icon: isLoading
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
label: Text(refreshLabel),
),
],
);
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:pweb/utils/dimensions.dart';
class QuoteStatusMessage extends StatelessWidget {
final String statusText;
final String? errorText;
final bool showDetails;
final VoidCallback onToggleDetails;
final AppDimensions dimensions;
final TextTheme theme;
final String showLabel;
final String hideLabel;
const QuoteStatusMessage({
super.key,
required this.statusText,
required this.errorText,
required this.showDetails,
required this.onToggleDetails,
required this.dimensions,
required this.theme,
required this.showLabel,
required this.hideLabel,
});
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(statusText, style: theme.bodyMedium),
if (errorText != null) ...[
SizedBox(height: dimensions.paddingSmall),
TextButton(
onPressed: onToggleDetails,
child: Text(showDetails ? hideLabel : showLabel),
),
if (showDetails) ...[
SizedBox(height: dimensions.paddingSmall),
Text(
errorText!,
style: theme.bodySmall,
),
],
],
],
);
}

View File

@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/quotation.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pweb/pages/payment_methods/payment_page/quote/actions.dart';
import 'package:pweb/pages/payment_methods/payment_page/quote/message.dart';
import 'package:pweb/utils/dimensions.dart';
class QuoteStatus extends StatefulWidget {
const QuoteStatus({super.key});
@override
State<QuoteStatus> createState() => _QuoteStatusState();
}
class _QuoteStatusState extends State<QuoteStatus> {
bool _showDetails = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dimensions = AppDimensions();
final loc = AppLocalizations.of(context)!;
return Consumer<QuotationProvider>(
builder: (context, provider, _) {
final statusText = _statusText(provider, loc);
final canRefresh = provider.canRequestQuote && !provider.isLoading;
final refreshLabel = provider.isLoading ? loc.quoteUpdating : loc.retry;
final error = provider.error;
final backgroundColor = theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3);
return Container(
width: double.infinity,
padding: EdgeInsets.all(dimensions.paddingMedium),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
QuoteStatusMessage(
statusText: statusText,
errorText: error?.toString(),
showDetails: _showDetails,
onToggleDetails: () => setState(() => _showDetails = !_showDetails),
dimensions: dimensions,
theme: theme.textTheme,
showLabel: loc.showDetails,
hideLabel: loc.hideDetails,
),
SizedBox(height: dimensions.paddingSmall),
QuoteStatusActions(
isLoading: provider.isLoading,
canRefresh: canRefresh,
autoRefreshEnabled: provider.autoRefreshEnabled,
onToggleAutoRefresh: provider.canRequestQuote
? (value) => context.read<QuotationProvider>().setAutoRefresh(value)
: null,
onRefresh: canRefresh ? () => context.read<QuotationProvider>().refreshNow() : null,
autoRefreshLabel: loc.quoteAutoRefresh,
refreshLabel: refreshLabel,
dimensions: dimensions,
theme: theme.textTheme,
),
],
),
);
},
);
}
String _statusText(QuotationProvider provider, AppLocalizations loc) {
if (provider.error != null) {
return loc.quoteErrorGeneric;
}
if (!provider.canRequestQuote) {
return loc.quoteUnavailable;
}
if (provider.isLoading) {
return loc.quoteUpdating;
}
if (provider.hasLiveQuote) {
final remaining = provider.timeToExpire;
if (remaining != null) {
return loc.quoteExpiresIn(_formatDuration(remaining));
}
}
if (provider.hasQuoteForCurrentIntent) {
return loc.quoteExpired;
}
return loc.quoteUnavailable;
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes;
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
if (duration.inHours > 0) {
final hours = duration.inHours;
final mins = minutes.remainder(60).toString().padLeft(2, '0');
return '$hours:$mins:$seconds';
}
return '$minutes:$seconds';
}
}

View File

@@ -1,44 +1,76 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/provider.dart';
import 'package:pshared/provider/payment/quotation.dart';
import 'package:pweb/models/button_state.dart';
import 'package:pweb/utils/dimensions.dart'; import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
class SendButton extends StatelessWidget { class SendButton extends StatelessWidget {
final VoidCallback onPressed; final VoidCallback? onPressed;
const SendButton({super.key, required this.onPressed}); const SendButton({
super.key,
required this.onPressed,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => Consumer2<QuotationProvider, PaymentProvider>(
final theme = Theme.of(context); builder: (context, quotation, payment, _) {
final dimensions = AppDimensions(); final theme = Theme.of(context);
final dimensions = AppDimensions();
return Center( final canSend = quotation.hasLiveQuote && !payment.isLoading;
child: SizedBox( final state = payment.isLoading
width: dimensions.buttonWidth, ? ButtonState.loading
height: dimensions.buttonHeight, : (canSend ? ButtonState.enabled : ButtonState.disabled);
child: InkWell( final isLoading = state == ButtonState.loading;
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), final isActive = state == ButtonState.enabled && onPressed != null;
onTap: onPressed,
child: Container( final backgroundColor = isActive
decoration: BoxDecoration( ? theme.colorScheme.primary
color: theme.colorScheme.primary, : theme.colorScheme.primary.withValues(alpha: 0.5);
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), final textColor = theme.colorScheme.onSecondary.withValues(alpha: isActive ? 1 : 0.7);
),
child: Center( return Center(
child: Text( child: SizedBox(
AppLocalizations.of(context)!.send, width: dimensions.buttonWidth,
style: theme.textTheme.bodyLarge?.copyWith( height: dimensions.buttonHeight,
color: theme.colorScheme.onSecondary, child: InkWell(
fontWeight: FontWeight.w600, borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
onTap: isActive ? onPressed : null,
child: Container(
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
),
child: Center(
child: isLoading
? SizedBox(
height: dimensions.iconSizeSmall,
width: dimensions.iconSizeSmall,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(textColor),
),
)
: Text(
AppLocalizations.of(context)!.send,
style: theme.textTheme.bodyLarge?.copyWith(
color: textColor,
fontWeight: FontWeight.w600,
),
),
),
), ),
), ),
), ),
), );
), },
), );
);
}
} }

View File

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

View File

@@ -45,25 +45,44 @@ class RecipientSection extends StatelessWidget {
); );
} }
return Column( return AnimatedBuilder(
crossAxisAlignment: CrossAxisAlignment.start, animation: recipientProvider,
children: [ builder: (context, _) {
SectionTitle(loc.recipient), final previousRecipient = recipientProvider.previousRecipient;
SizedBox(height: dimensions.paddingSmall), final hasQuery = recipientProvider.query.isNotEmpty;
RecipientSearchField(
controller: searchController, return Column(
onChanged: onSearchChanged, crossAxisAlignment: CrossAxisAlignment.start,
focusNode: searchFocusNode, children: [
), SectionTitle(loc.recipient),
if (recipientProvider.query.isNotEmpty) ...[ SizedBox(height: dimensions.paddingSmall),
SizedBox(height: dimensions.paddingMedium), RecipientSearchField(
RecipientSearchResults( controller: searchController,
dimensions: dimensions, onChanged: onSearchChanged,
recipientProvider: recipientProvider, focusNode: searchFocusNode,
onRecipientSelected: onRecipientSelected, ),
), 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

@@ -52,7 +52,7 @@ class WalletTopUpAddressBlock extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
border: Border.all(color: theme.colorScheme.outlineVariant), border: Border.all(color: theme.colorScheme.outlineVariant),
color: theme.colorScheme.surfaceVariant.withOpacity(0.4), color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
), ),
child: SelectableText( child: SelectableText(
address, address,
@@ -72,7 +72,7 @@ class WalletTopUpAddressBlock extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
border: Border.all(color: theme.colorScheme.outlineVariant), border: Border.all(color: theme.colorScheme.outlineVariant),
color: theme.colorScheme.surfaceVariant.withOpacity(0.4), color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
), ),
child: Center( child: Center(
child: QrImageView( child: QrImageView(

View File

@@ -23,7 +23,7 @@ class WalletTopUpInfoChip extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
border: Border.all(color: theme.colorScheme.outlineVariant), border: Border.all(color: theme.colorScheme.outlineVariant),
color: theme.colorScheme.surfaceVariant.withOpacity(0.4), color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

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