4 Commits
26 changed files with 411 additions and 692 deletions

No files matched your search

-1
View File
@@ -75,7 +75,6 @@ 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,13 +31,8 @@ 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
@@ -115,11 +110,6 @@ 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()
@@ -158,11 +148,6 @@ 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,17 +75,6 @@ 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()
@@ -113,8 +102,6 @@ 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)
@@ -146,14 +133,6 @@ 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)
@@ -170,7 +149,6 @@ 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
@@ -1,50 +0,0 @@
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
@@ -1,28 +0,0 @@
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,7 +53,6 @@ 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.ensureAccounts(ctx, a) subject, err := s.Identity.Subject(ctx, a)
if err != nil { if err != nil {
return Snapshot{}, err return Snapshot{}, err
} }
+6 -16
View File
@@ -76,25 +76,15 @@ 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
} }
@@ -1,87 +0,0 @@
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
}
@@ -1,200 +0,0 @@
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)
}
@@ -1,14 +0,0 @@
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,7 +27,9 @@ func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Accou
scales[row.Code] = *row.Scale scales[row.Code] = *row.Scale
} }
} }
var data accountListDTO var data struct {
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
@@ -37,7 +39,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 !c.validCustomerAccount(row, subject) { 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" {
return nil, payments.Unavailable return nil, payments.Unavailable
} }
scale, ok := scales[row.Currency] scale, ok := scales[row.Currency]
@@ -54,15 +54,14 @@ 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"`
AllowNegative bool `json:"allowNegative"` Metadata map[string]string `json:"metadata"`
Metadata map[string]string `json:"metadata"`
} }
type currencyDTO struct { type currencyDTO struct {
Code string `json:"code"` Code string `json:"code"`
@@ -73,7 +72,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, accepted ...int) error { func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any) error {
token, err := c.token() token, err := c.token()
if err != nil { if err != nil {
return err return err
@@ -99,11 +98,7 @@ 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.
expected := http.StatusOK if res.StatusCode != 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 {
+2 -4
View File
@@ -130,10 +130,8 @@ 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.
Signup and wallet access automatically ensure subject-bound RUB, USDT and USDC An empty wallet is expected until dedicated subject-bound currency ledgers and
ledgers as described in [account setup](payments.md#automatic-currency-accounts-72). saved methods are provisioned as described in [payment provisioning](payments.md).
Deploy the provider prerequisite and grant ledger-create permission first.
Saved funding and payout methods remain separate from currency-account setup.
## Recovery ## Recovery
+7 -36
View File
@@ -52,50 +52,22 @@ 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 create/read, account's organization membership, payment-create/read, ledger-account/balance
balance read and the permissions needed to resolve its stored payment methods. Verify 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.
### Automatic currency accounts (#72) Through Sendico's existing operator APIs, provision one dedicated unowned
organization ledger per customer currency, with active `asset` / `operating`
The backend ensures **RUB, USDT and USDC** accounts after confirmed identity intake semantics, no negative balance and metadata:
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 |
Existing customer ledgers and their balances are preserved, including manually These are financial-resource bindings maintained by trusted provisioning, not
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,
@@ -107,8 +79,7 @@ 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.
If setup reports a failure, the API returns an unavailable wallet rather than an An empty set of provisioned ledgers is an empty wallet. Existing organization
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
+23 -45
View File
@@ -302,54 +302,32 @@ 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) ## Empty currency accounts (task #66)
New customers receive RUB, USDT and USDC accounts through backend-owned setup The shared empty-account message used on Accounts and transfer screens explains
during signup. Existing customers receive missing defaults on their next wallet that no currency accounts have been set up and directs the customer to support
load. The UI requires no account-creation form or support contact. During initial to request setup. English and Russian no longer suggest that refreshing creates
loading it announces that the accounts are being prepared. Failure shows one accounts or imply that automatic provisioning is underway. This follows the
clear load error and a **Try again** action that reruns the same idempotent setup. existing support guidance for missing funding and payout setup. The application
An unexpected empty snapshot has its own setup-failure fallback, rather than has no configured support contact channel; the message therefore provides text
being shown simultaneously with an error. A failed refresh retains existing guidance without a nonfunctional contact button or invented contact details.
cards and the stale-data warning. Account-not-found copy requires loaded data.
The existing DTO → mapper → service → model → provider → UI boundaries remain UX review: the copy names the missing resource and the next step, retains the
intact. Account creation belongs to the backend/provider, and widgets invoke the existing wrapping text and accessible reading order, and does not alter loading,
existing provider refresh action. English and Russian use wrapping text and the error or populated-state behavior. Existing manual refresh remains available for
existing accessible button pattern; status text uses a live region. Dedicated accounts provisioned externally. This copy-only change adds no controls or
widget regressions cover loading, failure, retry and successful default accounts journey outcomes and requires no new analytics events. Existing transaction
on Accounts, Top up, Exchange and Payout at 320px with 1.6× text. Native device and journey signals and their production measurement limitations below still apply;
screen-reader QA remain a release check; tests do not claim physical-device QA. support resolution and external account provisioning are not measured in-app.
Measurement: `sxchnge_currency_accounts_setup_total{outcome="ready|failed"}` and Validation: `flutter gen-l10n`, `flutter analyze` in `frontend/app`,
`sxchnge_currency_accounts_setup_duration_seconds` cover the server setup attempt. `make test-frontend` (217 tests), and `git diff --check` passed. No Dart source
`ready` is emitted only after all default bindings are confirmed, including changed, so Dart formatting was not applicable. Existing responsive workspace
idempotent completion of provider wallet/route setup for newly bound ledgers. tests cover Russian at 320px with enlarged text; this copy-only change does not
Each signup/resend/wallet load is a separate attempt; a retry is not a new unique add a dedicated empty-state rendering or native screen-reader test. No security
customer conversion. The failure fraction uses all attempts as its denominator; scan was requested. Delivery: [task #66](https://git.sendico.io/tech/sxchnge/issues/66),
the histogram measures one attempt, not time across multiple browser sessions. branch `wallet-66`, [PR #68](https://git.sendico.io/tech/sxchnge/pulls/68).
No customer, ledger, currency, balance, credentials or form input is a metric Merge and deployment remain separate.
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
+98
View File
@@ -0,0 +1,98 @@
# Identity photo preview correction
Task [#69](https://git.sendico.io/tech/sxchnge/issues/69), branch `kyc-69`.
Delivery: [PR #71](https://git.sendico.io/tech/sxchnge/pulls/71).
This frontend change builds on the [identity verification journey](kyc.md)
and is independent of the birthday controls in task #67.
## Cause and correction
A synthetic valid PNG reproduced this Flutter 3.47.7 browser error:
```text
TypeError: Instance of '_UnmodifiableUint8ListView': type
'_UnmodifiableUint8ListView' is not a subtype of type 'NativeUint8List'
```
The stack reached `Uint8ListToJSUint8Array.toJS` from CanvasKit's browser
image decoder through `MemoryImage` and `ResizeImage`. File selection had
successfully decoded the original typed bytes. The `Photo` constructor then
copied them into an unmodifiable view, which was passed directly into the
preview's web decoder. Native decoding accepted that view, hiding the bug in
native-only tests. The generic image error builder presented the failure as a
damaged document even though the selected bytes were valid.
`PhotoPreview` now creates a native `Uint8List` copy for `MemoryImage` once per
selected photo. The shared domain model stays immutable, and validation,
providers, services and transport contracts remain unchanged. The preview still
requests a decode width of at most 1000 pixels and evicts its decoded cache entry
on replacement or disposal. The extra encoded-byte copy belongs to the preview
and is released with its provider/cache references; no secure-erasure guarantee
is made.
An actual preview failure now says that the preview could not be displayed and
suggests selecting the photo again. The English/Russian fallback keeps at least
the preview's usual height, can grow for long text, and has a separate live-region
semantic node. Existing replacement/removal controls stay available and selected
input is retained. This is distinct from validation's corrupt-file message.
## UX and measurement review
The outcome remains selecting readable ID and selfie photos, checking both on
review, and submitting for confirmed service acceptance. Existing KYC journey
attempts, retries, failure, submission and abandonment signals are reused. No
control-level events, image bytes, filenames, form values or raw exceptions are
added to telemetry. A selection success measures validation; it does not establish
that pixels were painted. Preview-only rendering failures are not separately
counted by existing telemetry. Server review/approval measurement and native
screen-reader QA retain the limitations documented in the original journey.
Regression tests use synthetic JPEG/PNG content only. They start and await real
image decoding outside the widget test's fake async zone, inspect the actual
rendered images, and surface stream errors so an error fallback cannot conceal a
failure. Checks cover portrait/landscape decoding, model immutability, bounded
decode width, stable provider reuse, replacement/removal cache eviction, both
review slots, returning to review, localized fallback/replacement, a 320px view
at 1.6 text scale, and error announcements isolated from surrounding instructions.
The `image` package is already a KYC runtime dependency; its direct app test
dependency is used only to create synthetic fixtures in memory.
## Validation and delivery
The original browser regression failed before the fix while its native equivalent
passed. The complete task diff was reviewed against frontend architecture, UX,
measurement, lifecycle and privacy requirements. Final validation passed:
- In `frontend/app`, `dart format --output=none --set-exit-if-changed
lib/kyc/preview.dart test/preview_test.dart`: two files, no changes.
- In `frontend/app`, `flutter analyze --no-pub`: no issues.
- In `frontend/app`, `flutter test --platform chrome test/preview_test.dart
--reporter expanded --no-pub --verbose`: all six regression tests passed.
- At the repository root, `make test-frontend`: all 228 tests passed across
the shared packages, theme example and app, including the six native preview
regressions and existing KYC journey/analytics tests.
- In `frontend/app`, `flutter build web --no-pub`: release build and Wasm
compatibility dry run succeeded.
Browser regression coverage uses Chrome; Safari and native assistive-technology
testing remain release checks. Generated localization and build code stays
ignored; the tracked app lockfile changes only `image` from transitive to direct
test dependency. The separate birthday worktree was preserved.
## Follow-up contribution review
The complete seven-file contribution was reviewed again before the authorized
merge and frontend rollout. No additional defects were found in the byte-copy
boundary, provider reuse, cache disposal, error semantics/localization, privacy,
architecture or regression coverage. No source changes were needed.
Current `main` (`ec4115d`, including the birthday, signup-delivery and account
setup changes) was merged into `kyc-69` before final verification. The two-file
format check and app analyzer passed; all six Chrome preview regressions and
all 240 tests in `make test-frontend` passed on the combined code. The original
228-test result above describes the earlier isolated branch.
The authorized rollout rebuilds only `frontend` from clean, pulled `main`, using
the repository's fresh stable-release resolver and digest-pinned local inputs.
Runtime image, HTTPS and served-bundle verification are recorded in task #69
after the rollout; generated local pins remain ignored.
+18 -4
View File
@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart';
@@ -15,8 +17,12 @@ class PhotoPreview extends StatefulWidget {
class _PhotoPreviewState extends State<PhotoPreview> { class _PhotoPreviewState extends State<PhotoPreview> {
late ImageProvider _image = _provider(); late ImageProvider _image = _provider();
ImageProvider _provider() => ImageProvider _provider() => ResizeImage.resizeIfNeeded(
ResizeImage.resizeIfNeeded(1000, null, MemoryImage(widget.photo.bytes)); 1000,
null,
// Web decoders require a native typed array, not the model's read-only view.
MemoryImage(Uint8List.fromList(widget.photo.bytes)),
);
@override @override
void didUpdateWidget(covariant PhotoPreview oldWidget) { void didUpdateWidget(covariant PhotoPreview oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -38,7 +44,15 @@ class _PhotoPreviewState extends State<PhotoPreview> {
height: 220, height: 220,
fit: BoxFit.contain, fit: BoxFit.contain,
semanticLabel: widget.label, semanticLabel: widget.label,
errorBuilder: (_, _, _) => errorBuilder: (_, _, _) => ConstrainedBox(
Text(AppLocalizations.of(context)!.kycErrorUnreadable), constraints: const BoxConstraints(minHeight: 220),
child: Center(
child: Semantics(
container: true,
liveRegion: true,
child: Text(AppLocalizations.of(context)!.kycErrorPreview),
),
),
),
); );
} }
+2 -4
View File
@@ -128,6 +128,7 @@
"kycErrorFormat": "Choose a JPEG or PNG image. Other formats are not supported.", "kycErrorFormat": "Choose a JPEG or PNG image. Other formats are not supported.",
"kycErrorLarge": "This image is too large. Choose one under 10 MB and 40 megapixels.", "kycErrorLarge": "This image is too large. Choose one under 10 MB and 40 megapixels.",
"kycErrorUnreadable": "This image is damaged or cannot be read. Choose another photo.", "kycErrorUnreadable": "This image is damaged or cannot be read. Choose another photo.",
"kycErrorPreview": "The photo preview could not be displayed. Try choosing the photo again.",
"kycErrorSmall": "The photo is too small. Use an image at least 600 pixels wide and high.", "kycErrorSmall": "The photo is too small. Use an image at least 600 pixels wide and high.",
"kycErrorIncomplete": "Choose both photos and confirm they belong to you before submitting.", "kycErrorIncomplete": "Choose both photos and confirm they belong to you before submitting.",
"kycErrorSession": "Your session has expired. Return to login, then check your verification status.", "kycErrorSession": "Your session has expired. Return to login, then check your verification status.",
@@ -160,10 +161,7 @@
"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": "We couldn’t finish setting up your currency accounts. Try again to continue.", "wEmptyAccounts": "No currency accounts have been set up for you yet. Contact support to request account setup.",
"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",
+2 -4
View File
@@ -128,6 +128,7 @@
"kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.", "kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.",
"kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.", "kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.",
"kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.", "kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.",
"kycErrorPreview": "Не удалось показать фото. Попробуйте выбрать его ещё раз.",
"kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.", "kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.",
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш документ.", "kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш документ.",
"kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.", "kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.",
@@ -160,10 +161,7 @@
"wRecent": "Последние операции", "wRecent": "Последние операции",
"wSeeAll": "Смотреть все", "wSeeAll": "Смотреть все",
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.", "wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
"wEmptyAccounts": "Не удалось завершить настройку валютных счетов. Повторите попытку, чтобы продолжить.", "wEmptyAccounts": "У вас ещё нет валютных счетов. Обратитесь в поддержку за помощью с их открытием.",
"wAccountsLoading": "Подготавливаем ваши счета в RUB, USDT и USDC…",
"wAccountsFailed": "Не удалось загрузить валютные счета. Повторите попытку, чтобы продолжить.",
"wAccountsRetry": "Повторить попытку",
"wRefresh": "Обновить", "wRefresh": "Обновить",
"wSource": "Со счёта", "wSource": "Со счёта",
"wDestination": "На счёт", "wDestination": "На счёт",
+3 -22
View File
@@ -65,34 +65,15 @@ class AccountsPage extends StatelessWidget {
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
if (p.loading) const LinearProgressIndicator(), if (p.loading) const LinearProgressIndicator(),
if ((p.loading || (!p.failed && p.data == null)) && if (p.failed)
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 && if (!p.loading && accounts.isEmpty) Text(l.wEmptyAccounts),
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),
+4 -16
View File
@@ -256,23 +256,11 @@ class TransferPageState extends State<TransferPage> {
), ),
] else ...[ ] else ...[
if (accounts.isEmpty) ...[ if (accounts.isEmpty) ...[
Semantics( Text(wallet.loading ? l.wWorking : l.wEmptyAccounts),
liveRegion: true, TextButton(
child: Text( onPressed: wallet.loading ? null : wallet.refresh,
wallet.loading || child: Text(l.wRefresh),
(!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
@@ -222,7 +222,7 @@ packages:
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image: image:
dependency: transitive dependency: "direct dev"
description: description:
name: image name: image
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
+1
View File
@@ -29,6 +29,7 @@ dependencies:
go_router: ^18.0.1 go_router: ^18.0.1
country_picker: ^2.0.28 country_picker: ^2.0.28
dev_dependencies: dev_dependencies:
image: ^4.9.0
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
-104
View File
@@ -1,104 +0,0 @@
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());
},
);
}
}
}
+229
View File
@@ -0,0 +1,229 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as images;
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_app/kyc/photo.dart';
import 'package:sxchnge_app/kyc/preview.dart';
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
Widget host(Widget child, {Locale locale = const Locale('en')}) => MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: SingleChildScrollView(child: child)),
);
// Start decoding outside FakeAsync as well as waiting outside it, including
// browser codec callbacks that otherwise remain pending in the test zone.
Future<void> show(WidgetTester tester, Widget widget) async {
await tester.runAsync(() => tester.pumpWidget(widget));
}
// pumpAndSettle alone does not wait for image codecs. Listen to the actual
// preview streams so a hidden errorBuilder cannot make this regression pass.
Future<void> decoded(WidgetTester tester, {bool succeeds = true}) async {
final providers = tester
.widgetList<Image>(find.byType(Image))
.map((image) => image.image)
.toList();
expect(providers, isNotEmpty);
for (final provider in providers) {
Object? renderError;
StackTrace? renderStack;
await tester.runAsync(() async {
final stream = provider.resolve(ImageConfiguration.empty);
final ready = Completer<void>();
final listener = ImageStreamListener(
(info, _) {
info.dispose();
if (!ready.isCompleted) ready.complete();
},
onError: (Object error, StackTrace? stack) {
renderError = error;
renderStack = stack;
if (!ready.isCompleted) ready.complete();
},
);
stream.addListener(listener);
try {
await ready.future.timeout(const Duration(seconds: 15));
} finally {
stream.removeListener(listener);
}
});
expect(
renderError,
succeeds ? isNull : isNotNull,
reason: succeeds ? '$renderError\n$renderStack' : null,
);
}
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
}
void main() {
late Uint8List png;
late Uint8List jpeg;
setUpAll(() {
// Synthetic, non-sensitive portrait and landscape photos in both formats.
png = images.encodePng(images.Image(width: 1200, height: 1600));
jpeg = images.encodeJpg(images.Image(width: 1600, height: 1200));
});
for (final format in ['PNG', 'JPEG']) {
testWidgets('accepted $format renders from an immutable photo model', (
tester,
) async {
final source = Uint8List.fromList(format == 'PNG' ? png : jpeg);
final photo = (await tester.runAsync(
() => FilePhotoService.validate(source),
))!;
final originalFirstByte = photo.bytes.first;
source[0] = 0;
expect(photo.bytes.first, originalFirstByte);
expect(() => photo.bytes[0] = 0, throwsUnsupportedError);
await show(
tester,
host(PhotoPreview(photo: photo, label: 'ID document photo')),
);
await decoded(tester);
final raw = tester.widget<RawImage>(find.byType(RawImage));
expect(raw.image, isNotNull);
expect(raw.image!.width, lessThanOrEqualTo(1000));
expect(
tester.widget<Image>(find.byType(Image)).semanticLabel,
'ID document photo',
);
expect(find.textContaining('damaged'), findsNothing);
expect(find.textContaining('could not be displayed'), findsNothing);
});
}
testWidgets('replacement and removal evict sensitive preview cache entries', (
tester,
) async {
final first = Photo(png);
await show(tester, host(PhotoPreview(photo: first, label: 'ID')));
await decoded(tester);
final initialProvider = tester.widget<Image>(find.byType(Image)).image;
final initialKey = await initialProvider.obtainKey(
ImageConfiguration.empty,
);
final cache = PaintingBinding.instance.imageCache;
expect(cache.statusForKey(initialKey).keepAlive, isTrue);
await show(tester, host(PhotoPreview(photo: first, label: 'Document')));
await decoded(tester);
expect(
identical(
tester.widget<Image>(find.byType(Image)).image,
initialProvider,
),
isTrue,
);
await show(tester, host(PhotoPreview(photo: Photo(jpeg), label: 'ID')));
await decoded(tester);
expect(cache.statusForKey(initialKey).untracked, isTrue);
final replacement = tester.widget<Image>(find.byType(Image)).image;
final replacementKey = await replacement.obtainKey(
ImageConfiguration.empty,
);
expect(cache.statusForKey(replacementKey).keepAlive, isTrue);
expect(tester.widget<RawImage>(find.byType(RawImage)).image!.width, 1000);
await show(tester, host(const SizedBox()));
await tester.pumpAndSettle();
expect(cache.statusForKey(replacementKey).untracked, isTrue);
});
testWidgets('ID and selfie both render on review and after returning', (
tester,
) async {
final passport = Photo(png);
final selfie = Photo(jpeg);
for (var visit = 0; visit < 2; visit++) {
await show(
tester,
host(
Column(
children: [
PhotoPanel(
kind: PhotoKind.passport,
photo: passport,
review: true,
),
PhotoPanel(kind: PhotoKind.selfie, photo: selfie, review: true),
],
),
),
);
await decoded(tester);
final previews = tester.widgetList<RawImage>(find.byType(RawImage));
expect(previews.length, 2);
expect(previews.every((image) => image.image != null), isTrue);
expect(find.text('Choose a different photo'), findsNothing);
await show(tester, host(const SizedBox()));
await tester.pumpAndSettle();
}
});
for (final locale in ['en', 'ru']) {
testWidgets(
'$locale preview failure explains recovery and can be replaced',
(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);
var replacements = 0;
var removals = 0;
await show(
tester,
host(
PhotoPanel(
kind: PhotoKind.passport,
photo: Photo(Uint8List.fromList([1, 2, 3])),
onPick: () => replacements++,
onRemove: () => removals++,
),
locale: Locale(locale),
),
);
await decoded(tester, succeeds: false);
final l = AppLocalizations.of(tester.element(find.byType(PhotoPanel)))!;
expect(find.text(l.kycErrorPreview), findsOneWidget);
expect(find.text(l.kycErrorUnreadable), findsNothing);
expect(
tester.getSemantics(find.text(l.kycErrorPreview)),
matchesSemantics(isLiveRegion: true, label: l.kycErrorPreview),
);
final replace = find.byKey(const ValueKey('pick-passport'));
await tester.ensureVisible(replace);
await tester.tap(replace);
final remove = find.text(l.kycRemove);
await tester.ensureVisible(remove);
await tester.tap(remove);
expect(replacements, 1);
expect(removals, 1);
await show(
tester,
host(
PhotoPanel(kind: PhotoKind.passport, photo: Photo(jpeg)),
locale: Locale(locale),
),
);
await decoded(tester);
expect(find.text(l.kycErrorPreview), findsNothing);
expect(tester.widget<RawImage>(find.byType(RawImage)).image, isNotNull);
},
);
}
}