From 5c255296d34043d4b9dd942b8f20f3cd96d424f4 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 23 Oct 2026 12:43:55 +0200 Subject: [PATCH 1/2] Set up default currency accounts automatically (#72) --- backend/product/app/runtime/services.go | 1 + backend/product/auth/flow/service.go | 15 ++ .../public/verification_integration_test.go | 22 ++ backend/product/payments/accounts.go | 50 +++++ backend/product/payments/accounts_test.go | 28 +++ backend/product/payments/model.go | 1 + backend/product/payments/service.go | 2 +- backend/product/payments/service_test.go | 22 +- backend/product/providers/sendico/accounts.go | 87 ++++++++ .../providers/sendico/accounts_test.go | 200 ++++++++++++++++++ .../providers/sendico/accounts_transport.go | 14 ++ backend/product/providers/sendico/payments.go | 6 +- .../providers/sendico/payments_transport.go | 25 ++- docs/local-sendico.md | 6 +- docs/payments.md | 43 +++- docs/ux/exchange-workspace.md | 56 +++-- frontend/app/lib/l10n/en.arb | 5 +- frontend/app/lib/l10n/ru.arb | 5 +- frontend/app/lib/workspace/accounts.dart | 25 ++- frontend/app/lib/workspace/transfer.dart | 20 +- frontend/app/test/accounts_setup_test.dart | 104 +++++++++ 21 files changed, 675 insertions(+), 62 deletions(-) create mode 100644 backend/product/payments/accounts.go create mode 100644 backend/product/payments/accounts_test.go create mode 100644 backend/product/providers/sendico/accounts.go create mode 100644 backend/product/providers/sendico/accounts_test.go create mode 100644 backend/product/providers/sendico/accounts_transport.go create mode 100644 frontend/app/test/accounts_setup_test.dart diff --git a/backend/product/app/runtime/services.go b/backend/product/app/runtime/services.go index 5e33af0..ac2b1ac 100644 --- a/backend/product/app/runtime/services.go +++ b/backend/product/app/runtime/services.go @@ -75,6 +75,7 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf return nil, nil, err } 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") } records, err := factorstore.Open(logger, db) diff --git a/backend/product/auth/flow/service.go b/backend/product/auth/flow/service.go index 4334bf7..999d2b3 100644 --- a/backend/product/auth/flow/service.go +++ b/backend/product/auth/flow/service.go @@ -31,8 +31,13 @@ type SignupFacts interface { Ready(context.Context, bson.ObjectID) error } +type CurrencyAccounts interface { + Ensure(context.Context, *customer.Account) error +} + type Service struct { Facts SignupFacts + Wallet CurrencyAccounts Accounts account.DB Customers accountservice.AccountService Tokens verification.DB @@ -110,6 +115,11 @@ func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key strin return nil, err } } + if s.Wallet != nil { + if err := s.Wallet.Ensure(ctx, a); err != nil { + return nil, err + } + } if raw != "" { u, _ := url.Parse(s.ActivationURL) q := u.Query() @@ -148,6 +158,11 @@ func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) e return err } } + if s.Wallet != nil { + if err := s.Wallet.Ensure(ctx, a); err != nil { + return err + } + } _, err = s.sendActivation(ctx, a, key) return err } diff --git a/backend/product/auth/http/v1/public/verification_integration_test.go b/backend/product/auth/http/v1/public/verification_integration_test.go index 31fe5da..2321cd0 100644 --- a/backend/product/auth/http/v1/public/verification_integration_test.go +++ b/backend/product/auth/http/v1/public/verification_integration_test.go @@ -75,6 +75,17 @@ func (m *capturedMail) Send(_ context.Context, r notifications.Message) error { } 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) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() @@ -102,6 +113,8 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) { require.NoError(t, err) provider := &signupProvider{fail: true} 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. sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}} records, err := factorstore.Open(zap.NewNop(), factory) @@ -133,6 +146,14 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) { signup["name"] = "Changed retry name" provider.fail = false 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, 1, provider.registrations) require.Equal(t, "Customer", provider.last.FullName) @@ -149,6 +170,7 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) { require.NoError(t, err) require.False(t, a.IsActive()) 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)) 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) diff --git a/backend/product/payments/accounts.go b/backend/product/payments/accounts.go new file mode 100644 index 0000000..d467cfe --- /dev/null +++ b/backend/product/payments/accounts.go @@ -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 +} diff --git a/backend/product/payments/accounts_test.go b/backend/product/payments/accounts_test.go new file mode 100644 index 0000000..b62bb3c --- /dev/null +++ b/backend/product/payments/accounts_test.go @@ -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)) +} diff --git a/backend/product/payments/model.go b/backend/product/payments/model.go index d063cf5..1d67dcb 100644 --- a/backend/product/payments/model.go +++ b/backend/product/payments/model.go @@ -53,6 +53,7 @@ func (*Record) Collection() string { return "payment_quotes" } type Provider interface { Namespace() string + EnsureAccounts(context.Context, string) error Accounts(context.Context, string) ([]Account, error) Quote(context.Context, string, Request, []Account) (Quote, error) Execute(context.Context, string, string) (RemoteOperation, error) diff --git a/backend/product/payments/service.go b/backend/product/payments/service.go index 7b79333..a9f4e00 100644 --- a/backend/product/payments/service.go +++ b/backend/product/payments/service.go @@ -206,7 +206,7 @@ func (s *Service) Fetch(ctx context.Context, a *customer.Account) (Snapshot, err if err := s.ready(); err != nil { return Snapshot{}, err } - subject, err := s.Identity.Subject(ctx, a) + subject, err := s.ensureAccounts(ctx, a) if err != nil { return Snapshot{}, err } diff --git a/backend/product/payments/service_test.go b/backend/product/payments/service_test.go index a3d78a9..bca6064 100644 --- a/backend/product/payments/service_test.go +++ b/backend/product/payments/service_test.go @@ -76,15 +76,25 @@ func (s *memoryStore) Attach(_ context.Context, r *Record, id string) error { } type provider struct { - mu sync.Mutex - accepted int - lost bool - status string - key string - namespace string + mu sync.Mutex + accepted int + lost bool + status string + key string + namespace string + setupCalls int + setupSubject string + setupErr error } 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) { return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil } diff --git a/backend/product/providers/sendico/accounts.go b/backend/product/providers/sendico/accounts.go new file mode 100644 index 0000000..aa2f737 --- /dev/null +++ b/backend/product/providers/sendico/accounts.go @@ -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 +} diff --git a/backend/product/providers/sendico/accounts_test.go b/backend/product/providers/sendico/accounts_test.go new file mode 100644 index 0000000..b6d6938 --- /dev/null +++ b/backend/product/providers/sendico/accounts_test.go @@ -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) +} diff --git a/backend/product/providers/sendico/accounts_transport.go b/backend/product/providers/sendico/accounts_transport.go new file mode 100644 index 0000000..22da7b2 --- /dev/null +++ b/backend/product/providers/sendico/accounts_transport.go @@ -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"` +} diff --git a/backend/product/providers/sendico/payments.go b/backend/product/providers/sendico/payments.go index 85c6def..7467fcd 100644 --- a/backend/product/providers/sendico/payments.go +++ b/backend/product/providers/sendico/payments.go @@ -27,9 +27,7 @@ func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Accou scales[row.Code] = *row.Scale } } - var data struct { - Accounts []accountDTO `json:"accounts"` - } + var data accountListDTO root := "/ledger_accounts/" + c.config.OrganizationID if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil { 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 { 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 } scale, ok := scales[row.Currency] diff --git a/backend/product/providers/sendico/payments_transport.go b/backend/product/providers/sendico/payments_transport.go index 63e70db..8476d56 100644 --- a/backend/product/providers/sendico/payments_transport.go +++ b/backend/product/providers/sendico/payments_transport.go @@ -54,14 +54,15 @@ type paymentDTO struct { Created time.Time `json:"createdAt"` } type accountDTO struct { - ID string `json:"ledgerAccountRef"` - ScopeRef string `json:"organizationRef"` - Owner string `json:"ownerRef"` - Currency string `json:"currency"` - Type string `json:"accountType"` - LedgerRole string `json:"role"` - Status string `json:"status"` - Metadata map[string]string `json:"metadata"` + ID string `json:"ledgerAccountRef"` + ScopeRef string `json:"organizationRef"` + Owner string `json:"ownerRef"` + Currency string `json:"currency"` + Type string `json:"accountType"` + LedgerRole string `json:"role"` + Status string `json:"status"` + AllowNegative bool `json:"allowNegative"` + Metadata map[string]string `json:"metadata"` } type currencyDTO struct { Code string `json:"code"` @@ -72,7 +73,7 @@ type balanceDTO struct { 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() if err != nil { return err @@ -98,7 +99,11 @@ func (c *Client) paymentRequest(ctx context.Context, method, path string, payloa } defer func() { _ = res.Body.Close() }() // 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 } var envelope struct { diff --git a/docs/local-sendico.md b/docs/local-sendico.md index a01a8a3..d3c9d01 100644 --- a/docs/local-sendico.md +++ b/docs/local-sendico.md @@ -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 authenticated Sendico currency and ledger reads through the same loopback 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 -saved methods are provisioned as described in [payment provisioning](payments.md). +Signup and wallet access automatically ensure subject-bound RUB, USDT and USDC +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 diff --git a/docs/payments.md b/docs/payments.md index b3305a2..70249fe 100644 --- a/docs/payments.md +++ b/docs/payments.md @@ -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 credentials or organization/source settings in Flutter. Provision the integration -account's organization membership, payment-create/read, ledger-account/balance -read and the permissions needed to resolve its stored payment methods. Verify +account's organization membership, payment-create/read, ledger-account create/read, +balance read and the permissions needed to resolve its stored payment methods. Verify those permissions in the intended environment before enabling real operations. -Through Sendico's existing operator APIs, provision one dedicated unowned -organization ledger per customer currency, with active `asset` / `operating` -semantics, no negative balance and metadata: +### Automatic currency accounts (#72) + +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 | | --- | --- | | `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_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 | -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 [task #72](https://git.sendico.io/tech/sxchnge/issues/72). 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 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, @@ -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, 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 integration exposes the durable operations initiated through this BFF, with live provider reconciliation; independent inbound deposits/history ingestion and diff --git a/docs/ux/exchange-workspace.md b/docs/ux/exchange-workspace.md index 8185eed..d90eab4 100644 --- a/docs/ux/exchange-workspace.md +++ b/docs/ux/exchange-workspace.md @@ -302,32 +302,42 @@ that contradicts displayed balances or usage without explaining the fixtures. - Keep unrelated accounts/history accessible during a pending operation. Do not 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 -that no currency accounts have been set up and directs the customer to support -to request setup. English and Russian no longer suggest that refreshing creates -accounts or imply that automatic provisioning is underway. This follows the -existing support guidance for missing funding and payout setup. The application -has no configured support contact channel; the message therefore provides text -guidance without a nonfunctional contact button or invented contact details. +New customers receive RUB, USDT and USDC accounts through backend-owned setup +during signup. Existing customers receive missing defaults on their next wallet +load. The UI requires no account-creation form or support contact. During initial +loading it announces that the accounts are being prepared. Failure shows one +clear load error and a **Try again** action that reruns the same idempotent setup. +An unexpected empty snapshot has its own setup-failure fallback, rather than +being shown simultaneously with an error. A failed refresh retains existing +cards and the stale-data warning. Account-not-found copy requires loaded data. -UX review: the copy names the missing resource and the next step, retains the -existing wrapping text and accessible reading order, and does not alter loading, -error or populated-state behavior. Existing manual refresh remains available for -accounts provisioned externally. This copy-only change adds no controls or -journey outcomes and requires no new analytics events. Existing transaction -journey signals and their production measurement limitations below still apply; -support resolution and external account provisioning are not measured in-app. +The existing DTO → mapper → service → model → provider → UI boundaries remain +intact. Account creation belongs to the backend/provider, and widgets invoke the +existing provider refresh action. English and Russian use wrapping text and the +existing accessible button pattern; status text uses a live region. Dedicated +widget regressions cover loading, failure, retry and successful default accounts +on Accounts, Top up, Exchange and Payout at 320px with 1.6× text. Native device and +screen-reader QA remain a release check; tests do not claim physical-device QA. -Validation: `flutter gen-l10n`, `flutter analyze` in `frontend/app`, -`make test-frontend` (217 tests), and `git diff --check` passed. No Dart source -changed, so Dart formatting was not applicable. Existing responsive workspace -tests cover Russian at 320px with enlarged text; this copy-only change does not -add a dedicated empty-state rendering or native screen-reader test. No security -scan was requested. Delivery: [task #66](https://git.sendico.io/tech/sxchnge/issues/66), -branch `wallet-66`, [PR #68](https://git.sendico.io/tech/sxchnge/pulls/68). -Merge and deployment remain separate. +Measurement: `sxchnge_currency_accounts_setup_total{outcome="ready|failed"}` and +`sxchnge_currency_accounts_setup_duration_seconds` cover the server setup attempt. +`ready` is emitted only after all default bindings are confirmed, including +idempotent completion of provider wallet/route setup for newly bound ledgers. +Each signup/resend/wallet load is a separate attempt; a retry is not a new unique +customer conversion. The failure fraction uses all attempts as its denominator; +the histogram measures one attempt, not time across multiple browser sessions. +No customer, ledger, currency, balance, credentials or form input is a metric +label. These server metrics do not measure client abandonment or prove that a +balance card was viewed. Existing transaction journey outcomes below are reused; +no speculative button-click events are added. + +Scope and rollout: [task #72](https://git.sendico.io/tech/sxchnge/issues/72), branch +`wallet-72`, [task #72](https://git.sendico.io/tech/sxchnge/issues/72). 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. ## Journey measurement plan diff --git a/frontend/app/lib/l10n/en.arb b/frontend/app/lib/l10n/en.arb index 3033c42..27fbbb8 100644 --- a/frontend/app/lib/l10n/en.arb +++ b/frontend/app/lib/l10n/en.arb @@ -160,7 +160,10 @@ "wRecent": "Recent activity", "wSeeAll": "See all", "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", "wSource": "From account", "wDestination": "To account", diff --git a/frontend/app/lib/l10n/ru.arb b/frontend/app/lib/l10n/ru.arb index 1fda984..e825655 100644 --- a/frontend/app/lib/l10n/ru.arb +++ b/frontend/app/lib/l10n/ru.arb @@ -160,7 +160,10 @@ "wRecent": "Последние операции", "wSeeAll": "Смотреть все", "wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.", - "wEmptyAccounts": "У вас ещё нет валютных счетов. Обратитесь в поддержку за помощью с их открытием.", + "wEmptyAccounts": "Не удалось завершить настройку валютных счетов. Повторите попытку, чтобы продолжить.", + "wAccountsLoading": "Подготавливаем ваши счета в RUB, USDT и USDC…", + "wAccountsFailed": "Не удалось загрузить валютные счета. Повторите попытку, чтобы продолжить.", + "wAccountsRetry": "Повторить попытку", "wRefresh": "Обновить", "wSource": "Со счёта", "wDestination": "На счёт", diff --git a/frontend/app/lib/workspace/accounts.dart b/frontend/app/lib/workspace/accounts.dart index abfb702..10fcc0e 100644 --- a/frontend/app/lib/workspace/accounts.dart +++ b/frontend/app/lib/workspace/accounts.dart @@ -65,15 +65,34 @@ class AccountsPage extends StatelessWidget { ), const SizedBox(height: 28), 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: const EdgeInsets.symmetric(vertical: 16), 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 && p.account(accountId) == null && - !p.loading) + !p.loading && + !p.failed && + p.data != null) Text(l.wNotFound), if (selected != null) ...[ const SizedBox(height: 24), diff --git a/frontend/app/lib/workspace/transfer.dart b/frontend/app/lib/workspace/transfer.dart index fd0ecaa..2da9abd 100644 --- a/frontend/app/lib/workspace/transfer.dart +++ b/frontend/app/lib/workspace/transfer.dart @@ -256,11 +256,23 @@ class TransferPageState extends State { ), ] else ...[ if (accounts.isEmpty) ...[ - Text(wallet.loading ? l.wWorking : l.wEmptyAccounts), - TextButton( - onPressed: wallet.loading ? null : wallet.refresh, - child: Text(l.wRefresh), + Semantics( + liveRegion: true, + child: Text( + 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 ...[ DropdownButtonFormField( key: ValueKey('source-${p.source}'), diff --git a/frontend/app/test/accounts_setup_test.dart b/frontend/app/test/accounts_setup_test.dart new file mode 100644 index 0000000..2a08a64 --- /dev/null +++ b/frontend/app/test/accounts_setup_test.dart @@ -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 pending = Completer(); + int calls = 0; + + @override + Future 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(); + 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()); + }, + ); + } + } +} -- 2.54.0 From 645181c65ce97d9a33403c7d43dbc44889efe5b7 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 23 Oct 2026 12:58:28 +0200 Subject: [PATCH 2/2] Record automatic currency account validation and delivery (#72) --- docs/payments.md | 2 +- docs/ux/exchange-workspace.md | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/payments.md b/docs/payments.md index 70249fe..23f1020 100644 --- a/docs/payments.md +++ b/docs/payments.md @@ -87,7 +87,7 @@ 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 [task #72](https://git.sendico.io/tech/sxchnge/issues/72). The public +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 diff --git a/docs/ux/exchange-workspace.md b/docs/ux/exchange-workspace.md index d90eab4..c89c1a4 100644 --- a/docs/ux/exchange-workspace.md +++ b/docs/ux/exchange-workspace.md @@ -334,11 +334,23 @@ 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`, [task #72](https://git.sendico.io/tech/sxchnge/issues/72). The provider +`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 Reuse the typed analytics abstraction and maintain a separate journey per active -- 2.54.0