Compare commits
17 Commits
aef5c99a22
...
SEND021
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9a605ce21 | ||
|
|
c3ec50c8e4 | ||
|
|
f3ad4c2d4f | ||
| 4aeb06fd31 | |||
|
|
d1786dc5d9 | ||
| f5bf8cf6d0 | |||
| 7daa4ab027 | |||
|
|
6f2309669b | ||
|
|
e4847cd137 | ||
| dbd06a4162 | |||
|
|
1ec6cd8386 | ||
| 6daf567baf | |||
| 23a57e543d | |||
|
|
8adfab94b5 | ||
|
|
db488a31e8 | ||
| 3836ff5ef3 | |||
|
|
63448ab267 |
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ¤cyStore{
|
return ¤cyStore{
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 "esStore{
|
return "esStore{
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
50
api/gateway/chain/storage/model/transfer_test.go
Normal file
50
api/gateway/chain/storage/model/transfer_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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" {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
15
api/server/interface/api/srequest/customer.go
Normal file
15
api/server/interface/api/srequest/customer.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -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{}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
25
api/server/internal/server/paymentapiimp/customer.go
Normal file
25
api/server/internal/server/paymentapiimp/customer.go
Normal 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)
|
||||||
|
}
|
||||||
153
api/server/internal/server/paymentapiimp/list.go
Normal file
153
api/server/internal/server/paymentapiimp/list.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
20
frontend/pshared/lib/api/responses/payment/payments.dart
Normal file
20
frontend/pshared/lib/api/responses/payment/payments.dart
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/api/responses/base.dart';
|
||||||
|
import 'package:pshared/api/responses/token.dart';
|
||||||
|
import 'package:pshared/data/dto/payment/payment.dart';
|
||||||
|
|
||||||
|
part '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);
|
||||||
|
}
|
||||||
41
frontend/pshared/lib/data/dto/payment/intent/customer.dart
Normal file
41
frontend/pshared/lib/data/dto/payment/intent/customer.dart
Normal 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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
frontend/pshared/lib/models/payment/customer.dart
Normal file
25
frontend/pshared/lib/models/payment/customer.dart
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
|
|||||||
1
frontend/pweb/lib/models/button_state.dart
Normal file
1
frontend/pweb/lib/models/button_state.dart
Normal file
@@ -0,0 +1 @@
|
|||||||
|
enum ButtonState { enabled, disabled, loading }
|
||||||
@@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user