Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
645181c65c | ||
|
|
9370eacd9c | ||
|
|
5c255296d3 | ||
|
|
ec4115d083 | ||
|
|
f3ff7fc016 | ||
|
|
658d715c8a | ||
|
|
288a2f0c02 | ||
|
|
4605a197ad | ||
|
|
62df9eac5f | ||
|
|
4b22f6ce12 | ||
|
|
b5c4772168 | ||
|
|
464bd2d565 | ||
|
|
f1ff40d072 | ||
|
|
417e6d96b6 | ||
|
|
c8ef9c3d14 | ||
|
|
3af60f38b1 |
No files matched your search
@@ -75,6 +75,7 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
|
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
|
||||||
|
deps.Auth.Wallet = deps.Payments
|
||||||
logger.Info("Sendico sign-up facts integration enabled")
|
logger.Info("Sendico sign-up facts integration enabled")
|
||||||
}
|
}
|
||||||
records, err := factorstore.Open(logger, db)
|
records, err := factorstore.Open(logger, db)
|
||||||
|
|||||||
@@ -31,8 +31,13 @@ type SignupFacts interface {
|
|||||||
Ready(context.Context, bson.ObjectID) error
|
Ready(context.Context, bson.ObjectID) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CurrencyAccounts interface {
|
||||||
|
Ensure(context.Context, *customer.Account) error
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
Facts SignupFacts
|
Facts SignupFacts
|
||||||
|
Wallet CurrencyAccounts
|
||||||
Accounts account.DB
|
Accounts account.DB
|
||||||
Customers accountservice.AccountService
|
Customers accountservice.AccountService
|
||||||
Tokens verification.DB
|
Tokens verification.DB
|
||||||
@@ -110,6 +115,11 @@ func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key strin
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if s.Wallet != nil {
|
||||||
|
if err := s.Wallet.Ensure(ctx, a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
if raw != "" {
|
if raw != "" {
|
||||||
u, _ := url.Parse(s.ActivationURL)
|
u, _ := url.Parse(s.ActivationURL)
|
||||||
q := u.Query()
|
q := u.Query()
|
||||||
@@ -148,6 +158,11 @@ func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if s.Wallet != nil {
|
||||||
|
if err := s.Wallet.Ensure(ctx, a); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
_, err = s.sendActivation(ctx, a, key)
|
_, err = s.sendActivation(ctx, a, key)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,17 @@ func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type signupWallet struct {
|
||||||
|
customers []bson.ObjectID
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *signupWallet) Ensure(_ context.Context, a *customer.Account) error {
|
||||||
|
s.customers = append(s.customers, a.ID)
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -102,6 +113,8 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
provider := &signupProvider{fail: true}
|
provider := &signupProvider{fail: true}
|
||||||
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()}
|
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()}
|
||||||
|
wallet := &signupWallet{err: merrors.ServiceUnavailable("account setup temporarily unavailable")}
|
||||||
|
service.Wallet = wallet
|
||||||
// Keep the real verification/customer stores and capture only session writes.
|
// Keep the real verification/customer stores and capture only session writes.
|
||||||
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
||||||
records, err := factorstore.Open(zap.NewNop(), factory)
|
records, err := factorstore.Open(zap.NewNop(), factory)
|
||||||
@@ -133,6 +146,14 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
signup["name"] = "Changed retry name"
|
signup["name"] = "Changed retry name"
|
||||||
provider.fail = false
|
provider.fail = false
|
||||||
w = post("signup", signup)
|
w = post("signup", signup)
|
||||||
|
require.Equal(t, 503, w.Code, w.Body.String())
|
||||||
|
require.Empty(t, mail.messages)
|
||||||
|
require.Len(t, wallet.customers, 1)
|
||||||
|
w = post("verification/send", map[string]string{"email": "user@example.com", "idempotencyKey": "wallet-retry"})
|
||||||
|
require.Equal(t, 503, w.Code, w.Body.String())
|
||||||
|
require.Empty(t, mail.messages)
|
||||||
|
wallet.err = nil
|
||||||
|
w = post("signup", signup)
|
||||||
require.Equal(t, 202, w.Code, w.Body.String())
|
require.Equal(t, 202, w.Code, w.Body.String())
|
||||||
require.Equal(t, 1, provider.registrations)
|
require.Equal(t, 1, provider.registrations)
|
||||||
require.Equal(t, "Customer", provider.last.FullName)
|
require.Equal(t, "Customer", provider.last.FullName)
|
||||||
@@ -149,6 +170,7 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.False(t, a.IsActive())
|
require.False(t, a.IsActive())
|
||||||
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
|
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
|
||||||
|
require.Equal(t, []bson.ObjectID{a.ID, a.ID, a.ID}, wallet.customers)
|
||||||
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
||||||
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
||||||
require.Equal(t, 403, post("login", login).Code)
|
require.Equal(t, 403, post("login", login).Code)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var accountSetup = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_currency_accounts_setup_total", Help: "Default currency account setup attempts, including checks of existing accounts."}, []string{"outcome"})
|
||||||
|
var accountSetupDuration = promauto.NewHistogram(prometheus.HistogramOpts{Name: "sxchnge_currency_accounts_setup_duration_seconds", Help: "Time to confirm all default currency accounts exist."})
|
||||||
|
|
||||||
|
func (s *Service) Ensure(ctx context.Context, a *customer.Account) error {
|
||||||
|
_, err := s.ensureAccounts(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.ServiceUnavailableWrap(err, "currency account setup unavailable")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureAccounts(ctx context.Context, a *customer.Account) (subject string, result error) {
|
||||||
|
started := time.Now()
|
||||||
|
defer func() {
|
||||||
|
outcome := "ready"
|
||||||
|
if result != nil {
|
||||||
|
outcome = "failed"
|
||||||
|
}
|
||||||
|
accountSetup.WithLabelValues(outcome).Inc()
|
||||||
|
accountSetupDuration.Observe(time.Since(started).Seconds())
|
||||||
|
}()
|
||||||
|
if s == nil || s.Identity == nil || s.Provider == nil {
|
||||||
|
return "", Unavailable
|
||||||
|
}
|
||||||
|
if a == nil || a.ID.IsZero() {
|
||||||
|
return "", NotFound
|
||||||
|
}
|
||||||
|
subject, result = s.Identity.Subject(ctx, a)
|
||||||
|
if result != nil {
|
||||||
|
return "", result
|
||||||
|
}
|
||||||
|
if result = s.Provider.EnsureAccounts(ctx, subject); result != nil {
|
||||||
|
s.Logger.Warn("Currency account setup incomplete", zap.String("account_ref", a.ID.Hex()), zap.Error(result))
|
||||||
|
return "", result
|
||||||
|
}
|
||||||
|
return subject, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSignupAndWalletReadEnsureCustomerCurrencyAccounts(t *testing.T) {
|
||||||
|
a := &customer.Account{}
|
||||||
|
a.ID = bson.NewObjectID()
|
||||||
|
p := &provider{namespace: "provider"}
|
||||||
|
s := &Service{Provider: p, Identity: identity{}, Logger: zap.NewNop(), Factors: &factors{}, Store: &memoryStore{records: map[bson.ObjectID]Record{}}}
|
||||||
|
require.NoError(t, s.Ensure(t.Context(), a))
|
||||||
|
require.Equal(t, "subject", p.setupSubject)
|
||||||
|
snapshot, err := s.Fetch(t.Context(), a)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshot.Accounts, 1)
|
||||||
|
require.Equal(t, 2, p.setupCalls)
|
||||||
|
p.setupErr = Unavailable
|
||||||
|
_, err = s.Fetch(t.Context(), a)
|
||||||
|
require.ErrorIs(t, err, Unavailable)
|
||||||
|
require.Error(t, s.Ensure(t.Context(), a))
|
||||||
|
require.Error(t, s.Ensure(t.Context(), nil))
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ func (*Record) Collection() string { return "payment_quotes" }
|
|||||||
|
|
||||||
type Provider interface {
|
type Provider interface {
|
||||||
Namespace() string
|
Namespace() string
|
||||||
|
EnsureAccounts(context.Context, string) error
|
||||||
Accounts(context.Context, string) ([]Account, error)
|
Accounts(context.Context, string) ([]Account, error)
|
||||||
Quote(context.Context, string, Request, []Account) (Quote, error)
|
Quote(context.Context, string, Request, []Account) (Quote, error)
|
||||||
Execute(context.Context, string, string) (RemoteOperation, error)
|
Execute(context.Context, string, string) (RemoteOperation, error)
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ func (s *Service) Fetch(ctx context.Context, a *customer.Account) (Snapshot, err
|
|||||||
if err := s.ready(); err != nil {
|
if err := s.ready(); err != nil {
|
||||||
return Snapshot{}, err
|
return Snapshot{}, err
|
||||||
}
|
}
|
||||||
subject, err := s.Identity.Subject(ctx, a)
|
subject, err := s.ensureAccounts(ctx, a)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Snapshot{}, err
|
return Snapshot{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,15 +76,25 @@ func (s *memoryStore) Attach(_ context.Context, r *Record, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type provider struct {
|
type provider struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
accepted int
|
accepted int
|
||||||
lost bool
|
lost bool
|
||||||
status string
|
status string
|
||||||
key string
|
key string
|
||||||
namespace string
|
namespace string
|
||||||
|
setupCalls int
|
||||||
|
setupSubject string
|
||||||
|
setupErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *provider) Namespace() string { return p.namespace }
|
func (p *provider) Namespace() string { return p.namespace }
|
||||||
|
func (p *provider) EnsureAccounts(_ context.Context, subject string) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.setupCalls++
|
||||||
|
p.setupSubject = subject
|
||||||
|
return p.setupErr
|
||||||
|
}
|
||||||
func (p *provider) Accounts(context.Context, string) ([]Account, error) {
|
func (p *provider) Accounts(context.Context, string) ([]Account, error) {
|
||||||
return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil
|
return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/payments"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) validCustomerAccount(a accountDTO, subject string) bool {
|
||||||
|
return validRef(a.ID) && a.ScopeRef == c.config.OrganizationID && a.Owner == "" &&
|
||||||
|
a.Type == "ACCOUNT_TYPE_ASSET" && a.LedgerRole == "ACCOUNT_ROLE_OPERATING" &&
|
||||||
|
a.Status == "ACCOUNT_STATUS_ACTIVE" && !a.AllowNegative && a.Metadata["sxchnge_subject_ref"] == subject &&
|
||||||
|
(a.Metadata["external_subject_ref"] == "" || a.Metadata["external_subject_ref"] == subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureAccounts also repairs older customers, using provider-enforced
|
||||||
|
// organization/subject/currency uniqueness for safe retries after lost responses.
|
||||||
|
func (c *Client) EnsureAccounts(ctx context.Context, subject string) error {
|
||||||
|
if !validRef(subject) {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
root := "/ledger_accounts/" + c.config.OrganizationID
|
||||||
|
var data accountListDTO
|
||||||
|
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
existing := map[string]bool{}
|
||||||
|
reconcile := map[string]bool{}
|
||||||
|
for _, a := range data.Accounts {
|
||||||
|
if a.Metadata["sxchnge_subject_ref"] != subject && a.Metadata["external_subject_ref"] != subject {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !c.validCustomerAccount(a, subject) || existing[a.Currency] {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
existing[a.Currency] = true
|
||||||
|
reconcile[a.Currency] = a.Metadata["external_subject_ref"] == subject
|
||||||
|
}
|
||||||
|
missing := []string{}
|
||||||
|
for _, code := range []string{"RUB", "USDT", "USDC"} {
|
||||||
|
if !existing[code] || reconcile[code] {
|
||||||
|
missing = append(missing, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Older providers can accept the metadata but cannot prevent duplicates.
|
||||||
|
// Require the explicit capability before issuing the first create.
|
||||||
|
if !data.ExternalSubjectAccounts {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
var catalog struct {
|
||||||
|
Rows []currencyDTO `json:"currencies"`
|
||||||
|
}
|
||||||
|
if err := c.paymentRequest(ctx, http.MethodGet, "/payments/fx/currencies/"+c.config.OrganizationID, nil, &catalog); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, code := range missing {
|
||||||
|
found := false
|
||||||
|
for _, currency := range catalog.Rows {
|
||||||
|
if currency.Code == code && currency.Scale != nil && *currency.Scale >= 0 && *currency.Scale <= 8 {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return merrors.OpaqueWrap(payments.Unavailable, payments.Unsupported, "default currency missing from provider catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Replaying a bound create also resumes Sendico's durable wallet/route setup
|
||||||
|
// if a previous response failed after the ledger itself had been committed.
|
||||||
|
for _, code := range missing {
|
||||||
|
var response struct {
|
||||||
|
Account accountDTO `json:"account"`
|
||||||
|
}
|
||||||
|
request := createAccountDTO{Currency: code, Type: "asset", LedgerRole: "operating", Metadata: map[string]string{"external_subject_ref": subject, "sxchnge_subject_ref": subject}}
|
||||||
|
if err := c.paymentRequest(ctx, http.MethodPost, root, request, &response, http.StatusCreated); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !c.validCustomerAccount(response.Account, subject) || response.Account.Currency != code || response.Account.Metadata["external_subject_ref"] != subject {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
type accountServer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
rows []accountDTO
|
||||||
|
posts []string
|
||||||
|
capable bool
|
||||||
|
missingCurrency bool
|
||||||
|
failCurrency string
|
||||||
|
loseResponse bool
|
||||||
|
wrongResponse bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func customerLedger(code string) accountDTO {
|
||||||
|
return accountDTO{ID: bson.NewObjectID().Hex(), ScopeRef: org, Currency: code, Type: "ACCOUNT_TYPE_ASSET", LedgerRole: "ACCOUNT_ROLE_OPERATING", Status: "ACCOUNT_STATUS_ACTIVE", Metadata: map[string]string{"sxchnge_subject_ref": subject, "external_subject_ref": subject}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *accountServer) serve(t *testing.T) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
require.NotEmpty(t, r.Header.Get("Authorization"))
|
||||||
|
var data any
|
||||||
|
switch {
|
||||||
|
case strings.Contains(r.URL.Path, "/currencies/"):
|
||||||
|
rows := []map[string]any{{"code": "RUB", "accountingScale": 2}, {"code": "USDT", "accountingScale": 6}, {"code": "USDC", "accountingScale": 6}}
|
||||||
|
if s.missingCurrency {
|
||||||
|
rows = rows[:2]
|
||||||
|
}
|
||||||
|
data = map[string]any{"currencies": rows}
|
||||||
|
case r.Method == http.MethodPost:
|
||||||
|
var request createAccountDTO
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
|
||||||
|
require.Equal(t, "asset", request.Type)
|
||||||
|
require.Equal(t, "operating", request.LedgerRole)
|
||||||
|
require.False(t, request.AllowNegative)
|
||||||
|
require.Equal(t, subject, request.Metadata["external_subject_ref"])
|
||||||
|
require.Equal(t, subject, request.Metadata["sxchnge_subject_ref"])
|
||||||
|
s.posts = append(s.posts, request.Currency)
|
||||||
|
if s.failCurrency == request.Currency {
|
||||||
|
s.failCurrency = ""
|
||||||
|
w.WriteHeader(503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var row accountDTO
|
||||||
|
for _, a := range s.rows {
|
||||||
|
if a.Currency == request.Currency && a.Metadata["external_subject_ref"] == subject {
|
||||||
|
row = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row.ID == "" {
|
||||||
|
row = customerLedger(request.Currency)
|
||||||
|
s.rows = append(s.rows, row)
|
||||||
|
}
|
||||||
|
if s.loseResponse {
|
||||||
|
s.loseResponse = false
|
||||||
|
w.WriteHeader(503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.wrongResponse {
|
||||||
|
row.Currency = "EUR"
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
data = map[string]any{"account": row}
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/balance"):
|
||||||
|
parts := strings.Split(r.URL.Path, "/")
|
||||||
|
data = map[string]any{"balance": balanceDTO{ID: parts[len(parts)-2], Money: moneyDTO{Amount: "45.00", Currency: "RUB"}}}
|
||||||
|
default:
|
||||||
|
data = map[string]any{"accounts": s.rows, "externalSubjectAccounts": s.capable}
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"status": "success", "data": data}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureCurrencyAccountsPreservesExistingAndRecoversPartialFailure(t *testing.T) {
|
||||||
|
for _, lost := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "partial failure", true: "lost response"}[lost], func(t *testing.T) {
|
||||||
|
s := &accountServer{capable: true, failCurrency: "USDT", loseResponse: lost}
|
||||||
|
if lost {
|
||||||
|
s.failCurrency = ""
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Error(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Len(t, s.rows, 1)
|
||||||
|
firstID := s.rows[0].ID
|
||||||
|
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Len(t, s.rows, 3)
|
||||||
|
require.Equal(t, firstID, s.rows[0].ID)
|
||||||
|
// Bound creates are replayed to finish interrupted wallet/route setup;
|
||||||
|
// the provider must return the original ledger each time.
|
||||||
|
require.Equal(t, 3, countCurrency(s.posts, "RUB"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func countCurrency(rows []string, code string) int {
|
||||||
|
n := 0
|
||||||
|
for _, r := range rows {
|
||||||
|
if r == code {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAccountsConcurrentRequestsUseStableSubjectBinding(t *testing.T) {
|
||||||
|
s := &accountServer{capable: true}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
errors := make(chan error, 12)
|
||||||
|
for range 12 {
|
||||||
|
go func() { errors <- c.EnsureAccounts(t.Context(), subject) }()
|
||||||
|
}
|
||||||
|
for range 12 {
|
||||||
|
require.NoError(t, <-errors)
|
||||||
|
}
|
||||||
|
require.Len(t, s.rows, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAccountsRejectsUnsafeProviderStateBeforeCreating(t *testing.T) {
|
||||||
|
for _, kind := range []string{"old provider", "missing currency", "frozen", "owned", "negative", "duplicate", "wrong subject"} {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
s := &accountServer{capable: true}
|
||||||
|
a := customerLedger("RUB")
|
||||||
|
switch kind {
|
||||||
|
case "old provider":
|
||||||
|
s.capable = false
|
||||||
|
case "missing currency":
|
||||||
|
s.missingCurrency = true
|
||||||
|
case "frozen":
|
||||||
|
a.Status = "ACCOUNT_STATUS_FROZEN"
|
||||||
|
s.rows = []accountDTO{a}
|
||||||
|
case "owned":
|
||||||
|
a.Owner = source
|
||||||
|
s.rows = []accountDTO{a}
|
||||||
|
case "negative":
|
||||||
|
a.AllowNegative = true
|
||||||
|
s.rows = []accountDTO{a}
|
||||||
|
case "duplicate":
|
||||||
|
s.rows = []accountDTO{a, customerLedger("RUB")}
|
||||||
|
case "wrong subject":
|
||||||
|
a.Metadata["sxchnge_subject_ref"] = source
|
||||||
|
s.rows = []accountDTO{a}
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Error(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Empty(t, s.posts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAccountsValidatesCreateReceipt(t *testing.T) {
|
||||||
|
s := &accountServer{capable: true, wrongResponse: true}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Error(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Len(t, s.posts, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAccountsKeepsExistingBindingsAndBalances(t *testing.T) {
|
||||||
|
a := customerLedger("RUB")
|
||||||
|
delete(a.Metadata, "external_subject_ref")
|
||||||
|
s := &accountServer{capable: true, rows: []accountDTO{a}}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Equal(t, []string{"USDT", "USDC"}, s.posts)
|
||||||
|
// A subsequent read uses the original RUB ledger and its authoritative funds.
|
||||||
|
s.rows = s.rows[:1]
|
||||||
|
accounts, err := c.Accounts(t.Context(), subject)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, accounts, 1)
|
||||||
|
require.Equal(t, a.ID, accounts[0].ID)
|
||||||
|
require.EqualValues(t, 4500, accounts[0].Available)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
type accountListDTO struct {
|
||||||
|
Accounts []accountDTO `json:"accounts"`
|
||||||
|
ExternalSubjectAccounts bool `json:"externalSubjectAccounts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createAccountDTO struct {
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Type string `json:"accountType"`
|
||||||
|
LedgerRole string `json:"role"`
|
||||||
|
AllowNegative bool `json:"allowNegative"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
}
|
||||||
@@ -27,9 +27,7 @@ func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Accou
|
|||||||
scales[row.Code] = *row.Scale
|
scales[row.Code] = *row.Scale
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var data struct {
|
var data accountListDTO
|
||||||
Accounts []accountDTO `json:"accounts"`
|
|
||||||
}
|
|
||||||
root := "/ledger_accounts/" + c.config.OrganizationID
|
root := "/ledger_accounts/" + c.config.OrganizationID
|
||||||
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
|
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -39,7 +37,7 @@ func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Accou
|
|||||||
if row.Metadata["sxchnge_subject_ref"] != subject {
|
if row.Metadata["sxchnge_subject_ref"] != subject {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !validRef(row.ID) || row.ScopeRef != c.config.OrganizationID || row.Owner != "" || row.Status != "ACCOUNT_STATUS_ACTIVE" || row.Type != "ACCOUNT_TYPE_ASSET" || row.LedgerRole != "ACCOUNT_ROLE_OPERATING" {
|
if !c.validCustomerAccount(row, subject) {
|
||||||
return nil, payments.Unavailable
|
return nil, payments.Unavailable
|
||||||
}
|
}
|
||||||
scale, ok := scales[row.Currency]
|
scale, ok := scales[row.Currency]
|
||||||
|
|||||||
@@ -54,14 +54,15 @@ type paymentDTO struct {
|
|||||||
Created time.Time `json:"createdAt"`
|
Created time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
type accountDTO struct {
|
type accountDTO struct {
|
||||||
ID string `json:"ledgerAccountRef"`
|
ID string `json:"ledgerAccountRef"`
|
||||||
ScopeRef string `json:"organizationRef"`
|
ScopeRef string `json:"organizationRef"`
|
||||||
Owner string `json:"ownerRef"`
|
Owner string `json:"ownerRef"`
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
Type string `json:"accountType"`
|
Type string `json:"accountType"`
|
||||||
LedgerRole string `json:"role"`
|
LedgerRole string `json:"role"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Metadata map[string]string `json:"metadata"`
|
AllowNegative bool `json:"allowNegative"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
}
|
}
|
||||||
type currencyDTO struct {
|
type currencyDTO struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
@@ -72,7 +73,7 @@ type balanceDTO struct {
|
|||||||
Money moneyDTO `json:"balance"`
|
Money moneyDTO `json:"balance"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any) error {
|
func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any, accepted ...int) error {
|
||||||
token, err := c.token()
|
token, err := c.token()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -98,7 +99,11 @@ func (c *Client) paymentRequest(ctx context.Context, method, path string, payloa
|
|||||||
}
|
}
|
||||||
defer func() { _ = res.Body.Close() }()
|
defer func() { _ = res.Body.Close() }()
|
||||||
// Never forward upstream errors, credentials, or arbitrary error bodies.
|
// Never forward upstream errors, credentials, or arbitrary error bodies.
|
||||||
if res.StatusCode != http.StatusOK {
|
expected := http.StatusOK
|
||||||
|
if len(accepted) != 0 {
|
||||||
|
expected = accepted[0]
|
||||||
|
}
|
||||||
|
if res.StatusCode != expected {
|
||||||
return payments.Unavailable
|
return payments.Unavailable
|
||||||
}
|
}
|
||||||
var envelope struct {
|
var envelope struct {
|
||||||
|
|||||||
@@ -130,8 +130,10 @@ This recovery retains the configured provider URL, account and replay namespace.
|
|||||||
layout and least-privilege identities, Mongo transactions/lease rotation, and
|
layout and least-privilege identities, Mongo transactions/lease rotation, and
|
||||||
authenticated Sendico currency and ledger reads through the same loopback
|
authenticated Sendico currency and ledger reads through the same loopback
|
||||||
connection and token used by BFF. It sends no email and executes no payment.
|
connection and token used by BFF. It sends no email and executes no payment.
|
||||||
An empty wallet is expected until dedicated subject-bound currency ledgers and
|
Signup and wallet access automatically ensure subject-bound RUB, USDT and USDC
|
||||||
saved methods are provisioned as described in [payment provisioning](payments.md).
|
ledgers as described in [account setup](payments.md#automatic-currency-accounts-72).
|
||||||
|
Deploy the provider prerequisite and grant ledger-create permission first.
|
||||||
|
Saved funding and payout methods remain separate from currency-account setup.
|
||||||
|
|
||||||
## Recovery
|
## Recovery
|
||||||
|
|
||||||
|
|||||||
+36
-7
@@ -52,22 +52,50 @@ coordinated deployment of the platform's financial services.
|
|||||||
|
|
||||||
Reuse `api.sendico` and its existing Vault-rendered token file. Do not put provider
|
Reuse `api.sendico` and its existing Vault-rendered token file. Do not put provider
|
||||||
credentials or organization/source settings in Flutter. Provision the integration
|
credentials or organization/source settings in Flutter. Provision the integration
|
||||||
account's organization membership, payment-create/read, ledger-account/balance
|
account's organization membership, payment-create/read, ledger-account create/read,
|
||||||
read and the permissions needed to resolve its stored payment methods. Verify
|
balance read and the permissions needed to resolve its stored payment methods. Verify
|
||||||
those permissions in the intended environment before enabling real operations.
|
those permissions in the intended environment before enabling real operations.
|
||||||
|
|
||||||
Through Sendico's existing operator APIs, provision one dedicated unowned
|
### Automatic currency accounts (#72)
|
||||||
organization ledger per customer currency, with active `asset` / `operating`
|
|
||||||
semantics, no negative balance and metadata:
|
The backend ensures **RUB, USDT and USDC** accounts after confirmed identity intake
|
||||||
|
during signup, before sending the activation email. Resending activation retries
|
||||||
|
the same setup. Every authenticated wallet load also ensures the defaults, so
|
||||||
|
existing customers receive missing accounts automatically when they next open
|
||||||
|
their wallet. This is lazy backfill, not an eager scan of dormant customers.
|
||||||
|
|
||||||
|
Setup creates dedicated unowned organization ledgers through the public
|
||||||
|
`POST /ledger_accounts/{organization}` API, with active `asset` / `operating`
|
||||||
|
semantics, zero initial funds, no negative balance and metadata:
|
||||||
|
|
||||||
| Ledger metadata | Meaning |
|
| Ledger metadata | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `sxchnge_subject_ref` | Confirmed independent subject reference from sign-up |
|
| `sxchnge_subject_ref` | Confirmed independent subject reference from sign-up |
|
||||||
|
| `external_subject_ref` | Same confirmed subject; provider-enforced organization/subject/currency uniqueness |
|
||||||
| `sxchnge_payout_method_ref` | Optional prevalidated same-currency saved payout method |
|
| `sxchnge_payout_method_ref` | Optional prevalidated same-currency saved payout method |
|
||||||
| `sxchnge_payout_label` | Safe customer-visible destination label, e.g. bank plus masked suffix |
|
| `sxchnge_payout_label` | Safe customer-visible destination label, e.g. bank plus masked suffix |
|
||||||
| `sxchnge_funding_method_ref` | Optional same-currency saved funding source supported by the platform route |
|
| `sxchnge_funding_method_ref` | Optional same-currency saved funding source supported by the platform route |
|
||||||
|
|
||||||
These are financial-resource bindings maintained by trusted provisioning, not
|
Existing customer ledgers and their balances are preserved, including manually
|
||||||
|
provisioned ledgers with the original `sxchnge_subject_ref` tag. Only missing
|
||||||
|
defaults are created; existing additional currencies remain visible. Repeated
|
||||||
|
bound creates reuse the ledger and resume Sendico's durable wallet/route setup
|
||||||
|
after an interrupted response. Frozen, duplicate or incorrectly owned bindings
|
||||||
|
fail instead of creating a replacement. All required currency precision entries
|
||||||
|
are checked against the provider catalog before issuing creates. Saved funding
|
||||||
|
and payout bindings remain separate from creation of the currency accounts.
|
||||||
|
|
||||||
|
Deploy [Sendico #2968](https://git.sendico.io/tech/sendico/issues/2968) first:
|
||||||
|
Ledger (including its unique index), then Product Edge, then SXCHNGE backend and
|
||||||
|
frontend from [PR #73](https://git.sendico.io/tech/sxchnge/pulls/73). The public
|
||||||
|
ledger list must advertise `externalSubjectAccounts: true` before SXCHNGE sends
|
||||||
|
any create requests. Older providers fail safely without creating accounts.
|
||||||
|
The acknowledgement assumes Ledger and Edge are deployed in that order; it
|
||||||
|
does not discover the version of a separately running Ledger. No new runtime
|
||||||
|
configuration or credentials are introduced. Existing deployments must give the
|
||||||
|
integration principal the ledger-create permission in addition to its reads.
|
||||||
|
|
||||||
|
These are financial-resource bindings maintained by server-side setup, not
|
||||||
client choices or new runtime configuration. Do not tag a pooled/integration-login
|
client choices or new runtime configuration. Do not tag a pooled/integration-login
|
||||||
ledger as a customer ledger. Do not place full bank/card details or secret values
|
ledger as a customer ledger. Do not place full bank/card details or secret values
|
||||||
in labels or metadata. The BFF checks subject, organization, account type, role,
|
in labels or metadata. The BFF checks subject, organization, account type, role,
|
||||||
@@ -79,7 +107,8 @@ review action. Funding uses a quote for the provisioned stored source; it does
|
|||||||
not mint money or treat demo top-ups as real deposits. Additional destinations,
|
not mint money or treat demo top-ups as real deposits. Additional destinations,
|
||||||
recipient onboarding and other funding instructions remain separate features.
|
recipient onboarding and other funding instructions remain separate features.
|
||||||
|
|
||||||
An empty set of provisioned ledgers is an empty wallet. Existing organization
|
If setup reports a failure, the API returns an unavailable wallet rather than an
|
||||||
|
empty snapshot or demo balances. Retrying the app load retries setup. Existing organization
|
||||||
payments and pooled balances are never imported into customer history. This
|
payments and pooled balances are never imported into customer history. This
|
||||||
integration exposes the durable operations initiated through this BFF, with live
|
integration exposes the durable operations initiated through this BFF, with live
|
||||||
provider reconciliation; independent inbound deposits/history ingestion and
|
provider reconciliation; independent inbound deposits/history ingestion and
|
||||||
|
|||||||
@@ -296,3 +296,66 @@ real token was checked read-only and remains unused and unexpired, with its
|
|||||||
account pending verification, ready for the user to reopen the link. PR #61 has
|
account pending verification, ready for the user to reopen the link. PR #61 has
|
||||||
not been merged into remote main; this local deployment does not imply a main
|
not been merged into remote main; this local deployment does not imply a main
|
||||||
branch merge.
|
branch merge.
|
||||||
|
|
||||||
|
### Accepted signup delivery (#62)
|
||||||
|
|
||||||
|
Task [#62](https://git.sendico.io/tech/sxchnge/issues/62), branch
|
||||||
|
`email-verification-62`, delivered in
|
||||||
|
[PR #63](https://git.sendico.io/tech/sxchnge/pulls/63). On October 23, signup
|
||||||
|
returned HTTP 202 and the local
|
||||||
|
SMTP provider accepted its message with status 250, while the user reported an
|
||||||
|
internal-error screen immediately after Create account. No subsequent
|
||||||
|
verification request from that attempt reached the BFF. The precise original
|
||||||
|
client exception was not captured; an ordinary isolated release reproduction
|
||||||
|
succeeded. The regression below deliberately injects an unavailable follow-up
|
||||||
|
send service to cover the post-acceptance failure path without claiming that
|
||||||
|
injected exception was the original cause.
|
||||||
|
|
||||||
|
Backend and preview signup mappers now return a `VerificationDelivery` domain
|
||||||
|
receipt with the recipient and simulation status. It means that signup accepted
|
||||||
|
the email-queue operation, not inbox delivery, account activation or a session.
|
||||||
|
The signup provider exposes it through its operation result. The form captures
|
||||||
|
the receipt before clearing password controllers, whose change listeners reset
|
||||||
|
auth state, and passes it as transient router extra. The verification provider
|
||||||
|
initializes directly in the sent state; mounting the page performs no send.
|
||||||
|
This replaces the backend adapter's hidden one-use signup cache. Every explicit
|
||||||
|
resend now invokes its service, including the first one after signup.
|
||||||
|
|
||||||
|
Routes without a receipt retain their existing behavior: unverified login may
|
||||||
|
request an email, a fresh verification page asks for an address, and emailed
|
||||||
|
links run confirmation. Recipient changes invalidate the displayed claim until
|
||||||
|
another send succeeds. New receipts replace stale recipients. The route codec
|
||||||
|
discards the receipt when serializing history, so reloads do not invent a queued
|
||||||
|
result or retain the address. No token or password is included in the receipt.
|
||||||
|
|
||||||
|
The existing email-delivery requested/succeeded signals now follow the accepted
|
||||||
|
signup receipt inside the instrumented service instead of a synthetic send on
|
||||||
|
page mount. They stay in the signup journey, occur once for the accepted
|
||||||
|
operation, and remain intermediate queue-acceptance signals. Resends retain
|
||||||
|
separate attempts and failures; confirmation remains the terminal outcome.
|
||||||
|
Simulation and privacy rules are unchanged, including the in-memory analytics
|
||||||
|
and cross-device correlation limitations above.
|
||||||
|
|
||||||
|
Frontend review covered the complete app/auth contribution and preserved the
|
||||||
|
DTO, mapper, service, domain model, provider and UI boundaries. Regressions cover
|
||||||
|
accepted signup followed by unavailable resend, explicit retry, normalized
|
||||||
|
receipt mapping, provider initialization/reset, recipient replacement, transient
|
||||||
|
history, event correlation and exclusion of form values from analytics. EN/RU
|
||||||
|
flow tests run at 390px with 1.5x text scaling; existing keyboard, autofill and
|
||||||
|
accessibility suites also pass. Changed-file Dart format checks, analysis in
|
||||||
|
`frontend/app` and `frontend/packages/sxchnge_auth`, and `make test-frontend`
|
||||||
|
(221 tests) pass. The new injected-failure regression failed on the previous
|
||||||
|
implementation and passes with the receipt handoff. A release-browser fixture
|
||||||
|
with the same backend adapter and a failing resend service shows Check your
|
||||||
|
inbox after signup with no send call, then the expected error only after an
|
||||||
|
explicit resend. It sends no email. Native Safari was not automated.
|
||||||
|
|
||||||
|
The frontend image was rebuilt with freshly resolved stable digest pins and
|
||||||
|
recreated without restarting BFF. The served JavaScript matches the new image
|
||||||
|
at SHA-256 `0a45ce6f0b4658b1f03d8e39b9e39fd3fbfcf6f1626cb3f17705dce2da130a6c`.
|
||||||
|
Normal browser-trust HTTPS serves the frontend and API with HTTP 200.
|
||||||
|
`sh infra/local/tools.sh verify` passes all seven services, Vault/database
|
||||||
|
checks and authenticated Sendico currency/ledger reads. No new customer email,
|
||||||
|
activation or payment was submitted for this change, and no security scan was
|
||||||
|
requested or run. The user's pre-existing browser tab must reload to use the
|
||||||
|
new bundle; their original Safari exception remains unobserved.
|
||||||
@@ -302,6 +302,55 @@ that contradicts displayed balances or usage without explaining the fixtures.
|
|||||||
- Keep unrelated accounts/history accessible during a pending operation. Do not
|
- Keep unrelated accounts/history accessible during a pending operation. Do not
|
||||||
block the whole product behind an exchange spinner.
|
block the whole product behind an exchange spinner.
|
||||||
|
|
||||||
|
## Automatic currency accounts (task #72)
|
||||||
|
|
||||||
|
New customers receive RUB, USDT and USDC accounts through backend-owned setup
|
||||||
|
during signup. Existing customers receive missing defaults on their next wallet
|
||||||
|
load. The UI requires no account-creation form or support contact. During initial
|
||||||
|
loading it announces that the accounts are being prepared. Failure shows one
|
||||||
|
clear load error and a **Try again** action that reruns the same idempotent setup.
|
||||||
|
An unexpected empty snapshot has its own setup-failure fallback, rather than
|
||||||
|
being shown simultaneously with an error. A failed refresh retains existing
|
||||||
|
cards and the stale-data warning. Account-not-found copy requires loaded data.
|
||||||
|
|
||||||
|
The existing DTO → mapper → service → model → provider → UI boundaries remain
|
||||||
|
intact. Account creation belongs to the backend/provider, and widgets invoke the
|
||||||
|
existing provider refresh action. English and Russian use wrapping text and the
|
||||||
|
existing accessible button pattern; status text uses a live region. Dedicated
|
||||||
|
widget regressions cover loading, failure, retry and successful default accounts
|
||||||
|
on Accounts, Top up, Exchange and Payout at 320px with 1.6× text. Native device and
|
||||||
|
screen-reader QA remain a release check; tests do not claim physical-device QA.
|
||||||
|
|
||||||
|
Measurement: `sxchnge_currency_accounts_setup_total{outcome="ready|failed"}` and
|
||||||
|
`sxchnge_currency_accounts_setup_duration_seconds` cover the server setup attempt.
|
||||||
|
`ready` is emitted only after all default bindings are confirmed, including
|
||||||
|
idempotent completion of provider wallet/route setup for newly bound ledgers.
|
||||||
|
Each signup/resend/wallet load is a separate attempt; a retry is not a new unique
|
||||||
|
customer conversion. The failure fraction uses all attempts as its denominator;
|
||||||
|
the histogram measures one attempt, not time across multiple browser sessions.
|
||||||
|
No customer, ledger, currency, balance, credentials or form input is a metric
|
||||||
|
label. These server metrics do not measure client abandonment or prove that a
|
||||||
|
balance card was viewed. Existing transaction journey outcomes below are reused;
|
||||||
|
no speculative button-click events are added.
|
||||||
|
|
||||||
|
Scope and rollout: [task #72](https://git.sendico.io/tech/sxchnge/issues/72), branch
|
||||||
|
`wallet-72`, [PR #73](https://git.sendico.io/tech/sxchnge/pulls/73). The provider
|
||||||
|
prerequisite and deployment order are documented in [payments](../payments.md).
|
||||||
|
Merge and deployment remain separate. Final validation is recorded in the task
|
||||||
|
and PR, including any transient failures and environmental blockers.
|
||||||
|
|
||||||
|
Final validation (2026-10-23): `flutter gen-l10n`, `flutter analyze` in
|
||||||
|
`frontend/app`, the changed-Dart formatting check, `make lint-backend`, and
|
||||||
|
`git diff --check` passed. `make test-frontend` passed all 242 tests on the current
|
||||||
|
app base, including the eight setup recovery scenarios. The full `make test`
|
||||||
|
passed with `TESTCONTAINERS_RYUK_DISABLED=true GOFLAGS='-p=2'`: earlier attempts
|
||||||
|
hit dependency DNS and Mongo/cleanup-helper startup timeouts. Disabling only the
|
||||||
|
failing helper retained every integration assertion, test timeout and race check;
|
||||||
|
each test's existing container cleanup still ran. No security scan was requested.
|
||||||
|
The live local provider catalog confirms RUB scale 2 and USDT/USDC scale 6.
|
||||||
|
Provider-side concurrency and frozen-binding checks passed against a disposable
|
||||||
|
Mongo replica set; see [Sendico PR #2970](https://git.sendico.io/tech/sendico/pulls/2970).
|
||||||
|
|
||||||
## Journey measurement plan
|
## Journey measurement plan
|
||||||
|
|
||||||
Reuse the typed analytics abstraction and maintain a separate journey per active
|
Reuse the typed analytics abstraction and maintain a separate journey per active
|
||||||
|
|||||||
@@ -167,3 +167,95 @@ This is a display correction within the existing KYC journey, so no additional
|
|||||||
analytics events or financial data collection are introduced. Tests cover mapping
|
analytics events or financial data collection are introduced. Tests cover mapping
|
||||||
without inferring remaining, unused/exhausted/over-cap/zero-cap ratios and EN/RU
|
without inferring remaining, unused/exhausted/over-cap/zero-cap ratios and EN/RU
|
||||||
usage display at 320px with 1.6 text scaling.
|
usage display at 320px with 1.6 text scaling.
|
||||||
|
|
||||||
|
## Residence country selection — task #64
|
||||||
|
|
||||||
|
Task [#64](https://git.sendico.io/tech/sxchnge/issues/64), branch `kyc-64`.
|
||||||
|
Delivery: [PR #65](https://git.sendico.io/tech/sxchnge/pulls/65).
|
||||||
|
The profile now shows a country name and opens a searchable country list instead
|
||||||
|
of asking customers to type a two-letter code. Names follow the English/Russian
|
||||||
|
app locale. Search accepts localized or English names and two-letter codes,
|
||||||
|
ignoring case and surrounding whitespace. Selection updates only
|
||||||
|
`IdentityProfile.residenceCountry` with the country code; the existing provider,
|
||||||
|
service, mapper and transport contracts remain unchanged.
|
||||||
|
|
||||||
|
The `country_picker` dependency supplies bundled country data, code lookup and
|
||||||
|
translations through `CountryService` and `CountryLocalizations`. Flutter Material
|
||||||
|
controls provide the search interface, explicit no-results recovery, minimum
|
||||||
|
touch targets, keyboard selection, a selected-state checkmark, Back and Escape.
|
||||||
|
The package's built-in list omits explicit no-results feedback and uses shorter
|
||||||
|
row targets, so its data/localizations are reused with standard Material controls.
|
||||||
|
There is no remote lookup, copied country table or extra application provider.
|
||||||
|
Cancelling preserves the current profile, prefilled codes display names, busy
|
||||||
|
state disables opening the selector, and the search controller is disposed with
|
||||||
|
its dialog. The list is a data catalogue; backend eligibility remains authoritative.
|
||||||
|
|
||||||
|
This change stays within the existing KYC preparation/submission journey and its
|
||||||
|
measurement definitions above. No selector events, queries, names or country
|
||||||
|
codes are added to telemetry. Existing acceptance/failure/retry/abandonment
|
||||||
|
signals and documented server-side review measurement gaps still apply.
|
||||||
|
|
||||||
|
Focused widget checks cover name/code search, code-only updates, reselection,
|
||||||
|
cancel, no-results recovery, clearing search, saved values, locale changes,
|
||||||
|
Russian names, disabled state, keyboard activation/selection/Escape and labeled
|
||||||
|
touch targets. A long country name fits at 320px with 1.6 text scale. Existing
|
||||||
|
EN/RU verification journeys now select a country and assert the profile code
|
||||||
|
before continuing through document submission. Native screen-reader and device
|
||||||
|
keyboard testing remain release checks; widget semantics checks do not establish
|
||||||
|
those device outcomes.
|
||||||
|
|
||||||
|
Validation: five changed Dart files passed the format check; `flutter analyze`
|
||||||
|
in `frontend/app` reported no issues; `make test-frontend` passed all 222 tests;
|
||||||
|
and `flutter build web` passed, including its Wasm compatibility dry run. Browser
|
||||||
|
smoke testing on the local demo opened the selector, searched for Poland and
|
||||||
|
confirmed that the profile displays Poland after selection. Focused and full
|
||||||
|
journey tests verify the stored `PL` code. Generated localization/build outputs
|
||||||
|
remain ignored and uncommitted.
|
||||||
|
|
||||||
|
## Birthday entry — task #67
|
||||||
|
|
||||||
|
Task [#67](https://git.sendico.io/tech/sxchnge/issues/67), branch `kyc-67`.
|
||||||
|
Delivery: [PR #70](https://git.sendico.io/tech/sxchnge/pulls/70).
|
||||||
|
This change is stacked on the residence-country selector in PR #65; merge #65
|
||||||
|
before the birthday PR.
|
||||||
|
|
||||||
|
The profile uses labeled Day, Month and Year controls. Day and year accept numeric
|
||||||
|
entry with birthday autofill hints; month selection uses localized month names
|
||||||
|
and standard Material keyboard/touch interaction. Customers can enter their birth
|
||||||
|
year directly. No calendar paging or transport-format punctuation is required.
|
||||||
|
The controls share a row when space allows and stack at narrow widths or enlarged
|
||||||
|
text sizes. English and Russian labels, month names and errors follow the app locale.
|
||||||
|
|
||||||
|
Valid calendar dates are normalized to the existing `IdentityProfile.dateOfBirth`
|
||||||
|
`YYYY-MM-DD` value through `IdentityProvider.updateProfile`. Partial, impossible
|
||||||
|
and future dates clear that profile value so Continue cannot retain an earlier
|
||||||
|
valid birthday. Local input components remain visible while correcting a date;
|
||||||
|
complete invalid dates receive a specific live-region error below the group.
|
||||||
|
Leap years, zero day/year and calendar rollover are checked. Today's date follows
|
||||||
|
the existing model policy; no minimum age or arbitrary oldest birth year is added.
|
||||||
|
Prefilled dates, locale changes and external profile replacement synchronize the
|
||||||
|
controls. Back navigation restores the valid saved date. Busy state disables all
|
||||||
|
three controls. Controllers are disposed with the widget.
|
||||||
|
|
||||||
|
The existing DTO, mapper, service, domain model and provider boundaries are retained.
|
||||||
|
No dependency, transport or backend change is needed. This remains part of the KYC
|
||||||
|
preparation-to-submission journey defined above: existing acceptance, failure,
|
||||||
|
retry and abandonment signals apply, without new field events or date collection.
|
||||||
|
The existing server-side review measurement gaps remain unchanged.
|
||||||
|
|
||||||
|
UX review and automated checks cover entry/correction, leap dates, invalid and
|
||||||
|
future-date recovery, numeric restrictions, prefill/replacement/clearing, locale
|
||||||
|
switching, disabled state, Tab/Enter/arrow-key month selection, Escape cancellation,
|
||||||
|
and Russian controls/errors at 320px with 1.6 text scale. Labeled and minimum-size
|
||||||
|
tap-target guidelines pass. Integration checks verify that invalid dates block
|
||||||
|
Continue, Back preserves the birthday, and EN/RU flows still reach submission
|
||||||
|
acceptance. Native screen-reader and mobile keyboard/autofill testing remain
|
||||||
|
release checks; widget semantics are not a claim of device-level validation.
|
||||||
|
|
||||||
|
Validation: all four changed Dart files pass the format check; `flutter analyze`
|
||||||
|
in `frontend/app` reports no issues; `make test-frontend` passes all 230 tests,
|
||||||
|
including the seven new birthday widget tests and the new navigation/gating test.
|
||||||
|
`flutter build web` passes, including its Wasm compatibility dry run. Browser
|
||||||
|
smoke testing of the release build entered 23 April 1990, verified the inline
|
||||||
|
error for 31 April and restored 23 April without losing month or year.
|
||||||
|
Generated localization and build outputs remain ignored and uncommitted.
|
||||||
@@ -99,10 +99,11 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final email = _email.text.trim();
|
final email = _email.text.trim();
|
||||||
|
final delivery = signup.state.result!.delivery;
|
||||||
TextInput.finishAutofillContext();
|
TextInput.finishAutofillContext();
|
||||||
_password.clear();
|
_password.clear();
|
||||||
_confirmation.clear();
|
_confirmation.clear();
|
||||||
context.goNamed(AppRoutes.verificationSent, extra: email);
|
context.goNamed(AppRoutes.verificationSent, extra: delivery ?? email);
|
||||||
} else {
|
} else {
|
||||||
final login = context.read<LoginProvider>();
|
final login = context.read<LoginProvider>();
|
||||||
await login.login(
|
await login.login(
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Birthday entry with a canonical date string at the profile boundary.
|
||||||
|
class BirthField extends StatefulWidget {
|
||||||
|
const BirthField({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
this.enabled = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BirthField> createState() => _BirthFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BirthFieldState extends State<BirthField> {
|
||||||
|
final _day = TextEditingController();
|
||||||
|
final _year = TextEditingController();
|
||||||
|
final _monthField = GlobalKey<FormFieldState<int>>();
|
||||||
|
int? _month;
|
||||||
|
late String _emitted;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_readValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(BirthField oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.value != _emitted) {
|
||||||
|
_readValue();
|
||||||
|
_monthField.currentState?.didChange(_month);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _readValue() {
|
||||||
|
_emitted = widget.value;
|
||||||
|
final parts = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(widget.value);
|
||||||
|
_year.text = parts?.group(1) ?? '';
|
||||||
|
final month = int.tryParse(parts?.group(2) ?? '');
|
||||||
|
_month = month != null && month >= 1 && month <= 12 ? month : null;
|
||||||
|
_day.text = parts?.group(3) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? get _date {
|
||||||
|
final day = int.tryParse(_day.text);
|
||||||
|
final year = int.tryParse(_year.text);
|
||||||
|
if (day == null ||
|
||||||
|
year == null ||
|
||||||
|
year < 1 ||
|
||||||
|
_year.text.length != 4 ||
|
||||||
|
_month == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final date = DateTime(year, _month!, day);
|
||||||
|
return date.year == year && date.month == _month && date.day == day
|
||||||
|
? date
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _future(DateTime date) =>
|
||||||
|
date.isAfter(DateUtils.dateOnly(DateTime.now()));
|
||||||
|
|
||||||
|
void _change() {
|
||||||
|
setState(() {});
|
||||||
|
final date = _date;
|
||||||
|
_emitted = date == null || _future(date)
|
||||||
|
? ''
|
||||||
|
: '${_year.text}-${_month.toString().padLeft(2, '0')}-'
|
||||||
|
'${_day.text.padLeft(2, '0')}';
|
||||||
|
widget.onChanged(_emitted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_day.dispose();
|
||||||
|
_year.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final date = _date;
|
||||||
|
final complete =
|
||||||
|
_day.text.isNotEmpty && _month != null && _year.text.length == 4;
|
||||||
|
final error = !complete
|
||||||
|
? null
|
||||||
|
: date == null
|
||||||
|
? l.kycBirthInvalid
|
||||||
|
: _future(date)
|
||||||
|
? l.kycBirthFuture
|
||||||
|
: null;
|
||||||
|
final months = DateFormat.MMMM(Localizations.localeOf(context).toString());
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(l.kycBirth, style: Theme.of(context).textTheme.labelLarge),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final day = _number(
|
||||||
|
key: 'birth-day',
|
||||||
|
controller: _day,
|
||||||
|
label: l.kycBirthDay,
|
||||||
|
length: 2,
|
||||||
|
autofill: AutofillHints.birthdayDay,
|
||||||
|
);
|
||||||
|
final month = DropdownButtonFormField<int>(
|
||||||
|
key: _monthField,
|
||||||
|
initialValue: _month,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: InputDecoration(labelText: l.kycBirthMonth),
|
||||||
|
items: [
|
||||||
|
for (var month = 1; month <= 12; month++)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: month,
|
||||||
|
child: Text(months.format(DateTime(2000, month))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: !widget.enabled
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
_month = value;
|
||||||
|
_change();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final year = _number(
|
||||||
|
key: 'birth-year',
|
||||||
|
controller: _year,
|
||||||
|
label: l.kycBirthYear,
|
||||||
|
length: 4,
|
||||||
|
autofill: AutofillHints.birthdayYear,
|
||||||
|
);
|
||||||
|
final scale = MediaQuery.textScalerOf(context).scale(16) / 16;
|
||||||
|
if (constraints.maxWidth < 400 * scale) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
day,
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
month,
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
year,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(flex: 2, child: day),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(flex: 4, child: month),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(flex: 3, child: year),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (error != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Semantics(
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(
|
||||||
|
error,
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _number({
|
||||||
|
required String key,
|
||||||
|
required TextEditingController controller,
|
||||||
|
required String label,
|
||||||
|
required int length,
|
||||||
|
required String autofill,
|
||||||
|
}) => TextFormField(
|
||||||
|
key: ValueKey(key),
|
||||||
|
controller: controller,
|
||||||
|
enabled: widget.enabled,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
autofillHints: [autofill],
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(length),
|
||||||
|
],
|
||||||
|
decoration: InputDecoration(labelText: label),
|
||||||
|
onChanged: (_) => _change(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'package:country_picker/country_picker.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class CountryField extends StatelessWidget {
|
||||||
|
const CountryField({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
this.enabled = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final country = CountryService().findByCode(value);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(l.kycCountry, style: Theme.of(context).textTheme.labelLarge),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Semantics(
|
||||||
|
label: l.kycCountry,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: !enabled
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final code = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _CountrySearch(selected: value),
|
||||||
|
);
|
||||||
|
if (context.mounted && code != null) onChanged(code);
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
country == null
|
||||||
|
? l.kycCountrySelect
|
||||||
|
: _name(context, country),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(Icons.expand_more),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _name(BuildContext context, Country country) =>
|
||||||
|
country.getTranslatedName(context) ?? country.name;
|
||||||
|
|
||||||
|
class _CountrySearch extends StatefulWidget {
|
||||||
|
const _CountrySearch({required this.selected});
|
||||||
|
|
||||||
|
final String selected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CountrySearch> createState() => _CountrySearchState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CountrySearchState extends State<_CountrySearch> {
|
||||||
|
final _query = TextEditingController();
|
||||||
|
final _countries = {
|
||||||
|
for (final country in CountryService().getAll())
|
||||||
|
country.countryCode: country,
|
||||||
|
}.values.toList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_query.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
return Focus(
|
||||||
|
autofocus: true,
|
||||||
|
onKeyEvent: (_, event) {
|
||||||
|
if (event is KeyDownEvent &&
|
||||||
|
event.logicalKey == LogicalKeyboardKey.escape) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
},
|
||||||
|
child: Dialog.fullscreen(
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: BackButton(onPressed: () => Navigator.of(context).pop()),
|
||||||
|
title: TextField(
|
||||||
|
key: const ValueKey('country-search'),
|
||||||
|
controller: _query,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.kycCountrySearch,
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
),
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
),
|
||||||
|
toolbarHeight: 96,
|
||||||
|
actions: [
|
||||||
|
if (_query.text.isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
tooltip: l.kycCountryClearSearch,
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () => setState(_query.clear),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _list(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _list(BuildContext context) {
|
||||||
|
final search = _query.text.trim().toLowerCase();
|
||||||
|
final countries = _countries.where((country) {
|
||||||
|
return _name(context, country).toLowerCase().contains(search) ||
|
||||||
|
country.name.toLowerCase().contains(search) ||
|
||||||
|
country.countryCode.toLowerCase() == search;
|
||||||
|
}).toList()..sort((a, b) => _name(context, a).compareTo(_name(context, b)));
|
||||||
|
if (countries.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(AppLocalizations.of(context)!.kycCountryEmpty),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: countries.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final country = countries[index];
|
||||||
|
final isSelected = country.countryCode == widget.selected;
|
||||||
|
return ListTile(
|
||||||
|
title: Text(_name(context, country)),
|
||||||
|
selected: isSelected,
|
||||||
|
trailing: isSelected ? const Icon(Icons.check) : null,
|
||||||
|
onTap: () => Navigator.of(context).pop(country.countryCode),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'birth.dart';
|
||||||
|
import 'country.dart';
|
||||||
|
|
||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
class ProfileForm extends StatelessWidget {
|
class ProfileForm extends StatelessWidget {
|
||||||
@@ -35,30 +38,18 @@ class ProfileForm extends StatelessWidget {
|
|||||||
onChanged: (value) => update(name: value),
|
onChanged: (value) => update(name: value),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
BirthField(
|
||||||
key: const ValueKey('kyc-birth'),
|
key: const ValueKey('kyc-birth'),
|
||||||
initialValue: p.profile.dateOfBirth,
|
value: p.profile.dateOfBirth,
|
||||||
enabled: !p.busy,
|
enabled: !p.busy,
|
||||||
keyboardType: TextInputType.datetime,
|
onChanged: (value) => update(birth: value),
|
||||||
autofillHints: const [AutofillHints.birthday],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: l.kycBirth,
|
|
||||||
helperText: l.kycBirthHelp,
|
|
||||||
),
|
|
||||||
onChanged: (value) => update(birth: value.trim()),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
CountryField(
|
||||||
key: const ValueKey('kyc-country'),
|
key: const ValueKey('kyc-country'),
|
||||||
initialValue: p.profile.residenceCountry,
|
value: p.profile.residenceCountry,
|
||||||
enabled: !p.busy,
|
enabled: !p.busy,
|
||||||
maxLength: 2,
|
onChanged: (value) => update(country: value),
|
||||||
textCapitalization: TextCapitalization.characters,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: l.kycCountry,
|
|
||||||
helperText: l.kycCountryHelp,
|
|
||||||
),
|
|
||||||
onChanged: (value) => update(country: value.trim().toUpperCase()),
|
|
||||||
),
|
),
|
||||||
if (!p.profile.isComplete) Text(l.kycProfileRequired),
|
if (!p.profile.isComplete) Text(l.kycProfileRequired),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -160,7 +160,10 @@
|
|||||||
"wRecent": "Recent activity",
|
"wRecent": "Recent activity",
|
||||||
"wSeeAll": "See all",
|
"wSeeAll": "See all",
|
||||||
"wEmptyActivity": "Your next move starts here. Your transactions will appear below.",
|
"wEmptyActivity": "Your next move starts here. Your transactions will appear below.",
|
||||||
"wEmptyAccounts": "Your accounts are not ready yet. Try refreshing.",
|
"wEmptyAccounts": "We couldn’t finish setting up your currency accounts. Try again to continue.",
|
||||||
|
"wAccountsLoading": "Getting your RUB, USDT and USDC accounts ready…",
|
||||||
|
"wAccountsFailed": "We couldn’t load your currency accounts. Try again to continue.",
|
||||||
|
"wAccountsRetry": "Try again",
|
||||||
"wRefresh": "Refresh",
|
"wRefresh": "Refresh",
|
||||||
"wSource": "From account",
|
"wSource": "From account",
|
||||||
"wDestination": "To account",
|
"wDestination": "To account",
|
||||||
@@ -269,10 +272,17 @@
|
|||||||
"kycProfileHelp": "Enter your details exactly as shown on your identity document.",
|
"kycProfileHelp": "Enter your details exactly as shown on your identity document.",
|
||||||
"kycFullName": "Full legal name",
|
"kycFullName": "Full legal name",
|
||||||
"kycBirth": "Date of birth",
|
"kycBirth": "Date of birth",
|
||||||
"kycBirthHelp": "YYYY-MM-DD, for example 1990-04-23",
|
"kycBirthDay": "Day",
|
||||||
|
"kycBirthMonth": "Month",
|
||||||
|
"kycBirthYear": "Year",
|
||||||
|
"kycBirthInvalid": "Enter a valid date of birth.",
|
||||||
|
"kycBirthFuture": "Date of birth cannot be in the future.",
|
||||||
"kycCountry": "Country of residence",
|
"kycCountry": "Country of residence",
|
||||||
"kycCountryHelp": "Two-letter country code, for example PL or DE",
|
"kycCountrySelect": "Select a country",
|
||||||
"kycProfileRequired": "Enter your full name, a valid date of birth and a two-letter country code to continue.",
|
"kycCountrySearch": "Search countries",
|
||||||
|
"kycCountryEmpty": "No countries found. Try another name or country code.",
|
||||||
|
"kycCountryClearSearch": "Clear search",
|
||||||
|
"kycProfileRequired": "Enter your full name, a valid date of birth and select your country of residence to continue.",
|
||||||
"kycContinue": "Continue to my account",
|
"kycContinue": "Continue to my account",
|
||||||
"kycOperationMinimum": "Per operation minimum",
|
"kycOperationMinimum": "Per operation minimum",
|
||||||
"kycOperationMaximum": "Per operation maximum",
|
"kycOperationMaximum": "Per operation maximum",
|
||||||
|
|||||||
@@ -160,7 +160,10 @@
|
|||||||
"wRecent": "Последние операции",
|
"wRecent": "Последние операции",
|
||||||
"wSeeAll": "Смотреть все",
|
"wSeeAll": "Смотреть все",
|
||||||
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
|
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
|
||||||
"wEmptyAccounts": "Ваши счета ещё не готовы. Попробуйте обновить страницу.",
|
"wEmptyAccounts": "Не удалось завершить настройку валютных счетов. Повторите попытку, чтобы продолжить.",
|
||||||
|
"wAccountsLoading": "Подготавливаем ваши счета в RUB, USDT и USDC…",
|
||||||
|
"wAccountsFailed": "Не удалось загрузить валютные счета. Повторите попытку, чтобы продолжить.",
|
||||||
|
"wAccountsRetry": "Повторить попытку",
|
||||||
"wRefresh": "Обновить",
|
"wRefresh": "Обновить",
|
||||||
"wSource": "Со счёта",
|
"wSource": "Со счёта",
|
||||||
"wDestination": "На счёт",
|
"wDestination": "На счёт",
|
||||||
@@ -269,10 +272,17 @@
|
|||||||
"kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.",
|
"kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.",
|
||||||
"kycFullName": "Полное имя по документу",
|
"kycFullName": "Полное имя по документу",
|
||||||
"kycBirth": "Дата рождения",
|
"kycBirth": "Дата рождения",
|
||||||
"kycBirthHelp": "ГГГГ-ММ-ДД, например 1990-04-23",
|
"kycBirthDay": "День",
|
||||||
|
"kycBirthMonth": "Месяц",
|
||||||
|
"kycBirthYear": "Год",
|
||||||
|
"kycBirthInvalid": "Укажите корректную дату рождения.",
|
||||||
|
"kycBirthFuture": "Дата рождения не может быть в будущем.",
|
||||||
"kycCountry": "Страна проживания",
|
"kycCountry": "Страна проживания",
|
||||||
"kycCountryHelp": "Код страны из двух букв, например PL или DE",
|
"kycCountrySelect": "Выберите страну",
|
||||||
"kycProfileRequired": "Укажите полное имя, корректную дату рождения и код страны из двух букв.",
|
"kycCountrySearch": "Поиск страны",
|
||||||
|
"kycCountryEmpty": "Страны не найдены. Попробуйте другое название или код страны.",
|
||||||
|
"kycCountryClearSearch": "Очистить поиск",
|
||||||
|
"kycProfileRequired": "Укажите полное имя, корректную дату рождения и выберите страну проживания.",
|
||||||
"kycContinue": "Перейти в личный кабинет",
|
"kycContinue": "Перейти в личный кабинет",
|
||||||
"kycOperationMinimum": "Минимум на операцию",
|
"kycOperationMinimum": "Минимум на операцию",
|
||||||
"kycOperationMaximum": "Максимум на операцию",
|
"kycOperationMaximum": "Максимум на операцию",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:flutter_web_plugins/url_strategy.dart';
|
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||||
|
|
||||||
|
import 'package:country_picker/country_picker.dart';
|
||||||
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
@@ -195,7 +197,10 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: SxchngeTheme.light(),
|
theme: SxchngeTheme.light(),
|
||||||
locale: locale.locale,
|
locale: locale.locale,
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: const [
|
||||||
|
...AppLocalizations.localizationsDelegates,
|
||||||
|
CountryLocalizations.delegate,
|
||||||
|
],
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
routerConfig: _router,
|
routerConfig: _router,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import 'package:flutter/widgets.dart';
|
|||||||
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
import '../auth/page.dart';
|
import '../auth/page.dart';
|
||||||
@@ -144,6 +146,9 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
|
|||||||
name: AppRoutes.verificationSent,
|
name: AppRoutes.verificationSent,
|
||||||
builder: (context, state) => VerificationPage(
|
builder: (context, state) => VerificationPage(
|
||||||
email: state.extra is String ? state.extra as String : null,
|
email: state.extra is String ? state.extra as String : null,
|
||||||
|
delivery: state.extra is VerificationDelivery
|
||||||
|
? state.extra as VerificationDelivery
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import '../routing/routes.dart';
|
|||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
class VerificationPage extends StatefulWidget {
|
class VerificationPage extends StatefulWidget {
|
||||||
const VerificationPage({super.key, this.email, this.token});
|
const VerificationPage({super.key, this.email, this.token, this.delivery});
|
||||||
final String? email;
|
final String? email;
|
||||||
final String? token;
|
final String? token;
|
||||||
|
final VerificationDelivery? delivery;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<VerificationPage> createState() => _VerificationPageState();
|
State<VerificationPage> createState() => _VerificationPageState();
|
||||||
@@ -25,9 +26,12 @@ class VerificationPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _VerificationPageState extends State<VerificationPage> {
|
class _VerificationPageState extends State<VerificationPage> {
|
||||||
final _form = GlobalKey<FormState>();
|
final _form = GlobalKey<FormState>();
|
||||||
late final _email = TextEditingController(text: widget.email);
|
late final _email = TextEditingController(
|
||||||
|
text: widget.delivery?.email ?? widget.email,
|
||||||
|
);
|
||||||
late final _provider = VerificationProvider(
|
late final _provider = VerificationProvider(
|
||||||
context.read<VerificationService>(),
|
context.read<VerificationService>(),
|
||||||
|
delivery: widget.delivery,
|
||||||
);
|
);
|
||||||
final _emailFocus = FocusNode();
|
final _emailFocus = FocusNode();
|
||||||
String? _recipient;
|
String? _recipient;
|
||||||
@@ -44,7 +48,7 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (widget.token != null) {
|
if (widget.token != null) {
|
||||||
_provider.confirm(widget.token!);
|
_provider.confirm(widget.token!);
|
||||||
} else if (_email.text.isNotEmpty) {
|
} else if (widget.delivery == null && _email.text.isNotEmpty) {
|
||||||
_send();
|
_send();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -58,14 +62,26 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant VerificationPage oldWidget) {
|
void didUpdateWidget(covariant VerificationPage oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.token == widget.token) return;
|
if (oldWidget.token == widget.token &&
|
||||||
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
oldWidget.email == widget.email &&
|
||||||
_provider.reset();
|
identical(oldWidget.delivery, widget.delivery)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (oldWidget.token != widget.token) {
|
||||||
|
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
||||||
|
}
|
||||||
|
_provider.reset(delivery: widget.delivery);
|
||||||
_lastWasSend = false;
|
_lastWasSend = false;
|
||||||
_validated = false;
|
_validated = false;
|
||||||
_email.clear();
|
_recipient = null;
|
||||||
|
_email.text = widget.delivery?.email ?? widget.email ?? '';
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted && widget.token != null) _provider.confirm(widget.token!);
|
if (!mounted) return;
|
||||||
|
if (widget.token != null) {
|
||||||
|
_provider.confirm(widget.token!);
|
||||||
|
} else if (widget.delivery == null && _email.text.isNotEmpty) {
|
||||||
|
_send();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,15 +65,34 @@ class AccountsPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
if (p.loading) const LinearProgressIndicator(),
|
if (p.loading) const LinearProgressIndicator(),
|
||||||
if (p.failed)
|
if ((p.loading || (!p.failed && p.data == null)) &&
|
||||||
|
accounts.isEmpty)
|
||||||
|
Semantics(liveRegion: true, child: Text(l.wAccountsLoading)),
|
||||||
|
if (p.failed && accounts.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
child: Text(l.wUnavailable, style: TextStyle(color: c.error)),
|
child: Text(l.wUnavailable, style: TextStyle(color: c.error)),
|
||||||
),
|
),
|
||||||
if (!p.loading && accounts.isEmpty) Text(l.wEmptyAccounts),
|
if (!p.loading &&
|
||||||
|
accounts.isEmpty &&
|
||||||
|
(p.failed || p.data != null)) ...[
|
||||||
|
Semantics(
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(p.failed ? l.wAccountsFailed : l.wEmptyAccounts),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: p.refresh,
|
||||||
|
child: Text(l.wAccountsRetry),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
if (accountId != null &&
|
if (accountId != null &&
|
||||||
p.account(accountId) == null &&
|
p.account(accountId) == null &&
|
||||||
!p.loading)
|
!p.loading &&
|
||||||
|
!p.failed &&
|
||||||
|
p.data != null)
|
||||||
Text(l.wNotFound),
|
Text(l.wNotFound),
|
||||||
if (selected != null) ...[
|
if (selected != null) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|||||||
@@ -256,11 +256,23 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
if (accounts.isEmpty) ...[
|
if (accounts.isEmpty) ...[
|
||||||
Text(wallet.loading ? l.wWorking : l.wEmptyAccounts),
|
Semantics(
|
||||||
TextButton(
|
liveRegion: true,
|
||||||
onPressed: wallet.loading ? null : wallet.refresh,
|
child: Text(
|
||||||
child: Text(l.wRefresh),
|
wallet.loading ||
|
||||||
|
(!wallet.failed && wallet.data == null)
|
||||||
|
? l.wAccountsLoading
|
||||||
|
: wallet.failed
|
||||||
|
? l.wAccountsFailed
|
||||||
|
: l.wEmptyAccounts,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
if (!wallet.loading &&
|
||||||
|
(wallet.failed || wallet.data != null))
|
||||||
|
TextButton(
|
||||||
|
onPressed: wallet.refresh,
|
||||||
|
child: Text(l.wAccountsRetry),
|
||||||
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
key: ValueKey('source-${p.source}'),
|
key: ValueKey('source-${p.source}'),
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
country_picker:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: country_picker
|
||||||
|
sha256: "8ef721183bd7e0a6017c413693cdc68051daceef5790f5420c3b4378b5c69115"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.28"
|
||||||
cross_file:
|
cross_file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -198,7 +206,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "18.0.1"
|
version: "18.0.1"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
@@ -525,6 +533,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
universal_io:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: universal_io
|
||||||
|
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.1"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ dependencies:
|
|||||||
shared_preferences: ^2.5.3
|
shared_preferences: ^2.5.3
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
go_router: ^18.0.1
|
go_router: ^18.0.1
|
||||||
|
country_picker: ^2.0.28
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
http: ^1.6.0
|
||||||
flutter:
|
flutter:
|
||||||
generate: true
|
generate: true
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/main.dart';
|
||||||
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
|
class SetupWallet extends DummyWalletService {
|
||||||
|
SetupWallet(String locale)
|
||||||
|
: super(
|
||||||
|
DummyIdentityService(delay: Duration.zero),
|
||||||
|
delay: Duration.zero,
|
||||||
|
initialLocale: locale,
|
||||||
|
);
|
||||||
|
|
||||||
|
Completer<WalletSnapshot> pending = Completer<WalletSnapshot>();
|
||||||
|
int calls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WalletSnapshot> fetch() {
|
||||||
|
calls++;
|
||||||
|
return pending.future;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WalletSnapshot readyAccounts() => WalletSnapshot(
|
||||||
|
accounts: [
|
||||||
|
for (final currency in ['RUB', 'USDT', 'USDC'])
|
||||||
|
CurrencyAccount(
|
||||||
|
id: currency.toLowerCase(),
|
||||||
|
currency: currency,
|
||||||
|
precision: currency == 'RUB' ? 2 : 6,
|
||||||
|
available: 0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
operations: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final locale in ['en', 'ru']) {
|
||||||
|
for (final path in ['/accounts', '/top-up', '/exchange', '/payout']) {
|
||||||
|
testWidgets(
|
||||||
|
'$path recovers account setup in $locale at narrow width and large text',
|
||||||
|
(t) async {
|
||||||
|
t.view.physicalSize = const Size(320, 1100);
|
||||||
|
t.view.devicePixelRatio = 1;
|
||||||
|
t.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||||
|
t.platformDispatcher.defaultRouteNameTestValue = path;
|
||||||
|
addTearDown(t.view.resetPhysicalSize);
|
||||||
|
addTearDown(t.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||||
|
final wallet = SetupWallet(locale);
|
||||||
|
await t.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
walletService: wallet,
|
||||||
|
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await t.pump();
|
||||||
|
await t.pump();
|
||||||
|
final loading = locale == 'en'
|
||||||
|
? 'Getting your RUB, USDT and USDC accounts ready…'
|
||||||
|
: 'Подготавливаем ваши счета в RUB, USDT и USDC…';
|
||||||
|
final failed = locale == 'en'
|
||||||
|
? 'We couldn’t load your currency accounts. Try again to continue.'
|
||||||
|
: 'Не удалось загрузить валютные счета. Повторите попытку, чтобы продолжить.';
|
||||||
|
final retry = locale == 'en' ? 'Try again' : 'Повторить попытку';
|
||||||
|
expect(find.text(loading), findsOneWidget);
|
||||||
|
expect(find.textContaining('Contact support'), findsNothing);
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
wallet.pending.completeError(
|
||||||
|
const WalletException(WalletError.unavailable),
|
||||||
|
);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.text(failed), findsOneWidget);
|
||||||
|
expect(find.text(loading), findsNothing);
|
||||||
|
expect(find.text(retry), findsOneWidget);
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
wallet.pending = Completer<WalletSnapshot>();
|
||||||
|
await t.ensureVisible(find.text(retry));
|
||||||
|
await t.tap(find.text(retry));
|
||||||
|
await t.pump();
|
||||||
|
expect(wallet.calls, 2);
|
||||||
|
expect(find.text(loading), findsOneWidget);
|
||||||
|
expect(find.text(failed), findsNothing);
|
||||||
|
wallet.pending.complete(readyAccounts());
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.text(loading), findsNothing);
|
||||||
|
expect(find.text(failed), findsNothing);
|
||||||
|
expect(find.text('RUB'), findsWidgets);
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
await t.pumpWidget(const SizedBox.shrink());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/kyc/birth.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final day = find.byKey(const ValueKey('birth-day'));
|
||||||
|
final year = find.byKey(const ValueKey('birth-year'));
|
||||||
|
final month = find.byType(DropdownButtonFormField<int>);
|
||||||
|
|
||||||
|
Future<void> mount(
|
||||||
|
WidgetTester t,
|
||||||
|
ValueNotifier<String> value, {
|
||||||
|
Locale locale = const Locale('en'),
|
||||||
|
bool enabled = true,
|
||||||
|
}) async {
|
||||||
|
await t.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: SxchngeTheme.light(),
|
||||||
|
locale: locale,
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: ValueListenableBuilder<String>(
|
||||||
|
valueListenable: value,
|
||||||
|
builder: (_, date, _) => BirthField(
|
||||||
|
value: date,
|
||||||
|
enabled: enabled,
|
||||||
|
onChanged: (date) => value.value = date,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> select(WidgetTester t, String name) async {
|
||||||
|
await t.ensureVisible(month);
|
||||||
|
await t.tap(month);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await t.tap(find.text(name).last);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('enters and corrects a birthday without ISO syntax', (t) async {
|
||||||
|
final value = ValueNotifier('');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(t, value);
|
||||||
|
expect(find.text('Date of birth'), findsOneWidget);
|
||||||
|
await t.enterText(day, '23');
|
||||||
|
await select(t, 'April');
|
||||||
|
expect(value.value, '');
|
||||||
|
await t.enterText(year, '1990');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '1990-04-23');
|
||||||
|
await t.enterText(day, '9');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '1990-04-09');
|
||||||
|
await t.enterText(year, '19');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '');
|
||||||
|
// A provider rebuild must retain the other components of an incomplete date.
|
||||||
|
expect(t.widget<TextFormField>(day).controller!.text, '9');
|
||||||
|
expect(find.text('April'), findsOneWidget);
|
||||||
|
await t.enterText(year, '1985');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '1985-04-09');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('leap dates, impossible days and future dates recover inline', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
final value = ValueNotifier('2000-02-29');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(t, value);
|
||||||
|
expect(find.text('February'), findsOneWidget);
|
||||||
|
await t.enterText(year, '2001');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '');
|
||||||
|
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
|
||||||
|
expect(t.widget<TextFormField>(day).controller!.text, '29');
|
||||||
|
await t.enterText(year, '2004');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '2004-02-29');
|
||||||
|
expect(find.text('Enter a valid date of birth.'), findsNothing);
|
||||||
|
await t.enterText(day, '31');
|
||||||
|
await select(t, 'April');
|
||||||
|
expect(value.value, '');
|
||||||
|
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
|
||||||
|
await t.enterText(day, '30');
|
||||||
|
await t.enterText(year, '${DateTime.now().year + 1}');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '');
|
||||||
|
expect(find.text('Date of birth cannot be in the future.'), findsOneWidget);
|
||||||
|
await t.enterText(year, '1990');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '1990-04-30');
|
||||||
|
await t.enterText(day, '0');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '');
|
||||||
|
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
|
||||||
|
await t.enterText(day, '1');
|
||||||
|
await t.enterText(year, '0000');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '');
|
||||||
|
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'prefill, external replacement, locale change and clear synchronize',
|
||||||
|
(t) async {
|
||||||
|
final value = ValueNotifier('1990-04-23');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(t, value);
|
||||||
|
expect(t.widget<TextFormField>(year).controller!.text, '1990');
|
||||||
|
value.value = '1985-12-09';
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(t.widget<TextFormField>(day).controller!.text, '09');
|
||||||
|
expect(t.widget<TextFormField>(year).controller!.text, '1985');
|
||||||
|
expect(find.text('December'), findsOneWidget);
|
||||||
|
await mount(t, value, locale: const Locale('ru'));
|
||||||
|
expect(find.text('Дата рождения'), findsOneWidget);
|
||||||
|
expect(find.text('декабрь'), findsOneWidget);
|
||||||
|
expect(value.value, '1985-12-09');
|
||||||
|
await select(t, 'апрель');
|
||||||
|
expect(value.value, '1985-04-09');
|
||||||
|
value.value = '';
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(t.widget<TextFormField>(day).controller!.text, '');
|
||||||
|
expect(t.widget<TextFormField>(year).controller!.text, '');
|
||||||
|
expect(find.text('апрель'), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('digits and length limits keep each component usable', (t) async {
|
||||||
|
final value = ValueNotifier('1990-04-23');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(t, value);
|
||||||
|
await t.enterText(day, '');
|
||||||
|
await t.enterText(day, 'a123');
|
||||||
|
await t.enterText(year, '');
|
||||||
|
await t.enterText(year, 'b19856');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(t.widget<TextFormField>(day).controller!.text, '12');
|
||||||
|
expect(t.widget<TextFormField>(year).controller!.text, '1985');
|
||||||
|
expect(value.value, '1985-04-12');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('busy state disables every birthday control', (t) async {
|
||||||
|
final value = ValueNotifier('1990-04-23');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(t, value, enabled: false);
|
||||||
|
expect(t.widget<TextFormField>(day).enabled, isFalse);
|
||||||
|
expect(t.widget<TextFormField>(year).enabled, isFalse);
|
||||||
|
expect(t.widget<DropdownButtonFormField<int>>(month).onChanged, isNull);
|
||||||
|
expect(value.value, '1990-04-23');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('keyboard can traverse, choose month, cancel and edit year', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
final value = ValueNotifier('1990-04-23');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(t, value);
|
||||||
|
await t.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await t.pump();
|
||||||
|
expect(
|
||||||
|
t
|
||||||
|
.widget<EditableText>(
|
||||||
|
find.descendant(of: day, matching: find.byType(EditableText)),
|
||||||
|
)
|
||||||
|
.focusNode
|
||||||
|
.hasFocus,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
await t.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await t.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await t.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||||
|
await t.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '1990-05-23');
|
||||||
|
await t.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await t.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '1990-05-23');
|
||||||
|
await t.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await t.pump();
|
||||||
|
expect(
|
||||||
|
t
|
||||||
|
.widget<EditableText>(
|
||||||
|
find.descendant(of: year, matching: find.byType(EditableText)),
|
||||||
|
)
|
||||||
|
.focusNode
|
||||||
|
.hasFocus,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Russian controls and errors fit 320px with large text and labeled targets',
|
||||||
|
(t) async {
|
||||||
|
t.view.physicalSize = const Size(320, 900);
|
||||||
|
t.view.devicePixelRatio = 1;
|
||||||
|
t.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||||
|
addTearDown(t.view.resetPhysicalSize);
|
||||||
|
addTearDown(t.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
final semantics = t.ensureSemantics();
|
||||||
|
final value = ValueNotifier('1990-09-23');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(t, value, locale: const Locale('ru'));
|
||||||
|
expect(find.text('сентябрь'), findsOneWidget);
|
||||||
|
expect(t, meetsGuideline(labeledTapTargetGuideline));
|
||||||
|
expect(t, meetsGuideline(androidTapTargetGuideline));
|
||||||
|
await t.enterText(day, '31');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.text('Укажите корректную дату рождения.'), findsOneWidget);
|
||||||
|
await select(t, 'март');
|
||||||
|
expect(value.value, '1990-03-31');
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
semantics.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'package:country_picker/country_picker.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/kyc/country.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final search = find.byKey(const ValueKey('country-search'));
|
||||||
|
final button = find.byType(OutlinedButton);
|
||||||
|
|
||||||
|
Future<void> mount(
|
||||||
|
WidgetTester tester,
|
||||||
|
ValueNotifier<String> value, {
|
||||||
|
Locale locale = const Locale('en'),
|
||||||
|
bool enabled = true,
|
||||||
|
}) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: SxchngeTheme.light(),
|
||||||
|
locale: locale,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
...AppLocalizations.localizationsDelegates,
|
||||||
|
CountryLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: ValueListenableBuilder<String>(
|
||||||
|
valueListenable: value,
|
||||||
|
builder: (_, code, _) => CountryField(
|
||||||
|
value: code,
|
||||||
|
enabled: enabled,
|
||||||
|
onChanged: (code) => value.value = code,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> open(WidgetTester tester) async {
|
||||||
|
await tester.tap(button);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'name search stores code, reselection and cancel preserve value',
|
||||||
|
(tester) async {
|
||||||
|
final value = ValueNotifier('');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(tester, value);
|
||||||
|
expect(find.text('Select a country'), findsOneWidget);
|
||||||
|
await open(tester);
|
||||||
|
await tester.enterText(search, 'polAND');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Poland'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(value.value, 'PL');
|
||||||
|
expect(find.text('Poland'), findsOneWidget);
|
||||||
|
expect(find.text('0/2'), findsNothing);
|
||||||
|
|
||||||
|
await open(tester);
|
||||||
|
await tester.enterText(search, ' de ');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Germany'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(value.value, 'DE');
|
||||||
|
expect(find.text('Germany'), findsOneWidget);
|
||||||
|
|
||||||
|
await open(tester);
|
||||||
|
await tester.enterText(search, 'poland');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byType(BackButton));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(value.value, 'DE');
|
||||||
|
expect(find.text('Germany'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('no-results state recovers through clear and Escape cancels', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final value = ValueNotifier('PL');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(tester, value);
|
||||||
|
await open(tester);
|
||||||
|
await tester.enterText(search, 'no-such-country');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('No countries found'), findsOneWidget);
|
||||||
|
expect(find.byType(ListTile), findsNothing);
|
||||||
|
expect(value.value, 'PL');
|
||||||
|
await tester.tap(find.byTooltip('Clear search'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('No countries found'), findsNothing);
|
||||||
|
expect(find.byType(ListTile), findsWidgets);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(search, findsNothing);
|
||||||
|
expect(value.value, 'PL');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('saved code follows locale and Russian names are searchable', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final value = ValueNotifier('PL');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(tester, value);
|
||||||
|
expect(find.text('Poland'), findsOneWidget);
|
||||||
|
await mount(tester, value, locale: const Locale('ru'));
|
||||||
|
expect(find.text('Польша'), findsOneWidget);
|
||||||
|
expect(value.value, 'PL');
|
||||||
|
await open(tester);
|
||||||
|
await tester.enterText(search, 'Германия');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byType(ListTile));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(value.value, 'DE');
|
||||||
|
expect(find.text('Германия'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('busy field cannot open or change selection', (tester) async {
|
||||||
|
final value = ValueNotifier('PL');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(tester, value, enabled: false);
|
||||||
|
expect(tester.widget<OutlinedButton>(button).onPressed, isNull);
|
||||||
|
await tester.tap(button);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(search, findsNothing);
|
||||||
|
expect(value.value, 'PL');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'keyboard and labeled touch targets work at 320px with large text',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(320, 900);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
final semantics = tester.ensureSemantics();
|
||||||
|
final value = ValueNotifier('GS');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(tester, value);
|
||||||
|
expect(tester, meetsGuideline(labeledTapTargetGuideline));
|
||||||
|
expect(tester, meetsGuideline(androidTapTargetGuideline));
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(search, findsOneWidget);
|
||||||
|
await tester.enterText(search, 'GS');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.widget<ListTile>(find.byType(ListTile)).selected, isTrue);
|
||||||
|
expect(tester, meetsGuideline(labeledTapTargetGuideline));
|
||||||
|
expect(tester, meetsGuideline(androidTapTargetGuideline));
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(search, findsNothing);
|
||||||
|
expect(value.value, 'GS');
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
semantics.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
|||||||
|
|
||||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/kyc/birth.dart';
|
||||||
|
import 'package:sxchnge_app/kyc/country.dart';
|
||||||
import 'package:sxchnge_app/main.dart';
|
import 'package:sxchnge_app/main.dart';
|
||||||
import 'package:sxchnge_app/providers/locale.dart';
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
@@ -65,12 +67,35 @@ void main() {
|
|||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> submit(WidgetTester t) async {
|
Future<void> profile(WidgetTester t) async {
|
||||||
await tap(t, find.byKey(const ValueKey('kyc-start')));
|
await tap(t, find.byKey(const ValueKey('kyc-start')));
|
||||||
await t.enterText(find.byKey(const ValueKey('kyc-name')), 'Example Person');
|
await t.enterText(find.byKey(const ValueKey('kyc-name')), 'Example Person');
|
||||||
await t.enterText(find.byKey(const ValueKey('kyc-birth')), '1990-04-23');
|
await t.enterText(find.byKey(const ValueKey('birth-day')), '23');
|
||||||
await t.enterText(find.byKey(const ValueKey('kyc-country')), 'PL');
|
await tap(t, find.byType(DropdownButtonFormField<int>));
|
||||||
|
// The month label follows the selected app locale.
|
||||||
|
await tap(t, find.textContaining(RegExp(r'^(April|апрель)$')).last);
|
||||||
|
await t.enterText(find.byKey(const ValueKey('birth-year')), '1990');
|
||||||
await t.pumpAndSettle();
|
await t.pumpAndSettle();
|
||||||
|
expect(t.widget<BirthField>(find.byType(BirthField)).value, '1990-04-23');
|
||||||
|
await tap(
|
||||||
|
t,
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const ValueKey('kyc-country')),
|
||||||
|
matching: find.byType(OutlinedButton),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await t.enterText(find.byKey(const ValueKey('country-search')), 'PL');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await tap(t, find.byType(ListTile));
|
||||||
|
expect(
|
||||||
|
t.widget<CountryField>(find.byKey(const ValueKey('kyc-country'))).value,
|
||||||
|
'PL',
|
||||||
|
);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> submit(WidgetTester t) async {
|
||||||
|
await profile(t);
|
||||||
await tap(t, find.byKey(const ValueKey('pick-passport')));
|
await tap(t, find.byKey(const ValueKey('pick-passport')));
|
||||||
await tap(t, find.byKey(const ValueKey('kyc-next')));
|
await tap(t, find.byKey(const ValueKey('kyc-next')));
|
||||||
await tap(t, find.byKey(const ValueKey('pick-selfie')));
|
await tap(t, find.byKey(const ValueKey('pick-selfie')));
|
||||||
@@ -79,6 +104,41 @@ void main() {
|
|||||||
await tap(t, find.byKey(const ValueKey('kyc-submit')));
|
await tap(t, find.byKey(const ValueKey('kyc-submit')));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'invalid birthday blocks advancing and valid birthday survives Back',
|
||||||
|
(t) async {
|
||||||
|
await open(t);
|
||||||
|
await profile(t);
|
||||||
|
await tap(t, find.byKey(const ValueKey('pick-passport')));
|
||||||
|
final next = find.byKey(const ValueKey('kyc-next'));
|
||||||
|
await t.enterText(find.byKey(const ValueKey('birth-day')), '31');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(t.widget<FilledButton>(next).onPressed, isNull);
|
||||||
|
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
|
||||||
|
await t.enterText(find.byKey(const ValueKey('birth-day')), '23');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(t.widget<FilledButton>(next).onPressed, isNotNull);
|
||||||
|
await tap(t, next);
|
||||||
|
await tap(t, find.text('Back'));
|
||||||
|
expect(t.widget<BirthField>(find.byType(BirthField)).value, '1990-04-23');
|
||||||
|
expect(
|
||||||
|
t
|
||||||
|
.widget<TextFormField>(find.byKey(const ValueKey('birth-day')))
|
||||||
|
.controller!
|
||||||
|
.text,
|
||||||
|
'23',
|
||||||
|
);
|
||||||
|
expect(find.text('April'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
t
|
||||||
|
.widget<TextFormField>(find.byKey(const ValueKey('birth-year')))
|
||||||
|
.controller!
|
||||||
|
.text,
|
||||||
|
'1990',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('two photos, review, pending and approval with service limits', (
|
testWidgets('two photos, review, pending and approval with service limits', (
|
||||||
t,
|
t,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -42,6 +42,44 @@ Future<GoRouter> openRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
testWidgets('queued signup receipt is transient and updates its recipient', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final router = await openRoute(tester, '/');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
router.goNamed(
|
||||||
|
AppRoutes.verificationSent,
|
||||||
|
extra: const VerificationDelivery(
|
||||||
|
email: 'first@example.com',
|
||||||
|
isSimulated: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||||
|
expect(find.text('first@example.com'), findsOneWidget);
|
||||||
|
router.goNamed(
|
||||||
|
AppRoutes.verificationSent,
|
||||||
|
extra: const VerificationDelivery(
|
||||||
|
email: 'second@example.com',
|
||||||
|
isSimulated: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('first@example.com'), findsNothing);
|
||||||
|
expect(find.text('second@example.com'), findsOneWidget);
|
||||||
|
final restored = router.routeInformationParser.restoreRouteInformation(
|
||||||
|
router.routerDelegate.currentConfiguration,
|
||||||
|
)!;
|
||||||
|
expect(restored.state.toString(), isNot(contains('second@example.com')));
|
||||||
|
expect(restored.uri.toString(), '/verification-sent');
|
||||||
|
router.goNamed(AppRoutes.login);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
router.goNamed(AppRoutes.verificationSent);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('verification-idle')), findsOneWidget);
|
||||||
|
expect(find.text('second@example.com'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
for (final language in ['en', 'ru']) {
|
for (final language in ['en', 'ru']) {
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'verified link keeps success visible and prefills login ($language)',
|
'verified link keeps success visible and prefills login ($language)',
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/main.dart';
|
||||||
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
|
class DeliveryBackend extends BackendAuthServices {
|
||||||
|
DeliveryBackend(List<String> requests)
|
||||||
|
: super(
|
||||||
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||||
|
clientId: 'web',
|
||||||
|
client: MockClient((request) async {
|
||||||
|
requests.add(request.url.path);
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'data': request.url.path.endsWith('/signup')
|
||||||
|
? {'userId': 'customer'}
|
||||||
|
: {'accepted': true},
|
||||||
|
}),
|
||||||
|
202,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
int sends = 0;
|
||||||
|
bool failResend = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<VerificationResult> send({
|
||||||
|
required String email,
|
||||||
|
required String locale,
|
||||||
|
}) async {
|
||||||
|
sends++;
|
||||||
|
if (failResend) {
|
||||||
|
throw const VerificationException(VerificationError.internal);
|
||||||
|
}
|
||||||
|
return super.send(email: email, locale: locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final language in ['en', 'ru']) {
|
||||||
|
testWidgets(
|
||||||
|
'accepted signup survives an unavailable resend service ($language)',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.platformDispatcher.textScaleFactorTestValue = 1.5;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
final requests = <String>[];
|
||||||
|
final backend = DeliveryBackend(requests);
|
||||||
|
final analytics = DummyAnalyticsService();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
backend: backend,
|
||||||
|
analyticsService: analytics,
|
||||||
|
localeProvider: LocaleProvider(initialLocale: Locale(language)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final signup = find.text(language == 'ru' ? 'Регистрация' : 'Sign up');
|
||||||
|
await tester.ensureVisible(signup);
|
||||||
|
await tester.tap(signup);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
for (final entry in {
|
||||||
|
'name': 'Ada Lovelace',
|
||||||
|
'email': 'ada@example.com',
|
||||||
|
'password': 'password preserved',
|
||||||
|
'confirmation': 'password preserved',
|
||||||
|
}.entries) {
|
||||||
|
await tester.enterText(find.byKey(ValueKey(entry.key)), entry.value);
|
||||||
|
}
|
||||||
|
final submit = find.byKey(const ValueKey('submit'));
|
||||||
|
await tester.ensureVisible(submit);
|
||||||
|
await tester.tap(submit);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||||
|
expect(find.text('ada@example.com'), findsOneWidget);
|
||||||
|
expect(backend.sends, 0);
|
||||||
|
expect(requests, ['/api/v1/accounts/signup']);
|
||||||
|
final router = GoRouter.of(tester.element(find.byType(Scaffold)));
|
||||||
|
final restored = router.routeInformationParser.restoreRouteInformation(
|
||||||
|
router.routerDelegate.currentConfiguration,
|
||||||
|
)!;
|
||||||
|
expect(restored.uri.toString(), '/verification-sent');
|
||||||
|
expect(restored.state.toString(), isNot(contains('ada@example.com')));
|
||||||
|
expect(
|
||||||
|
analytics.events.where(
|
||||||
|
(event) => event.name == 'auth_signup_succeeded',
|
||||||
|
),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
analytics.events.where(
|
||||||
|
(event) => event.name == 'auth_email_delivery_succeeded',
|
||||||
|
),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
final resend = find.byKey(const ValueKey('verification-resend'));
|
||||||
|
await tester.ensureVisible(resend);
|
||||||
|
await tester.tap(resend);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('verification-error')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(backend.sends, 1);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<TextFormField>(
|
||||||
|
find.byKey(const ValueKey('verification-email')),
|
||||||
|
)
|
||||||
|
.controller!
|
||||||
|
.text,
|
||||||
|
'ada@example.com',
|
||||||
|
);
|
||||||
|
backend.failResend = false;
|
||||||
|
await tester.ensureVisible(resend);
|
||||||
|
await tester.tap(resend);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||||
|
expect(backend.sends, 2);
|
||||||
|
expect(requests.last, '/api/v1/accounts/verification/send');
|
||||||
|
expect(requests, hasLength(2));
|
||||||
|
expect(
|
||||||
|
analytics.events.map((event) => event.journeyId).toSet(),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
analytics.events.map((event) => event.properties).toString(),
|
||||||
|
isNot(contains('ada@example.com')),
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,9 +12,13 @@ abstract final class BackendMapper {
|
|||||||
return VerificationResult(isSimulated: false, verifiedEmail: dto.email);
|
return VerificationResult(isSimulated: false, verifiedEmail: dto.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
static AuthResult signup(BackendDTO dto) {
|
static AuthResult signup(BackendDTO dto, {required String email}) {
|
||||||
if (dto.userId.isEmpty) throw const FormatException('Missing user');
|
if (dto.userId.isEmpty) throw const FormatException('Missing user');
|
||||||
return AuthResult(userId: dto.userId, isSimulated: false);
|
return AuthResult(
|
||||||
|
userId: dto.userId,
|
||||||
|
isSimulated: false,
|
||||||
|
delivery: VerificationDelivery(email: email, isSimulated: false),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static FactorChallenge challenge(BackendDTO dto, String id) {
|
static FactorChallenge challenge(BackendDTO dto, String id) {
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import '../models/verification.dart';
|
|||||||
abstract final class AuthMapper {
|
abstract final class AuthMapper {
|
||||||
static AuthResult fromDTO(AuthDTO dto) =>
|
static AuthResult fromDTO(AuthDTO dto) =>
|
||||||
AuthResult(userId: dto.userId, isSimulated: dto.simulated);
|
AuthResult(userId: dto.userId, isSimulated: dto.simulated);
|
||||||
|
|
||||||
|
static AuthResult signup(AuthDTO dto, {required String email}) => AuthResult(
|
||||||
|
userId: dto.userId,
|
||||||
|
isSimulated: dto.simulated,
|
||||||
|
delivery: VerificationDelivery(email: email, isSimulated: dto.simulated),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract final class VerificationMapper {
|
abstract final class VerificationMapper {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'two_factor.dart';
|
import 'two_factor.dart';
|
||||||
|
import 'verification.dart';
|
||||||
|
|
||||||
/// Ephemeral input: never persist, log, or include passwords in provider state.
|
/// Ephemeral input: never persist, log, or include passwords in provider state.
|
||||||
class LoginRequest {
|
class LoginRequest {
|
||||||
@@ -31,9 +32,11 @@ class AuthResult {
|
|||||||
required this.userId,
|
required this.userId,
|
||||||
required this.isSimulated,
|
required this.isSimulated,
|
||||||
this.challenge,
|
this.challenge,
|
||||||
|
this.delivery,
|
||||||
});
|
});
|
||||||
final String userId;
|
final String userId;
|
||||||
final FactorChallenge? challenge;
|
final FactorChallenge? challenge;
|
||||||
|
final VerificationDelivery? delivery;
|
||||||
final bool isSimulated;
|
final bool isSimulated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ enum VerificationStatus {
|
|||||||
|
|
||||||
enum VerificationError { expired, invalid, internal, needsAssistance }
|
enum VerificationError { expired, invalid, internal, needsAssistance }
|
||||||
|
|
||||||
|
/// An accepted email-queue operation, not proof of inbox delivery or activation.
|
||||||
|
class VerificationDelivery {
|
||||||
|
const VerificationDelivery({required this.email, required this.isSimulated});
|
||||||
|
final String email;
|
||||||
|
final bool isSimulated;
|
||||||
|
}
|
||||||
|
|
||||||
class VerificationException implements Exception {
|
class VerificationException implements Exception {
|
||||||
const VerificationException(this.code);
|
const VerificationException(this.code);
|
||||||
final VerificationError code;
|
final VerificationError code;
|
||||||
|
|||||||
@@ -4,13 +4,22 @@ import '../models/verification.dart';
|
|||||||
import '../services/verification.dart';
|
import '../services/verification.dart';
|
||||||
|
|
||||||
class VerificationProvider extends ChangeNotifier {
|
class VerificationProvider extends ChangeNotifier {
|
||||||
VerificationProvider(this.service);
|
VerificationProvider(this.service, {VerificationDelivery? delivery})
|
||||||
|
: _state = _initialState(delivery);
|
||||||
final VerificationService service;
|
final VerificationService service;
|
||||||
VerificationState _state = const VerificationState(VerificationStatus.idle);
|
VerificationState _state;
|
||||||
VerificationState get state => _state;
|
VerificationState get state => _state;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
int _generation = 0;
|
int _generation = 0;
|
||||||
|
|
||||||
|
static VerificationState _initialState(VerificationDelivery? delivery) =>
|
||||||
|
delivery == null
|
||||||
|
? const VerificationState(VerificationStatus.idle)
|
||||||
|
: VerificationState(
|
||||||
|
VerificationStatus.sent,
|
||||||
|
isSimulated: delivery.isSimulated,
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> send({required String email, required String locale}) => _run(
|
Future<void> send({required String email, required String locale}) => _run(
|
||||||
() => service.send(email: email.trim(), locale: locale),
|
() => service.send(email: email.trim(), locale: locale),
|
||||||
VerificationStatus.sending,
|
VerificationStatus.sending,
|
||||||
@@ -61,10 +70,10 @@ class VerificationProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void reset() {
|
void reset({VerificationDelivery? delivery}) {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
++_generation;
|
++_generation;
|
||||||
_state = const VerificationState(VerificationStatus.idle);
|
_state = _initialState(delivery);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ class BackendAuthServices
|
|||||||
final _pending = <String, ({String token, String challengeId})>{};
|
final _pending = <String, ({String token, String challengeId})>{};
|
||||||
final _grants = <String, AuthSession>{};
|
final _grants = <String, AuthSession>{};
|
||||||
final _retryKeys = <String, String>{};
|
final _retryKeys = <String, String>{};
|
||||||
String? _queuedSignup;
|
|
||||||
AuthSession? _session;
|
AuthSession? _session;
|
||||||
AuthSession? get session => _session;
|
AuthSession? get session => _session;
|
||||||
@override
|
@override
|
||||||
@@ -113,9 +112,8 @@ class BackendAuthServices
|
|||||||
'locale': r.locale,
|
'locale': r.locale,
|
||||||
'idempotencyKey': key,
|
'idempotencyKey': key,
|
||||||
});
|
});
|
||||||
final result = BackendMapper.signup(dto);
|
final result = BackendMapper.signup(dto, email: email);
|
||||||
_retryKeys.remove('signup:$email');
|
_retryKeys.remove('signup:$email');
|
||||||
_queuedSignup = email;
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,14 +139,6 @@ class BackendAuthServices
|
|||||||
required String locale,
|
required String locale,
|
||||||
}) async {
|
}) async {
|
||||||
final normalized = email.trim().toLowerCase();
|
final normalized = email.trim().toLowerCase();
|
||||||
// Signup already queued this email. The existing confirmation page requests
|
|
||||||
// its initial delivery on mount; consume that receipt once.
|
|
||||||
if (_queuedSignup == normalized) {
|
|
||||||
_queuedSignup = null;
|
|
||||||
return VerificationMapper.fromDTO(
|
|
||||||
const VerificationDTO(simulated: false),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final key = _retryKeys.putIfAbsent('activation:$normalized', _key);
|
final key = _retryKeys.putIfAbsent('activation:$normalized', _key);
|
||||||
try {
|
try {
|
||||||
await _post('verification/send', {
|
await _post('verification/send', {
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ class DummySignupService implements SignupService, SimulationInfo {
|
|||||||
Future<AuthResult> signup(SignupRequest request) async {
|
Future<AuthResult> signup(SignupRequest request) async {
|
||||||
await Future<void>.delayed(delay);
|
await Future<void>.delayed(delay);
|
||||||
if (failure case final error?) throw AuthException(error);
|
if (failure case final error?) throw AuthException(error);
|
||||||
return AuthMapper.fromDTO(
|
return AuthMapper.signup(
|
||||||
const AuthDTO(userId: 'demo-user', simulated: true),
|
const AuthDTO(userId: 'demo-user', simulated: true),
|
||||||
|
email: request.email.trim(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,13 +111,27 @@ class InstrumentedAuthServices
|
|||||||
simulation: _simulation(loginService),
|
simulation: _simulation(loginService),
|
||||||
);
|
);
|
||||||
@override
|
@override
|
||||||
Future<AuthResult> signup(SignupRequest request) => _run(
|
Future<AuthResult> signup(SignupRequest request) async {
|
||||||
JourneyKind.signup,
|
final result = await _run(
|
||||||
JourneyStep.signup,
|
JourneyKind.signup,
|
||||||
() => signupService.signup(request),
|
JourneyStep.signup,
|
||||||
(r) => r.isSimulated,
|
() => signupService.signup(request),
|
||||||
simulation: _simulation(signupService),
|
(r) => r.isSimulated,
|
||||||
);
|
simulation: _simulation(signupService),
|
||||||
|
);
|
||||||
|
final delivery = result.delivery;
|
||||||
|
if (delivery != null) {
|
||||||
|
final operation = recorder.begin(
|
||||||
|
JourneyKind.verification,
|
||||||
|
JourneyStep.emailDelivery,
|
||||||
|
onboarding: true,
|
||||||
|
simulated: delivery.isSimulated,
|
||||||
|
);
|
||||||
|
recorder.finish(operation, simulated: delivery.isSimulated);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<VerificationResult> send({
|
Future<VerificationResult> send({
|
||||||
required String email,
|
required String email,
|
||||||
|
|||||||
@@ -142,11 +142,11 @@ void main() {
|
|||||||
.isSimulated,
|
.isSimulated,
|
||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
expect(
|
final accepted = await const DummySignupService(delay: Duration.zero)
|
||||||
(await const DummySignupService(delay: Duration.zero).signup(signup))
|
.signup(signup);
|
||||||
.isSimulated,
|
expect(accepted.isSimulated, isTrue);
|
||||||
isTrue,
|
expect(accepted.delivery?.email, 'a@example.com');
|
||||||
);
|
expect(accepted.delivery?.isSimulated, isTrue);
|
||||||
await expectLater(
|
await expectLater(
|
||||||
const DummyLoginService(
|
const DummyLoginService(
|
||||||
delay: Duration.zero,
|
delay: Duration.zero,
|
||||||
|
|||||||
@@ -89,35 +89,48 @@ void main() {
|
|||||||
expect(keys[0], keys[1]);
|
expect(keys[0], keys[1]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
test('signup receipt avoids duplicate delivery and subsequent resend reaches API', () async {
|
test(
|
||||||
final paths = <String>[];
|
'signup returns its receipt and every explicit resend reaches API',
|
||||||
final service = BackendAuthServices(
|
() async {
|
||||||
baseUri: Uri.parse('https://api.example/api/v1'),
|
final paths = <String>[];
|
||||||
clientId: 'web',
|
final service = BackendAuthServices(
|
||||||
client: MockClient((request) async {
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||||
paths.add(request.url.path);
|
clientId: 'web',
|
||||||
if (request.url.path.endsWith('/signup')) {
|
client: MockClient((request) async {
|
||||||
expect(jsonDecode(request.body)['password'], ' password preserved ');
|
paths.add(request.url.path);
|
||||||
return response({'userId': 'user'});
|
if (request.url.path.endsWith('/signup')) {
|
||||||
}
|
expect(
|
||||||
return response({'accepted': true});
|
jsonDecode(request.body)['password'],
|
||||||
}),
|
' password preserved ',
|
||||||
);
|
);
|
||||||
addTearDown(service.close);
|
return response({'userId': 'user'});
|
||||||
final result = await service.signup(
|
}
|
||||||
const SignupRequest(
|
return response({'accepted': true});
|
||||||
name: 'User',
|
}),
|
||||||
email: 'USER@example.com',
|
);
|
||||||
password: ' password preserved ',
|
addTearDown(service.close);
|
||||||
locale: 'en',
|
final result = await service.signup(
|
||||||
),
|
const SignupRequest(
|
||||||
);
|
name: 'User',
|
||||||
expect(result.isSimulated, isFalse);
|
email: 'USER@example.com',
|
||||||
await service.send(email: 'user@example.com', locale: 'en');
|
password: ' password preserved ',
|
||||||
expect(paths, ['/api/v1/accounts/signup']);
|
locale: 'en',
|
||||||
await service.send(email: 'user@example.com', locale: 'en');
|
),
|
||||||
expect(paths.last, '/api/v1/accounts/verification/send');
|
);
|
||||||
});
|
expect(result.isSimulated, isFalse);
|
||||||
|
expect(result.delivery?.email, 'user@example.com');
|
||||||
|
expect(result.delivery?.isSimulated, isFalse);
|
||||||
|
expect(paths, ['/api/v1/accounts/signup']);
|
||||||
|
await service.send(email: 'user@example.com', locale: 'en');
|
||||||
|
expect(paths, [
|
||||||
|
'/api/v1/accounts/signup',
|
||||||
|
'/api/v1/accounts/verification/send',
|
||||||
|
]);
|
||||||
|
await service.send(email: 'user@example.com', locale: 'en');
|
||||||
|
expect(paths.last, '/api/v1/accounts/verification/send');
|
||||||
|
expect(paths, hasLength(3));
|
||||||
|
},
|
||||||
|
);
|
||||||
test('login confirms backend session only after factor and consumes local grant once', () async {
|
test('login confirms backend session only after factor and consumes local grant once', () async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
var verified = false;
|
var verified = false;
|
||||||
|
|||||||
@@ -28,6 +28,36 @@ class PendingService implements VerificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
test(
|
||||||
|
'accepted delivery initializes sent state without calling the service',
|
||||||
|
() async {
|
||||||
|
final service = PendingService();
|
||||||
|
final provider = VerificationProvider(
|
||||||
|
service,
|
||||||
|
delivery: const VerificationDelivery(
|
||||||
|
email: 'ada@example.com',
|
||||||
|
isSimulated: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(provider.dispose);
|
||||||
|
expect(provider.state.status, VerificationStatus.sent);
|
||||||
|
expect(provider.state.isSimulated, isFalse);
|
||||||
|
expect(provider.state.result, isNull);
|
||||||
|
expect(service.calls, 0);
|
||||||
|
final resend = provider.send(email: 'ada@example.com', locale: 'en');
|
||||||
|
expect(provider.state.status, VerificationStatus.sending);
|
||||||
|
expect(service.calls, 1);
|
||||||
|
service.pending.completeError(
|
||||||
|
const VerificationException(VerificationError.internal),
|
||||||
|
);
|
||||||
|
await resend;
|
||||||
|
expect(provider.state.status, VerificationStatus.error);
|
||||||
|
expect(provider.state.result, isNull);
|
||||||
|
provider.reset();
|
||||||
|
expect(provider.state.status, VerificationStatus.idle);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
for (final scenario in {
|
for (final scenario in {
|
||||||
'demo-valid': VerificationStatus.verified,
|
'demo-valid': VerificationStatus.verified,
|
||||||
'demo-expired': VerificationStatus.expired,
|
'demo-expired': VerificationStatus.expired,
|
||||||
|
|||||||
Reference in new issue
Block a user