10 Commits
45 changed files with 1662 additions and 144 deletions

No files matched your search

+1
View File
@@ -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)
+15
View File
@@ -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)
+50
View File
@@ -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
}
+28
View File
@@ -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))
}
+1
View File
@@ -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)
+1 -1
View File
@@ -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
} }
+16 -6
View File
@@ -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 {
+4 -2
View File
@@ -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
View File
@@ -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
+63
View File
@@ -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.
+45 -23
View File
@@ -302,32 +302,54 @@ 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.
## Empty currency accounts (task #66) ## Automatic currency accounts (task #72)
The shared empty-account message used on Accounts and transfer screens explains New customers receive RUB, USDT and USDC accounts through backend-owned setup
that no currency accounts have been set up and directs the customer to support during signup. Existing customers receive missing defaults on their next wallet
to request setup. English and Russian no longer suggest that refreshing creates load. The UI requires no account-creation form or support contact. During initial
accounts or imply that automatic provisioning is underway. This follows the loading it announces that the accounts are being prepared. Failure shows one
existing support guidance for missing funding and payout setup. The application clear load error and a **Try again** action that reruns the same idempotent setup.
has no configured support contact channel; the message therefore provides text An unexpected empty snapshot has its own setup-failure fallback, rather than
guidance without a nonfunctional contact button or invented contact details. being shown simultaneously with an error. A failed refresh retains existing
cards and the stale-data warning. Account-not-found copy requires loaded data.
UX review: the copy names the missing resource and the next step, retains the The existing DTO → mapper → service → model → provider → UI boundaries remain
existing wrapping text and accessible reading order, and does not alter loading, intact. Account creation belongs to the backend/provider, and widgets invoke the
error or populated-state behavior. Existing manual refresh remains available for existing provider refresh action. English and Russian use wrapping text and the
accounts provisioned externally. This copy-only change adds no controls or existing accessible button pattern; status text uses a live region. Dedicated
journey outcomes and requires no new analytics events. Existing transaction widget regressions cover loading, failure, retry and successful default accounts
journey signals and their production measurement limitations below still apply; on Accounts, Top up, Exchange and Payout at 320px with 1.6× text. Native device and
support resolution and external account provisioning are not measured in-app. screen-reader QA remain a release check; tests do not claim physical-device QA.
Validation: `flutter gen-l10n`, `flutter analyze` in `frontend/app`, Measurement: `sxchnge_currency_accounts_setup_total{outcome="ready|failed"}` and
`make test-frontend` (217 tests), and `git diff --check` passed. No Dart source `sxchnge_currency_accounts_setup_duration_seconds` cover the server setup attempt.
changed, so Dart formatting was not applicable. Existing responsive workspace `ready` is emitted only after all default bindings are confirmed, including
tests cover Russian at 320px with enlarged text; this copy-only change does not idempotent completion of provider wallet/route setup for newly bound ledgers.
add a dedicated empty-state rendering or native screen-reader test. No security Each signup/resend/wallet load is a separate attempt; a retry is not a new unique
scan was requested. Delivery: [task #66](https://git.sendico.io/tech/sxchnge/issues/66), customer conversion. The failure fraction uses all attempts as its denominator;
branch `wallet-66`, [PR #68](https://git.sendico.io/tech/sxchnge/pulls/68). the histogram measures one attempt, not time across multiple browser sessions.
Merge and deployment remain separate. 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
+48
View File
@@ -211,3 +211,51 @@ smoke testing on the local demo opened the selector, searched for Poland and
confirmed that the profile displays Poland after selection. Focused and full confirmed that the profile displays Poland after selection. Focused and full
journey tests verify the stored `PL` code. Generated localization/build outputs journey tests verify the stored `PL` code. Generated localization/build outputs
remain ignored and uncommitted. 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.
+2 -1
View File
@@ -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(
+206
View File
@@ -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(),
);
}
+4 -9
View File
@@ -2,6 +2,7 @@ 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 'country.dart';
import '../generated/i18n/app_localizations.dart'; import '../generated/i18n/app_localizations.dart';
@@ -37,17 +38,11 @@ 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),
CountryField( CountryField(
+9 -2
View File
@@ -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": "No currency accounts have been set up for you yet. Contact support to request account setup.", "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,7 +272,11 @@
"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",
"kycCountrySelect": "Select a country", "kycCountrySelect": "Select a country",
"kycCountrySearch": "Search countries", "kycCountrySearch": "Search countries",
+9 -2
View File
@@ -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,7 +272,11 @@
"kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.", "kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.",
"kycFullName": "Полное имя по документу", "kycFullName": "Полное имя по документу",
"kycBirth": "Дата рождения", "kycBirth": "Дата рождения",
"kycBirthHelp": "ГГГГ-ММ-ДД, например 1990-04-23", "kycBirthDay": "День",
"kycBirthMonth": "Месяц",
"kycBirthYear": "Год",
"kycBirthInvalid": "Укажите корректную дату рождения.",
"kycBirthFuture": "Дата рождения не может быть в будущем.",
"kycCountry": "Страна проживания", "kycCountry": "Страна проживания",
"kycCountrySelect": "Выберите страну", "kycCountrySelect": "Выберите страну",
"kycCountrySearch": "Поиск страны", "kycCountrySearch": "Поиск страны",
+5
View File
@@ -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(
+24 -8
View File
@@ -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();
}
}); });
} }
+22 -3
View File
@@ -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),
+16 -4
View File
@@ -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}'),
+1 -1
View File
@@ -206,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"
+1
View File
@@ -32,6 +32,7 @@ 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
+104
View File
@@ -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());
},
);
}
}
}
+236
View File
@@ -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();
},
);
}
+48 -2
View File
@@ -11,6 +11,7 @@ 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/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';
@@ -66,10 +67,16 @@ 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 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();
expect(t.widget<BirthField>(find.byType(BirthField)).value, '1990-04-23');
await tap( await tap(
t, t,
find.descendant( find.descendant(
@@ -85,6 +92,10 @@ void main() {
'PL', 'PL',
); );
await t.pumpAndSettle(); 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')));
@@ -93,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 {
+38
View File
@@ -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)',
+153
View File
@@ -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,