Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efeac88a49 | ||
|
|
3b551f1565 | ||
|
|
645181c65c | ||
|
|
9370eacd9c | ||
|
|
5c255296d3 | ||
|
|
62df9eac5f |
No files matched your search
@@ -13,9 +13,9 @@
|
|||||||
- Never commit autogenerated code to the repository. This applies to backend,
|
- Never commit autogenerated code to the repository. This applies to backend,
|
||||||
frontend, shared packages, and every other module, including generated
|
frontend, shared packages, and every other module, including generated
|
||||||
protobuf bindings, API clients, serializers, and framework-generated code.
|
protobuf bindings, API clients, serializers, and framework-generated code.
|
||||||
- Commit the source definitions, generator configuration, and reproducible
|
- Commit the schema source definitions, generator configuration, and reproducible
|
||||||
generation commands instead. Generate code locally or during build and CI
|
generation commands instead. Generate code from the schema source at build
|
||||||
workflows before any dependent validation or compilation steps.
|
time, before any dependent validation or compilation steps, including in CI.
|
||||||
- Keep generated outputs ignored by Git. Do not force-add ignored generated
|
- Keep generated outputs ignored by Git. Do not force-add ignored generated
|
||||||
files or copy generated code into tracked source files.
|
files or copy generated code into tracked source files.
|
||||||
- Change the source definitions or generator inputs rather than editing
|
- Change the source definitions or generator inputs rather than editing
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
|
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
|
||||||
|
deps.Auth.Wallet = deps.Payments
|
||||||
logger.Info("Sendico sign-up facts integration enabled")
|
logger.Info("Sendico sign-up facts integration enabled")
|
||||||
}
|
}
|
||||||
records, err := factorstore.Open(logger, db)
|
records, err := factorstore.Open(logger, db)
|
||||||
|
|||||||
@@ -31,8 +31,13 @@ type SignupFacts interface {
|
|||||||
Ready(context.Context, bson.ObjectID) error
|
Ready(context.Context, bson.ObjectID) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CurrencyAccounts interface {
|
||||||
|
Ensure(context.Context, *customer.Account) error
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
Facts SignupFacts
|
Facts SignupFacts
|
||||||
|
Wallet CurrencyAccounts
|
||||||
Accounts account.DB
|
Accounts account.DB
|
||||||
Customers accountservice.AccountService
|
Customers accountservice.AccountService
|
||||||
Tokens verification.DB
|
Tokens verification.DB
|
||||||
@@ -110,6 +115,11 @@ func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key strin
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if s.Wallet != nil {
|
||||||
|
if err := s.Wallet.Ensure(ctx, a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
if raw != "" {
|
if raw != "" {
|
||||||
u, _ := url.Parse(s.ActivationURL)
|
u, _ := url.Parse(s.ActivationURL)
|
||||||
q := u.Query()
|
q := u.Query()
|
||||||
@@ -148,6 +158,11 @@ func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if s.Wallet != nil {
|
||||||
|
if err := s.Wallet.Ensure(ctx, a); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
_, err = s.sendActivation(ctx, a, key)
|
_, err = s.sendActivation(ctx, a, key)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,17 @@ func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type signupWallet struct {
|
||||||
|
customers []bson.ObjectID
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *signupWallet) Ensure(_ context.Context, a *customer.Account) error {
|
||||||
|
s.customers = append(s.customers, a.ID)
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -102,6 +113,8 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
provider := &signupProvider{fail: true}
|
provider := &signupProvider{fail: true}
|
||||||
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()}
|
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()}
|
||||||
|
wallet := &signupWallet{err: merrors.ServiceUnavailable("account setup temporarily unavailable")}
|
||||||
|
service.Wallet = wallet
|
||||||
// Keep the real verification/customer stores and capture only session writes.
|
// Keep the real verification/customer stores and capture only session writes.
|
||||||
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
||||||
records, err := factorstore.Open(zap.NewNop(), factory)
|
records, err := factorstore.Open(zap.NewNop(), factory)
|
||||||
@@ -133,6 +146,14 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
signup["name"] = "Changed retry name"
|
signup["name"] = "Changed retry name"
|
||||||
provider.fail = false
|
provider.fail = false
|
||||||
w = post("signup", signup)
|
w = post("signup", signup)
|
||||||
|
require.Equal(t, 503, w.Code, w.Body.String())
|
||||||
|
require.Empty(t, mail.messages)
|
||||||
|
require.Len(t, wallet.customers, 1)
|
||||||
|
w = post("verification/send", map[string]string{"email": "user@example.com", "idempotencyKey": "wallet-retry"})
|
||||||
|
require.Equal(t, 503, w.Code, w.Body.String())
|
||||||
|
require.Empty(t, mail.messages)
|
||||||
|
wallet.err = nil
|
||||||
|
w = post("signup", signup)
|
||||||
require.Equal(t, 202, w.Code, w.Body.String())
|
require.Equal(t, 202, w.Code, w.Body.String())
|
||||||
require.Equal(t, 1, provider.registrations)
|
require.Equal(t, 1, provider.registrations)
|
||||||
require.Equal(t, "Customer", provider.last.FullName)
|
require.Equal(t, "Customer", provider.last.FullName)
|
||||||
@@ -149,6 +170,7 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.False(t, a.IsActive())
|
require.False(t, a.IsActive())
|
||||||
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
|
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
|
||||||
|
require.Equal(t, []bson.ObjectID{a.ID, a.ID, a.ID}, wallet.customers)
|
||||||
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
||||||
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
||||||
require.Equal(t, 403, post("login", login).Code)
|
require.Equal(t, 403, post("login", login).Code)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var accountSetup = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_currency_accounts_setup_total", Help: "Default currency account setup attempts, including checks of existing accounts."}, []string{"outcome"})
|
||||||
|
var accountSetupDuration = promauto.NewHistogram(prometheus.HistogramOpts{Name: "sxchnge_currency_accounts_setup_duration_seconds", Help: "Time to confirm all default currency accounts exist."})
|
||||||
|
|
||||||
|
func (s *Service) Ensure(ctx context.Context, a *customer.Account) error {
|
||||||
|
_, err := s.ensureAccounts(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.ServiceUnavailableWrap(err, "currency account setup unavailable")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureAccounts(ctx context.Context, a *customer.Account) (subject string, result error) {
|
||||||
|
started := time.Now()
|
||||||
|
defer func() {
|
||||||
|
outcome := "ready"
|
||||||
|
if result != nil {
|
||||||
|
outcome = "failed"
|
||||||
|
}
|
||||||
|
accountSetup.WithLabelValues(outcome).Inc()
|
||||||
|
accountSetupDuration.Observe(time.Since(started).Seconds())
|
||||||
|
}()
|
||||||
|
if s == nil || s.Identity == nil || s.Provider == nil {
|
||||||
|
return "", Unavailable
|
||||||
|
}
|
||||||
|
if a == nil || a.ID.IsZero() {
|
||||||
|
return "", NotFound
|
||||||
|
}
|
||||||
|
subject, result = s.Identity.Subject(ctx, a)
|
||||||
|
if result != nil {
|
||||||
|
return "", result
|
||||||
|
}
|
||||||
|
if result = s.Provider.EnsureAccounts(ctx, subject); result != nil {
|
||||||
|
s.Logger.Warn("Currency account setup incomplete", zap.String("account_ref", a.ID.Hex()), zap.Error(result))
|
||||||
|
return "", result
|
||||||
|
}
|
||||||
|
return subject, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSignupAndWalletReadEnsureCustomerCurrencyAccounts(t *testing.T) {
|
||||||
|
a := &customer.Account{}
|
||||||
|
a.ID = bson.NewObjectID()
|
||||||
|
p := &provider{namespace: "provider"}
|
||||||
|
s := &Service{Provider: p, Identity: identity{}, Logger: zap.NewNop(), Factors: &factors{}, Store: &memoryStore{records: map[bson.ObjectID]Record{}}}
|
||||||
|
require.NoError(t, s.Ensure(t.Context(), a))
|
||||||
|
require.Equal(t, "subject", p.setupSubject)
|
||||||
|
snapshot, err := s.Fetch(t.Context(), a)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshot.Accounts, 1)
|
||||||
|
require.Equal(t, 2, p.setupCalls)
|
||||||
|
p.setupErr = Unavailable
|
||||||
|
_, err = s.Fetch(t.Context(), a)
|
||||||
|
require.ErrorIs(t, err, Unavailable)
|
||||||
|
require.Error(t, s.Ensure(t.Context(), a))
|
||||||
|
require.Error(t, s.Ensure(t.Context(), nil))
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ func (*Record) Collection() string { return "payment_quotes" }
|
|||||||
|
|
||||||
type Provider interface {
|
type Provider interface {
|
||||||
Namespace() string
|
Namespace() string
|
||||||
|
EnsureAccounts(context.Context, string) error
|
||||||
Accounts(context.Context, string) ([]Account, error)
|
Accounts(context.Context, string) ([]Account, error)
|
||||||
Quote(context.Context, string, Request, []Account) (Quote, error)
|
Quote(context.Context, string, Request, []Account) (Quote, error)
|
||||||
Execute(context.Context, string, string) (RemoteOperation, error)
|
Execute(context.Context, string, string) (RemoteOperation, error)
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ func (s *Service) Fetch(ctx context.Context, a *customer.Account) (Snapshot, err
|
|||||||
if err := s.ready(); err != nil {
|
if err := s.ready(); err != nil {
|
||||||
return Snapshot{}, err
|
return Snapshot{}, err
|
||||||
}
|
}
|
||||||
subject, err := s.Identity.Subject(ctx, a)
|
subject, err := s.ensureAccounts(ctx, a)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Snapshot{}, err
|
return Snapshot{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,15 +76,25 @@ func (s *memoryStore) Attach(_ context.Context, r *Record, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type provider struct {
|
type provider struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
accepted int
|
accepted int
|
||||||
lost bool
|
lost bool
|
||||||
status string
|
status string
|
||||||
key string
|
key string
|
||||||
namespace string
|
namespace string
|
||||||
|
setupCalls int
|
||||||
|
setupSubject string
|
||||||
|
setupErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *provider) Namespace() string { return p.namespace }
|
func (p *provider) Namespace() string { return p.namespace }
|
||||||
|
func (p *provider) EnsureAccounts(_ context.Context, subject string) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.setupCalls++
|
||||||
|
p.setupSubject = subject
|
||||||
|
return p.setupErr
|
||||||
|
}
|
||||||
func (p *provider) Accounts(context.Context, string) ([]Account, error) {
|
func (p *provider) Accounts(context.Context, string) ([]Account, error) {
|
||||||
return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil
|
return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/payments"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) validCustomerAccount(a accountDTO, subject string) bool {
|
||||||
|
return validRef(a.ID) && a.ScopeRef == c.config.OrganizationID && a.Owner == "" &&
|
||||||
|
a.Type == "ACCOUNT_TYPE_ASSET" && a.LedgerRole == "ACCOUNT_ROLE_OPERATING" &&
|
||||||
|
a.Status == "ACCOUNT_STATUS_ACTIVE" && !a.AllowNegative && a.Metadata["sxchnge_subject_ref"] == subject &&
|
||||||
|
(a.Metadata["external_subject_ref"] == "" || a.Metadata["external_subject_ref"] == subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureAccounts also repairs older customers, using provider-enforced
|
||||||
|
// organization/subject/currency uniqueness for safe retries after lost responses.
|
||||||
|
func (c *Client) EnsureAccounts(ctx context.Context, subject string) error {
|
||||||
|
if !validRef(subject) {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
root := "/ledger_accounts/" + c.config.OrganizationID
|
||||||
|
var data accountListDTO
|
||||||
|
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
existing := map[string]bool{}
|
||||||
|
reconcile := map[string]bool{}
|
||||||
|
for _, a := range data.Accounts {
|
||||||
|
if a.Metadata["sxchnge_subject_ref"] != subject && a.Metadata["external_subject_ref"] != subject {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !c.validCustomerAccount(a, subject) || existing[a.Currency] {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
existing[a.Currency] = true
|
||||||
|
reconcile[a.Currency] = a.Metadata["external_subject_ref"] == subject
|
||||||
|
}
|
||||||
|
missing := []string{}
|
||||||
|
for _, code := range []string{"RUB", "USDT", "USDC"} {
|
||||||
|
if !existing[code] || reconcile[code] {
|
||||||
|
missing = append(missing, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Older providers can accept the metadata but cannot prevent duplicates.
|
||||||
|
// Require the explicit capability before issuing the first create.
|
||||||
|
if !data.ExternalSubjectAccounts {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
var catalog struct {
|
||||||
|
Rows []currencyDTO `json:"currencies"`
|
||||||
|
}
|
||||||
|
if err := c.paymentRequest(ctx, http.MethodGet, "/payments/fx/currencies/"+c.config.OrganizationID, nil, &catalog); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, code := range missing {
|
||||||
|
found := false
|
||||||
|
for _, currency := range catalog.Rows {
|
||||||
|
if currency.Code == code && currency.Scale != nil && *currency.Scale >= 0 && *currency.Scale <= 8 {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return merrors.OpaqueWrap(payments.Unavailable, payments.Unsupported, "default currency missing from provider catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Replaying a bound create also resumes Sendico's durable wallet/route setup
|
||||||
|
// if a previous response failed after the ledger itself had been committed.
|
||||||
|
for _, code := range missing {
|
||||||
|
var response struct {
|
||||||
|
Account accountDTO `json:"account"`
|
||||||
|
}
|
||||||
|
request := createAccountDTO{Currency: code, Type: "asset", LedgerRole: "operating", Metadata: map[string]string{"external_subject_ref": subject, "sxchnge_subject_ref": subject}}
|
||||||
|
if err := c.paymentRequest(ctx, http.MethodPost, root, request, &response, http.StatusCreated); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !c.validCustomerAccount(response.Account, subject) || response.Account.Currency != code || response.Account.Metadata["external_subject_ref"] != subject {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
type accountServer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
rows []accountDTO
|
||||||
|
posts []string
|
||||||
|
capable bool
|
||||||
|
missingCurrency bool
|
||||||
|
failCurrency string
|
||||||
|
loseResponse bool
|
||||||
|
wrongResponse bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func customerLedger(code string) accountDTO {
|
||||||
|
return accountDTO{ID: bson.NewObjectID().Hex(), ScopeRef: org, Currency: code, Type: "ACCOUNT_TYPE_ASSET", LedgerRole: "ACCOUNT_ROLE_OPERATING", Status: "ACCOUNT_STATUS_ACTIVE", Metadata: map[string]string{"sxchnge_subject_ref": subject, "external_subject_ref": subject}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *accountServer) serve(t *testing.T) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
require.NotEmpty(t, r.Header.Get("Authorization"))
|
||||||
|
var data any
|
||||||
|
switch {
|
||||||
|
case strings.Contains(r.URL.Path, "/currencies/"):
|
||||||
|
rows := []map[string]any{{"code": "RUB", "accountingScale": 2}, {"code": "USDT", "accountingScale": 6}, {"code": "USDC", "accountingScale": 6}}
|
||||||
|
if s.missingCurrency {
|
||||||
|
rows = rows[:2]
|
||||||
|
}
|
||||||
|
data = map[string]any{"currencies": rows}
|
||||||
|
case r.Method == http.MethodPost:
|
||||||
|
var request createAccountDTO
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
|
||||||
|
require.Equal(t, "asset", request.Type)
|
||||||
|
require.Equal(t, "operating", request.LedgerRole)
|
||||||
|
require.False(t, request.AllowNegative)
|
||||||
|
require.Equal(t, subject, request.Metadata["external_subject_ref"])
|
||||||
|
require.Equal(t, subject, request.Metadata["sxchnge_subject_ref"])
|
||||||
|
s.posts = append(s.posts, request.Currency)
|
||||||
|
if s.failCurrency == request.Currency {
|
||||||
|
s.failCurrency = ""
|
||||||
|
w.WriteHeader(503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var row accountDTO
|
||||||
|
for _, a := range s.rows {
|
||||||
|
if a.Currency == request.Currency && a.Metadata["external_subject_ref"] == subject {
|
||||||
|
row = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row.ID == "" {
|
||||||
|
row = customerLedger(request.Currency)
|
||||||
|
s.rows = append(s.rows, row)
|
||||||
|
}
|
||||||
|
if s.loseResponse {
|
||||||
|
s.loseResponse = false
|
||||||
|
w.WriteHeader(503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.wrongResponse {
|
||||||
|
row.Currency = "EUR"
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
data = map[string]any{"account": row}
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/balance"):
|
||||||
|
parts := strings.Split(r.URL.Path, "/")
|
||||||
|
data = map[string]any{"balance": balanceDTO{ID: parts[len(parts)-2], Money: moneyDTO{Amount: "45.00", Currency: "RUB"}}}
|
||||||
|
default:
|
||||||
|
data = map[string]any{"accounts": s.rows, "externalSubjectAccounts": s.capable}
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"status": "success", "data": data}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureCurrencyAccountsPreservesExistingAndRecoversPartialFailure(t *testing.T) {
|
||||||
|
for _, lost := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "partial failure", true: "lost response"}[lost], func(t *testing.T) {
|
||||||
|
s := &accountServer{capable: true, failCurrency: "USDT", loseResponse: lost}
|
||||||
|
if lost {
|
||||||
|
s.failCurrency = ""
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Error(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Len(t, s.rows, 1)
|
||||||
|
firstID := s.rows[0].ID
|
||||||
|
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Len(t, s.rows, 3)
|
||||||
|
require.Equal(t, firstID, s.rows[0].ID)
|
||||||
|
// Bound creates are replayed to finish interrupted wallet/route setup;
|
||||||
|
// the provider must return the original ledger each time.
|
||||||
|
require.Equal(t, 3, countCurrency(s.posts, "RUB"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func countCurrency(rows []string, code string) int {
|
||||||
|
n := 0
|
||||||
|
for _, r := range rows {
|
||||||
|
if r == code {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAccountsConcurrentRequestsUseStableSubjectBinding(t *testing.T) {
|
||||||
|
s := &accountServer{capable: true}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
errors := make(chan error, 12)
|
||||||
|
for range 12 {
|
||||||
|
go func() { errors <- c.EnsureAccounts(t.Context(), subject) }()
|
||||||
|
}
|
||||||
|
for range 12 {
|
||||||
|
require.NoError(t, <-errors)
|
||||||
|
}
|
||||||
|
require.Len(t, s.rows, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAccountsRejectsUnsafeProviderStateBeforeCreating(t *testing.T) {
|
||||||
|
for _, kind := range []string{"old provider", "missing currency", "frozen", "owned", "negative", "duplicate", "wrong subject"} {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
s := &accountServer{capable: true}
|
||||||
|
a := customerLedger("RUB")
|
||||||
|
switch kind {
|
||||||
|
case "old provider":
|
||||||
|
s.capable = false
|
||||||
|
case "missing currency":
|
||||||
|
s.missingCurrency = true
|
||||||
|
case "frozen":
|
||||||
|
a.Status = "ACCOUNT_STATUS_FROZEN"
|
||||||
|
s.rows = []accountDTO{a}
|
||||||
|
case "owned":
|
||||||
|
a.Owner = source
|
||||||
|
s.rows = []accountDTO{a}
|
||||||
|
case "negative":
|
||||||
|
a.AllowNegative = true
|
||||||
|
s.rows = []accountDTO{a}
|
||||||
|
case "duplicate":
|
||||||
|
s.rows = []accountDTO{a, customerLedger("RUB")}
|
||||||
|
case "wrong subject":
|
||||||
|
a.Metadata["sxchnge_subject_ref"] = source
|
||||||
|
s.rows = []accountDTO{a}
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Error(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Empty(t, s.posts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAccountsValidatesCreateReceipt(t *testing.T) {
|
||||||
|
s := &accountServer{capable: true, wrongResponse: true}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Error(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Len(t, s.posts, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAccountsKeepsExistingBindingsAndBalances(t *testing.T) {
|
||||||
|
a := customerLedger("RUB")
|
||||||
|
delete(a.Metadata, "external_subject_ref")
|
||||||
|
s := &accountServer{capable: true, rows: []accountDTO{a}}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Equal(t, []string{"USDT", "USDC"}, s.posts)
|
||||||
|
// A subsequent read uses the original RUB ledger and its authoritative funds.
|
||||||
|
s.rows = s.rows[:1]
|
||||||
|
accounts, err := c.Accounts(t.Context(), subject)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, accounts, 1)
|
||||||
|
require.Equal(t, a.ID, accounts[0].ID)
|
||||||
|
require.EqualValues(t, 4500, accounts[0].Available)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
type accountListDTO struct {
|
||||||
|
Accounts []accountDTO `json:"accounts"`
|
||||||
|
ExternalSubjectAccounts bool `json:"externalSubjectAccounts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createAccountDTO struct {
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Type string `json:"accountType"`
|
||||||
|
LedgerRole string `json:"role"`
|
||||||
|
AllowNegative bool `json:"allowNegative"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
}
|
||||||
@@ -27,9 +27,7 @@ func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Accou
|
|||||||
scales[row.Code] = *row.Scale
|
scales[row.Code] = *row.Scale
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var data struct {
|
var data accountListDTO
|
||||||
Accounts []accountDTO `json:"accounts"`
|
|
||||||
}
|
|
||||||
root := "/ledger_accounts/" + c.config.OrganizationID
|
root := "/ledger_accounts/" + c.config.OrganizationID
|
||||||
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
|
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -39,7 +37,7 @@ func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Accou
|
|||||||
if row.Metadata["sxchnge_subject_ref"] != subject {
|
if row.Metadata["sxchnge_subject_ref"] != subject {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !validRef(row.ID) || row.ScopeRef != c.config.OrganizationID || row.Owner != "" || row.Status != "ACCOUNT_STATUS_ACTIVE" || row.Type != "ACCOUNT_TYPE_ASSET" || row.LedgerRole != "ACCOUNT_ROLE_OPERATING" {
|
if !c.validCustomerAccount(row, subject) {
|
||||||
return nil, payments.Unavailable
|
return nil, payments.Unavailable
|
||||||
}
|
}
|
||||||
scale, ok := scales[row.Currency]
|
scale, ok := scales[row.Currency]
|
||||||
|
|||||||
@@ -54,14 +54,15 @@ type paymentDTO struct {
|
|||||||
Created time.Time `json:"createdAt"`
|
Created time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
type accountDTO struct {
|
type accountDTO struct {
|
||||||
ID string `json:"ledgerAccountRef"`
|
ID string `json:"ledgerAccountRef"`
|
||||||
ScopeRef string `json:"organizationRef"`
|
ScopeRef string `json:"organizationRef"`
|
||||||
Owner string `json:"ownerRef"`
|
Owner string `json:"ownerRef"`
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
Type string `json:"accountType"`
|
Type string `json:"accountType"`
|
||||||
LedgerRole string `json:"role"`
|
LedgerRole string `json:"role"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Metadata map[string]string `json:"metadata"`
|
AllowNegative bool `json:"allowNegative"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
}
|
}
|
||||||
type currencyDTO struct {
|
type currencyDTO struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
@@ -72,7 +73,7 @@ type balanceDTO struct {
|
|||||||
Money moneyDTO `json:"balance"`
|
Money moneyDTO `json:"balance"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any) error {
|
func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any, accepted ...int) error {
|
||||||
token, err := c.token()
|
token, err := c.token()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -98,7 +99,11 @@ func (c *Client) paymentRequest(ctx context.Context, method, path string, payloa
|
|||||||
}
|
}
|
||||||
defer func() { _ = res.Body.Close() }()
|
defer func() { _ = res.Body.Close() }()
|
||||||
// Never forward upstream errors, credentials, or arbitrary error bodies.
|
// Never forward upstream errors, credentials, or arbitrary error bodies.
|
||||||
if res.StatusCode != http.StatusOK {
|
expected := http.StatusOK
|
||||||
|
if len(accepted) != 0 {
|
||||||
|
expected = accepted[0]
|
||||||
|
}
|
||||||
|
if res.StatusCode != expected {
|
||||||
return payments.Unavailable
|
return payments.Unavailable
|
||||||
}
|
}
|
||||||
var envelope struct {
|
var envelope struct {
|
||||||
|
|||||||
@@ -130,8 +130,10 @@ This recovery retains the configured provider URL, account and replay namespace.
|
|||||||
layout and least-privilege identities, Mongo transactions/lease rotation, and
|
layout and least-privilege identities, Mongo transactions/lease rotation, and
|
||||||
authenticated Sendico currency and ledger reads through the same loopback
|
authenticated Sendico currency and ledger reads through the same loopback
|
||||||
connection and token used by BFF. It sends no email and executes no payment.
|
connection and token used by BFF. It sends no email and executes no payment.
|
||||||
An empty wallet is expected until dedicated subject-bound currency ledgers and
|
Signup and wallet access automatically ensure subject-bound RUB, USDT and USDC
|
||||||
saved methods are provisioned as described in [payment provisioning](payments.md).
|
ledgers as described in [account setup](payments.md#automatic-currency-accounts-72).
|
||||||
|
Deploy the provider prerequisite and grant ledger-create permission first.
|
||||||
|
Saved funding and payout methods remain separate from currency-account setup.
|
||||||
|
|
||||||
## Recovery
|
## Recovery
|
||||||
|
|
||||||
|
|||||||
+36
-7
@@ -52,22 +52,50 @@ coordinated deployment of the platform's financial services.
|
|||||||
|
|
||||||
Reuse `api.sendico` and its existing Vault-rendered token file. Do not put provider
|
Reuse `api.sendico` and its existing Vault-rendered token file. Do not put provider
|
||||||
credentials or organization/source settings in Flutter. Provision the integration
|
credentials or organization/source settings in Flutter. Provision the integration
|
||||||
account's organization membership, payment-create/read, ledger-account/balance
|
account's organization membership, payment-create/read, ledger-account create/read,
|
||||||
read and the permissions needed to resolve its stored payment methods. Verify
|
balance read and the permissions needed to resolve its stored payment methods. Verify
|
||||||
those permissions in the intended environment before enabling real operations.
|
those permissions in the intended environment before enabling real operations.
|
||||||
|
|
||||||
Through Sendico's existing operator APIs, provision one dedicated unowned
|
### Automatic currency accounts (#72)
|
||||||
organization ledger per customer currency, with active `asset` / `operating`
|
|
||||||
semantics, no negative balance and metadata:
|
The backend ensures **RUB, USDT and USDC** accounts after confirmed identity intake
|
||||||
|
during signup, before sending the activation email. Resending activation retries
|
||||||
|
the same setup. Every authenticated wallet load also ensures the defaults, so
|
||||||
|
existing customers receive missing accounts automatically when they next open
|
||||||
|
their wallet. This is lazy backfill, not an eager scan of dormant customers.
|
||||||
|
|
||||||
|
Setup creates dedicated unowned organization ledgers through the public
|
||||||
|
`POST /ledger_accounts/{organization}` API, with active `asset` / `operating`
|
||||||
|
semantics, zero initial funds, no negative balance and metadata:
|
||||||
|
|
||||||
| Ledger metadata | Meaning |
|
| Ledger metadata | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `sxchnge_subject_ref` | Confirmed independent subject reference from sign-up |
|
| `sxchnge_subject_ref` | Confirmed independent subject reference from sign-up |
|
||||||
|
| `external_subject_ref` | Same confirmed subject; provider-enforced organization/subject/currency uniqueness |
|
||||||
| `sxchnge_payout_method_ref` | Optional prevalidated same-currency saved payout method |
|
| `sxchnge_payout_method_ref` | Optional prevalidated same-currency saved payout method |
|
||||||
| `sxchnge_payout_label` | Safe customer-visible destination label, e.g. bank plus masked suffix |
|
| `sxchnge_payout_label` | Safe customer-visible destination label, e.g. bank plus masked suffix |
|
||||||
| `sxchnge_funding_method_ref` | Optional same-currency saved funding source supported by the platform route |
|
| `sxchnge_funding_method_ref` | Optional same-currency saved funding source supported by the platform route |
|
||||||
|
|
||||||
These are financial-resource bindings maintained by trusted provisioning, not
|
Existing customer ledgers and their balances are preserved, including manually
|
||||||
|
provisioned ledgers with the original `sxchnge_subject_ref` tag. Only missing
|
||||||
|
defaults are created; existing additional currencies remain visible. Repeated
|
||||||
|
bound creates reuse the ledger and resume Sendico's durable wallet/route setup
|
||||||
|
after an interrupted response. Frozen, duplicate or incorrectly owned bindings
|
||||||
|
fail instead of creating a replacement. All required currency precision entries
|
||||||
|
are checked against the provider catalog before issuing creates. Saved funding
|
||||||
|
and payout bindings remain separate from creation of the currency accounts.
|
||||||
|
|
||||||
|
Deploy [Sendico #2968](https://git.sendico.io/tech/sendico/issues/2968) first:
|
||||||
|
Ledger (including its unique index), then Product Edge, then SXCHNGE backend and
|
||||||
|
frontend from [PR #73](https://git.sendico.io/tech/sxchnge/pulls/73). The public
|
||||||
|
ledger list must advertise `externalSubjectAccounts: true` before SXCHNGE sends
|
||||||
|
any create requests. Older providers fail safely without creating accounts.
|
||||||
|
The acknowledgement assumes Ledger and Edge are deployed in that order; it
|
||||||
|
does not discover the version of a separately running Ledger. No new runtime
|
||||||
|
configuration or credentials are introduced. Existing deployments must give the
|
||||||
|
integration principal the ledger-create permission in addition to its reads.
|
||||||
|
|
||||||
|
These are financial-resource bindings maintained by server-side setup, not
|
||||||
client choices or new runtime configuration. Do not tag a pooled/integration-login
|
client choices or new runtime configuration. Do not tag a pooled/integration-login
|
||||||
ledger as a customer ledger. Do not place full bank/card details or secret values
|
ledger as a customer ledger. Do not place full bank/card details or secret values
|
||||||
in labels or metadata. The BFF checks subject, organization, account type, role,
|
in labels or metadata. The BFF checks subject, organization, account type, role,
|
||||||
@@ -79,7 +107,8 @@ review action. Funding uses a quote for the provisioned stored source; it does
|
|||||||
not mint money or treat demo top-ups as real deposits. Additional destinations,
|
not mint money or treat demo top-ups as real deposits. Additional destinations,
|
||||||
recipient onboarding and other funding instructions remain separate features.
|
recipient onboarding and other funding instructions remain separate features.
|
||||||
|
|
||||||
An empty set of provisioned ledgers is an empty wallet. Existing organization
|
If setup reports a failure, the API returns an unavailable wallet rather than an
|
||||||
|
empty snapshot or demo balances. Retrying the app load retries setup. Existing organization
|
||||||
payments and pooled balances are never imported into customer history. This
|
payments and pooled balances are never imported into customer history. This
|
||||||
integration exposes the durable operations initiated through this BFF, with live
|
integration exposes the durable operations initiated through this BFF, with live
|
||||||
provider reconciliation; independent inbound deposits/history ingestion and
|
provider reconciliation; independent inbound deposits/history ingestion and
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,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",
|
||||||
|
|||||||
@@ -161,7 +161,10 @@
|
|||||||
"wRecent": "Последние операции",
|
"wRecent": "Последние операции",
|
||||||
"wSeeAll": "Смотреть все",
|
"wSeeAll": "Смотреть все",
|
||||||
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
|
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
|
||||||
"wEmptyAccounts": "У вас ещё нет валютных счетов. Обратитесь в поддержку за помощью с их открытием.",
|
"wEmptyAccounts": "Не удалось завершить настройку валютных счетов. Повторите попытку, чтобы продолжить.",
|
||||||
|
"wAccountsLoading": "Подготавливаем ваши счета в RUB, USDT и USDC…",
|
||||||
|
"wAccountsFailed": "Не удалось загрузить валютные счета. Повторите попытку, чтобы продолжить.",
|
||||||
|
"wAccountsRetry": "Повторить попытку",
|
||||||
"wRefresh": "Обновить",
|
"wRefresh": "Обновить",
|
||||||
"wSource": "Со счёта",
|
"wSource": "Со счёта",
|
||||||
"wDestination": "На счёт",
|
"wDestination": "На счёт",
|
||||||
|
|||||||
@@ -65,15 +65,34 @@ class AccountsPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
if (p.loading) const LinearProgressIndicator(),
|
if (p.loading) const LinearProgressIndicator(),
|
||||||
if (p.failed)
|
if ((p.loading || (!p.failed && p.data == null)) &&
|
||||||
|
accounts.isEmpty)
|
||||||
|
Semantics(liveRegion: true, child: Text(l.wAccountsLoading)),
|
||||||
|
if (p.failed && accounts.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
child: Text(l.wUnavailable, style: TextStyle(color: c.error)),
|
child: Text(l.wUnavailable, style: TextStyle(color: c.error)),
|
||||||
),
|
),
|
||||||
if (!p.loading && accounts.isEmpty) Text(l.wEmptyAccounts),
|
if (!p.loading &&
|
||||||
|
accounts.isEmpty &&
|
||||||
|
(p.failed || p.data != null)) ...[
|
||||||
|
Semantics(
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(p.failed ? l.wAccountsFailed : l.wEmptyAccounts),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: p.refresh,
|
||||||
|
child: Text(l.wAccountsRetry),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
if (accountId != null &&
|
if (accountId != null &&
|
||||||
p.account(accountId) == null &&
|
p.account(accountId) == null &&
|
||||||
!p.loading)
|
!p.loading &&
|
||||||
|
!p.failed &&
|
||||||
|
p.data != null)
|
||||||
Text(l.wNotFound),
|
Text(l.wNotFound),
|
||||||
if (selected != null) ...[
|
if (selected != null) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|||||||
@@ -256,11 +256,23 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
if (accounts.isEmpty) ...[
|
if (accounts.isEmpty) ...[
|
||||||
Text(wallet.loading ? l.wWorking : l.wEmptyAccounts),
|
Semantics(
|
||||||
TextButton(
|
liveRegion: true,
|
||||||
onPressed: wallet.loading ? null : wallet.refresh,
|
child: Text(
|
||||||
child: Text(l.wRefresh),
|
wallet.loading ||
|
||||||
|
(!wallet.failed && wallet.data == null)
|
||||||
|
? l.wAccountsLoading
|
||||||
|
: wallet.failed
|
||||||
|
? l.wAccountsFailed
|
||||||
|
: l.wEmptyAccounts,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
if (!wallet.loading &&
|
||||||
|
(wallet.failed || wallet.data != null))
|
||||||
|
TextButton(
|
||||||
|
onPressed: wallet.refresh,
|
||||||
|
child: Text(l.wAccountsRetry),
|
||||||
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
key: ValueKey('source-${p.source}'),
|
key: ValueKey('source-${p.source}'),
|
||||||
|
|||||||
@@ -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());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user