116 changed files with 8478 additions and 233 deletions

No files matched your search

+7
View File
@@ -54,6 +54,9 @@ package-notifications-interface: generate-notifications
# Local product stack. Releases are resolved afresh, then pinned by digest. # Local product stack. Releases are resolved afresh, then pinned by digest.
COMPOSE = docker compose --project-directory . -f infra/local/compose.yml --env-file .local/dev/images.env COMPOSE = docker compose --project-directory . -f infra/local/compose.yml --env-file .local/dev/images.env
ifneq ($(wildcard .local/dev/sendico/settings.env),)
COMPOSE += -f infra/local/sendico/compose.yml
endif
.PHONY: build up down logs ps test-dev .PHONY: build up down logs ps test-dev
build: build:
sh infra/local/tools.sh build sh infra/local/tools.sh build
@@ -92,3 +95,7 @@ email:
test-dev-tooling: test-dev-tooling:
sh infra/local/tools.sh test sh infra/local/tools.sh test
.PHONY: sendico
sendico:
sh infra/local/tools.sh sendico
+11 -9
View File
@@ -9,11 +9,11 @@ The consumer app includes responsive login/signup, English and Russian
localization, email verification and recovery screens, and URL-based navigation localization, email verification and recovery screens, and URL-based navigation
with `go_router`. Its interface uses the shared theme's colors and typography. with `go_router`. Its interface uses the shared theme's colors and typography.
**Authentication, email verification and analytics use dummy services.** No real The default preview uses dummy services. Configuring the existing backend URL
account or session is created, no email is delivered, and no analytics data is and client ID selects authenticated BFF adapters for auth, identity, limits and
transmitted by those app services. The backend now contains locally owned copies wallet quotations/payment execution. Optional Sendico configuration and dedicated
of Sendico’s BFF runtime, auth/session stack and account service. The app is not customer financial resources are required for real operations; analytics still
connected to that backend or to Sendico yet. defaults to an in-memory sink. See [payment setup and rollout](docs/payments.md).
## Repository structure ## Repository structure
@@ -256,7 +256,9 @@ gaps and delivery phases. Selected Sendico code has been adapted with recorded
[provenance](backend/PROVENANCE.md); the projects share no runtime modules. [provenance](backend/PROVENANCE.md); the projects share no runtime modules.
Run `make test-backend`, `make lint-backend` or `make run-backend` from the root. Run `make test-backend`, `make lint-backend` or `make run-backend` from the root.
The copied runtime installs local authentication transport and initializes its The runtime installs local authentication, verification delivery, identity and
account service. Signup/profile endpoints, verification delivery/completion, wallet transports. Public Sendico adapters support independent customer facts,
Flutter HTTP adapters and live Sendico operations remain to be wired. See the reviewed identity/limits and quotation/execution through the BFF. Deployment and
[backend run guide](backend/README.md) for local MongoDB configuration. financial-resource provisioning are separate; backend account-language saving
and additional funding/recipient onboarding remain follow-up work. See the
[backend run guide](backend/README.md) and [payment integration](docs/payments.md).
+7
View File
@@ -43,3 +43,10 @@ contracts; local customers retain no platform tenancy. See
`go test -race ./...` and `go vet ./...` validate this module. Tests require Docker `go test -race ./...` and `go vet ./...` validate this module. Tests require Docker
for disposable Mongo replica sets. Root `make test` and `make lint-backend` also for disposable Mongo replica sets. Root `make test` and `make lint-backend` also
validate `shared`. See [source provenance](../PROVENANCE.md). validate `shared`. See [source provenance](../PROVENANCE.md).
## Customer quotations and payments
With `api.sendico`, runtime composes the customer-scoped payment service and
public HTTP provider adapter. Flutter backend mode selects this path automatically;
missing provider configuration never returns demo money. See
[contracts, provisioning, authorization and rollout](../../docs/payments.md).
+2
View File
@@ -7,6 +7,7 @@ import (
"git.sendico.io/tech/sxchnge/product/auth/storage" "git.sendico.io/tech/sxchnge/product/auth/storage"
"git.sendico.io/tech/sxchnge/product/customers" "git.sendico.io/tech/sxchnge/product/customers"
"git.sendico.io/tech/sxchnge/product/identity" "git.sendico.io/tech/sxchnge/product/identity"
"git.sendico.io/tech/sxchnge/product/payments"
"git.sendico.io/tech/sxchnge/product/twofactor" "git.sendico.io/tech/sxchnge/product/twofactor"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -19,6 +20,7 @@ type Dependencies struct {
Auth *flow.Service Auth *flow.Service
Factors *twofactor.Service Factors *twofactor.Service
Identity *identity.Service Identity *identity.Service
Payments *payments.Service
Token middleware.TokenConfig Token middleware.TokenConfig
Signature middleware.Signature Signature middleware.Signature
LoginLimits middleware.LoginRateLimits LoginLimits middleware.LoginRateLimits
+2
View File
@@ -6,6 +6,7 @@ import (
auth "git.sendico.io/tech/sxchnge/product/auth/http/v1" auth "git.sendico.io/tech/sxchnge/product/auth/http/v1"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/public" "git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
identity "git.sendico.io/tech/sxchnge/product/identity/http/v1" identity "git.sendico.io/tech/sxchnge/product/identity/http/v1"
paymenthttp "git.sendico.io/tech/sxchnge/product/payments/http/v1"
factors "git.sendico.io/tech/sxchnge/product/twofactor/http/v1" factors "git.sendico.io/tech/sxchnge/product/twofactor/http/v1"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
) )
@@ -24,5 +25,6 @@ func Install(scope api.Scope, deps *api.Dependencies) error {
factors.Install(routes, deps.Factors, scope.Logger) factors.Install(routes, deps.Factors, scope.Logger)
} }
identity.Install(routes, deps.Identity, scope.Logger) identity.Install(routes, deps.Identity, scope.Logger)
paymenthttp.Install(routes, deps.Payments, scope.Logger)
return nil return nil
} }
+9
View File
@@ -10,6 +10,7 @@ import (
"git.sendico.io/tech/sxchnge/product/customers" "git.sendico.io/tech/sxchnge/product/customers"
"git.sendico.io/tech/sxchnge/product/identity" "git.sendico.io/tech/sxchnge/product/identity"
"git.sendico.io/tech/sxchnge/product/notifications" "git.sendico.io/tech/sxchnge/product/notifications"
"git.sendico.io/tech/sxchnge/product/payments"
"git.sendico.io/tech/sxchnge/product/providers/sendico" "git.sendico.io/tech/sxchnge/product/providers/sendico"
"git.sendico.io/tech/sxchnge/product/twofactor" "git.sendico.io/tech/sxchnge/product/twofactor"
"git.sendico.io/tech/sxchnge/product/twofactor/delivery" "git.sendico.io/tech/sxchnge/product/twofactor/delivery"
@@ -69,6 +70,11 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
} }
deps.Identity = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("identity")} deps.Identity = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("identity")}
deps.Auth.Facts = deps.Identity deps.Auth.Facts = deps.Identity
paymentStore, err := payments.OpenStore(db)
if err != nil {
return nil, nil, err
}
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("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)
@@ -80,6 +86,9 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
return nil, nil, err return nil, nil, err
} }
} }
if deps.Payments != nil {
deps.Payments.Factors = deps.Factors
}
if config.Sendico != nil && deps.Auth == nil { if config.Sendico != nil && deps.Auth == nil {
return nil, nil, merrors.InvalidArgument("Sendico sign-up facts require notifications configuration") return nil, nil, merrors.InvalidArgument("Sendico sign-up facts require notifications configuration")
} }
+12 -4
View File
@@ -179,10 +179,11 @@ func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key s
err = s.Sender.Send(ctx, notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"url": u.String()}}) err = s.Sender.Send(ctx, notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"url": u.String()}})
return result.ExpiresAt, err return result.ExpiresAt, err
} }
func (s *Service) Activate(ctx context.Context, raw string) error { func (s *Service) Activate(ctx context.Context, raw string) (string, error) {
if len(raw) != 43 { if len(raw) != 43 {
return verification.ErorrTokenNotFound() return "", verification.ErorrTokenNotFound()
} }
var email string
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) { _, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw) token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
if err != nil { if err != nil {
@@ -200,7 +201,14 @@ func (s *Service) Activate(ctx context.Context, raw string) error {
return nil, err return nil, err
} }
} }
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive)) if err := s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive)); err != nil {
return nil, err
}
email = a.Login
return nil, nil
}) })
return err if err != nil {
return "", err
}
return email, nil
} }
@@ -139,11 +139,15 @@ func (pr *PublicRouter) activate(r *http.Request) http.HandlerFunc {
if err := decodeFlow(r, &req); err != nil { if err := decodeFlow(r, &req); err != nil {
return pr.flowError(err) return pr.flowError(err)
} }
if err := pr.flow.Activate(r.Context(), req.Token); err != nil { email, err := pr.flow.Activate(r.Context(), req.Token)
if err != nil {
return pr.flowError(err) return pr.flowError(err)
} }
pr.logger.Info("Account activation completed") pr.logger.Info("Account activation completed")
return response.Accepted(pr.logger, map[string]bool{"verified": true}) return response.Accepted(pr.logger, struct {
Verified bool `json:"verified"`
Email string `json:"email"`
}{Verified: true, Email: email})
} }
func challengeScope(raw string) string { func challengeScope(raw string) string {
h := sha256.Sum256([]byte(raw)) h := sha256.Sum256([]byte(raw))
@@ -152,17 +152,33 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
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)
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).Code) invalid := post("verification/confirm", map[string]string{"token": "invalid"})
require.Equal(t, 400, invalid.Code)
require.NotContains(t, invalid.Body.String(), a.Login)
guard := &activationGuard{SignupFacts: service.Facts, block: true} guard := &activationGuard{SignupFacts: service.Facts, block: true}
service.Facts = guard service.Facts = guard
require.Equal(t, 503, post("verification/confirm", map[string]string{"token": activation}).Code) blocked := post("verification/confirm", map[string]string{"token": activation})
require.Equal(t, 503, blocked.Code)
require.NotContains(t, blocked.Body.String(), a.Login)
a, err = stores.Accounts.GetByEmail(ctx, a.Login) a, err = stores.Accounts.GetByEmail(ctx, a.Login)
require.NoError(t, err) require.NoError(t, err)
require.False(t, a.IsActive()) require.False(t, a.IsActive())
guard.block = false guard.block = false
w = post("verification/confirm", map[string]string{"token": activation}) w = post("verification/confirm", map[string]string{"token": activation})
require.Equal(t, 202, w.Code, w.Body.String()) require.Equal(t, 202, w.Code, w.Body.String())
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code) require.Equal(t, "no-store", w.Header().Get("Cache-Control"))
var confirmed struct {
Data struct {
Verified bool `json:"verified"`
Email string `json:"email"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &confirmed))
require.True(t, confirmed.Data.Verified)
require.Equal(t, a.Login, confirmed.Data.Email)
used := post("verification/confirm", map[string]string{"token": activation})
require.Equal(t, 400, used.Code)
require.NotContains(t, used.Body.String(), a.Login)
a, err = stores.Accounts.GetByEmail(ctx, a.Login) a, err = stores.Accounts.GetByEmail(ctx, a.Login)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, customer.AccountActive, a.Status) require.Equal(t, customer.AccountActive, a.Status)
+10
View File
@@ -147,3 +147,13 @@ func (s *Service) SubmitVerification(ctx context.Context, a *customer.Account, k
s.Logger.Info("Identity proofs accepted for review", zap.String("account_ref", a.ID.Hex()), zap.String("submission_ref", v.SubmissionID)) s.Logger.Info("Identity proofs accepted for review", zap.String("account_ref", a.ID.Hex()), zap.String("submission_ref", v.SubmissionID))
return v, nil return v, nil
} }
// Subject resolves the authenticated customer's confirmed provider binding.
// Only server-side services may use this resource; clients cannot select it.
func (s *Service) Subject(ctx context.Context, a *customer.Account) (string, error) {
b, err := s.binding(ctx, a)
if err != nil {
return "", err
}
return b.SubjectRef, nil
}
+84
View File
@@ -0,0 +1,84 @@
package paymenthttp
import (
"git.sendico.io/tech/sxchnge/product/payments"
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
"time"
)
type requestDTO struct {
Kind string `json:"kind"`
Source string `json:"source"`
Destination string `json:"destination"`
Recipient string `json:"recipient"`
Amount int64 `json:"amount"`
}
func (d requestDTO) model() payments.Request {
return payments.Request{Kind: d.Kind, Source: d.Source, Destination: d.Destination, Recipient: d.Recipient, Amount: d.Amount}
}
func fromRequest(r payments.Request) requestDTO {
return requestDTO{r.Kind, r.Source, r.Destination, r.Recipient, r.Amount}
}
type quoteDTO struct {
ID string `json:"id"`
Request requestDTO `json:"request"`
Fee int64 `json:"fee"`
Debit int64 `json:"debit"`
Credit int64 `json:"credit"`
ExpiresAt time.Time `json:"expiresAt"`
Numerator int64 `json:"rateNumerator"`
Denominator int64 `json:"rateDenominator"`
}
func fromQuote(q payments.Quote) quoteDTO {
return quoteDTO{q.ID, fromRequest(q.Request), q.Fee, q.Debit, q.Credit, q.ExpiresAt, q.RateNumerator, q.RateDenominator}
}
type operationDTO struct {
ID string `json:"id"`
Quote quoteDTO `json:"quote"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
}
func fromOperation(o payments.Operation) operationDTO {
return operationDTO{o.ID, fromQuote(o.Quote), o.Status, o.CreatedAt}
}
type accountDTO struct {
ID string `json:"id"`
Currency string `json:"currency"`
Precision int `json:"precision"`
Available int64 `json:"available"`
PayoutMethod string `json:"payoutMethod"`
PayoutLabel string `json:"payoutLabel"`
CanFund bool `json:"canFund"`
}
type snapshotDTO struct {
Accounts []accountDTO `json:"accounts"`
Operations []operationDTO `json:"operations"`
}
func fromSnapshot(s payments.Snapshot) snapshotDTO {
v := snapshotDTO{Accounts: []accountDTO{}, Operations: []operationDTO{}}
for _, a := range s.Accounts {
v.Accounts = append(v.Accounts, accountDTO{a.ID, a.Currency, a.Precision, a.Available, a.PayoutMethod, a.PayoutLabel, a.FundingMethod != ""})
}
for i := len(s.Operations) - 1; i >= 0; i-- {
v.Operations = append(v.Operations, fromOperation(s.Operations[i]))
}
return v
}
type challengeDTO struct {
ID string `json:"challengeId"`
Target string `json:"target"`
ExpiresAt time.Time `json:"expiresAt"`
ResendAt time.Time `json:"resendAt"`
}
func fromChallenge(c factor.Challenge) challengeDTO {
return challengeDTO{c.ID, c.Target, c.ExpiresAt, c.ResendAt}
}
+164
View File
@@ -0,0 +1,164 @@
package paymenthttp
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/payments"
verification "git.sendico.io/tech/sxchnge/product/verification/store"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/http/response"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
mservice "git.sendico.io/tech/sxchnge/shared/service"
)
type Registrar interface {
AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc)
}
type router struct {
service *payments.Service
logger mlogger.Logger
}
func Install(reg Registrar, s *payments.Service, logger mlogger.Logger) {
h := &router{s, logger.Named("payment_http")}
reg.AccountHandler(mservice.Accounts, "/wallet/profile", api.Get, h.profile)
reg.AccountHandler(mservice.Accounts, "/wallet", api.Get, h.fetch)
reg.AccountHandler(mservice.Accounts, "/wallet/quote", api.Post, h.quote)
reg.AccountHandler(mservice.Accounts, "/wallet/submit", api.Post, h.submit)
reg.AccountHandler(mservice.Accounts, "/wallet/submission", api.Post, h.reconcile)
reg.AccountHandler(mservice.Accounts, "/wallet/challenge", api.Post, h.challenge)
reg.AccountHandler(mservice.Accounts, "/wallet/resend", api.Post, h.resend)
reg.AccountHandler(mservice.Accounts, "/wallet/verify", api.Post, h.verify)
}
func decode(r *http.Request, dst any) error {
d := json.NewDecoder(io.LimitReader(r.Body, 8193))
d.DisallowUnknownFields()
if d.Decode(dst) != nil {
return payments.InvalidAmount
}
var extra any
if d.Decode(&extra) != io.EOF {
return payments.InvalidAmount
}
return nil
}
func (h *router) result(data any, err error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if err == nil {
response.Ok(h.logger, data)(w, r)
return
}
code := "unavailable"
status := http.StatusServiceUnavailable
var f payments.Failure
if errors.As(err, &f) {
code = string(f)
if f != payments.Unavailable && f != payments.Uncertain {
status = http.StatusBadRequest
}
}
switch {
case errors.Is(err, verification.ErrTokenExpired):
code = "factorExpired"
status = 400
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
code = "factorLocked"
status = 400
case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed), errors.Is(err, merrors.ErrUnauthorized):
code = "factorInvalid"
status = 400
}
if throttle, ok := verification.Throttle(err); ok {
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(throttle.RetryAfter.Seconds()))))
code = "factorCooldown"
status = 429
}
response.Error(h.logger, mservice.Accounts, status, code, "Unable to complete wallet operation")(w, r)
}
}
func (h *router) fetch(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
v, e := h.service.Fetch(r.Context(), a)
return h.result(fromSnapshot(v), e)
}
func (h *router) quote(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d requestDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Quote(r.Context(), a, d.model())
return h.result(fromQuote(v), e)
}
type actionDTO struct {
Quote string `json:"quoteId"`
Key string `json:"idempotencyKey"`
Challenge string `json:"challengeId"`
Code string `json:"code"`
}
func (h *router) submit(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Submit(r.Context(), a, d.Quote, d.Key)
return h.result(fromOperation(v), e)
}
func (h *router) reconcile(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Reconcile(r.Context(), a, d.Key)
if v == nil {
return h.result(nil, e)
}
return h.result(fromOperation(*v), e)
}
func requestIP(r *http.Request) string {
if ip := ipguard.ClientIP(r); ip != nil {
return ip.String()
}
return "unresolved"
}
func (h *router) challenge(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Challenge(r.Context(), a, d.Quote, requestIP(r))
return h.result(fromChallenge(v), e)
}
func (h *router) resend(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Resend(r.Context(), a, d.Quote, d.Challenge, d.Key, requestIP(r))
return h.result(fromChallenge(v), e)
}
func (h *router) verify(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
e := h.service.Authorize(r.Context(), a, d.Quote, d.Challenge, d.Code, d.Key)
return h.result(struct {
Key string `json:"idempotencyKey"`
}{d.Key}, e)
}
func (h *router) profile(_ *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
return h.result(struct {
Locale string `json:"locale"`
}{a.Locale}, nil)
}
@@ -0,0 +1,36 @@
package paymenthttp
import (
"net/http"
"net/http/httptest"
"strings"
"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 TestUnconfiguredWalletCannotReturnDemoFunds(t *testing.T) {
h := &router{logger: zap.NewNop()}
a := &customer.Account{}
a.ID = bson.NewObjectID()
request := httptest.NewRequest(http.MethodGet, "/accounts/wallet", nil)
recorder := httptest.NewRecorder()
h.fetch(request, a, nil)(recorder, request)
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
require.Equal(t, "no-store", recorder.Header().Get("Cache-Control"))
require.NotContains(t, recorder.Body.String(), "7500")
}
func TestQuoteRejectsUnknownFieldsAndTrailingRequests(t *testing.T) {
h := &router{logger: zap.NewNop()}
a := &customer.Account{}
a.ID = bson.NewObjectID()
for _, body := range []string{`{"kind":"payout","subject":"foreign"}`, `{"amount":10} {"amount":20}`, `{"amount":1.2}`} {
request := httptest.NewRequest(http.MethodPost, "/accounts/wallet/quote", strings.NewReader(body))
recorder := httptest.NewRecorder()
h.quote(request, a, nil)(recorder, request)
require.Equal(t, http.StatusBadRequest, recorder.Code)
}
}
+83
View File
@@ -0,0 +1,83 @@
// Package payments owns customer quotation ownership and durable execution intent.
// Money and settlement remain authoritative in the provider.
package payments
import (
"context"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/shared/database/record"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Account struct {
ID, Currency string
Precision int
Available int64
PayoutMethod, PayoutLabel, FundingMethod string
}
type Request struct {
Kind, Source, Destination, Recipient string
Amount int64
}
type Quote struct {
ID string
Request Request
Fee, Debit, Credit int64
ExpiresAt time.Time
RateNumerator, RateDenominator int64
}
type Operation struct {
ID string
Quote Quote
Status string
CreatedAt time.Time
}
type Snapshot struct {
Accounts []Account
Operations []Operation
}
type Record struct {
storable.Base `bson:",inline"`
Customer bson.ObjectID `bson:"customer"`
Namespace string `bson:"namespace"`
Subject string `bson:"subject"`
Quote Quote `bson:"quote"`
ProviderQuote string `bson:"providerQuote"`
Key string `bson:"key"`
Payment string `bson:"payment"`
}
func (*Record) Collection() string { return "payment_quotes" }
type Provider interface {
Namespace() string
Accounts(context.Context, string) ([]Account, error)
Quote(context.Context, string, Request, []Account) (Quote, error)
Execute(context.Context, string, string) (RemoteOperation, error)
Payment(context.Context, string) (RemoteOperation, error)
}
type RemoteOperation struct {
ID, QuoteRef, Status string
CreatedAt time.Time
}
type Identity interface {
Subject(context.Context, *customer.Account) (string, error)
}
type Failure string
func (e Failure) Error() string { return string(e) }
const (
Unavailable Failure = "unavailable"
InvalidAmount Failure = "invalidAmount"
InvalidRecipient Failure = "invalidRecipient"
InsufficientFunds Failure = "insufficientFunds"
LimitExceeded Failure = "limitExceeded"
ExpiredQuote Failure = "expiredQuote"
Unsupported Failure = "unsupported"
NotFound Failure = "notFound"
Uncertain Failure = "uncertain"
FactorRequired Failure = "factorRequired"
)
+237
View File
@@ -0,0 +1,237 @@
package payments
import (
"context"
"errors"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/twofactor"
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
verification "git.sendico.io/tech/sxchnge/product/verification/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type Service struct {
Store Store
Provider Provider
Identity Identity
Factors twofactor.API
Logger mlogger.Logger
}
func (s *Service) ready() error {
if s == nil || s.Store == nil || s.Provider == nil || s.Identity == nil || s.Factors == nil {
return Unavailable
}
return nil
}
func (s *Service) record(ctx context.Context, a *customer.Account, id string) (*Record, error) {
if err := s.ready(); err != nil {
return nil, err
}
if a == nil || a.ID.IsZero() {
return nil, NotFound
}
ref, err := bson.ObjectIDFromHex(id)
if err != nil {
return nil, NotFound
}
r, err := s.Store.Get(ctx, ref, a.ID, s.Provider.Namespace())
if errors.Is(err, merrors.ErrNoData) {
return nil, NotFound
}
if err != nil {
return nil, err
}
subject, err := s.Identity.Subject(ctx, a)
if err != nil {
return nil, err
}
if subject != r.Subject {
return nil, Unavailable
}
return r, nil
}
func (s *Service) Quote(ctx context.Context, a *customer.Account, request Request) (Quote, error) {
if err := s.ready(); err != nil {
return Quote{}, err
}
subject, err := s.Identity.Subject(ctx, a)
if err != nil {
return Quote{}, err
}
accounts, err := s.Provider.Accounts(ctx, subject)
if err != nil {
return Quote{}, err
}
q, err := s.Provider.Quote(ctx, subject, request, accounts)
if err != nil {
return Quote{}, err
}
providerRef := q.ID
q.ID = bson.NewObjectID().Hex()
r := &Record{Customer: a.ID, Namespace: s.Provider.Namespace(), Subject: subject, Quote: q, ProviderQuote: providerRef}
r.SetID(mustID(q.ID))
if err = s.Store.Create(ctx, r); err != nil {
return Quote{}, err
}
s.Logger.Info("Customer quotation stored", zap.String("quote_ref", q.ID), zap.String("kind", request.Kind))
return q, nil
}
func mustID(id string) bson.ObjectID { value, _ := bson.ObjectIDFromHex(id); return value }
func operationScope(r *Record) factor.Scope {
return factor.Scope{Purpose: verification.PurposePayment, ContextRef: r.Quote.ID}
}
func (s *Service) Challenge(ctx context.Context, a *customer.Account, id, ip string) (factor.Challenge, error) {
r, err := s.record(ctx, a, id)
if err != nil {
return factor.Challenge{}, err
}
if !time.Now().Before(r.Quote.ExpiresAt) || r.Key != "" {
return factor.Challenge{}, ExpiredQuote
}
return s.Factors.Begin(ctx, factor.Request{Subject: a.ID, Scope: operationScope(r), Deadline: r.Quote.ExpiresAt, IdempotencyKey: "payment:" + r.Quote.ID, IP: ip})
}
func (s *Service) Resend(ctx context.Context, a *customer.Account, id, challenge, key, ip string) (factor.Challenge, error) {
r, err := s.record(ctx, a, id)
if err != nil {
return factor.Challenge{}, err
}
if !time.Now().Before(r.Quote.ExpiresAt) || r.Key != "" {
return factor.Challenge{}, ExpiredQuote
}
if !validKey(key) {
return factor.Challenge{}, InvalidAmount
}
return s.Factors.Resend(ctx, factor.Request{Subject: a.ID, Scope: operationScope(r), Deadline: r.Quote.ExpiresAt, IdempotencyKey: key, IP: ip}, challenge)
}
func validKey(key string) bool {
if len(key) < 16 || len(key) > 128 {
return false
}
for _, c := range key {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
return false
}
}
return true
}
// Authorize consumes the challenge atomically with a durable execution intent.
// No upstream I/O occurs inside the transaction. Replaying the same authorization
// recovers a lost response without reusing the one-use verification code.
func (s *Service) Authorize(ctx context.Context, a *customer.Account, id, challenge, code, key string) error {
r, err := s.record(ctx, a, id)
if err != nil {
return err
}
if !validKey(key) {
return InvalidAmount
}
if r.Key != "" {
if r.Key == key {
return nil
}
return ExpiredQuote
}
return s.Factors.Complete(ctx, a.ID, operationScope(r), challenge, code, func(tx context.Context) error {
if !time.Now().Before(r.Quote.ExpiresAt) {
return ExpiredQuote
}
return s.Store.Authorize(tx, r, key)
})
}
func (s *Service) execute(ctx context.Context, r *Record) (Operation, error) {
var remote RemoteOperation
var err error
if r.Payment != "" {
remote, err = s.Provider.Payment(ctx, r.Payment)
} else {
remote, err = s.Provider.Execute(ctx, r.ProviderQuote, "sxchnge:payment:"+r.ID.Hex())
}
if err != nil {
s.Logger.Warn("Payment execution needs reconciliation", zap.String("quote_ref", r.Quote.ID))
return Operation{}, merrors.OpaqueWrap(Uncertain, err, "provider payment outcome unknown")
}
if remote.QuoteRef != r.ProviderQuote || remote.ID == "" || (r.Payment != "" && r.Payment != remote.ID) {
return Operation{}, Uncertain
}
if r.Payment == "" {
if err = s.Store.Attach(ctx, r, remote.ID); err != nil {
return Operation{}, merrors.OpaqueWrap(Uncertain, err, "persist provider payment reference")
}
}
s.Logger.Info("Payment execution reconciled", zap.String("quote_ref", r.Quote.ID), zap.String("payment_ref", remote.ID), zap.String("state", remote.Status))
return Operation{ID: r.Quote.ID, Quote: r.Quote, Status: remote.Status, CreatedAt: remote.CreatedAt}, nil
}
func (s *Service) Submit(ctx context.Context, a *customer.Account, id, key string) (Operation, error) {
r, err := s.record(ctx, a, id)
if err != nil {
return Operation{}, err
}
if !validKey(key) || r.Key == "" || r.Key != key {
return Operation{}, FactorRequired
}
return s.execute(ctx, r)
}
// Reconcile replays the already authorized durable intent with the same provider
// key. A timeout or an empty upstream list is never treated as proof of absence.
func (s *Service) Reconcile(ctx context.Context, a *customer.Account, key string) (*Operation, error) {
if err := s.ready(); err != nil {
return nil, err
}
if !validKey(key) {
return nil, NotFound
}
r, err := s.Store.ByKey(ctx, a.ID, s.Provider.Namespace(), key)
if errors.Is(err, merrors.ErrNoData) {
return nil, nil
}
if err != nil {
return nil, err
}
r, err = s.record(ctx, a, r.ID.Hex())
if err != nil {
return nil, err
}
op, err := s.execute(ctx, r)
return &op, err
}
func (s *Service) Fetch(ctx context.Context, a *customer.Account) (Snapshot, error) {
if err := s.ready(); err != nil {
return Snapshot{}, err
}
subject, err := s.Identity.Subject(ctx, a)
if err != nil {
return Snapshot{}, err
}
rows, err := s.Store.List(ctx, a.ID, s.Provider.Namespace())
if err != nil {
return Snapshot{}, err
}
result := Snapshot{Operations: []Operation{}}
for _, r := range rows {
if r.Subject != subject {
return Snapshot{}, Unavailable
}
op, err := s.execute(ctx, &r)
if errors.Is(err, Uncertain) {
// A durable authorization with an unknown provider outcome remains
// pending. It must not hide unrelated balances or report failure.
op = Operation{ID: r.Quote.ID, Quote: r.Quote, Status: "pending", CreatedAt: r.CreatedAt}
} else if err != nil {
return Snapshot{}, err
}
result.Operations = append(result.Operations, op)
}
result.Accounts, err = s.Provider.Accounts(ctx, subject)
if err != nil {
return Snapshot{}, err
}
return result, nil
}
+189
View File
@@ -0,0 +1,189 @@
package payments
import (
"context"
"sync"
"testing"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type memoryStore struct {
mu sync.Mutex
records map[bson.ObjectID]Record
}
func (s *memoryStore) Create(_ context.Context, r *Record) error {
s.mu.Lock()
defer s.mu.Unlock()
s.records[r.ID] = *r
return nil
}
func (s *memoryStore) Get(_ context.Context, id, customer bson.ObjectID, namespace string) (*Record, error) {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.records[id]
if !ok || r.Customer != customer || r.Namespace != namespace {
return nil, merrors.ErrNoData
}
return &r, nil
}
func (s *memoryStore) ByKey(_ context.Context, customer bson.ObjectID, namespace, key string) (*Record, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, r := range s.records {
if r.Customer == customer && r.Namespace == namespace && r.Key == key {
return &r, nil
}
}
return nil, merrors.ErrNoData
}
func (s *memoryStore) List(_ context.Context, customer bson.ObjectID, namespace string) ([]Record, error) {
s.mu.Lock()
defer s.mu.Unlock()
rows := []Record{}
for _, r := range s.records {
if r.Customer == customer && r.Namespace == namespace && r.Key != "" {
rows = append(rows, r)
}
}
return rows, nil
}
func (s *memoryStore) Authorize(_ context.Context, r *Record, key string) error {
s.mu.Lock()
defer s.mu.Unlock()
current := s.records[r.ID]
if current.Key != "" {
return Uncertain
}
current.Key = key
s.records[r.ID] = current
return nil
}
func (s *memoryStore) Attach(_ context.Context, r *Record, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
current := s.records[r.ID]
current.Payment = id
s.records[r.ID] = current
return nil
}
type provider struct {
mu sync.Mutex
accepted int
lost bool
status string
key string
namespace string
}
func (p *provider) Namespace() string { return p.namespace }
func (p *provider) Accounts(context.Context, string) ([]Account, error) {
return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil
}
func (p *provider) Quote(_ context.Context, _ string, r Request, _ []Account) (Quote, error) {
return Quote{ID: "provider-quote", Request: r, ExpiresAt: time.Now().Add(time.Minute)}, nil
}
func (p *provider) Execute(_ context.Context, quote, key string) (RemoteOperation, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.key == "" {
p.accepted++
p.key = key
}
if p.key != key {
return RemoteOperation{}, Unavailable
}
if p.lost {
p.lost = false
return RemoteOperation{}, Unavailable
}
return RemoteOperation{ID: "payment", QuoteRef: quote, Status: p.status, CreatedAt: time.Now()}, nil
}
func (p *provider) Payment(context.Context, string) (RemoteOperation, error) {
return RemoteOperation{ID: "payment", QuoteRef: "provider-quote", Status: p.status, CreatedAt: time.Now()}, nil
}
type identity struct{}
func (identity) Subject(context.Context, *customer.Account) (string, error) { return "subject", nil }
type factors struct {
calls int
subject bson.ObjectID
scope factor.Scope
}
func (f *factors) Begin(_ context.Context, r factor.Request) (factor.Challenge, error) {
f.subject = r.Subject
f.scope = r.Scope
return factor.Challenge{ID: "challenge"}, nil
}
func (f *factors) Resend(ctx context.Context, r factor.Request, _ string) (factor.Challenge, error) {
return f.Begin(ctx, r)
}
func (f *factors) Complete(ctx context.Context, subject bson.ObjectID, scope factor.Scope, id, code string, action func(context.Context) error) error {
if subject != f.subject || scope != f.scope || id != "challenge" || code != "123456" {
return FactorRequired
}
f.calls++
return action(ctx)
}
func TestCustomerExecutionOwnershipAndLostResponse(t *testing.T) {
ctx := context.Background()
a := &customer.Account{}
a.ID = bson.NewObjectID()
other := &customer.Account{}
other.ID = bson.NewObjectID()
store := &memoryStore{records: map[bson.ObjectID]Record{}}
p := &provider{lost: true, status: "pending", namespace: "provider"}
f := &factors{}
s := &Service{Store: store, Provider: p, Identity: identity{}, Factors: f, Logger: zap.NewNop()}
q, err := s.Quote(ctx, a, Request{Kind: "payout", Source: "ledger", Amount: 1000})
require.NoError(t, err)
_, err = s.Submit(ctx, a, q.ID, "unapproved-key-123")
require.ErrorIs(t, err, FactorRequired)
require.Zero(t, p.accepted)
_, err = s.Challenge(ctx, other, q.ID, "")
require.ErrorIs(t, err, NotFound)
_, err = s.Challenge(ctx, a, q.ID, "")
require.NoError(t, err)
require.ErrorIs(t, s.Authorize(ctx, a, q.ID, "challenge", "bad-code", "authorized-key-123"), FactorRequired)
require.NoError(t, s.Authorize(ctx, a, q.ID, "challenge", "123456", "authorized-key-123"))
require.NoError(t, s.Authorize(ctx, a, q.ID, "challenge", "123456", "authorized-key-123"))
require.Equal(t, 1, f.calls)
_, err = s.Submit(ctx, a, q.ID, "authorized-key-123")
require.ErrorIs(t, err, Uncertain)
op, err := s.Reconcile(ctx, other, "authorized-key-123")
require.NoError(t, err)
require.Nil(t, op)
op, err = s.Reconcile(ctx, a, "authorized-key-123")
require.NoError(t, err)
require.Equal(t, "pending", op.Status)
require.Equal(t, 1, p.accepted)
p.status = "completed"
snapshot, err := s.Fetch(ctx, a)
require.NoError(t, err)
require.Equal(t, "completed", snapshot.Operations[0].Status)
require.ErrorIs(t, s.Authorize(ctx, a, q.ID, "challenge", "123456", "different-key-1234"), ExpiredQuote)
// A changed provider namespace cannot execute the old quote.
p.namespace = "rotated-provider"
_, err = s.Submit(ctx, a, q.ID, "authorized-key-123")
require.ErrorIs(t, err, NotFound)
}
func TestExpiredQuoteCannotAuthorize(t *testing.T) {
a := &customer.Account{}
a.ID = bson.NewObjectID()
r := Record{Customer: a.ID, Namespace: "p", Subject: "subject", Quote: Quote{ID: bson.NewObjectID().Hex(), ExpiresAt: time.Now().Add(-time.Second)}}
r.SetID(mustID(r.Quote.ID))
s := &Service{Store: &memoryStore{records: map[bson.ObjectID]Record{r.ID: r}}, Provider: &provider{namespace: "p"}, Identity: identity{}, Factors: &factors{}, Logger: zap.NewNop()}
_, err := s.Challenge(context.Background(), a, r.Quote.ID, "")
require.ErrorIs(t, err, ExpiredQuote)
}
+88
View File
@@ -0,0 +1,88 @@
package payments
import (
"context"
db "git.sendico.io/tech/sxchnge/shared/database"
deps "git.sendico.io/tech/sxchnge/shared/database/dependencies"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/source"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Store interface {
Create(context.Context, *Record) error
Get(context.Context, bson.ObjectID, bson.ObjectID, string) (*Record, error)
ByKey(context.Context, bson.ObjectID, string, string) (*Record, error)
List(context.Context, bson.ObjectID, string) ([]Record, error)
Authorize(context.Context, *Record, string) error
Attach(context.Context, *Record, string) error
}
type mongoStore struct{ records repository.Repository }
func OpenStore(factory db.Factory) (Store, error) {
d, err := deps.ResolveDependencies(factory)
if err != nil {
return nil, err
}
r := repository.CreateRepository(source.Majority(d.Database), "payment_quotes")
err = r.CreateIndex(&ri.Definition{Name: "customer_execution_key", Unique: true, Keys: []ri.Key{{Field: "customer", Sort: ri.Asc}, {Field: "namespace", Sort: ri.Asc}, {Field: "key", Sort: ri.Asc}}, PartialFilter: repository.Query().Comparison(repository.Field("key"), builder.Gt, "")})
if err != nil {
return nil, err
}
if err = r.CreateIndex(&ri.Definition{Name: "provider_quote_unique", Unique: true, Keys: []ri.Key{{Field: "namespace", Sort: ri.Asc}, {Field: "providerQuote", Sort: ri.Asc}}, PartialFilter: repository.Query().Comparison(repository.Field("providerQuote"), builder.Gt, "")}); err != nil {
return nil, err
}
return &mongoStore{r}, nil
}
func scope(customer bson.ObjectID, namespace string) repository.FilterQuery {
return repository.Query().Filter(repository.Field("customer"), customer).Filter(repository.Field("namespace"), namespace)
}
func (s *mongoStore) Create(ctx context.Context, r *Record) error {
return s.records.Insert(ctx, r, nil)
}
func (s *mongoStore) Get(ctx context.Context, id, customer bson.ObjectID, namespace string) (*Record, error) {
r := &Record{}
err := s.records.FindOneByFilter(ctx, scope(customer, namespace).Filter(repository.IDField(), id), r)
return r, err
}
func (s *mongoStore) ByKey(ctx context.Context, customer bson.ObjectID, namespace, key string) (*Record, error) {
r := &Record{}
err := s.records.FindOneByFilter(ctx, scope(customer, namespace).Filter(repository.Field("key"), key), r)
return r, err
}
func (s *mongoStore) List(ctx context.Context, customer bson.ObjectID, namespace string) ([]Record, error) {
var rows []Record
err := s.records.FindAllByFilter(ctx, scope(customer, namespace).Comparison(repository.Field("key"), builder.Gt, "").Sort(repository.Field("createdAt"), true), &rows)
return rows, err
}
func (s *mongoStore) Authorize(ctx context.Context, r *Record, key string) error {
matched, err := s.records.PatchOneByFilter(ctx, scope(r.Customer, r.Namespace).Filter(repository.IDField(), r.ID).Filter(repository.Field("key"), ""), repository.Patch().Set(repository.Field("key"), key), &Record{})
if err != nil {
return err
}
if !matched {
return Uncertain
}
return nil
}
func (s *mongoStore) Attach(ctx context.Context, r *Record, id string) error {
matched, err := s.records.PatchOneByFilter(ctx, scope(r.Customer, r.Namespace).Filter(repository.IDField(), r.ID).Filter(repository.Field("payment"), ""), repository.Patch().Set(repository.Field("payment"), id), &Record{})
if err != nil {
return err
}
if matched {
return nil
}
current, err := s.Get(ctx, r.ID, r.Customer, r.Namespace)
if err != nil {
return err
}
if current.Payment != id {
return Uncertain
}
return nil
}
+66
View File
@@ -0,0 +1,66 @@
package payments
import (
"context"
"net/url"
"testing"
"time"
db "git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func TestDurableAuthorizationTransactionAndCustomerIsolation(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx)
require.NoError(t, err)
u, err := url.Parse(uri)
require.NoError(t, err)
q := u.Query()
q.Set("directConnection", "true")
u.RawQuery = q.Encode()
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "payments"}})
require.NoError(t, err)
t.Cleanup(factory.CloseConnection)
store, err := OpenStore(factory)
require.NoError(t, err)
customer := bson.NewObjectID()
r := &Record{Customer: customer, Namespace: "provider", Subject: "subject"}
r.SetID(bson.NewObjectID())
require.NoError(t, store.Create(ctx, r))
_, err = store.Get(ctx, r.ID, bson.NewObjectID(), r.Namespace)
require.ErrorIs(t, err, merrors.ErrNoData)
_, err = factory.TransactionFactory().CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
require.NoError(t, store.Authorize(tx, r, "authorized-key-123"))
return nil, Uncertain
})
require.ErrorIs(t, err, Uncertain)
stored, err := store.Get(ctx, r.ID, customer, r.Namespace)
require.NoError(t, err)
require.Empty(t, stored.Key)
require.NoError(t, store.Authorize(ctx, r, "authorized-key-123"))
require.ErrorIs(t, store.Authorize(ctx, r, "other-key-123456"), Uncertain)
another := &Record{Customer: customer, Namespace: r.Namespace}
another.SetID(bson.NewObjectID())
require.NoError(t, store.Create(ctx, another))
require.ErrorIs(t, store.Authorize(ctx, another, "authorized-key-123"), merrors.ErrDataConflict)
require.NoError(t, store.Attach(ctx, r, "payment-1"))
require.NoError(t, store.Attach(ctx, r, "payment-1"))
require.ErrorIs(t, store.Attach(ctx, r, "different-payment"), Uncertain)
reopened, err := OpenStore(factory)
require.NoError(t, err)
stored, err = reopened.ByKey(ctx, customer, r.Namespace, "authorized-key-123")
require.NoError(t, err)
require.Equal(t, "payment-1", stored.Payment)
_, err = reopened.ByKey(ctx, customer, "other-provider", "authorized-key-123")
require.ErrorIs(t, err, merrors.ErrNoData)
}
@@ -0,0 +1,110 @@
package sendico
import (
"context"
"net/http"
"git.sendico.io/tech/sxchnge/product/payments"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Accounts exposes only dedicated, organization-owned customer ledgers tagged by
// trusted platform provisioning. The integration login's personal/pooled ledgers
// never become customer balances. Method bindings are provisioned on that ledger.
func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Account, error) {
if !validRef(subject) {
return nil, payments.Unavailable
}
var currencies struct {
Rows []currencyDTO `json:"currencies"`
}
if err := c.paymentRequest(ctx, http.MethodGet, "/payments/fx/currencies/"+c.config.OrganizationID, nil, &currencies); err != nil {
return nil, err
}
scales := map[string]int{}
for _, row := range currencies.Rows {
if row.Scale != nil && *row.Scale >= 0 && *row.Scale <= 8 {
scales[row.Code] = *row.Scale
}
}
var data struct {
Accounts []accountDTO `json:"accounts"`
}
root := "/ledger_accounts/" + c.config.OrganizationID
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
return nil, err
}
accounts := []payments.Account{}
for _, row := range data.Accounts {
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" {
return nil, payments.Unavailable
}
scale, ok := scales[row.Currency]
if !ok {
return nil, payments.Unsupported
}
var balance struct {
Value balanceDTO `json:"balance"`
}
if err := c.paymentRequest(ctx, http.MethodGet, root+"/"+row.ID+"/balance", nil, &balance); err != nil {
return nil, err
}
account, err := mapAccount(row, balance.Value, scale)
if err != nil {
return nil, err
}
accounts = append(accounts, account)
}
return accounts, nil
}
func (c *Client) Quote(ctx context.Context, subject string, r payments.Request, accounts []payments.Account) (payments.Quote, error) {
mapped, err := mapIntent(subject, r, accounts)
if err != nil {
return payments.Quote{}, err
}
var response struct {
Quote paymentQuoteDTO `json:"quote"`
Subject string `json:"externalSubjectRef"`
}
err = c.paymentRequest(ctx, http.MethodPost, "/payments/quote/"+c.config.OrganizationID, quoteRequestDTO{Key: "sxchnge:quote:" + bson.NewObjectID().Hex(), Intent: mapped.wire}, &response)
if err != nil {
return payments.Quote{}, err
}
if response.Subject != subject {
return payments.Quote{}, payments.Unavailable
}
return mapPaymentQuote(response.Quote, mapped.request, mapped.source, mapped.destination)
}
func (c *Client) Execute(ctx context.Context, quote, key string) (payments.RemoteOperation, error) {
if !safePaymentRef(quote) {
return payments.RemoteOperation{}, payments.Unavailable
}
var result struct {
Payment paymentDTO `json:"payment"`
}
payload := struct {
Quote string `json:"quoteRef"`
Key string `json:"idempotencyKey"`
}{quote, key}
err := c.paymentRequest(ctx, http.MethodPost, "/payments/by-quote/"+c.config.OrganizationID, payload, &result)
if err != nil {
return payments.RemoteOperation{}, err
}
return mapPayment(result.Payment)
}
func (c *Client) Payment(ctx context.Context, id string) (payments.RemoteOperation, error) {
if !safePaymentRef(id) {
return payments.RemoteOperation{}, payments.Unavailable
}
var result struct {
Payment paymentDTO `json:"payment"`
}
err := c.paymentRequest(ctx, http.MethodGet, "/payments/by-ref/"+id, nil, &result)
if err != nil {
return payments.RemoteOperation{}, err
}
return mapPayment(result.Payment)
}
@@ -0,0 +1,211 @@
package sendico
import (
"math/big"
"strconv"
"strings"
"time"
"git.sendico.io/tech/sxchnge/product/payments"
)
const maxMinor int64 = 9007199254740991
func safePaymentRef(value string) bool {
if len(value) == 0 || len(value) > 200 {
return false
}
for _, c := range value {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == ':') {
return false
}
}
return true
}
// Decimal parsing never rounds provider values or converts money through floats.
func minorMoney(m moneyDTO, currency string, scale int) (int64, error) {
if m.Currency != currency || scale < 0 || scale > 8 {
return 0, payments.Unavailable
}
parts := strings.Split(m.Amount, ".")
if len(parts) > 2 || parts[0] == "" {
return 0, payments.Unavailable
}
digits := parts[0]
if len(parts) == 2 {
if parts[1] == "" || len(parts[1]) > scale {
return 0, payments.Unavailable
}
digits += parts[1] + strings.Repeat("0", scale-len(parts[1]))
} else {
digits += strings.Repeat("0", scale)
}
for _, r := range digits {
if r < '0' || r > '9' {
return 0, payments.Unavailable
}
}
value, err := strconv.ParseInt(digits, 10, 64)
if err != nil || value > maxMinor {
return 0, payments.Unavailable
}
return value, nil
}
func majorMoney(minor int64, scale int) string {
digits := strconv.FormatInt(minor, 10)
if scale == 0 {
return digits
}
if len(digits) <= scale {
digits = strings.Repeat("0", scale+1-len(digits)) + digits
}
return digits[:len(digits)-scale] + "." + digits[len(digits)-scale:]
}
func mapPaymentQuote(dto paymentQuoteDTO, r payments.Request, source, destination payments.Account) (payments.Quote, error) {
if !dto.Executable || dto.State != "executable" {
switch dto.BlockReason {
case "limit_blocked":
return payments.Quote{}, payments.LimitExceeded
case "amount_too_small", "amount_too_large":
return payments.Quote{}, payments.InvalidAmount
default:
return payments.Quote{}, payments.Unsupported
}
}
if !safePaymentRef(dto.ID) || !dto.Expires.After(time.Now()) {
return payments.Quote{}, payments.ExpiredQuote
}
debit, err := minorMoney(dto.Amounts.Debit, source.Currency, source.Precision)
if err != nil {
return payments.Quote{}, err
}
principal, err := minorMoney(dto.Amounts.Principal, source.Currency, source.Precision)
if err != nil {
return payments.Quote{}, err
}
credit, err := minorMoney(dto.Amounts.Credit, destination.Currency, destination.Precision)
if err != nil {
return payments.Quote{}, err
}
if principal != r.Amount || debit < principal || credit <= 0 {
return payments.Quote{}, payments.Unavailable
}
if r.Kind != "topUp" && debit > source.Available {
return payments.Quote{}, payments.InsufficientFunds
}
num, den := big.NewInt(credit), big.NewInt(principal)
num.Mul(num, new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(source.Precision)), nil))
den.Mul(den, new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(destination.Precision)), nil))
gcd := new(big.Int).GCD(nil, nil, num, den)
num.Quo(num, gcd)
den.Quo(den, gcd)
if !num.IsInt64() || !den.IsInt64() || num.Int64() > maxMinor || den.Int64() > maxMinor {
return payments.Quote{}, payments.Unsupported
}
return payments.Quote{ID: dto.ID, Request: r, Fee: debit - principal, Debit: debit, Credit: credit, ExpiresAt: dto.Expires, RateNumerator: num.Int64(), RateDenominator: den.Int64()}, nil
}
func mapPayment(dto paymentDTO) (payments.RemoteOperation, error) {
if !safePaymentRef(dto.ID) || !safePaymentRef(dto.Quote.ID) || dto.Created.IsZero() {
return payments.RemoteOperation{}, payments.Unavailable
}
status := "pending"
switch dto.State {
case "orchestration_state_settled":
status = "completed"
case "orchestration_state_failed":
status = "failed"
case "orchestration_state_created", "orchestration_state_executing", "orchestration_state_needs_attention":
default:
return payments.RemoteOperation{}, payments.Unavailable
}
return payments.RemoteOperation{ID: dto.ID, QuoteRef: dto.Quote.ID, Status: status, CreatedAt: dto.Created}, nil
}
func accountByID(accounts []payments.Account, id string) (payments.Account, error) {
for _, a := range accounts {
if a.ID == id {
return a, nil
}
}
return payments.Account{}, payments.NotFound
}
func ledgerEndpoint(id string) endpointDTO {
return endpointDTO{Type: "ledger", Data: map[string]string{"account_ref": id}}
}
func methodEndpoint(id string) endpointDTO {
return endpointDTO{Type: "paymentMethod", Data: map[string]string{"ref": id}}
}
type mappedIntent struct {
wire paymentIntentDTO
request payments.Request
source, destination payments.Account
}
func mapIntent(subject string, r payments.Request, accounts []payments.Account) (mappedIntent, error) {
source, err := accountByID(accounts, r.Source)
if err != nil {
return mappedIntent{}, err
}
if r.Amount <= 0 || r.Amount > maxMinor {
return mappedIntent{}, payments.InvalidAmount
}
intent := paymentIntentDTO{Subject: subject, Source: ledgerEndpoint(source.ID), Amount: moneyDTO{Amount: majorMoney(r.Amount, source.Precision), Currency: source.Currency}, FeeTreatment: "add_to_source"}
destination := source
switch r.Kind {
case "payout":
r.Destination = ""
if source.PayoutMethod == "" || r.Recipient != source.PayoutMethod {
return mappedIntent{}, payments.InvalidRecipient
}
intent.Kind = "payout"
intent.Destination = methodEndpoint(source.PayoutMethod)
// Store the safe provisioned display label, never present an opaque method ID.
r.Recipient = source.PayoutLabel
case "exchange":
r.Recipient = ""
destination, err = accountByID(accounts, r.Destination)
if err != nil {
return mappedIntent{}, err
}
if source.ID == destination.ID || source.Currency == destination.Currency {
return mappedIntent{}, payments.Unsupported
}
intent.Kind = "fx_conversion"
intent.Destination = ledgerEndpoint(destination.ID)
case "topUp":
r.Destination = ""
r.Recipient = ""
if source.FundingMethod == "" {
return mappedIntent{}, payments.Unsupported
}
intent.Kind = "internal_transfer"
intent.Source = methodEndpoint(source.FundingMethod)
intent.Destination = ledgerEndpoint(source.ID)
default:
return mappedIntent{}, payments.Unsupported
}
return mappedIntent{intent, r, source, destination}, nil
}
func mapAccount(row accountDTO, balance balanceDTO, scale int) (payments.Account, error) {
if balance.ID != row.ID {
return payments.Account{}, payments.Unavailable
}
minor, err := minorMoney(balance.Money, row.Currency, scale)
if err != nil {
return payments.Account{}, err
}
account := payments.Account{ID: row.ID, Currency: row.Currency, Precision: scale, Available: minor, PayoutMethod: row.Metadata["sxchnge_payout_method_ref"], PayoutLabel: row.Metadata["sxchnge_payout_label"], FundingMethod: row.Metadata["sxchnge_funding_method_ref"]}
for _, ref := range []string{account.PayoutMethod, account.FundingMethod} {
if ref != "" && !validRef(ref) {
return payments.Account{}, payments.Unavailable
}
}
if account.PayoutMethod != "" && (strings.TrimSpace(account.PayoutLabel) == "" || len(account.PayoutLabel) > 140) {
return payments.Account{}, payments.Unavailable
}
return account, nil
}
@@ -0,0 +1,103 @@
package sendico
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.sendico.io/tech/sxchnge/product/payments"
"github.com/stretchr/testify/require"
)
func TestPaymentPublicWireAndExactMoney(t *testing.T) {
ctx := context.Background()
ledger := "507f1f77bcf86cd799439015"
method := "507f1f77bcf86cd799439016"
calls := 0
acknowledge := true
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NotEmpty(t, r.Header.Get("Authorization"))
var result any
switch r.URL.Path {
case "/api/v1/payments/fx/currencies/" + org:
result = map[string]any{"currencies": []any{map[string]any{"code": "EUR", "accountingScale": 2}}}
case "/api/v1/ledger_accounts/" + org:
result = map[string]any{"accounts": []any{
map[string]any{"ledgerAccountRef": source, "organizationRef": org, "ownerRef": source, "currency": "EUR", "accountType": "ACCOUNT_TYPE_ASSET", "role": "ACCOUNT_ROLE_OPERATING", "status": "ACCOUNT_STATUS_ACTIVE"},
map[string]any{"ledgerAccountRef": ledger, "organizationRef": org, "currency": "EUR", "accountType": "ACCOUNT_TYPE_ASSET", "role": "ACCOUNT_ROLE_OPERATING", "status": "ACCOUNT_STATUS_ACTIVE", "metadata": map[string]string{"sxchnge_subject_ref": subject, "sxchnge_payout_method_ref": method, "sxchnge_payout_label": "Saved bank ending 1234"}},
}}
case "/api/v1/ledger_accounts/" + org + "/" + ledger + "/balance":
result = map[string]any{"balance": map[string]any{"ledgerAccountRef": ledger, "balance": moneyDTO{"75.00", "EUR"}}}
case "/api/v1/payments/quote/" + org:
var request quoteRequestDTO
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
require.Equal(t, subject, request.Intent.Subject)
require.Equal(t, "20.01", request.Intent.Amount.Amount)
require.Equal(t, ledger, request.Intent.Source.Data["account_ref"])
require.Equal(t, method, request.Intent.Destination.Data["ref"])
confirmedSubject := subject
if !acknowledge {
confirmedSubject = ""
}
result = map[string]any{"externalSubjectRef": confirmedSubject, "quote": paymentQuoteDTO{ID: "quote:1", State: "executable", Executable: true, Expires: time.Now().Add(time.Minute), Amounts: quoteAmountsDTO{Principal: moneyDTO{"20.01", "EUR"}, Debit: moneyDTO{"21.01", "EUR"}, Credit: moneyDTO{"20.01", "EUR"}}}}
case "/api/v1/payments/by-quote/" + org:
calls++
var payload map[string]string
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
require.Equal(t, "stable-execution", payload["idempotencyKey"])
require.Equal(t, "quote:1", payload["quoteRef"])
result = map[string]any{"payment": paymentDTO{ID: "payment:1", State: "orchestration_state_needs_attention", Quote: paymentQuoteDTO{ID: "quote:1"}, Created: time.Now()}}
case "/api/v1/payments/by-ref/payment:1":
result = map[string]any{"payment": paymentDTO{ID: "payment:1", State: "orchestration_state_settled", Quote: paymentQuoteDTO{ID: "quote:1"}, Created: time.Now()}}
default:
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(404)
return
}
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"status": "success", "data": result}))
}))
defer server.Close()
client, err := Open(config(t, server.URL))
require.NoError(t, err)
accounts, err := client.Accounts(ctx, subject)
require.NoError(t, err)
require.Len(t, accounts, 1)
require.EqualValues(t, 7500, accounts[0].Available)
q, err := client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: ledger, Amount: 2001, Recipient: method}, accounts)
require.NoError(t, err)
require.EqualValues(t, 100, q.Fee)
require.EqualValues(t, 2101, q.Debit)
require.Equal(t, "Saved bank ending 1234", q.Request.Recipient)
_, err = client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: source, Amount: 2001, Recipient: method}, accounts)
require.ErrorIs(t, err, payments.NotFound)
_, err = client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: ledger, Amount: 2001, Recipient: "foreign-method"}, accounts)
require.ErrorIs(t, err, payments.InvalidRecipient)
acknowledge = false
_, err = client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: ledger, Amount: 2001, Recipient: method}, accounts)
require.ErrorIs(t, err, payments.Unavailable)
acknowledge = true
op, err := client.Execute(ctx, q.ID, "stable-execution")
require.NoError(t, err)
require.Equal(t, "pending", op.Status)
require.Equal(t, 1, calls)
op, err = client.Payment(ctx, op.ID)
require.NoError(t, err)
require.Equal(t, "completed", op.Status)
}
func TestPaymentMappingRejectsUnsafeOrUnexecutableValues(t *testing.T) {
for _, amount := range []string{"1e3", "0.001", "-2", "NaN", "90071992547409.92", ""} {
_, err := minorMoney(moneyDTO{amount, "EUR"}, "EUR", 2)
require.Error(t, err, amount)
}
_, err := minorMoney(moneyDTO{"10.00", "USD"}, "EUR", 2)
require.Error(t, err)
_, err = mapPayment(paymentDTO{ID: "p", State: "new_unknown_state", Quote: paymentQuoteDTO{ID: "q"}, Created: time.Now()})
require.Error(t, err)
_, err = mapPaymentQuote(paymentQuoteDTO{State: "blocked", BlockReason: "limit_blocked"}, payments.Request{}, payments.Account{}, payments.Account{})
require.ErrorIs(t, err, payments.LimitExceeded)
_, err = mapPaymentQuote(paymentQuoteDTO{State: "executable", Executable: false}, payments.Request{}, payments.Account{}, payments.Account{})
require.Error(t, err)
}
@@ -0,0 +1,123 @@
package sendico
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"time"
"git.sendico.io/tech/sxchnge/product/payments"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
)
type moneyDTO struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}
type endpointDTO struct {
Type string `json:"type"`
Data map[string]string `json:"data"`
}
// Explicit wire fields avoid coupling local domain models to provider JSON.
type quoteRequestDTO struct {
Key string `json:"idempotencyKey"`
Intent paymentIntentDTO `json:"intent"`
}
type paymentIntentDTO struct {
Kind string `json:"kind"`
Source endpointDTO `json:"source"`
Destination endpointDTO `json:"destination"`
Amount moneyDTO `json:"amount"`
Subject string `json:"external_subject_ref"`
FeeTreatment string `json:"fee_treatment"`
}
type quoteAmountsDTO struct {
Principal moneyDTO `json:"sourcePrincipal"`
Debit moneyDTO `json:"sourceDebitTotal"`
Credit moneyDTO `json:"destinationSettlement"`
}
type paymentQuoteDTO struct {
ID string `json:"quoteRef"`
State string `json:"state"`
Executable bool `json:"executable"`
BlockReason string `json:"blockReason"`
Expires time.Time `json:"expiresAt"`
Amounts quoteAmountsDTO `json:"amounts"`
}
type paymentDTO struct {
ID string `json:"paymentRef"`
State string `json:"state"`
Quote paymentQuoteDTO `json:"lastQuote"`
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"`
}
type currencyDTO struct {
Code string `json:"code"`
Scale *int `json:"accountingScale"`
}
type balanceDTO struct {
ID string `json:"ledgerAccountRef"`
Money moneyDTO `json:"balance"`
}
func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any) error {
token, err := c.token()
if err != nil {
return err
}
var body []byte
if payload != nil {
body, err = json.Marshal(payload)
if err != nil {
return merrors.OpaqueWrap(payments.Unavailable, err, "encode Sendico payment request")
}
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, bytes.NewReader(body))
if err != nil {
return merrors.OpaqueWrap(payments.Unavailable, err, "create Sendico payment request")
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
client := *c.http
client.Timeout = 30 * time.Second
res, err := client.Do(req)
if err != nil {
return merrors.OpaqueWrap(payments.Unavailable, err, "Sendico payment request failed")
}
defer func() { _ = res.Body.Close() }()
// Never forward upstream errors, credentials, or arbitrary error bodies.
if res.StatusCode != http.StatusOK {
return payments.Unavailable
}
var envelope struct {
Status string `json:"status"`
Data json.RawMessage `json:"data"`
}
d := json.NewDecoder(io.LimitReader(res.Body, 4*1024*1024+1))
if err = d.Decode(&envelope); err != nil {
return merrors.OpaqueWrap(payments.Unavailable, err, "decode Sendico payment envelope")
}
if envelope.Status != "success" {
return payments.Unavailable
}
var extra any
if d.Decode(&extra) != io.EOF {
return payments.Unavailable
}
if err = json.Unmarshal(envelope.Data, dst); err != nil {
return merrors.OpaqueWrap(payments.Unavailable, err, "decode Sendico payment payload")
}
return nil
}
+1 -1
View File
@@ -87,7 +87,7 @@ func validScope(scope model.Scope) bool {
return false return false
} }
switch scope.Purpose { switch scope.Purpose {
case vmodel.PurposeLogin, vmodel.PurposePayout, vmodel.PurposeFactorPreference: case vmodel.PurposeLogin, vmodel.PurposePayment, vmodel.PurposePayout, vmodel.PurposeFactorPreference:
return true return true
} }
return false return false
@@ -21,6 +21,7 @@ const (
PurposeFactorPreference VerificationPurpose = "factor_preference" PurposeFactorPreference VerificationPurpose = "factor_preference"
PurposeFactorProof VerificationPurpose = "factor_proof" PurposeFactorProof VerificationPurpose = "factor_proof"
PurposePayout VerificationPurpose = "payout" PurposePayout VerificationPurpose = "payout"
PurposePayment VerificationPurpose = "payment"
) )
func VPToString(code VerificationPurpose) string { func VPToString(code VerificationPurpose) string {
@@ -45,6 +46,8 @@ func VPFromString(s string) (VerificationPurpose, error) {
return PurposeFactorPreference, nil return PurposeFactorPreference, nil
case string(PurposeFactorProof): case string(PurposeFactorProof):
return PurposeFactorProof, nil return PurposeFactorProof, nil
case string(PurposePayment):
return PurposePayment, nil
case string(PurposePayout): case string(PurposePayout):
return PurposePayout, nil return PurposePayout, nil
default: default:
+73
View File
@@ -927,3 +927,76 @@ routes proof cases to Identity proof review while blocking generic login linking
and assignment. Apply #2886 before #2887 and deploy the combined Sendico result and assignment. Apply #2886 before #2887 and deploy the combined Sendico result
before this SXCHNGE flow. The public proof/limits contract is unchanged; this before this SXCHNGE flow. The public proof/limits contract is unchanged; this
SXCHNGE follow-up changes documentation only and requires consistency/diff checks. SXCHNGE follow-up changes documentation only and requires consistency/diff checks.
## Sendico quotations and payment execution — task #50
[Task #50](https://git.sendico.io/tech/sxchnge/issues/50), branch `payments-50`,
connects backend wallet mode through the authenticated customer BFF to Sendico's
public account, quotation, by-quote execution and payment-status APIs. It is
stacked on `identity-verification-48` / PR #49; merge #47 → #49 first.
The separate public-provider prerequisite is
[Sendico PR #2897](https://git.sendico.io/tech/sendico/pulls/2897), task #2896.
It validates independent customer context and selects the reviewed ACCOUNT limits
resource rather than the integration login or an unassigned CUSTOMER policy.
Deploy its Product Edge and Quotation/Orchestrator consumers together before
SXCHNGE. The BFF requires an exact customer-context acknowledgement from quotes.
The contribution adds durable customer/namespace/subject-bound quotation ownership,
transactional payment-factor authorization, unique quote/execution keys, stable
provider replay, payment-reference reconciliation and authoritative status polling.
Live UI uses provisioned destinations, accurate quote expiry, explicit missing
method/loading/error/pending states and long labels at narrow widths. It clears
account state on customer changes. DTO/mapper/service/model/provider boundaries,
shared database wrappers, existing credentials and explicit demo mode are retained.
Validation passed: `make test`, `make lint-backend`, `make test-frontend` after the
final UI review, changed-Dart format verification, and app/wallet `flutter analyze`.
The final frontend run passed 21 wallet and 81 app tests, plus the unchanged shared
package suites. Tests include synthetic public HTTP, Mongo transaction rollback,
unique-key/customer isolation, lost-response reconciliation, factor retry and
session replacement. Sendico's full backend test/lint targets also passed.
[Payment contracts, provisioning, UX measurement and limitations](payments.md)
document rollout. Dedicated customer ledger/method bindings and platform access
must be provisioned through existing operator APIs; this work does not run live
payments or deploy either repository. Generic ambiguous rejections remain pending
for operational reconciliation. Cross-session analytics retention, additional
funding/recipient onboarding, backend locale saving and external history ingestion
remain separate capabilities. Unrelated notifications runtime changes are excluded.
Delivered in [PR #51](https://git.sendico.io/tech/sxchnge/pulls/51), targeting
`identity-verification-48`. Merge order is #47 → #49 → #51; deploy Sendico #2897
before SXCHNGE #51. Both implementation branches are pushed and remain unmerged.
## Saved payout destinations UX — #54
Branch `payout-destinations-54`, based on `local-stand-52`. Implements Settings
creation, details, rename, replacement and removal; explicit payout selection;
optional saving and one-time preparation for crypto, cards and Russian personal
accounts. Shared typed service/model/provider layers separate destination identity,
metadata/execution revisions, ownership, provider bindings, contextual eligibility,
save outcomes and financial outcomes. The customer-scoped demo replays stable
created/duplicate results, preserves immutable quote/history snapshots, rechecks at
acceptance, and demonstrates card-binding restoration without creating a second
card. Bank directory/paste handling follows the reviewed Sendico interface.
EN/RU and 320 px/1.6× layouts are tested. Formatting (44 changed Dart files),
analysis of app/wallet/analytics, `make test-frontend` (including 89 app tests), and
`flutter build web` with Wasm dry run passed. Browser smoke inspection confirmed
bank form rendering, labeled controls and keyboard focus. Full assistive-technology
and real-provider audits were not performed. No security scans were requested/run.
Persistence and bindings in this delivery are simulated and reset on reload.
Authenticated live mode retains explicit use of its provisioned payout destination;
new types fail closed until the backend supplies the documented capabilities and
registry/atomic-acceptance contracts. No routing, ownership-verification system,
provider integration or financial engine was added. See
[the specification and integration boundary](ux/payout-destinations.md).
Stacked merge order: #47 → #49 → #51 → #53 → this contribution. The unrelated
notifications runtime edit is preserved outside task commits. Delivery links follow.
Delivery: [task #54](https://git.sendico.io/tech/sxchnge/issues/54) and
[PR #55](https://git.sendico.io/tech/sxchnge/pulls/55). Implementation commit
`24af794`; PR base `local-stand-52`. No merge performed.
+2
View File
@@ -1,5 +1,7 @@
# Local development # Local development
For the connected Sendico stand, see [local Sendico integration](local-sendico.md).
Task [#42](https://git.sendico.io/tech/sxchnge/issues/42), Task [#42](https://git.sendico.io/tech/sxchnge/issues/42),
[PR #43](https://git.sendico.io/tech/sxchnge/pulls/43), branch `dev-env-42`. [PR #43](https://git.sendico.io/tech/sxchnge/pulls/43), branch `dev-env-42`.
+158
View File
@@ -0,0 +1,158 @@
# Local Sendico integration
Task [#52](https://git.sendico.io/tech/sxchnge/issues/52), branch `local-stand-52`,
based on `payments-50` / [PR #51](https://git.sendico.io/tech/sxchnge/pulls/51).
The local Sendico checkout uses `payments-2896` /
[PR #2897](https://git.sendico.io/tech/sendico/pulls/2897). Merge the prerequisites
before this infrastructure PR. No PR is merged by local startup.
## Running the configured stands
Start Sendico first from its checkout using its documented Make workflow. Its
Product API must respond at `http://localhost:8080/api/v1/health`. Then, here:
```sh
make build
make up
make test-dev
```
Open [SXCHNGE](https://localhost:5126),
[Sendico Product](http://localhost:3000), and
[Sendico Backoffice](http://localhost:4000).
SXCHNGE's Caddy CA is exported to `.local/dev/ca.pem`; trust that public CA in
the browser/OS if necessary. Bootstrap does not modify the system trust store.
The optional integration is selected by ignored `.local/dev/sendico/settings.env`.
Make and the containerized bootstrap both include
`infra/local/sendico/compose.yml` whenever that file exists. Standalone SXCHNGE
still uses six services. The connected stack adds one `sendico` session worker.
`make ps`, `make logs`, and `make down` include it automatically.
## Identity and configuration
The configured local stand has a dedicated `SXCHNGE Local` organization, active
integration account and account-bound confidential API client. The account is
an organization member with organization read/update, ledger read, payment
create/read, and recipient create/read/update permissions. Customer subjects
remain independent from that account. No pooled balances, customer funds or
payout destinations are seeded by this setup. Existing stand data is retained.
This identity was created through a one-time local administrative bootstrap;
runtime traffic and session creation use only the public HTTP API.
For another stand, an operator must first provision the organization, account,
client and grants in Sendico. Resolve actual IDs from that stand; do not copy
another environment's references. Initialize SXCHNGE's Vault using `make up`
before configuring the integration. Supply this JSON privately on stdin:
```json
{
"organization_ref": "<organization object ID>",
"source_account_ref": "<integration account object ID>",
"client_id": "<confidential client ID>",
"device_id": "sxchnge-local-bff",
"login": "<integration login>",
"password": "<integration password>",
"client_secret": "<confidential client secret>"
}
```
```sh
# Save operator input outside tracked files, with mode 0600. Never echo it.
make sendico < /private/path/sendico-input.json
make up
make test-dev
```
`make sendico` validates references and identifiers, stores only login/password/
client_secret at Vault `kv/services/bff/sendico`, and writes the non-secret client,
device, organization and source references into ignored local settings. The BFF
runtime is generated from `infra/local/bff/runtime.yml` with the existing
`api.sendico` configuration. Re-run configuration after changing that base YAML.
Remove the temporary input file once credentials have been stored in Vault.
No Sendico configuration parameters or application transport rules change.
## Connection and renewal
The session worker shares BFF's network namespace and binds a private loopback
TCP bridge at `127.0.0.1:18081`, forwarding to
`host.docker.internal:8080`. This uses the local stand's existing public HTTP API
port; it requires a local Docker engine with `host.docker.internal` support, as
provided by Docker Desktop. The bridge publishes no new host port. The adapter's
existing HTTPS requirement and loopback-only HTTP exception remain intact.
The worker authenticates to Vault with its own AppRole, reads exactly
`services/bff/sendico`, and revokes its five-minute Vault token after reading.
It cannot read Mongo credentials, JWT signing keys, NATS auth or administrator
material. Its initial login uses Sendico's public `/accounts/login/api` grant,
validates the access token's source account, full-session status and expiry, and
atomically publishes the token in a volume mounted read-only by BFF. Credentials
and response bodies stay in private tmpfs files and never appear in logs or
command arguments. Flutter receives no platform credentials.
Successful login repeats every 20 minutes before the one-hour access token
expires. A failure retains the prior token, logs one generic actionable message,
and retries after five minutes. The worker becomes unhealthy after 25 minutes
without renewal or when the upstream health endpoint fails; the adapter
separately rejects expired or mismatched tokens.
BFF waits for a recent token at startup. Following credential changes, restart
the worker with `make up` followed by the Compose restart command below, or wait
for its next renewal. Never change the integration account on an existing
financial environment without reviewing resource and idempotency bindings.
```sh
docker compose --project-directory . -f infra/local/compose.yml \
-f infra/local/sendico/compose.yml --env-file .local/dev/images.env restart sendico
```
`make test-dev` checks seven healthy services, verified browser TLS, the Vault
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).
## Recovery
The existing stand had application processes stopped inside still-running Air
containers after a NATS disconnect and Mongo replica-state interruption. Restore
discovery first, then Treasury and the other service announcers. Wait until each
stage is registered before restarting dependent services: billing fees and
Ledger, then Limits/gateways/quotation/receiving routes, then payment orchestrator,
compliance assessment and Product BFF. The Sendico Makefile provides its ordered
backend startup workflow. Inspect current logs and health instead of assuming a
running development container has a live application process. Do not delete
volumes or reset financial data to recover service registration.
If an old Caddy leaf certificate expires while the stack is stopped, restart
frontend after startup so Caddy issues a fresh certificate. Preserve `caddy-data`
and the established CA. A subsequent verified TLS check must succeed.
## Acceptance and operator UX review
Validated on 2026-10-16: `make build`, `make up`, `make test-dev-tooling`,
`make test-dev-infra`, `make test-dev`, and `git diff --check` passed. Both product
sites, Sendico Backoffice and both API health endpoints returned HTTP 200;
SXCHNGE's browser endpoint was checked with its exported CA. All Sendico services
with health checks were healthy. Forced worker restart issued a different valid
same-account token and preserved successful authenticated currency/ledger reads.
The existing notification sender edit remains outside this task's commit.
The setup journey starts with the operator's Make invocation and completes when
Compose confirms service readiness; `make test-dev` additionally confirms the
authenticated integration. Existing command exit codes and Compose progress /
health states distinguish configuration, dependency, authentication and readiness
failures. A renewed-session log records credential recovery, while stale renewal
or upstream outage changes container health. Each new operator invocation is a
new attempt; worker retries remain within that process's lifetime. Command wall
time measures setup duration. Interrupted commands are incomplete attempts, not
successful launches. These are local operator signals, with no retained analytics
or cross-invocation conversion/abandonment reporting added.
The UX review covered missing bootstrap material, malformed operator input,
authentication failure, retained retry context, stale TLS recovery, successful
startup and confirmed API access. Commands reuse existing Make/Compose patterns,
errors give a recovery action, and success follows readiness. No secret values
appear in terminal feedback. Product layouts, focus and screen-reader behavior
are unchanged; browser interaction and funded payment settlement are not claimed
by HTTP readiness checks. No email or payment was sent during validation.
+168
View File
@@ -0,0 +1,168 @@
# Customer quotations and payment execution
Task [SXCHNGE #50](https://git.sendico.io/tech/sxchnge/issues/50), branch
`payments-50`, based on `identity-verification-48` / PR #49. Provider prerequisite:
[Sendico #2896](https://git.sendico.io/tech/sendico/issues/2896).
## Boundary and behavior
The authenticated app uses `BackendWalletService` whenever backend authentication
is configured. Explicit service injection remains available for tests; demo mode
continues to use the isolated in-memory wallet. Missing provider configuration
returns an unavailable wallet, never demonstration balances or fake settlements.
The path is Flutter DTO → mapper → service → domain model → provider → UI, then
SXCHNGE's authenticated customer BFF → Sendico public HTTP. The existing confirmed
identity binding resolves each signed-in customer to an independent subject.
Neither a browser-selected organization nor an integration-login-owned balance
is used as that customer's financial resource.
Consumed public endpoints, below the existing configured API prefix:
- `GET /payments/fx/currencies/{organization}` for accounting scales;
- `GET /ledger_accounts/{organization}` and
`GET /ledger_accounts/{organization}/{ledger}/balance`;
- `POST /payments/quote/{organization}` with `external_subject_ref`;
- `POST /payments/by-quote/{organization}`;
- `GET /payments/by-ref/{payment}` for authoritative status.
The adapter requires the exact `externalSubjectRef` acknowledgement on the quote
response. An old deployment that ignores the new request field cannot silently
produce an acceptable integration-principal quote. Public provider DTOs and
mappers stay inside the adapter. No Sendico modules, databases, gRPC clients or
message bus are imported by SXCHNGE.
Amounts are parsed and formatted as exact decimal strings at the provider
boundary and represented as integer minor units locally. Unknown scales, values
beyond the browser's exact integer range, excess fractional precision, unknown
payment states, unexecutable quotes and mismatched payment/quote references fail
closed. Source principal, total debit and net destination credit come from the
quote; rates account for different currency precisions. Only same-currency saved
payout/funding methods are currently represented by the wallet review model.
Exchange connects two dedicated currency ledgers belonging to the same customer.
## Provisioning and rollout
Merge/deploy the identity prerequisites first: SXCHNGE #47 → #49 and the Sendico
identity-proof/limits-assignment work documented in `identity-verification.md`.
Deploy the Sendico #2896 Product Edge and the Quotation/Orchestrator builds that
consume the changed shared payment-storage Limits adapter together. Then deploy
SXCHNGE #50. The Edge acknowledgement is a compatibility guard; it does not replace
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
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:
| Ledger metadata | Meaning |
| --- | --- |
| `sxchnge_subject_ref` | Confirmed independent subject reference from sign-up |
| `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
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,
status and owner on each account projection/quotation. It accepts only the bound
saved payout method and replaces its opaque reference with the safe label in the
reviewed quote. No arbitrary recipient identifier or free-text bank input can
be executed from the live UI. Missing bindings have an explanatory disabled
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
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
pagination are not added by this contribution. Ledger balances are posted ledger
projections; execution-time solvency and holds remain the platform's authority.
## Authorization, idempotency and recovery
Local routes are under `/accounts/wallet`: snapshot GET, `/profile` GET,
`/quote`, `/challenge`, `/resend`, `/verify`, `/submit` and `/submission` POST.
All use the existing full authenticated-account wrapper, access token and browser
CSRF guard. Pending-login credentials cannot access them. Local quote references
are random BFF IDs; provider quote/payment references are not accepted as user
ownership evidence.
`payment_quotes` stores customer, adapter namespace, confirmed subject, immutable
quote, upstream quote reference, authorized execution key and eventual payment
reference. A unique partial index enforces customer/namespace/key uniqueness.
The existing second-factor service uses the dedicated `payment` purpose and
exact local quote context. Successful code verification consumes its one-use
proof in the same local Mongo transaction that records execution authorization.
No external I/O runs in that transaction and raw factor proofs never leave the
server. Verification response retries retain their key and recover committed
intent. Resend uses a separate stable-per-retry issuance key.
Submit/reconciliation replays `sxchnge:payment:<local-quote-id>` at Sendico.
A quote cannot be authorized under a second execution key. A network failure or
lost provider response remains uncertain; it never permits execution with a new
provider key. Once the provider payment reference is attached, refresh uses GET.
Authorization is durable: reopening the wallet reconciles authorized intents even
if the original client never received verification/submission success. Unknown
outcomes remain pending and do not hide unrelated balances. `needs_attention`
also stays pending; only `settled`/`failed` become terminal UI outcomes.
Generic upstream errors cannot prove that no payment exists. An unresolved
pre-execution rejection (including an expired quote after durable authorization)
therefore requires operational reconciliation rather than automatic release or a
new payment. Inspect the local quote/provider reference and platform audit trail;
never delete/rekey a record to bypass uncertainty. The UI's live copy does not
promise a debit, refund or completion from mere acceptance. Only local drafts,
not durable authorized intents, count as abandoned when a user leaves.
The adapter rejects changed-session HTTP responses, and the app clears wallet
snapshots/journeys when the authenticated customer changes. Account-language
reads use the authenticated customer. Backend language saving is deliberately
unsupported and reports failure; the previous demo setting is not presented as
persisted backend state.
## UX, observation and validation
The existing typed transfer journey recorder measures draft → quotation →
second-factor challenge/verification → submission → authoritative settlement.
Attempts/retries share their client journey; accepted operations finish once
when the wallet observes a terminal platform status. An uncertain result is not
success. Leaving an unresolved asynchronous operation is not draft abandonment.
Use the existing per-attempt timestamps for durations and a 30-minute inactivity
window for unfinished local drafts; server-authorized pending operations remain
outside that abandonment denominator. Compare draft-to-quote and quote-to-submit
drop-off, intent-to-terminal completion/failure and time to terminal outcome.
The existing recorder provides in-memory client evidence only: deployment of a
retained analytics sink and backend event delivery is needed for cross-session
conversion/settlement measurement. No amounts, recipients, bank labels, codes or
credentials enter analytics. Structured server logs use quote/payment references
and bounded states; operational reconciliation uses the durable database record.
Tests cover public wire requests, exact money, source/destination isolation,
blocked/unknown states, factor errors and retry keys, changed-session responses,
durable authorization rollback/uniqueness, lost-response reconciliation and
customer/provider-namespace isolation. Widget checks exercise saved destinations
and the missing-destination state at 320px with 1.6× text, alongside the existing
EN/RU workspace flows. Default Flutter keyboard/focus semantics are retained;
full browser/screen-reader acceptance and live funded settlement are deployment
checks, not claimed by synthetic tests. No live payments or deployment are run.
Validation passed after review: `make test` (backend race tests and all frontend
suites), `make lint-backend`, a subsequent `make test-frontend` after the final
long-label UI adjustment, changed-Dart format verification, and `flutter analyze`
in `frontend/app` and `frontend/packages/sxchnge_wallet`. The final frontend run
passed 21 wallet, 20 KYC, 7 analytics, 40 auth, 3 theme, 3 theme-example and 81 app
tests. The provider prerequisite passed Sendico's full backend test/lint targets.
Delivery: [SXCHNGE PR #51](https://git.sendico.io/tech/sxchnge/pulls/51), stacked
on PR #49. Merge #47 → #49 → #51. The separate
[Sendico provider PR #2897](https://git.sendico.io/tech/sendico/pulls/2897) must be
deployed before enabling this consumer. Both implementation branches are pushed;
merge, deployment and real financial-resource provisioning remain separate.
+147
View File
@@ -149,3 +149,150 @@ text uses a normal text node to remain exposed to assistive technology.
Delivered in [PR #13](https://git.sendico.io/tech/sxchnge/pulls/13), branch Delivered in [PR #13](https://git.sendico.io/tech/sxchnge/pulls/13), branch
`onboarding-12`. The final browser accessibility tree exposes the recipient text, `onboarding-12`. The final browser accessibility tree exposes the recipient text,
demo status, status heading and recovery actions. PR is open for review. demo status, status heading and recovery actions. PR is open for review.
## Browser credential autofill
Task: [#56](https://git.sendico.io/tech/sxchnge/issues/56), branch `auth-56`.
Delivery: [PR #58](https://git.sendico.io/tech/sxchnge/pulls/58), implementation
commit `86b6aaf`. Open for review; no merge or deployment is included.
The consumer auth form groups its mounted inputs in one `AutofillGroup` and
declares their purpose explicitly:
| Input | Flutter hints | Browser autocomplete |
| --- | --- | --- |
| Signup name | `name` with the name keyboard | `name` |
| Account email | `username`, then `email`, with the email keyboard | `username` |
| Login password | `password` | `current-password` |
| Signup password and confirmation | `newPassword` on both | `new-password` |
Flutter uses the first hint on web and Apple platforms. Email is the credential
username; the email keyboard remains configured separately. Visibility toggles
keep the password role, so autofill does not depend on detecting masked input.
The confirmation field participates in the same group as the first password,
allowing the browser to fill both without copying one controller into the other.
Signup acceptance finalizes autofill before clearing password controllers and
navigating to email verification. Login finalizes on success; when a second
factor is required, it waits for verification and login completion. The password
remains only in the existing form controller during that challenge, then clears
on completed navigation or cancellation. Invalid input, rejected credentials and
failed verification do not request saving. Switching auth mode, abandoning the
form or leaving for unverified-account recovery cancels the autofill context.
Provider/service/model boundaries and existing request/outcome telemetry are
unchanged; no field events or credential telemetry are added. Browser popup
display and pre-submit autofill abandonment remain outside current measurement.
### Verification and release check
Focused widget tests send a platform autofill response with all four signup
values and check EN/RU controller updates, grouped credential metadata, visibility
changes, mode switching and abandonment. Auth tests cover validation failures,
service rejection/retry, stale responses, signup/login acceptance, unverified
recovery, invalid second-factor codes and cancellation without premature saving.
Existing narrow/desktop layouts and enlarged-text tests cover the unchanged UI.
Native Safari verification remains a release check: computer-control permission
was unavailable in this session. On the deployed HTTPS origin with Safari
AutoFill enabled, focus the signup email and password fields, choose a suggested
strong password, verify that both password fields populate, then confirm that
login offers the saved credential. Repeat after showing/hiding passwords and
switching modes. Browser settings, stored credentials and browser heuristics
determine whether suggestions appear; automated Flutter tests cannot establish
that the system popup was displayed or that credentials were saved.
References: [Apple Password AutoFill](https://developer.apple.com/documentation/security/enabling-password-autofill-on-an-html-input-element),
[Flutter autofill hints](https://api.flutter.dev/flutter/material/TextField/autofillHints.html),
and [AutofillGroup](https://api.flutter.dev/flutter/widgets/AutofillGroup-class.html).
Recorded validation: `dart format --output=none --set-exit-if-changed` passes for
all five changed Dart files; `flutter analyze` passes in `frontend/app`;
`make test-frontend` passes all 212 tests across the app and shared packages.
`flutter build web` passes. The built page's default rendering path was inspected
in the in-app browser: login exposes `username`/`current-password` in one HTML
form; signup exposes `username`, `name` and two `new-password` inputs in one form.
This verifies the browser metadata, not Safari's credential popup. With Flutter
web accessibility semantics enabled, unfocused semantic inputs initially use
framework-default autocomplete values until focused; password-manager behavior
with assistive technology still requires a native browser audit.
## Verification links and login handoff
Task: [#60](https://git.sendico.io/tech/sxchnge/issues/60), branch
`email-verification-60`. Email links use `/verify-email?token=…`; the web entry
point installs Flutter's path URL strategy before initializing the application.
This matches Caddy's existing SPA fallback and preserves the path and query on a
fresh page load. Previously the default hash strategy could start at login and
never submit the activation token. Unknown routes retain the canonical login
fallback; invalid, expired and failed confirmation attempts remain on the
verification page with the existing recovery actions.
Activation consumes the token, checks the pending account and accepted signup
facts, and activates the account in the existing database transaction. Only
after a successful commit does the API return `{verified: true, email: ...}`.
Failure responses do not disclose the account email. Responses retain the
shared no-store policy. The frontend backend DTO and mapper produce a
`VerificationResult`; the verification provider exposes that result only in the
verified state and clears it on reset, loading and failure. A verified response
from an older backend without the optional email can still show success.
Success stays visible until the user chooses **Continue to login**. This replaces
the misleading workspace action that could redirect an unauthenticated visitor
back to login. The verified email travels through transient router extras into
the editable login controller; the password starts empty and receives focus.
Normal password and second-factor checks remain required. The email is not
added to URLs, serialized history, persistent storage or analytics. A link opened
in another tab or device can receive the verified address from the API without
depending on the originating signup form's memory.
Existing requested/succeeded/failed email-confirmation journey signals remain
the measurement source. Success continues to mean that the confirmation service
returned a verified result, with simulated outcomes kept separate. No field or
prefill event is needed. Existing limits around pre-submit abandonment,
cross-device journey correlation and the in-memory analytics sink still apply.
Regression coverage includes transactional activation and rollback, returned
email and failure privacy, used-token rejection, backend response mapping,
provider identity clearing, EN/RU success-to-login navigation, editable prefill,
empty focused password, and exclusion of email from serialized navigation state.
The supplied private diagnostic token is not a test fixture or documentation
input; it is kept out of repository sources and tracker records.
### Validation and local release (#60)
Delivery: [PR #61](https://git.sendico.io/tech/sxchnge/pulls/61), implementation
commit `5f3b443`. Reviewed the complete backend and frontend contribution against
their module guidelines and the UX/observability checklists. No task-caused
review findings remain. Existing recovery, loading and accessible status states
are retained; EN/RU widget tests and existing small-screen/text-scale coverage
pass. Browser startup was checked against a separately built demo with synthetic
tokens: direct success remains visible, Continue to login shows the verified
email with an empty focused password, and an expired link offers resend/back.
Native Safari was unavailable to automation, so browser-specific helper behavior
is not claimed as verified by this check.
On 2026-10-19, changed-file Dart formatting, `flutter analyze` in both affected
packages (`frontend/app`, `frontend/packages/sxchnge_auth`), `make test-frontend`
(217 tests), `GOFLAGS=-p=1 make test`, and `make lint-backend` passed. The focused
transactional activation integration test also passed independently. Initial
test-container startup and image-resolution attempts failed while Docker was
stalled. After the user-authorized Docker Desktop restart, the unchanged checks
passed; no assertions, timeouts or gates were weakened. No security scan was
requested or run.
The task branch was integrated locally into the existing `identity-facts-57`
stand checkout, preserving its unrelated configuration. Fresh stable image
versions/digests were resolved successfully with `sh infra/local/tools.sh images`.
Compose rebuilt BFF and frontend with `build --pull bff frontend`, then recreated
them and the Sendico session sidecar (which shares BFF's network namespace).
Docker restart recovery also unsealed Sendico's existing Vault with its saved
local key and restarted affected existing services in dependency order.
The updated BFF, frontend and Sendico sidecar are healthy. The authenticated
Sendico currency/ledger check passes. HTTPS serves the direct verification path;
the served JavaScript matches the new frontend image with SHA-256
`b194c0cf5dcc62634c13c7fd3d2f7e482272cb7abc1e470c62629400bf2d5d21`.
A live synthetic invalid token returns HTTP 400 without an email. The supplied
real token was checked read-only and remains unused and unexpired, with its
account pending verification, ready for the user to reopen the link. PR #61 has
not been merged into remote main; this local deployment does not imply a main
branch merge.
+256
View File
@@ -0,0 +1,256 @@
# Saved payout destinations
Task: tech/sxchnge#54. Branch: `payout-destinations-54`.
## Delivery and integration boundary
The Flutter app implements Settings management and payout selection/new/one-time
flows with the injectable `DestinationService` in `sxchnge_wallet`. The included
customer-scoped **demo** service is process-local: reload discards its saved
records, attempts, directory fixtures and audit. No real card data may be entered.
The existing live wallet remains connected to provisioned Sendico destinations,
which now require explicit selection. Its existing financial adapter is unchanged.
This is UX and supporting contract implementation, not a production destination
registry or new payment-provider integration. Live mode never falls back to a
simulated destination store. It exposes no new destination creation capabilities
until the BFF implements the contract below. `BackendWalletService` explicitly
rejects new typed inputs rather than dropping them or using a different destination.
Actual persistent storage, public Sendico capability/directory adapters, secure
card collection, binding recovery and atomic financial acceptance are integration
requirements, not claims established by client tests. The injected service boundary
lets a production implementation replace the demo without DTO/networking in widgets.
## Screen annotations
The four primary destinations remain Accounts, Activity, Limits and Settings.
```text
Settings → Payout methods
Add payout method [primary action]
Search saved methods [nickname and safe display metadata]
Crypto addresses [type group; recently used first]
nickname / asset · network · shortened address
availability / required action [text, not color alone]
Bank cards
nickname / brand · masked suffix
Russian bank accounts
nickname / bank · masked suffix
```
```text
Add payout method
Type [explicit selection]
Type-specific destination fields [labels and inline validation]
Save for future payouts [payout only; unchecked]
Name for this method (optional) [Settings or saving enabled]
Validation / eligibility / saving [separate feedback]
Save payout method | Continue [no ordinary confirmation dialog]
```
```text
Pay out
Source account / available funds
Payout destination [explicit selection every time]
Saved methods | New destination
Amount [preserved during navigation/recovery]
Review
full destination, source debit, fee, receiving amount/asset
network and memo/tag for crypto; card remains masked
Pay out [amount/currency] [explicit consequential confirmation]
Processing → confirmed settlement [existing separate wallet states]
```
Settings: select type → details → Save. Payout: new destination → details → optional
saving → Continue. Reuse: select saved method → amount → review → confirm. No
mandatory nickname, routine ownership warning, automatic selection/substitution,
or extra verification screen is added. Removal has a consequential confirmation.
A replacement creates a new record; the original remains until explicitly removed.
Lists group by destination type, sort recent use within groups and search safe
labels. Empty category, search no-match, unavailable data and no eligible methods
are distinct. Opening creation/management uses transient Navigator state, preserving
payout input without destination details in URLs or serialized browser history.
## Domain and service contract
`DestinationInput` is `SavedReference(id, executionRevision)` or
`OneTimeDestination(details)`. Validation and contextual eligibility apply equally
to both; quoting/submitting a one-time input must not create a reusable record.
A destination has a customer-owned opaque ID independent of provider bindings.
Ownership declaration, service verification and payout eligibility are separate.
Unverified details may be saved when saving policy permits. Ownership status is
shown only when required/actionable. Format checks never establish ownership or
receiving capability, and successful persistence never establishes payout eligibility.
`metadataRevision` protects nickname updates; `executionRevision` protects identity,
lifecycle and binding changes. Rename cannot invalidate a valid execution quote.
Removal and binding changes require execution revalidation. Immutable identities
are changed by replacement. Last-use ordering is not an execution change.
Required service operations: capabilities; list/get; typed field validation;
contextual assessment; create; creation-attempt lookup; duplicate resolution;
rename with expected metadata revision; replacement; removal with expected
revisions; bank lookup with cancellation; immutable quote preparation. Remediation
capabilities must come from the service, never provider-specific widget logic.
Eligibility uses current capabilities, available routes, destination requirements,
ownership/security policy, bindings and payout context including amount when relevant.
States are eligible, temporarily unavailable, requires action and unsupported.
Settings retains active unavailable methods. A contextless Settings assessment is
informational and never proof of eligibility for a particular payout. Selection,
quote preparation and acceptance revalidate independently. Losing eligibility or
removal preserves inputs and requires explicit reselection; never substitute.
On Continue: validate fields; assess contextual eligibility; optionally persist
under saving policy; proceed only if eligible. Persistence may succeed while the
payout is ineligible. Definitive optional save failure allows retry or explicit
one-time continuation. Unknown outcomes offer status lookup; eligible one-time
continuation does not cancel/repeat a potentially successful save. Confirmation of
saving, validation, submission and settlement are separate states.
### Canonical identities and duplicate outcomes
- Crypto: service-defined asset ID (including token-contract identity), canonical
network ID, network-normalized address and applicable memo/tag. Identical address
strings across networks/assets are distinct. Preserve presentation case and
meaningful memo contents. Display enough metadata to distinguish same-symbol tokens.
- Russian bank: service-normalized BIK, account number and holder identity. No
frontend fuzzy matching or inference from an account prefix.
- Card: reliable service/provider fingerprint where available, never brand/last-four,
expiry or capture token. Provider-scoped fingerprints are not globally portable.
Missing reliable fingerprint permits distinct records; never guess/merge.
Creation keys identify immutable payloads. Replay returns the same **created** or
**duplicate** result even after the referenced record is renamed/removed. Attempt
lookup distinguishes these outcomes. Concurrent creation must serialize duplicate
resolution within the authenticated customer scope. Use existing method revalidates
that record and never creates another. Existing nicknames remain unchanged.
The demo uses a synchronous customer-scoped mutation boundary; production needs
persistent unique identity/idempotency constraints with transactions or equivalent.
No duplicate endpoint may reveal another customer's destination or attempt.
### Bank input adapted from Sendico
Reviewed local Sendico checkout `c5a3440fa`, especially
`docs/russian-bank-methods.md` and its form, lookup, parsing and validation tests.
The form has Account holder, Bank details and optional nickname sections. Structured
first/last names and optional patronymic preserve compound names; arbitrary full
names are shown unsplit. Business/tax selectors are excluded from personal-account UX.
Bank search is debounced 350 ms from two characters, with cancellation and stale
response rejection. Results identify name, BIK and city. Selected bank is compact,
with Change bank and expandable correspondent details. Account number accepts
whitespace and preserves leading zeros; 20-digit/control-key checking is not ownership
verification. Prefix hints remain advisory.
Local paste import recognizes explicit EN/RU labels, previews recognized values,
flags conflicts, preserves untouched fields and never persists/transmits raw pasted
text. Directory/name/correspondent mismatches remain visible. Invalid BIK, unknown
bank, unsupported institution and service failure are distinct. On reuse, cosmetic
directory labels may refresh; execution-material changes require review/requote.
Never silently replace BIK, account or holder. Mergers/BIK changes can require a
replacement; discontinued banks remain saved but ineligible.
### Cards and binding lifecycle
Card saving requires reusable support or a defined restoration mechanism. An existing
card remains saved after card/binding expiry, revocation or provider outage.
Requires action means a supported recovery exists; operational outages are temporary;
without a reusable binding or supported recovery the UI must not promise reuse.
Bindings may be multiple, provider-scoped and nonportable. Re-tokenization requires
trusted same-card identification; otherwise create a replacement. Secure capture is
transient. Saved card details contain local identity/display metadata, and bindings
contain separate opaque references; PAN/CVV never enter application storage.
## Acceptance, security and retention
The BFF's financial acceptance boundary must atomically validate current quote,
customer authorization, execution revision, lifecycle, eligibility and requirements,
then persist the accepted immutable snapshot/intent. A preceding API assessment or
frontend check cannot guarantee financial correctness. Removal committed first rejects
acceptance; acceptance committed first survives later removal. Rejection preserves
drafts and returns reselect/review/refresh/requote actions. Expired or materially changed
quotes need explicit confirmation of recalculated terms. This is a required backend
contract; the demo's synchronous acceptance guard only demonstrates its semantics.
Removal from the active list, binding deactivation and data erasure are separate.
Removed records cannot serve new payouts; accepted payouts retain immutable snapshots.
Production retention must assign explicit expiry/cleanup policies to abandoned quotes,
capture references, idempotency records and required financial history. Do not promise
complete erasure of retained financial records. The demo stores no browser data and
expires transient quote leases; all remaining simulation data ends on reload.
Authorize every operation server-side and audit actor, timestamp, opaque method/attempt,
action and outcome, without destination details. Metadata edits use optimistic concurrency.
Risk-based step-up is service-policy driven; no mandatory MFA is introduced. No raw
destination, pasted text, nickname, amount or account data enters logs, analytics or replay.
## Localization, accessibility and measurement
EN/RU copy is maintained in the app ARB sources. Specific states cover saving,
uncertainty, duplicate reuse, changed/removed methods, directory failures, binding
authorization and quote recovery. Ordinary success says only "Payout method saved."
Forms use associated labels, field errors, a focusable live feedback area, text status
and keyboard controls. Names stack below 480 px in a 720 px maximum form. Review
320 px layouts at 1.6× text scale, long labels, full-address wrapping and 48 px targets.
`JourneyKind.destination` / `destinationSave` measures service-confirmed saving/resolution
separately from the existing payout quote/submission/settlement signals. Retries share
recorder correlation; confirmed save closes its journey. Idle/discard attempts use the
shared 30-minute inactivity/disposal rule. Simulation events are explicitly marked.
No production persistence, cross-session funnel or financial outcome is inferred from
the demo recorder. Actual outcome events and backend audit are integration requirements.
## Validation scenarios and recorded review
Tests exercise one-time nonpersistence, idempotent/repeated/concurrent creation, stable
duplicate replay after rename/removal, lost-response reconciliation, customer isolation,
metadata concurrency, execution invalidation at acceptance, asset/network/token identity,
case/memo handling, fingerprint fallback, card binding recovery states, bank control keys,
directory changes, paste conflicts and DTO mappings. Widget tests exercise Settings
save/rename/remove, explicit selection, one-time payout, optional-save failure recovery,
removal during review, privacy-safe outcomes and EN/RU narrow layouts. Existing auth,
factor, wallet and workspace regressions are included in the required frontend suite.
Research: [Wise recipient management](https://wise.com/help/articles/2491526/how-do-i-add-or-delete-recipients),
[Coinbase address books](https://help.coinbase.com/en/exchange/managing-my-account/how-does-the-address-book-work),
[Coinbase memo/tag requirements](https://help.coinbase.com/en/coinbase/trading-and-funding/sending-or-receiving-cryptocurrency/destination-tag-memo-faq),
[Stripe tokenization](https://docs.stripe.com/api/tokens),
[W3C form validation](https://www.w3.org/WAI/tutorials/forms/validation/).
Final validation commands and PR links are recorded below at delivery.
### Completion review — 2026-10-16
- Applied `review-frontend-change`, UX Design and UX Observability reviews across
app, wallet and analytics. Extracted form/search/selection/management providers;
widgets consume typed models and provider actions. Fixed narrow bank-header
overflow and bottom feedback overlapping management controls.
- Added structured immutable destination review, explicit simulated receiving
asset/amount/timing terms, independent card-binding restoration and a typed
created/duplicate analytics outcome. Nickname edits do not change execution
revisions. New typed inputs are explicitly rejected by the legacy live adapter.
- `dart format --output=none --set-exit-if-changed` over 44 changed Dart files:
passed, no changes. `git diff --check`: passed.
- `flutter analyze` in `frontend/app`, `frontend/packages/sxchnge_wallet` and
`frontend/packages/sxchnge_analytics`: passed, no issues.
- `make test-frontend`: passed all seven package/application suites, including
89 application tests and the destination domain/recovery/concurrency scenarios.
- `flutter build web` in `frontend/app`: passed, including the Wasm dry run.
- Browser smoke check on a temporary local web preview: method selection and bank
form rendered; accessibility labels exposed; Tab moved from type to First name.
Automated EN/RU forms at 320 px and 1.6× scale passed. A full assistive-technology
audit and production/provider validation were not performed.
- An exploratory repository-root `flutter analyze` also inspected the unrelated
standalone `website/flutter/brand_palette.dart`, which lacks Flutter package
context and reported `dart:ui` errors. The required affected-package analyses
above passed; no unrelated website code or analyzer settings were changed.
- No backend code or infrastructure changes, live payments, deployment, merge or
security scanner runs were performed. Live persistence/atomic acceptance remain
the explicitly documented integration boundary.
Delivery: [task #54](https://git.sendico.io/tech/sxchnge/issues/54),
[PR #55](https://git.sendico.io/tech/sxchnge/pulls/55), implementation commit
`24af794`. PR base is `local-stand-52`; merge after #47 → #49 → #51 → #53.
+9 -1
View File
@@ -15,7 +15,15 @@ flutter run -d chrome
A responsive login/sign-up preview with a themed brand panel, email and password validation, password visibility controls, keyboard submission, and inline feedback. Sign-up additionally asks for a name and matching password confirmation. The 12-character sign-up minimum is a UI preview policy and must be aligned with the backend when authentication is integrated. A responsive login/sign-up preview with a themed brand panel, email and password validation, password visibility controls, keyboard submission, and inline feedback. Sign-up additionally asks for a name and matching password confirmation. The 12-character sign-up minimum is a UI preview policy and must be aligned with the backend when authentication is integrated.
No authentication endpoint is configured. Submission validates locally and calls the shared dummy authentication services; it never creates an account, starts a session, sends credentials, or persists them. Passwords are cleared when switching modes. Password managers/autofill integration is deferred until real authentication exists. LoginProvider and SignupProvider own loading/results/errors; SxchngeApp accepts injected loginService and signupService implementations. Dummy completion is explicitly labeled and does not establish a real session. Without backend configuration the app uses dummy services. The existing
`SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID` select authenticated backend auth,
identity, limits and wallet adapters. Wallet operations use Sendico through the
BFF after the provider prerequisites and dedicated customer resource bindings
are provisioned; they do not use demo balances in backend mode. LoginProvider,
SignupProvider and shared wallet providers own loading/results/errors.
SxchngeApp retains explicit service injection for tests and previews. See
[quotation/payment integration](../../docs/payments.md) for setup, supported
funding/destinations, authorization, reconciliation and rollout.
The interface uses `ColorScheme` and `TextTheme` roles from `sxchnge_theme`; the desktop brand panel installs the package's dark theme locally. Entrance animation respects reduced-motion preferences. The public website remains separate in `website/`. The interface uses `ColorScheme` and `TextTheme` roles from `sxchnge_theme`; the desktop brand panel installs the package's dark theme locally. Entrance animation respects reduced-motion preferences. The public website remains separate in `website/`.
+150 -119
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -18,7 +19,8 @@ import 'validation.dart';
import '../generated/i18n/app_localizations.dart'; import '../generated/i18n/app_localizations.dart';
class AuthForm extends StatefulWidget { class AuthForm extends StatefulWidget {
const AuthForm({super.key}); const AuthForm({super.key, this.email});
final String? email;
@override @override
State<AuthForm> createState() => _AuthFormState(); State<AuthForm> createState() => _AuthFormState();
@@ -32,7 +34,7 @@ class _AuthFormState extends State<AuthForm> {
bool _showPassword = false; bool _showPassword = false;
bool _showConfirmation = false; bool _showConfirmation = false;
bool _submitted = false; bool _submitted = false;
final _email = TextEditingController(); late final _email = TextEditingController(text: widget.email);
final _name = TextEditingController(); final _name = TextEditingController();
AuthProvider get _provider => AuthProvider get _provider =>
@@ -63,6 +65,8 @@ class _AuthFormState extends State<AuthForm> {
void _switch(bool signUp) { void _switch(bool signUp) {
if (signUp == _signUp) return; if (signUp == _signUp) return;
FocusScope.of(context).unfocus();
TextInput.finishAutofillContext(shouldSave: false);
context.read<JourneyRecorder>().abandon(JourneyExit.modeChange); context.read<JourneyRecorder>().abandon(JourneyExit.modeChange);
context.read<LoginProvider>().reset(); context.read<LoginProvider>().reset();
context.read<SignupProvider>().reset(); context.read<SignupProvider>().reset();
@@ -95,6 +99,7 @@ class _AuthFormState extends State<AuthForm> {
return; return;
} }
final email = _email.text.trim(); final email = _email.text.trim();
TextInput.finishAutofillContext();
_password.clear(); _password.clear();
_confirmation.clear(); _confirmation.clear();
context.goNamed(AppRoutes.verificationSent, extra: email); context.goNamed(AppRoutes.verificationSent, extra: email);
@@ -107,13 +112,13 @@ class _AuthFormState extends State<AuthForm> {
); );
if (!mounted || _signUp) return; if (!mounted || _signUp) return;
if (login.state.error == AuthError.accountNotVerified) { if (login.state.error == AuthError.accountNotVerified) {
TextInput.finishAutofillContext(shouldSave: false);
_password.clear(); _password.clear();
context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim()); context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim());
return; return;
} }
final challenge = login.state.result?.challenge; final challenge = login.state.result?.challenge;
if (challenge != null) { if (challenge != null) {
_password.clear();
final grant = await verifyFactor( final grant = await verifyFactor(
context, context,
challenge: challenge, challenge: challenge,
@@ -123,6 +128,8 @@ class _AuthFormState extends State<AuthForm> {
); );
if (!mounted || _signUp) return; if (!mounted || _signUp) return;
if (grant == null) { if (grant == null) {
TextInput.finishAutofillContext(shouldSave: false);
_password.clear();
login.reset(); login.reset();
context.read<JourneyRecorder>().abandon(JourneyExit.navigation); context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
return; return;
@@ -137,9 +144,13 @@ class _AuthFormState extends State<AuthForm> {
!_signUp && !_signUp &&
identical(login.state.result, accepted) && identical(login.state.result, accepted) &&
login.state.status == AuthStatus.success) { login.state.status == AuthStatus.success) {
TextInput.finishAutofillContext();
_password.clear();
context.go(offer ? '/identity?onboarding=1' : '/accounts'); context.go(offer ? '/identity?onboarding=1' : '/accounts');
} }
} }
} else if (login.state.status == AuthStatus.success) {
TextInput.finishAutofillContext();
} }
} }
} }
@@ -195,150 +206,170 @@ class _AuthFormState extends State<AuthForm> {
expandedInsets: EdgeInsets.zero, expandedInsets: EdgeInsets.zero,
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
Form( AutofillGroup(
key: _form, // Leaving an unfinished form must not offer to save its credentials.
autovalidateMode: _submitted onDisposeAction: AutofillContextAction.cancel,
? AutovalidateMode.onUserInteraction child: Form(
: AutovalidateMode.disabled, key: _form,
onChanged: () { autovalidateMode: _submitted
if (_provider.state.status == AuthStatus.success || ? AutovalidateMode.onUserInteraction
_provider.state.status == AuthStatus.error) { : AutovalidateMode.disabled,
_provider.reset(); onChanged: () {
} if (_provider.state.status == AuthStatus.success ||
}, _provider.state.status == AuthStatus.error) {
child: Column( _provider.reset();
crossAxisAlignment: CrossAxisAlignment.stretch, }
children: [ },
if (_signUp) ...[ child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_signUp) ...[
TextFormField(
key: const ValueKey('name'),
controller: _name,
enabled: !state.isLoading,
decoration: InputDecoration(
labelText: l.name,
prefixIcon: Icon(Icons.person_outline),
errorMaxLines: 3,
),
textInputAction: TextInputAction.next,
keyboardType: TextInputType.name,
autofillHints: const [AutofillHints.name],
textCapitalization: TextCapitalization.words,
validator: (value) => AuthValidation.name(value, l),
),
const SizedBox(height: 20),
],
TextFormField( TextFormField(
key: const ValueKey('name'), key: const ValueKey('email'),
controller: _name, controller: _email,
enabled: !state.isLoading, enabled: !state.isLoading,
decoration: InputDecoration( decoration: InputDecoration(
labelText: l.name, labelText: l.email,
prefixIcon: Icon(Icons.person_outline), hintText: l.emailHint,
prefixIcon: Icon(Icons.alternate_email),
errorMaxLines: 3, errorMaxLines: 3,
), ),
keyboardType: TextInputType.emailAddress,
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
textCapitalization: TextCapitalization.words, autocorrect: false,
validator: (value) => AuthValidation.name(value, l), validator: (value) => AuthValidation.email(value, l),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
],
TextFormField(
key: const ValueKey('email'),
controller: _email,
enabled: !state.isLoading,
decoration: InputDecoration(
labelText: l.email,
hintText: l.emailHint,
prefixIcon: Icon(Icons.alternate_email),
errorMaxLines: 3,
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autocorrect: false,
validator: (value) => AuthValidation.email(value, l),
),
const SizedBox(height: 20),
TextFormField(
key: const ValueKey('password'),
enabled: !state.isLoading,
controller: _password,
obscureText: !_showPassword,
autocorrect: false,
enableSuggestions: false,
textInputAction: _signUp
? TextInputAction.next
: TextInputAction.done,
onFieldSubmitted: (_) {
if (!_signUp) _submit();
},
decoration: InputDecoration(
labelText: l.password,
prefixIcon: const Icon(Icons.lock_outline),
errorMaxLines: 3,
helperText: _signUp ? l.passwordHint : null,
helperMaxLines: 3,
suffixIcon: IconButton(
tooltip: _showPassword ? l.hidePassword : l.showPassword,
onPressed: () =>
setState(() => _showPassword = !_showPassword),
icon: Icon(
_showPassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
),
),
validator: (value) =>
AuthValidation.password(value, l, signUp: _signUp),
),
if (_signUp) ...[
const SizedBox(height: 20),
TextFormField( TextFormField(
key: const ValueKey('confirmation'), key: const ValueKey('password'),
enabled: !state.isLoading, enabled: !state.isLoading,
controller: _confirmation, controller: _password,
obscureText: !_showConfirmation, autofocus: widget.email?.isNotEmpty ?? false,
obscureText: !_showPassword,
autofillHints: [
_signUp
? AutofillHints.newPassword
: AutofillHints.password,
],
autocorrect: false, autocorrect: false,
enableSuggestions: false, enableSuggestions: false,
textInputAction: TextInputAction.done, textInputAction: _signUp
onFieldSubmitted: (_) => _submit(), ? TextInputAction.next
: TextInputAction.done,
onFieldSubmitted: (_) {
if (!_signUp) _submit();
},
decoration: InputDecoration( decoration: InputDecoration(
labelText: l.confirmPassword, labelText: l.password,
prefixIcon: const Icon(Icons.lock_outline), prefixIcon: const Icon(Icons.lock_outline),
errorMaxLines: 3, errorMaxLines: 3,
helperText: _signUp ? l.passwordHint : null,
helperMaxLines: 3,
suffixIcon: IconButton( suffixIcon: IconButton(
tooltip: _showConfirmation tooltip: _showPassword ? l.hidePassword : l.showPassword,
? l.hideConfirmation onPressed: () =>
: l.showConfirmation, setState(() => _showPassword = !_showPassword),
onPressed: () => setState(
() => _showConfirmation = !_showConfirmation,
),
icon: Icon( icon: Icon(
_showConfirmation _showPassword
? Icons.visibility_off_outlined ? Icons.visibility_off_outlined
: Icons.visibility_outlined, : Icons.visibility_outlined,
), ),
), ),
), ),
validator: (value) => validator: (value) =>
AuthValidation.confirmation(value, _password.text, l), AuthValidation.password(value, l, signUp: _signUp),
), ),
], if (_signUp) ...[
const SizedBox(height: 28), const SizedBox(height: 20),
FilledButton( TextFormField(
key: const ValueKey('submit'), key: const ValueKey('confirmation'),
onPressed: state.isLoading || state.status == AuthStatus.success enabled: !state.isLoading,
? null controller: _confirmation,
: _submit, obscureText: !_showConfirmation,
child: Padding( autofillHints: const [AutofillHints.newPassword],
padding: const EdgeInsets.symmetric(vertical: 4), autocorrect: false,
child: Row( enableSuggestions: false,
mainAxisAlignment: MainAxisAlignment.center, textInputAction: TextInputAction.done,
children: [ onFieldSubmitted: (_) => _submit(),
Flexible( decoration: InputDecoration(
child: Text( labelText: l.confirmPassword,
state.isLoading prefixIcon: const Icon(Icons.lock_outline),
? l.authWorking errorMaxLines: 3,
: (_signUp ? l.createAccount : l.letsGo), suffixIcon: IconButton(
textAlign: TextAlign.center, tooltip: _showConfirmation
? l.hideConfirmation
: l.showConfirmation,
onPressed: () => setState(
() => _showConfirmation = !_showConfirmation,
),
icon: Icon(
_showConfirmation
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
), ),
), ),
const SizedBox(width: 12), ),
state.isLoading validator: (value) =>
? const SizedBox( AuthValidation.confirmation(value, _password.text, l),
width: 20, ),
height: 20, ],
child: CircularProgressIndicator(strokeWidth: 2), const SizedBox(height: 28),
) FilledButton(
: const Icon(Icons.arrow_forward, size: 20), key: const ValueKey('submit'),
], onPressed:
state.isLoading || state.status == AuthStatus.success
? null
: _submit,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
state.isLoading
? l.authWorking
: (_signUp ? l.createAccount : l.letsGo),
textAlign: TextAlign.center,
),
),
const SizedBox(width: 12),
state.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.arrow_forward, size: 20),
],
),
), ),
), ),
), ],
], ),
), ),
), ),
if (state.status == AuthStatus.success || if (state.status == AuthStatus.success ||
+3 -2
View File
@@ -10,7 +10,8 @@ import 'form.dart';
import '../generated/i18n/app_localizations.dart'; import '../generated/i18n/app_localizations.dart';
class AuthPage extends StatefulWidget { class AuthPage extends StatefulWidget {
const AuthPage({super.key}); const AuthPage({super.key, this.email});
final String? email;
@override @override
State<AuthPage> createState() => _AuthPageState(); State<AuthPage> createState() => _AuthPageState();
@@ -56,7 +57,7 @@ class _AuthPageState extends State<AuthPage> {
Center( Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440), constraints: const BoxConstraints(maxWidth: 440),
child: AuthForm(key: _formKey), child: AuthForm(key: _formKey, email: widget.email),
), ),
), ),
], ],
+225
View File
@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'paste.dart';
import '../generated/i18n/app_localizations.dart';
class BankFields extends StatefulWidget {
const BankFields({
super.key,
required this.onChanged,
this.initial,
this.errorField,
});
final ValueChanged<DestinationDetails?> onChanged;
final RussianBankDestination? initial;
final String? errorField;
@override
State<BankFields> createState() => _BankFieldsState();
}
class _BankFieldsState extends State<BankFields> {
late final first = TextEditingController(text: widget.initial?.firstName);
late final last = TextEditingController(text: widget.initial?.lastName);
late final patronymic = TextEditingController(
text: widget.initial?.patronymic,
);
late final account = TextEditingController(text: widget.initial?.account);
final query = TextEditingController();
late final search = BankSearchProvider(
context.read<DestinationsProvider>().service,
);
late Bank? bank = widget.initial?.bank;
String? pastedHolder;
bool conflict = false;
Map<String, String> pasted = {};
bool get mismatch =>
bank != null &&
((pasted['bank'] != null && pasted['bank'] != bank!.name) ||
(pasted['correspondent'] != null &&
pasted['correspondent'] != bank!.correspondent));
void changed() => widget.onChanged(
bank == null
? null
: RussianBankDestination(
firstName: first.text,
lastName: last.text,
patronymic: patronymic.text,
bank: bank!,
account: account.text,
),
);
Future<void> paste() async {
final parsed = await showDialog<BankPaste>(
context: context,
builder: (_) => const BankPasteDialog(),
);
if (!mounted || parsed == null) return;
for (final entry in {
'firstName': first,
'lastName': last,
'patronymic': patronymic,
'account': account,
}.entries) {
if (parsed.conflicts.contains(entry.key)) {
entry.value.clear();
} else if (parsed.values[entry.key] case final String value) {
entry.value.text = value;
}
}
setState(() {
pasted = parsed.values;
conflict = parsed.conflicts.isNotEmpty;
pastedHolder = parsed.values['holder'];
});
if (parsed.conflicts.contains('bik')) {
setState(() => bank = null);
}
if (parsed.values['bik'] case final String bik) {
query.text = bik;
search.search(bik);
setState(() => bank = null);
}
changed();
}
@override
void dispose() {
for (final c in [first, last, patronymic, account, query]) {
c.dispose();
}
search.dispose();
super.dispose();
}
Widget field(
String label,
TextEditingController c, {
String? id,
bool numeric = false,
String? error,
}) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: TextField(
key: id == null ? null : ValueKey(id),
controller: c,
keyboardType: numeric ? TextInputType.number : TextInputType.name,
decoration: InputDecoration(labelText: label, errorText: error),
onChanged: (_) => changed(),
),
);
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l.dHolder, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
LayoutBuilder(
builder: (context, b) {
final a = field(
l.dFirst,
first,
id: 'destination-first',
error: widget.errorField == 'holder' ? l.dInvalid : null,
);
final z = field(l.dLast, last, id: 'destination-last');
return b.maxWidth >= 480
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: a),
const SizedBox(width: 12),
Expanded(child: z),
],
)
: Column(children: [a, z]);
},
),
field(l.dPatronymic, patronymic),
if (pastedHolder != null) SelectableText(pastedHolder!),
if (conflict) Text(l.dPasteConflict),
Text(l.dBankDetails, style: Theme.of(context).textTheme.titleMedium),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: paste,
icon: const Icon(Icons.content_paste),
label: Text(l.dPaste),
),
),
ListenableBuilder(
listenable: search,
builder: (context, _) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (bank == null) ...[
TextField(
controller: query,
decoration: InputDecoration(labelText: l.dBankSearch),
onChanged: search.search,
),
if (search.loading) const LinearProgressIndicator(),
for (final b in search.results)
ListTile(
title: Text(b.name),
subtitle: Text('${b.bik} · ${b.city}'),
onTap: () {
setState(() => bank = b);
changed();
},
),
if (search.error != null)
Text(
search.error == DestinationIssue.unavailable
? l.dBankFailure
: l.dBankUnknown,
),
if (search.error == DestinationIssue.unavailable)
TextButton(
onPressed: () => search.search(query.text),
child: Text(l.wRefresh),
),
] else ...[
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(bank!.name),
subtitle: Text('${l.dBik} ${bank!.bik}'),
trailing: TextButton(
onPressed: () {
setState(() => bank = null);
changed();
},
child: Text(l.dChangeBank),
),
),
ExpansionTile(
title: Text(l.dBankDetails),
children: [
SelectableText(
'${l.dCorrespondent}: ${bank!.correspondent}',
),
],
),
if (!bank!.supported) Text(l.dUnsupported),
],
],
),
),
const SizedBox(height: 16),
field(
l.dAccount,
account,
id: 'destination-account',
numeric: true,
error: widget.errorField == 'account' ? l.dInvalid : null,
),
],
);
}
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../generated/i18n/app_localizations.dart';
String destinationType(AppLocalizations l, DestinationType type) =>
switch (type) {
DestinationType.crypto => l.dCrypto,
DestinationType.card => l.dCard,
DestinationType.russianBank => l.dBank,
};
String destinationIssue(AppLocalizations l, DestinationIssue issue) =>
switch (issue) {
DestinationIssue.invalid => l.dInvalid,
DestinationIssue.unavailable => l.dTemporary,
DestinationIssue.uncertain => l.dUncertain,
DestinationIssue.conflict => l.dConflict,
DestinationIssue.removed => l.dRemoved,
DestinationIssue.expired => l.dCardExpired,
DestinationIssue.bankChanged => l.dBankChanged,
DestinationIssue.authorization => l.dAuthorization,
DestinationIssue.unsupported => l.dUnsupported,
DestinationIssue.duplicate => l.dDuplicate,
DestinationIssue.replacementRequired => l.dReplacementRequired,
};
String eligibilityText(AppLocalizations l, PayoutEligibility e) =>
e.reason != null
? destinationIssue(l, e.reason!)
: switch (e.state) {
EligibilityState.eligible => l.dEligible,
EligibilityState.temporarilyUnavailable => l.dTemporary,
EligibilityState.requiresAction => l.dAction,
EligibilityState.unsupported => l.dUnsupported,
};
+128
View File
@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../generated/i18n/app_localizations.dart';
class CryptoFields extends StatefulWidget {
const CryptoFields({
super.key,
required this.capabilities,
required this.onChanged,
this.initial,
this.errorField,
});
final DestinationCapabilities capabilities;
final ValueChanged<DestinationDetails?> onChanged;
final CryptoDestination? initial;
final String? errorField;
@override
State<CryptoFields> createState() => _CryptoFieldsState();
}
class _CryptoFieldsState extends State<CryptoFields> {
late final address = TextEditingController(text: widget.initial?.address);
late final memo = TextEditingController(text: widget.initial?.memo);
late String? assetId = widget.initial?.assetId;
late String? networkId = widget.initial?.networkId;
void changed() {
final a = widget.capabilities.assets
.where((a) => a.id == assetId && a.networkId == networkId)
.firstOrNull;
widget.onChanged(
a == null
? null
: CryptoDestination(
assetId: a.id,
networkId: a.networkId,
address: address.text,
memo: memo.text,
assetLabel: a.label,
networkLabel: a.networkLabel,
),
);
}
@override
void dispose() {
address.dispose();
memo.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final assets = widget.capabilities.assets;
final selected = assets
.where((a) => a.id == assetId && a.networkId == networkId)
.firstOrNull;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<String>(
key: ValueKey('asset-$assetId'),
initialValue: assets.any((a) => a.id == assetId) ? assetId : null,
isExpanded: true,
decoration: InputDecoration(labelText: l.dAsset),
items: [
for (final a in assets)
DropdownMenuItem(value: a.id, child: Text(a.label)),
],
onChanged: (v) {
setState(() {
assetId = v;
networkId = null;
memo.clear();
});
changed();
},
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
key: ValueKey('network-$assetId-$networkId'),
initialValue:
assets.any((a) => a.id == assetId && a.networkId == networkId)
? networkId
: null,
isExpanded: true,
decoration: InputDecoration(labelText: l.dNetwork),
items: [
for (final a in assets.where((a) => a.id == assetId))
DropdownMenuItem(value: a.networkId, child: Text(a.networkLabel)),
],
onChanged: (v) {
setState(() => networkId = v);
changed();
},
),
const SizedBox(height: 16),
TextField(
key: const ValueKey('destination-address'),
controller: address,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: l.dAddress,
errorText: widget.errorField == 'address' ? l.dInvalid : null,
),
onChanged: (_) => changed(),
),
if (selected?.memoSupported == true) ...[
const SizedBox(height: 16),
TextField(
key: const ValueKey('destination-memo'),
controller: memo,
decoration: InputDecoration(
labelText: l.dMemo,
errorText: widget.errorField == 'memo' ? l.dInvalid : null,
),
onChanged: (_) => changed(),
),
],
const SizedBox(height: 12),
Text(l.dCryptoHelp),
],
);
}
}
+161
View File
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'copy.dart';
import 'details.dart';
import 'form.dart';
import 'nickname.dart';
import '../generated/i18n/app_localizations.dart';
class DestinationDetailPage extends StatefulWidget {
const DestinationDetailPage({super.key, required this.destination});
final SavedDestination destination;
@override
State<DestinationDetailPage> createState() => _DestinationDetailPageState();
}
class _DestinationDetailPageState extends State<DestinationDetailPage> {
late final p = DestinationManagementProvider(
context.read<DestinationsProvider>().service,
widget.destination,
);
late final nickname = TextEditingController(
text: widget.destination.nickname,
);
Future<void> remove() async {
final l = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l.dRemoveTitle),
content: Text(l.dRemoveHelp),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l.wStay),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(l.dRemove),
),
],
),
);
if (!mounted || confirmed != true) return;
await p.remove();
if (mounted && p.removed) Navigator.pop(context);
}
@override
void dispose() {
p.dispose();
nickname.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return ListenableBuilder(
listenable: p,
builder: (context, _) => Scaffold(
appBar: AppBar(title: Text(l.dDetails)),
body: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 120),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DestinationDetailsView(details: p.destination.details),
const SizedBox(height: 24),
DestinationNickname(
controller: nickname,
enabled: !p.busy,
onChanged: (_) {},
),
if (p.busy) const LinearProgressIndicator(),
if (p.error != null) ...[
Semantics(
liveRegion: true,
child: Text(destinationIssue(l, p.error!)),
),
TextButton(
onPressed: p.busy ? null : p.refresh,
child: Text(l.dReload),
),
],
const SizedBox(height: 16),
FilledButton(
onPressed: p.busy
? null
: () async {
await p.rename(nickname.text);
if (context.mounted && p.error == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.dSaved)));
}
},
child: Text(l.dRename),
),
if (p.destination.details is CardDestination &&
context
.read<DestinationsProvider>()
.capabilities
?.cardRecovery ==
true)
OutlinedButton(
onPressed: p.busy
? null
: () async {
final restored =
await Navigator.push<DestinationInput>(
context,
MaterialPageRoute(
builder: (_) => DestinationFormPage(
initial: p.destination.details,
restoring: p.destination.input,
),
),
);
if (mounted && restored != null) {
await p.refresh();
}
},
child: Text(l.dUpdateAuthorization),
),
TextButton(
onPressed: p.busy
? null
: () async {
await Navigator.push<DestinationInput>(
context,
MaterialPageRoute(
builder: (_) => DestinationFormPage(
initial: p.destination.details,
replacing: p.destination.id,
),
),
);
},
child: Text(l.dReplace),
),
TextButton(
onPressed: p.busy ? null : remove,
child: Text(l.dRemove),
),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../generated/i18n/app_localizations.dart';
/// Structured review renders the immutable service snapshot, never a nickname.
class DestinationDetailsView extends StatelessWidget {
const DestinationDetailsView({super.key, required this.details});
final DestinationDetails details;
Widget field(String label, String value) => Padding(
padding: const EdgeInsets.only(top: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [Text(label), SelectableText(value)],
),
);
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: switch (details) {
CryptoDestination d => [
field(l.dAsset, d.assetLabel),
field(l.dNetwork, d.networkLabel),
field(l.dAddress, d.address),
field(l.dMemo, d.memo.isEmpty ? l.dNoMemo : d.memo),
],
CardDestination d => [field(l.dCard, d.display)],
RussianBankDestination d => [
field(l.dHolder, d.holder),
field(l.dBankSearch, d.bank.name),
field(l.dBik, d.bank.bik),
field(l.dAccount, d.account),
field(l.dCorrespondent, d.bank.correspondent),
],
},
);
}
}
+292
View File
@@ -0,0 +1,292 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'bank.dart';
import 'copy.dart';
import 'crypto.dart';
import 'nickname.dart';
import '../generated/i18n/app_localizations.dart';
class DestinationFormPage extends StatefulWidget {
const DestinationFormPage({
super.key,
this.payoutContext,
this.initial,
this.replacing,
this.restoring,
});
final PayoutContext? payoutContext;
final DestinationDetails? initial;
final String? replacing;
final SavedReference? restoring;
@override
State<DestinationFormPage> createState() => _DestinationFormPageState();
}
class _DestinationFormPageState extends State<DestinationFormPage> {
late final p = DestinationFormProvider(
context.read<DestinationsProvider>().service,
context.read<AnalyticsService>(),
context: widget.payoutContext,
initial: widget.initial,
replacing: widget.replacing,
restoring: widget.restoring,
);
late DestinationType? type = widget.initial?.type;
final nickname = TextEditingController();
final focus = FocusNode();
Future<void> proceed({
bool withoutSaving = false,
bool existing = false,
}) async {
if (existing) {
await p.useExisting();
} else {
await p.proceed(withoutSaving: withoutSaving);
}
if (!mounted) return;
if (p.result != null) {
final l = AppLocalizations.of(context)!;
if (p.saved?.outcome == SaveOutcome.created || widget.restoring != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
widget.restoring == null ? l.dSaved : l.dAuthorizationUpdated,
),
),
);
}
Navigator.pop(context, p.result);
return;
}
focus.requestFocus();
}
@override
void dispose() {
p.dispose();
nickname.dispose();
focus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final caps = context.watch<DestinationsProvider>().capabilities;
return ListenableBuilder(
listenable: p,
builder: (context, _) => PopScope(
canPop: !p.busy,
child: Scaffold(
appBar: AppBar(title: Text(l.dAdd)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (caps?.simulated == true) ...[
Text(l.dDemo),
const SizedBox(height: 16),
],
Text(l.dOwn),
const SizedBox(height: 20),
if (caps == null || caps.types.isEmpty)
Text(l.dNoCapabilities)
else ...[
DropdownButtonFormField<DestinationType>(
initialValue: caps.types.contains(type) ? type : null,
isExpanded: true,
decoration: InputDecoration(labelText: l.dType),
items: [
for (final t in caps.types)
DropdownMenuItem(
value: t,
child: Text(
destinationType(l, t),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
onChanged:
p.busy || p.uncertain || widget.restoring != null
? null
: (v) {
setState(() => type = v);
p.change(null);
},
),
const SizedBox(height: 20),
AbsorbPointer(
absorbing: p.busy || p.uncertain,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (type == DestinationType.crypto)
CryptoFields(
capabilities: caps,
initial: widget.initial is CryptoDestination
? widget.initial as CryptoDestination
: null,
errorField: p.errorField,
onChanged: p.change,
),
if (type == DestinationType.russianBank)
BankFields(
initial:
widget.initial is RussianBankDestination
? widget.initial as RussianBankDestination
: null,
errorField: p.errorField,
onChanged: p.change,
),
if (type == DestinationType.card &&
caps.simulated) ...[
Text(l.dCardDemo),
const SizedBox(height: 16),
RadioGroup<String>(
groupValue: p.details is CardDestination
? (p.details as CardDestination).capture
: null,
onChanged: (value) {
if (value != null) {
p.change(
CardDestination(
capture: value,
brand: 'Demo card',
last4: '4242',
expiryMonth: 12,
expiryYear: 2030,
),
);
}
},
child: Column(
children: [
RadioListTile<String>(
value: 'demo-card-ready',
title: Text(l.dCardReady),
),
RadioListTile<String>(
value: 'demo-card-recover',
title: Text(l.dCardRecover),
),
],
),
),
],
if (type == DestinationType.card && !caps.simulated)
Text(l.dNoCapabilities),
if (widget.payoutContext != null) ...[
const SizedBox(height: 16),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: p.save,
onChanged: (v) => p.preferences(save: v),
title: Text(l.dSaveFuture),
),
],
if (widget.restoring == null &&
(widget.payoutContext == null || p.save)) ...[
const SizedBox(height: 16),
DestinationNickname(
controller: nickname,
enabled: !p.busy && !p.uncertain,
onChanged: (v) => p.preferences(nickname: v),
),
],
],
),
),
const SizedBox(height: 16),
Focus(
focusNode: focus,
skipTraversal: true,
child: Semantics(
liveRegion: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (p.error != null)
Text(
p.uncertain
? l.dUncertain
: p.errorField != null
? l.dInvalid
: '${p.savingFailed ? '${l.dSaveFailed}\n' : ''}${destinationIssue(l, p.error!)}',
key: const ValueKey('destination-error'),
),
if (p.eligibility != null &&
!p.eligibility!.eligible)
Text(
'${p.saved != null ? '${l.dSavedIneligible}\n' : ''}${eligibilityText(l, p.eligibility!)}',
),
if (p.saved?.outcome ==
SaveOutcome.duplicate) ...[
Text(l.dDuplicate),
TextButton(
onPressed: p.busy
? null
: () => proceed(existing: true),
child: Text(l.dUseExisting),
),
],
if (p.saved?.outcome == SaveOutcome.created &&
p.result == null)
Text(l.dSaved),
],
),
),
),
if (p.busy) const LinearProgressIndicator(),
if (p.uncertain)
TextButton(
onPressed: p.busy ? null : () => proceed(),
child: Text(l.dCheckSave),
),
if (p.savingFailed && widget.payoutContext != null)
TextButton(
onPressed: p.busy
? null
: () => proceed(withoutSaving: true),
child: Text(l.dWithoutSave),
),
if (p.save && widget.payoutContext != null)
Text(l.dSaveHelp),
const SizedBox(height: 12),
FilledButton(
key: const ValueKey('destination-continue'),
onPressed: p.busy || p.details == null
? null
: () => proceed(),
child: Text(
widget.restoring != null
? l.dUpdateAuthorization
: widget.payoutContext == null
? l.dSave
: p.error != null && !p.uncertain
? l.dRetrySave
: l.dContinue,
),
),
],
],
),
),
),
),
),
),
);
}
}
+165
View File
@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'copy.dart';
import 'detail.dart';
import 'form.dart';
import '../generated/i18n/app_localizations.dart';
class DestinationsPage extends StatefulWidget {
const DestinationsPage({super.key, this.payoutContext});
final PayoutContext? payoutContext;
@override
State<DestinationsPage> createState() => _DestinationsPageState();
}
class _DestinationsPageState extends State<DestinationsPage> {
String query = '';
late final selection = DestinationSelectionProvider(
context.read<DestinationsProvider>().service,
widget.payoutContext ?? const PayoutContext(''),
);
Future<void> refresh() async {
final p = context.read<DestinationsProvider>();
await p.refresh();
if (!mounted) return;
await selection.assess(p.methods);
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) refresh();
});
}
Future<void> add() async {
final result = await Navigator.push<DestinationInput>(
context,
MaterialPageRoute(
builder: (_) =>
DestinationFormPage(payoutContext: widget.payoutContext),
),
);
if (!mounted) return;
if (result != null && widget.payoutContext != null) {
Navigator.pop(context, result);
} else {
await refresh();
}
}
Future<void> choose(SavedDestination d) async {
if (widget.payoutContext == null) {
await Navigator.push<void>(
context,
MaterialPageRoute(
builder: (_) => DestinationDetailPage(destination: d),
),
);
if (mounted) await refresh();
} else {
final ref = await selection.select(d);
if (mounted && ref != null) Navigator.pop(context, ref);
}
}
@override
void dispose() {
selection.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final p = context.watch<DestinationsProvider>();
return Scaffold(
appBar: AppBar(
title: Text(widget.payoutContext == null ? l.dTitle : l.dChoose),
actions: [
IconButton(
tooltip: l.dReload,
onPressed: p.loading ? null : refresh,
icon: const Icon(Icons.refresh),
),
],
),
body: ListenableBuilder(
listenable: selection,
builder: (context, _) => ListView(
padding: const EdgeInsets.all(24),
children: [
if (p.capabilities?.simulated == true) ...[
Text(l.dDemo),
const SizedBox(height: 16),
],
FilledButton.icon(
onPressed: p.loading || (p.capabilities?.types.isEmpty ?? true)
? null
: add,
icon: const Icon(Icons.add),
label: Text(widget.payoutContext == null ? l.dAdd : l.dNew),
),
if (p.capabilities?.types.isEmpty == true)
Text(l.dManagementUnavailable),
const SizedBox(height: 20),
TextField(
decoration: InputDecoration(
labelText: l.dSearch,
prefixIcon: const Icon(Icons.search),
),
onChanged: (v) => setState(() => query = v),
),
if (p.loading || selection.busy) const LinearProgressIndicator(),
if (p.failed) Text(l.dUnavailable),
if (selection.error != null)
Semantics(
liveRegion: true,
child: Text(destinationIssue(l, selection.error!)),
),
if (widget.payoutContext != null &&
!p.loading &&
p.methods.isNotEmpty &&
!selection.assessments.values.any((a) => a.eligible))
Text(l.dNoEligible),
for (final type
in p.capabilities?.types ?? <DestinationType>[]) ...[
const SizedBox(height: 24),
Text(
destinationType(l, type),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
if (p.search(query, type).isEmpty && !p.loading && !p.failed)
Text(query.isEmpty ? l.dEmpty : l.dNoMatches),
for (final d in p.search(query, type))
Card(
child: ListTile(
contentPadding: const EdgeInsets.all(16),
title: Text(d.label),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (d.nickname.isNotEmpty) Text(d.details.display),
if (selection.assessments[d.id]
case final PayoutEligibility e)
Text(eligibilityText(l, e)),
],
),
trailing: const Icon(Icons.chevron_right),
onTap: selection.busy ? null : () => choose(d),
),
),
],
],
),
),
);
}
}
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import '../generated/i18n/app_localizations.dart';
class DestinationNickname extends StatelessWidget {
const DestinationNickname({
super.key,
required this.controller,
required this.onChanged,
this.enabled = true,
});
final TextEditingController controller;
final ValueChanged<String> onChanged;
final bool enabled;
@override
Widget build(BuildContext context) => TextField(
key: const ValueKey('destination-nickname'),
controller: controller,
enabled: enabled,
maxLength: 80,
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.dNickname,
),
onChanged: onChanged,
);
}
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../generated/i18n/app_localizations.dart';
class BankPasteDialog extends StatefulWidget {
const BankPasteDialog({super.key});
@override
State<BankPasteDialog> createState() => _BankPasteDialogState();
}
class _BankPasteDialogState extends State<BankPasteDialog> {
final text = TextEditingController();
BankPaste? result;
@override
void dispose() {
text.clear();
text.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final labels = {
'firstName': l.dFirst,
'lastName': l.dLast,
'patronymic': l.dPatronymic,
'account': l.dAccount,
'bik': 'BIK',
'bank': l.dBankSearch,
'holder': l.dHolder,
'correspondent': l.dCorrespondent,
};
return AlertDialog(
title: Text(l.dPaste),
content: SizedBox(
width: 480,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(l.dPasteHelp),
const SizedBox(height: 12),
TextField(
controller: text,
maxLines: 6,
decoration: InputDecoration(labelText: l.dBankDetails),
onChanged: (_) => setState(() => result = null),
),
if (result != null) ...[
const SizedBox(height: 12),
if (result!.conflicts.isNotEmpty) Text(l.dPasteConflict),
for (final e in result!.values.entries)
Text('${labels[e.key]}: ${e.value}'),
],
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l.wClose),
),
FilledButton(
onPressed: result == null
? () => setState(() => result = BankPaste.parse(text.text))
: result!.values.isEmpty
? null
: () => Navigator.pop(context, result),
child: Text(result == null ? l.dPasteReview : l.dPasteApply),
),
],
);
}
}
+104 -1
View File
@@ -63,6 +63,7 @@
"verificationRequest": "Send a new verification email", "verificationRequest": "Send a new verification email",
"verificationRetry": "Retry verification", "verificationRetry": "Retry verification",
"verificationBack": "Back to login", "verificationBack": "Back to login",
"verificationContinue": "Continue to login",
"verificationHint": "No email? Check your spam folder or request another below.", "verificationHint": "No email? Check your spam folder or request another below.",
"verificationDemoLink": "Continue to confirmation", "verificationDemoLink": "Continue to confirmation",
"verificationStep": "CONFIRM YOUR EMAIL", "verificationStep": "CONFIRM YOUR EMAIL",
@@ -277,5 +278,107 @@
"kycOperationMaximum": "Per operation maximum", "kycOperationMaximum": "Per operation maximum",
"kycErrorAccess": "Verification is unavailable for this account. Contact support if this continues.", "kycErrorAccess": "Verification is unavailable for this account. Contact support if this continues.",
"kycUpgradePendingTitle": "Limits upgrade pending", "kycUpgradePendingTitle": "Limits upgrade pending",
"kycUpgradePendingBody": "Your identity review is approved. We are waiting for confirmation of the limits upgrade. You do not need to submit your photos again." "kycUpgradePendingBody": "Your identity review is approved. We are waiting for confirmation of the limits upgrade. You do not need to submit your photos again.",
"wNoPayoutMethod": "No payout destination is set up for this account. Contact support to add one.",
"wNoFundingMethod": "No funding method is set up for this account. Contact support for funding instructions.",
"wLivePending": "Your payment is awaiting a final outcome. Refresh to see its latest status and balance.",
"wLiveFailed": "Your payment did not complete. Refresh your accounts to see the current available balance.",
"wLiveQuoteNotice": "Quote expires in {seconds} seconds.",
"@wLiveQuoteNotice": {
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"dTitle": "Payout methods",
"dAdd": "Add payout method",
"dChoose": "Payout destination",
"dNew": "Use a new destination",
"dSave": "Save payout method",
"dContinue": "Continue",
"dSaveFuture": "Save for future payouts",
"dSaveHelp": "Continue will also save this destination.",
"dNickname": "Name for this method (optional)",
"dSaved": "Payout method saved.",
"dSaveFailed": "Couldn’t save these details. Your input is still here.",
"dRetrySave": "Retry saving",
"dWithoutSave": "Continue without saving",
"dUncertain": "We couldn’t confirm saving. This method may already be saved.",
"dCheckSave": "Check save status",
"dDuplicate": "This destination is already saved. Use the existing method.",
"dUseExisting": "Use existing method",
"dCrypto": "Crypto address",
"dCard": "Bank card",
"dBank": "Russian bank account",
"dType": "Destination type",
"dSearch": "Search saved methods",
"dEmpty": "No saved methods in this category.",
"dNoMatches": "No matching methods.",
"dEligible": "Available",
"dTemporary": "Temporarily unavailable for this payout.",
"dAction": "Action required",
"dUnsupported": "This method isn’t supported for this payout.",
"dUnavailable": "Couldn’t load methods. Try again.",
"dNoCapabilities": "No destination types are currently available.",
"dDemo": "Demo destinations — saving lasts until reload. No real payout or card collection is connected.",
"dAsset": "Receiving asset",
"dNetwork": "Network",
"dAddress": "Wallet address",
"dMemo": "Memo / destination tag",
"dNoMemo": "No memo / tag",
"dCryptoHelp": "Use the network supported by your receiving wallet. A valid address format does not guarantee delivery.",
"dHolder": "Account holder",
"dFirst": "First name",
"dLast": "Last name",
"dPatronymic": "Patronymic (optional)",
"dBankDetails": "Bank details",
"dBankSearch": "Bank name or BIK",
"dAccount": "20-digit account number",
"dCorrespondent": "Correspondent account",
"dChangeBank": "Change bank",
"dBankUnknown": "Bank not found. Check the name or BIK.",
"dBankFailure": "Bank directory is unavailable. Your input is preserved.",
"dBankChanged": "Bank details changed. Review them and add a replacement destination.",
"dPaste": "Paste bank details",
"dPasteHelp": "Paste labelled bank details. Review recognized values before applying them.",
"dPasteReview": "Review recognized details",
"dPasteApply": "Use these details",
"dPasteConflict": "Conflicting values were found. Correct the affected fields in the form.",
"dInvalid": "Check the entered details and correct the highlighted field.",
"dCardDemo": "Choose a simulated secure card capture. Do not enter a real card number.",
"dCardReady": "Demo card •••• 4242",
"dCardRecover": "Demo card requiring renewed authorization",
"dCardExpired": "This card has expired. Add a replacement card.",
"dAuthorization": "Authorization needs updating. Follow the service-supported recovery or add a replacement.",
"dRemoved": "This method was removed. Your draft is preserved. Choose a destination to continue.",
"dConflict": "This method changed in another session. Refresh and review the latest version.",
"dRename": "Rename",
"dRemove": "Remove",
"dReplace": "Add replacement",
"dRemoveTitle": "Remove payout method?",
"dRemoveHelp": "Removed methods cannot be used for new payouts. Submitted payouts and required financial records are retained.",
"dDetails": "Destination details",
"dReload": "Refresh methods",
"dSelect": "Select destination",
"dQuoteChanged": "Payout terms changed. Your draft is preserved. Select a destination and review a new quote.",
"dOwn": "Use a wallet, card, or account that belongs to you.",
"dSavedIneligible": "Saved. This method is unavailable for the current payout.",
"dSaveFailedLabel": "Saving failed",
"dNoEligible": "No methods are available for this payout. You can add a destination or refresh.",
"dDemoTerms": "Demo receiving amounts use a fixed 1:1 ratio. No real payout is made.",
"dTiming": "Expected processing time: {seconds} seconds",
"@dTiming": {
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"dBik": "BIK",
"dBankMismatch": "Pasted bank details differ from the selected bank. Check the details before continuing.",
"dManagementUnavailable": "Saving payout methods isn’t available yet. You can still use your configured payout destination.",
"dUpdateAuthorization": "Update authorization",
"dAuthorizationUpdated": "Authorization updated.",
"dReplacementRequired": "Couldn’t confirm this is the same card. Add a replacement destination."
} }
+104 -1
View File
@@ -63,6 +63,7 @@
"verificationRequest": "Отправить новое письмо", "verificationRequest": "Отправить новое письмо",
"verificationRetry": "Повторить подтверждение", "verificationRetry": "Повторить подтверждение",
"verificationBack": "Вернуться ко входу", "verificationBack": "Вернуться ко входу",
"verificationContinue": "Перейти ко входу",
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.", "verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
"verificationDemoLink": "Перейти к подтверждению", "verificationDemoLink": "Перейти к подтверждению",
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ", "verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
@@ -277,5 +278,107 @@
"kycOperationMaximum": "Максимум на операцию", "kycOperationMaximum": "Максимум на операцию",
"kycErrorAccess": "Проверка недоступна для этого аккаунта. Если это повторяется, обратитесь в поддержку.", "kycErrorAccess": "Проверка недоступна для этого аккаунта. Если это повторяется, обратитесь в поддержку.",
"kycUpgradePendingTitle": "Ожидается повышение лимитов", "kycUpgradePendingTitle": "Ожидается повышение лимитов",
"kycUpgradePendingBody": "Проверка личности одобрена. Ожидается подтверждение повышения лимитов. Отправлять фото повторно не нужно." "kycUpgradePendingBody": "Проверка личности одобрена. Ожидается подтверждение повышения лимитов. Отправлять фото повторно не нужно.",
"wNoPayoutMethod": "Для этого счёта не настроен способ вывода. Обратитесь в поддержку.",
"wNoFundingMethod": "Для этого счёта не настроен способ пополнения. Обратитесь в поддержку за инструкциями.",
"wLivePending": "Ожидается окончательный результат платежа. Обновите страницу, чтобы проверить статус и баланс.",
"wLiveFailed": "Платёж не завершён. Обновите счета, чтобы проверить доступный баланс.",
"wLiveQuoteNotice": "Предложение истекает через {seconds} сек.",
"@wLiveQuoteNotice": {
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"dTitle": "Способы вывода",
"dAdd": "Добавить способ вывода",
"dChoose": "Реквизиты для вывода",
"dNew": "Использовать новые реквизиты",
"dSave": "Сохранить способ вывода",
"dContinue": "Продолжить",
"dSaveFuture": "Сохранить для будущих выводов",
"dSaveHelp": "При продолжении реквизиты также будут сохранены.",
"dNickname": "Название способа вывода (необязательно)",
"dSaved": "Способ вывода сохранён.",
"dSaveFailed": "Не удалось сохранить реквизиты. Введённые данные остались в форме.",
"dRetrySave": "Повторить сохранение",
"dWithoutSave": "Продолжить без сохранения",
"dUncertain": "Не удалось подтвердить сохранение. Возможно, способ уже сохранён.",
"dCheckSave": "Проверить статус сохранения",
"dDuplicate": "Эти реквизиты уже сохранены. Используйте существующий способ.",
"dUseExisting": "Использовать существующий способ",
"dCrypto": "Криптоадрес",
"dCard": "Банковская карта",
"dBank": "Российский банковский счёт",
"dType": "Тип реквизитов",
"dSearch": "Поиск сохранённых способов",
"dEmpty": "В этой категории нет сохранённых способов.",
"dNoMatches": "Подходящие способы не найдены.",
"dEligible": "Доступно",
"dTemporary": "Временно недоступно для этого вывода.",
"dAction": "Требуется действие",
"dUnsupported": "Этот способ не поддерживается для данного вывода.",
"dUnavailable": "Не удалось загрузить способы. Повторите попытку.",
"dNoCapabilities": "Сейчас нет доступных типов реквизитов.",
"dDemo": "Демонстрационные реквизиты — сохранение действует до перезагрузки. Реальный вывод и ввод карт не подключены.",
"dAsset": "Получаемый актив",
"dNetwork": "Сеть",
"dAddress": "Адрес кошелька",
"dMemo": "Memo / тег назначения",
"dNoMemo": "Без memo / тега",
"dCryptoHelp": "Выберите сеть, поддерживаемую кошельком получателя. Верный формат адреса не гарантирует зачисление.",
"dHolder": "Владелец счёта",
"dFirst": "Имя",
"dLast": "Фамилия",
"dPatronymic": "Отчество (необязательно)",
"dBankDetails": "Банковские реквизиты",
"dBankSearch": "Название банка или БИК",
"dAccount": "20-значный номер счёта",
"dCorrespondent": "Корреспондентский счёт",
"dChangeBank": "Изменить банк",
"dBankUnknown": "Банк не найден. Проверьте название или БИК.",
"dBankFailure": "Справочник банков недоступен. Введённые данные сохранены.",
"dBankChanged": "Банковские реквизиты изменились. Проверьте их и добавьте новые реквизиты.",
"dPaste": "Вставить банковские реквизиты",
"dPasteHelp": "Вставьте реквизиты с названиями полей. Проверьте распознанные значения перед применением.",
"dPasteReview": "Проверить распознанные реквизиты",
"dPasteApply": "Использовать эти реквизиты",
"dPasteConflict": "Найдены противоречивые значения. Исправьте соответствующие поля в форме.",
"dInvalid": "Проверьте введённые реквизиты и исправьте выделенное поле.",
"dCardDemo": "Выберите демонстрационный результат безопасного ввода карты. Не вводите настоящий номер карты.",
"dCardReady": "Демо-карта •••• 4242",
"dCardRecover": "Демо-карта с истёкшей авторизацией",
"dCardExpired": "Срок действия карты истёк. Добавьте новую карту.",
"dAuthorization": "Необходимо обновить авторизацию. Используйте доступное восстановление или добавьте новые реквизиты.",
"dRemoved": "Этот способ удалён. Черновик сохранён. Выберите реквизиты для продолжения.",
"dConflict": "Этот способ изменён в другой сессии. Обновите и проверьте актуальную версию.",
"dRename": "Переименовать",
"dRemove": "Удалить",
"dReplace": "Добавить новые реквизиты",
"dRemoveTitle": "Удалить способ вывода?",
"dRemoveHelp": "Удалённые способы недоступны для новых выводов. Отправленные заявки и обязательные финансовые записи сохраняются.",
"dDetails": "Реквизиты",
"dReload": "Обновить способы",
"dSelect": "Выбрать реквизиты",
"dQuoteChanged": "Условия вывода изменились. Черновик сохранён. Выберите реквизиты и проверьте новую котировку.",
"dOwn": "Используйте принадлежащий вам кошелёк, карту или счёт.",
"dSavedIneligible": "Сохранено. Этот способ недоступен для текущего вывода.",
"dSaveFailedLabel": "Ошибка сохранения",
"dNoEligible": "Для этого вывода нет доступных способов. Добавьте реквизиты или обновите список.",
"dDemoTerms": "Демонстрационные суммы зачисления рассчитаны по фиксированному курсу 1:1. Реальный вывод не выполняется.",
"dTiming": "Ожидаемое время обработки: {seconds} сек.",
"@dTiming": {
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"dBik": "БИК",
"dBankMismatch": "Вставленные реквизиты отличаются от выбранного банка. Проверьте их перед продолжением.",
"dManagementUnavailable": "Сохранение способов вывода пока недоступно. Вы можете использовать ранее настроенные реквизиты для вывода.",
"dUpdateAuthorization": "Обновить авторизацию",
"dAuthorizationUpdated": "Авторизация обновлена.",
"dReplacementRequired": "Не удалось подтвердить, что это та же карта. Добавьте новые реквизиты."
} }
+39 -6
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_analytics/sxchnge_analytics.dart';
@@ -18,6 +20,7 @@ import 'routing/router.dart';
import 'generated/i18n/app_localizations.dart'; import 'generated/i18n/app_localizations.dart';
Future<void> main() async { Future<void> main() async {
usePathUrlStrategy();
final binding = WidgetsFlutterBinding.ensureInitialized(); final binding = WidgetsFlutterBinding.ensureInitialized();
final locale = await LocaleProvider.load(binding.platformDispatcher.locales); final locale = await LocaleProvider.load(binding.platformDispatcher.locales);
const apiUrl = String.fromEnvironment('SXCHNGE_API_URL'); const apiUrl = String.fromEnvironment('SXCHNGE_API_URL');
@@ -37,6 +40,7 @@ class SxchngeApp extends StatefulWidget {
this.identityService, this.identityService,
this.limitsService, this.limitsService,
this.walletService, this.walletService,
this.destinationService,
this.twoFactorService, this.twoFactorService,
this.photoService = const FilePhotoService(), this.photoService = const FilePhotoService(),
this.loginService = const DummyLoginService(), this.loginService = const DummyLoginService(),
@@ -49,6 +53,7 @@ class SxchngeApp extends StatefulWidget {
final IdentityService? identityService; final IdentityService? identityService;
final LimitsService? limitsService; final LimitsService? limitsService;
final WalletService? walletService; final WalletService? walletService;
final DestinationService? destinationService;
final TwoFactorService? twoFactorService; final TwoFactorService? twoFactorService;
final PhotoService photoService; final PhotoService photoService;
final LoginService loginService; final LoginService loginService;
@@ -63,8 +68,11 @@ class _SxchngeAppState extends State<SxchngeApp> {
late final _locale = widget.localeProvider ?? LocaleProvider(); late final _locale = widget.localeProvider ?? LocaleProvider();
late final _analytics = widget.analyticsService ?? DummyAnalyticsService(); late final _analytics = widget.analyticsService ?? DummyAnalyticsService();
late final _factors = late final _authFactors =
widget.backend ?? widget.twoFactorService ?? DummyTwoFactorService(); widget.backend ?? widget.twoFactorService ?? DummyTwoFactorService();
late final _factors = _walletService is BackendWalletService
? WalletFactorService(_walletService, _authFactors)
: _authFactors;
late final _security = SecurityProvider(_factors, _analytics); late final _security = SecurityProvider(_factors, _analytics);
late final _identity = late final _identity =
widget.identityService ?? widget.identityService ??
@@ -76,15 +84,27 @@ class _SxchngeAppState extends State<SxchngeApp> {
)); ));
late final _walletService = late final _walletService =
widget.walletService ?? widget.walletService ??
DummyWalletService( (widget.backend != null
_identity, ? BackendWalletService(
initialLocale: _locale.locale.languageCode, baseUri: widget.backend!.baseUri,
factors: _factors, accessToken: () => widget.backend!.session?.accessToken,
); )
: DummyWalletService(
_identity,
initialLocale: _locale.locale.languageCode,
factors: _authFactors,
));
late final _destinationService =
widget.destinationService ??
(_walletService.isSimulated && _walletService is DummyWalletService
? _walletService.destinations
: const UnavailableDestinationService());
late final _destinations = DestinationsProvider(_destinationService);
late final _wallet = WalletProvider( late final _wallet = WalletProvider(
_walletService, _walletService,
_analytics, _analytics,
factors: _factors, factors: _factors,
destinations: _destinationService,
); );
late final _limits = late final _limits =
widget.limitsService ?? widget.limitsService ??
@@ -109,14 +129,22 @@ class _SxchngeAppState extends State<SxchngeApp> {
: () => widget.backend!.session != null, : () => widget.backend!.session != null,
)..routeInformationProvider.addListener(_routeChanged); )..routeInformationProvider.addListener(_routeChanged);
String _previousPath = '/'; String _previousPath = '/';
String? _walletCustomer;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_previousPath = _router.routeInformationProvider.value.uri.path; _previousPath = _router.routeInformationProvider.value.uri.path;
_walletCustomer = widget.backend?.session?.userId;
} }
void _routeChanged() { void _routeChanged() {
final customer = widget.backend?.session?.userId;
if (customer != _walletCustomer) {
_walletCustomer = customer;
_wallet.reset();
_destinations.reset();
}
final path = _router.routeInformationProvider.value.uri.path; final path = _router.routeInformationProvider.value.uri.path;
if (path == '/' && _previousPath != '/' && _previousPath != '/two-factor') { if (path == '/' && _previousPath != '/' && _previousPath != '/two-factor') {
_journeys.abandon(JourneyExit.navigation); _journeys.abandon(JourneyExit.navigation);
@@ -130,6 +158,10 @@ class _SxchngeAppState extends State<SxchngeApp> {
_router.dispose(); _router.dispose();
_journeys.dispose(); _journeys.dispose();
_wallet.dispose(); _wallet.dispose();
_destinations.dispose();
if (_walletService is BackendWalletService) {
_walletService.close();
}
_security.dispose(); _security.dispose();
if (_identity is BackendIdentityService) { if (_identity is BackendIdentityService) {
_identity.close(); _identity.close();
@@ -143,6 +175,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
Widget build(BuildContext context) => MultiProvider( Widget build(BuildContext context) => MultiProvider(
providers: [ providers: [
ChangeNotifierProvider.value(value: _wallet), ChangeNotifierProvider.value(value: _wallet),
ChangeNotifierProvider.value(value: _destinations),
ChangeNotifierProvider.value(value: _security), ChangeNotifierProvider.value(value: _security),
Provider<TwoFactorService>.value(value: _factors), Provider<TwoFactorService>.value(value: _factors),
Provider<JourneyRecorder>.value(value: _journeys), Provider<JourneyRecorder>.value(value: _journeys),
+10 -1
View File
@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart'; import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../auth/page.dart'; import '../auth/page.dart';
import '../destinations/list.dart';
import '../kyc/page.dart'; import '../kyc/page.dart';
import '../security/challenge.dart'; import '../security/challenge.dart';
import '../verification/page.dart'; import '../verification/page.dart';
@@ -82,6 +83,12 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
GoRoute( GoRoute(
path: '/settings', path: '/settings',
builder: (context, state) => const SettingsPage(), builder: (context, state) => const SettingsPage(),
routes: [
GoRoute(
path: 'payout-methods',
builder: (context, state) => const DestinationsPage(),
),
],
), ),
GoRoute( GoRoute(
path: '/top-up', path: '/top-up',
@@ -128,7 +135,9 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
GoRoute( GoRoute(
path: '/', path: '/',
name: AppRoutes.login, name: AppRoutes.login,
builder: (context, state) => const AuthPage(), builder: (context, state) => AuthPage(
email: state.extra is String ? state.extra as String : null,
),
), ),
GoRoute( GoRoute(
path: '/verification-sent', path: '/verification-sent',
+13 -7
View File
@@ -318,15 +318,21 @@ class _VerificationPageState extends State<VerificationPage> {
if (verified) ...[ if (verified) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton( FilledButton(
onPressed: () => context.go('/accounts'), key: const ValueKey('verification-continue'),
child: Text(l.wEnter), onPressed: () => context.goNamed(
AppRoutes.login,
extra: state.result?.verifiedEmail,
),
child: Text(l.verificationContinue),
),
],
if (!verified) ...[
const SizedBox(height: 16),
TextButton(
onPressed: () => context.goNamed(AppRoutes.login),
child: Text(l.verificationBack),
), ),
], ],
const SizedBox(height: 16),
TextButton(
onPressed: () => context.goNamed(AppRoutes.login),
child: Text(l.verificationBack),
),
], ],
), ),
), ),
+20 -11
View File
@@ -28,18 +28,27 @@ String statusText(AppLocalizations l, OperationStatus s) => switch (s) {
OperationStatus.completed => l.wCompleted, OperationStatus.completed => l.wCompleted,
OperationStatus.failed => l.wFailed, OperationStatus.failed => l.wFailed,
}; };
String fundsText(AppLocalizations l, WalletOperation o) => String fundsText(
switch ((o.status, o.quote.request.kind)) { AppLocalizations l,
(OperationStatus.pending, TransferKind.topUp) => l.wPendingTopUp, WalletOperation o, {
(OperationStatus.pending, TransferKind.exchange) => l.wPendingExchange, bool simulated = true,
(OperationStatus.pending, TransferKind.payout) => l.wPendingPayout, }) => !simulated && o.status == OperationStatus.pending
(OperationStatus.completed, TransferKind.topUp) => l.wCompleteTopUp, ? l.wLivePending
(OperationStatus.completed, TransferKind.exchange) => l.wCompleteExchange, : !simulated && o.status == OperationStatus.failed
(OperationStatus.completed, TransferKind.payout) => l.wCompletePayout, ? l.wLiveFailed
(OperationStatus.failed, TransferKind.topUp) => l.wFailedTopUp, : switch ((o.status, o.quote.request.kind)) {
_ => l.wFailedOutgoing, (OperationStatus.pending, TransferKind.topUp) => l.wPendingTopUp,
}; (OperationStatus.pending, TransferKind.exchange) => l.wPendingExchange,
(OperationStatus.pending, TransferKind.payout) => l.wPendingPayout,
(OperationStatus.completed, TransferKind.topUp) => l.wCompleteTopUp,
(OperationStatus.completed, TransferKind.exchange) =>
l.wCompleteExchange,
(OperationStatus.completed, TransferKind.payout) => l.wCompletePayout,
(OperationStatus.failed, TransferKind.topUp) => l.wFailedTopUp,
_ => l.wFailedOutgoing,
};
String walletError(AppLocalizations l, WalletError e) => switch (e) { String walletError(AppLocalizations l, WalletError e) => switch (e) {
WalletError.destinationChanged => l.dQuoteChanged,
WalletError.invalidAmount => l.wInvalidAmount, WalletError.invalidAmount => l.wInvalidAmount,
WalletError.insufficientFunds => l.wInsufficient, WalletError.insufficientFunds => l.wInsufficient,
WalletError.limitExceeded => l.wLimitError, WalletError.limitExceeded => l.wLimitError,
+1 -1
View File
@@ -97,7 +97,7 @@ class _OperationPageState extends State<OperationPage> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
fundsText(l, o), fundsText(l, o, simulated: p.service.isSimulated),
style: TextStyle( style: TextStyle(
color: o.status == OperationStatus.failed color: o.status == OperationStatus.failed
? c.onErrorContainer ? c.onErrorContainer
+9
View File
@@ -106,6 +106,15 @@ class SettingsPage extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Card(
child: ListTile(
leading: const Icon(Icons.account_balance_outlined),
title: Text(l.dTitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/settings/payout-methods'),
),
),
const SizedBox(height: 16),
const SecuritySettings(), const SecuritySettings(),
const SizedBox(height: 20), const SizedBox(height: 20),
TextButton( TextButton(
+16 -2
View File
@@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@@ -6,6 +8,8 @@ import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart'; import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../destinations/details.dart';
import 'copy.dart'; import 'copy.dart';
import '../generated/i18n/app_localizations.dart'; import '../generated/i18n/app_localizations.dart';
@@ -49,13 +53,23 @@ class TransferSummary extends StatelessWidget {
q.request.kind == TransferKind.payout q.request.kind == TransferKind.payout
? l.wRecipientGets ? l.wRecipientGets
: l.wCredit, : l.wCredit,
money(context, b, q.credit), q.payoutTerms == null
? money(context, b, q.credit)
: '${NumberFormat.decimalPatternDigits(locale: Localizations.localeOf(context).toLanguageTag(), decimalDigits: q.payoutTerms!.precision).format(q.payoutTerms!.amount / pow(10, q.payoutTerms!.precision))} ${q.payoutTerms!.assetLabel}',
large: true, large: true,
), ),
if (q.payoutTerms != null) ...[
const SizedBox(height: 12),
Text(l.dTiming(q.payoutTerms!.expectedSeconds)),
if (p.service.isSimulated) Text(l.dDemoTerms),
],
if (q.request.kind == TransferKind.payout) ...[ if (q.request.kind == TransferKind.payout) ...[
const Divider(height: 32), const Divider(height: 32),
Text(l.wRecipient), Text(l.wRecipient),
SelectableText(q.request.recipient), if (q.payoutSnapshot != null)
DestinationDetailsView(details: q.payoutSnapshot!)
else
SelectableText(q.request.recipient),
], ],
], ],
), ),
+111 -14
View File
@@ -11,6 +11,7 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart'; import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../security/challenge.dart'; import '../security/challenge.dart';
import '../destinations/list.dart';
import 'copy.dart'; import 'copy.dart';
import 'summary.dart'; import 'summary.dart';
@@ -32,7 +33,7 @@ class TransferPageState extends State<TransferPage> {
source: widget.source, source: widget.source,
); );
final _amount = TextEditingController(); final _amount = TextEditingController();
final _recipient = TextEditingController();
final _focus = FocusNode(); final _focus = FocusNode();
final _content = GlobalKey(); final _content = GlobalKey();
Timer? _timer; Timer? _timer;
@@ -41,7 +42,13 @@ class TransferPageState extends State<TransferPage> {
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _provider.addListener(_changed); if (mounted) {
_provider.addListener(_changed);
final destinations = context.read<DestinationsProvider>();
if (destinations.capabilities == null && !destinations.loading) {
unawaited(destinations.refresh());
}
}
}); });
_timer = Timer.periodic(const Duration(seconds: 1), (_) { _timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted && _provider.quote != null) setState(() {}); if (mounted && _provider.quote != null) setState(() {});
@@ -118,7 +125,7 @@ class TransferPageState extends State<TransferPage> {
_provider.removeListener(_changed); _provider.removeListener(_changed);
_provider.dispose(); _provider.dispose();
_amount.dispose(); _amount.dispose();
_recipient.dispose();
_focus.dispose(); _focus.dispose();
super.dispose(); super.dispose();
} }
@@ -133,6 +140,17 @@ class TransferPageState extends State<TransferPage> {
final t = Theme.of(context); final t = Theme.of(context);
final accounts = wallet.data?.accounts ?? []; final accounts = wallet.data?.accounts ?? [];
final source = wallet.account(p.source); final source = wallet.account(p.source);
final live = !wallet.service.isSimulated;
final destinations = context.watch<DestinationsProvider>();
final legacyPayout =
live &&
!(destinations.capabilities?.types.isNotEmpty ?? false) &&
destinations.methods.isEmpty;
final expiresIn =
p.quote?.expiresAt.difference(DateTime.now()).inSeconds ?? 0;
final unavailableMethod =
live &&
((widget.kind == TransferKind.topUp && !(source?.canFund ?? false)));
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Center( child: Center(
@@ -207,7 +225,15 @@ class TransferPageState extends State<TransferPage> {
] else if (p.quote != null) ...[ ] else if (p.quote != null) ...[
TransferSummary(quote: p.quote!), TransferSummary(quote: p.quote!),
const SizedBox(height: 16), const SizedBox(height: 16),
Text(p.expired ? l.wExpired : l.wQuoteNotice), Text(
p.expired
? l.wExpired
: live
? l.wLiveQuoteNotice(
expiresIn < 0 ? 0 : expiresIn,
)
: l.wQuoteNotice,
),
const SizedBox(height: 20), const SizedBox(height: 20),
if (p.expired) if (p.expired)
FilledButton( FilledButton(
@@ -218,7 +244,11 @@ class TransferPageState extends State<TransferPage> {
FilledButton( FilledButton(
key: const ValueKey('transfer-confirm'), key: const ValueKey('transfer-confirm'),
onPressed: p.busy ? null : _confirm, onPressed: p.busy ? null : _confirm,
child: Text(l.wConfirm), child: Text(
widget.kind == TransferKind.payout
? '${l.wPayout} ${money(context, source!, p.quote!.debit)}'
: l.wConfirm,
),
), ),
TextButton( TextButton(
onPressed: p.busy ? null : p.edit, onPressed: p.busy ? null : p.edit,
@@ -299,21 +329,88 @@ class TransferPageState extends State<TransferPage> {
), ),
if (widget.kind == TransferKind.payout) ...[ if (widget.kind == TransferKind.payout) ...[
const SizedBox(height: 20), const SizedBox(height: 20),
TextField( if (legacyPayout) ...[
key: const ValueKey('transfer-recipient'), if (source?.payoutMethod.isNotEmpty == true)
controller: _recipient, OutlinedButton.icon(
enabled: !p.busy, key: const ValueKey(
decoration: InputDecoration( 'choose-configured-destination',
labelText: l.wRecipient, ),
hintText: l.wRecipientHint, icon: const Icon(
Icons.account_balance_outlined,
),
label: Text(source!.payoutLabel),
onPressed: p.busy
? null
: () => p.change(
recipient: source.payoutMethod,
payoutLabel: source.payoutLabel,
),
),
if (source?.payoutMethod.isEmpty ?? true)
Text(l.wNoPayoutMethod),
] else
OutlinedButton.icon(
key: const ValueKey(
'choose-payout-destination',
),
icon: const Icon(
Icons.account_balance_wallet_outlined,
),
label: Text(
p.payoutDestination == null
? l.dSelect
: p.payoutLabel,
),
onPressed: p.busy
? null
: () async {
final selected =
await Navigator.push<
DestinationInput
>(
context,
MaterialPageRoute(
builder: (_) =>
DestinationsPage(
payoutContext:
PayoutContext(
p.source,
amount: source
?.parse(
p.amount,
),
),
),
),
);
if (!context.mounted ||
selected == null) {
return;
}
await p.selectPayout(selected);
},
), ),
onChanged: (v) => p.change(recipient: v), ],
if (unavailableMethod) ...[
const SizedBox(height: 12),
Text(
widget.kind == TransferKind.payout
? l.wNoPayoutMethod
: l.wNoFundingMethod,
), ),
], ],
const SizedBox(height: 28), const SizedBox(height: 28),
FilledButton( FilledButton(
key: const ValueKey('transfer-review'), key: const ValueKey('transfer-review'),
onPressed: p.busy ? null : p.review, onPressed:
p.busy ||
unavailableMethod ||
(widget.kind == TransferKind.payout &&
(legacyPayout
? p.recipient.isEmpty
: p.payoutDestination == null))
? null
: p.review,
child: Text(l.wReview), child: Text(l.wReview),
), ),
if (p.error == WalletError.limitExceeded) if (p.error == WalletError.limitExceeded)
+1 -1
View File
@@ -185,7 +185,7 @@ packages:
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
+2
View File
@@ -14,6 +14,8 @@ dependencies:
path: ../packages/sxchnge_analytics path: ../packages/sxchnge_analytics
flutter: flutter:
sdk: flutter sdk: flutter
flutter_web_plugins:
sdk: flutter
sxchnge_auth: sxchnge_auth:
path: ../packages/sxchnge_auth path: ../packages/sxchnge_auth
sxchnge_theme: sxchnge_theme:
+38
View File
@@ -92,6 +92,14 @@ void main() {
findsOneWidget, findsOneWidget,
); );
expect(find.byKey(const ValueKey('verification-sent')), findsNothing); expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect( expect(
tester tester
.widget<TextFormField>(find.byKey(const ValueKey('email'))) .widget<TextFormField>(find.byKey(const ValueKey('email')))
@@ -153,6 +161,14 @@ void main() {
'/verification-sent', '/verification-sent',
); );
expect(backend.sends, 1); expect(backend.sends, 1);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect( expect(
find.textContaining('a verification email is on its way'), find.textContaining('a verification email is on its way'),
findsOneWidget, findsOneWidget,
@@ -198,6 +214,14 @@ void main() {
find.text('Сервис недоступен. Попробуйте ещё раз.'), find.text('Сервис недоступен. Попробуйте ещё раз.'),
findsOneWidget, findsOneWidget,
); );
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
await tester.tap(button); await tester.tap(button);
await tester.pump(); await tester.pump();
expect(service.calls, hasLength(2)); expect(service.calls, hasLength(2));
@@ -207,6 +231,12 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget); expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
expect(find.byKey(const ValueKey('auth-error')), findsNothing); expect(find.byKey(const ValueKey('auth-error')), findsNothing);
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}, },
); );
@@ -237,6 +267,14 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('confirmation')), findsOneWidget); expect(find.byKey(const ValueKey('confirmation')), findsOneWidget);
expect(find.byKey(const ValueKey('preview-result')), findsNothing); expect(find.byKey(const ValueKey('preview-result')), findsNothing);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
} }
+28 -2
View File
@@ -55,6 +55,12 @@ void main() {
await submit(tester); await submit(tester);
expect(find.text('Enter your email address.'), findsOneWidget); expect(find.text('Enter your email address.'), findsOneWidget);
expect(find.text('Enter your password.'), findsOneWidget); expect(find.text('Enter your password.'), findsOneWidget);
expect(
tester.testTextInput.log.where(
(call) => call.method == 'TextInput.finishAutofillContext',
),
isEmpty,
);
await tester.enterText( await tester.enterText(
find.byKey(const ValueKey('email')), find.byKey(const ValueKey('email')),
'test@example.com', 'test@example.com',
@@ -79,6 +85,12 @@ void main() {
await tester.testTextInput.receiveAction(TextInputAction.done); await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget); expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}, },
); );
@@ -104,12 +116,26 @@ void main() {
); );
await submit(tester); await submit(tester);
expect(find.text('Passwords do not match.'), findsOneWidget); expect(find.text('Passwords do not match.'), findsOneWidget);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
await tester.enterText( await tester.enterText(
find.byKey(const ValueKey('confirmation')), find.byKey(const ValueKey('confirmation')),
'example-password', 'example-password',
); );
await submit(tester); await submit(tester);
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget); expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
);
await tester.ensureVisible( await tester.ensureVisible(
find.byKey(const ValueKey('demo-confirmation')), find.byKey(const ValueKey('demo-confirmation')),
); );
@@ -119,8 +145,8 @@ void main() {
find.byKey(const ValueKey('verification-verified')), find.byKey(const ValueKey('verification-verified')),
findsOneWidget, findsOneWidget,
); );
await tester.ensureVisible(find.text('Back to login')); await tester.ensureVisible(find.text('Continue to login'));
await tester.tap(find.text('Back to login')); await tester.tap(find.text('Continue to login'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('confirmation')), findsNothing); expect(find.byKey(const ValueKey('confirmation')), findsNothing);
expect(find.text('example-password'), findsNothing); expect(find.text('example-password'), findsNothing);
+149
View File
@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
Finder field(String key) => find.byKey(ValueKey(key));
EditableTextState editor(WidgetTester tester, String key) =>
tester.state<EditableTextState>(
find.descendant(of: field(key), matching: find.byType(EditableText)),
);
void main() {
for (final language in ['en', 'ru']) {
testWidgets(
'platform autofill fills the signup credential group ($language)',
(tester) async {
tester.view.physicalSize = const Size(1440, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
SxchngeApp(
localeProvider: LocaleProvider(initialLocale: Locale(language)),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.text(language == 'ru' ? 'Регистрация' : 'Sign up'),
);
await tester.pumpAndSettle();
await tester.showKeyboard(field('email'));
final configuration = tester.testTextInput.setClientArgs!;
expect(
configuration['inputType']['name'],
'TextInputType.emailAddress',
);
final fields = (configuration['fields'] as List).cast<Map>();
expect(fields, hasLength(4));
expect(
fields.map((field) => field['autofill']['hints'].first),
containsAll(['name', 'username', 'newPassword', 'newPassword']),
);
const values = {
'name': 'Alex Example',
'email': 'alex@example.com',
'password': 'generated-example-password',
'confirmation': 'generated-example-password',
};
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
SystemChannels.textInput.name,
SystemChannels.textInput.codec.encodeMethodCall(
MethodCall('TextInputClient.updateEditingStateWithTag', [
0,
{
for (final entry in values.entries)
editor(tester, entry.key).autofillId: TextEditingValue(
text: entry.value,
).toJSON(),
},
]),
),
(_) {},
);
await tester.pumpAndSettle();
for (final entry in values.entries) {
expect(
editor(tester, entry.key).currentTextEditingValue.text,
entry.value,
);
}
// A visibility change must retain both the supplied value and its role.
await tester.tap(
find.byTooltip(
language == 'ru' ? 'Показать пароль' : 'Show password',
),
);
await tester.pump();
expect(editor(tester, 'password').widget.obscureText, isFalse);
expect(editor(tester, 'password').widget.autofillHints, [
'newPassword',
]);
expect(
editor(tester, 'password').currentTextEditingValue.text,
values['password'],
);
await tester.tap(find.text(language == 'ru' ? 'Войти' : 'Log in'));
await tester.pumpAndSettle();
await tester.showKeyboard(field('email'));
final loginFields =
(tester.testTextInput.setClientArgs!['fields'] as List).cast<Map>();
expect(loginFields, hasLength(2));
expect(loginFields.map((field) => field['autofill']['hints'].first), [
'username',
'password',
]);
expect(
editor(tester, 'password').currentTextEditingValue.text,
isEmpty,
);
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: false),
),
);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
},
);
}
testWidgets('leaving an unfinished auth form cancels saving', (tester) async {
await tester.pumpWidget(const SxchngeApp());
await tester.pumpAndSettle();
await tester.enterText(field('email'), 'alex@example.com');
await tester.enterText(field('password'), 'unfinished-password');
tester.testTextInput.log.clear();
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: false),
),
);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
});
}
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
const testDestination = CryptoDestination(
assetId: 'demo:ethereum:native',
networkId: 'eip155:1',
address: '0x1234567890123456789012345678901234567890',
assetLabel: 'ETH',
networkLabel: 'Ethereum',
);
Future<void> seedDestination(DummyDestinationService service) async {
await service.create(
testDestination,
'Test wallet',
'test-destination-creation',
);
}
Future<void> selectPayout(WidgetTester t) async {
final button = find.byKey(const ValueKey('choose-payout-destination'));
await t.ensureVisible(button);
await t.tap(button);
await t.pumpAndSettle();
final row = find.text('Test wallet');
await t.ensureVisible(row);
await t.tap(row);
await t.pumpAndSettle();
}
+245
View File
@@ -0,0 +1,245 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.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';
import 'destination_helpers.dart';
Future<void> click(WidgetTester t, Finder finder) async {
await t.ensureVisible(finder);
await t.pumpAndSettle();
await t.tap(finder);
await t.pumpAndSettle();
}
Future<DummyWalletService> open(
WidgetTester t, {
String path = '/settings/payout-methods',
String locale = 'en',
DummyDestinationService? destinations,
DummyAnalyticsService? analytics,
}) async {
t.platformDispatcher.defaultRouteNameTestValue = path;
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
final wallet = DummyWalletService(
DummyIdentityService(delay: Duration.zero),
delay: Duration.zero,
destinations: destinations,
initialLocale: locale,
settlement: Duration.zero,
);
await t.pumpWidget(
SxchngeApp(
walletService: wallet,
analyticsService: analytics,
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
),
);
await t.pumpAndSettle();
addTearDown(() => t.pumpWidget(const SizedBox.shrink()));
return wallet;
}
Future<void> cryptoForm(WidgetTester t, {bool payout = false}) async {
await click(
t,
find.text(payout ? 'Use a new destination' : 'Add payout method').first,
);
await click(t, find.byType(DropdownButtonFormField<DestinationType>));
await click(t, find.text('Crypto address').last);
await click(t, find.byKey(const ValueKey('asset-null')));
await click(t, find.text('ETH').last);
await click(t, find.byType(DropdownButtonFormField<String>).last);
await click(t, find.text('Ethereum').last);
await t.enterText(
find.byKey(const ValueKey('destination-address')),
testDestination.address,
);
await t.pump();
}
void main() {
testWidgets('Settings saves without nickname and exposes renamed method', (
t,
) async {
final wallet = await open(t);
await cryptoForm(t);
expect(find.text('Save for future payouts'), findsNothing);
await click(t, find.byKey(const ValueKey('destination-continue')));
expect((await wallet.destinations.list()).length, 1);
expect(find.textContaining('ETH · Ethereum'), findsOneWidget);
await click(t, find.textContaining('ETH · Ethereum'));
await t.enterText(
find.byKey(const ValueKey('destination-nickname')),
'My own wallet',
);
await click(t, find.text('Rename'));
expect((await wallet.destinations.list()).single.nickname, 'My own wallet');
await click(t, find.text('Remove'));
expect(find.textContaining('Submitted payouts'), findsOneWidget);
await click(t, find.widgetWithText(FilledButton, 'Remove'));
expect(await wallet.destinations.list(), isEmpty);
expect(t.takeException(), isNull);
});
testWidgets('new one-time payout keeps saving unchecked and never persists', (
t,
) async {
final wallet = await open(t, path: '/payout');
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
await click(t, find.byKey(const ValueKey('choose-payout-destination')));
await cryptoForm(t, payout: true);
expect(
t.widget<CheckboxListTile>(find.byType(CheckboxListTile)).value,
isFalse,
);
expect(find.byKey(const ValueKey('destination-nickname')), findsNothing);
await click(t, find.byKey(const ValueKey('destination-continue')));
await click(t, find.byKey(const ValueKey('transfer-review')));
expect(find.textContaining(testDestination.address), findsOneWidget);
await click(t, find.byKey(const ValueKey('transfer-confirm')));
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
expect(await wallet.destinations.list(), isEmpty);
});
testWidgets(
'optional saving failure permits explicit one-time continuation',
(t) async {
final service = DummyDestinationService();
final wallet = await open(t, path: '/payout', destinations: service);
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
await click(t, find.byKey(const ValueKey('choose-payout-destination')));
await cryptoForm(t, payout: true);
await click(t, find.text('Save for future payouts'));
service.nextSaveError = DestinationIssue.conflict;
await click(t, find.byKey(const ValueKey('destination-continue')));
expect(find.textContaining('Couldn’t save'), findsOneWidget);
await click(t, find.text('Continue without saving'));
await click(t, find.byKey(const ValueKey('transfer-review')));
await click(t, find.byKey(const ValueKey('transfer-confirm')));
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
expect(await wallet.destinations.list(), isEmpty);
},
);
testWidgets(
'saved destination is never selected automatically and removal preserves amount',
(t) async {
final service = DummyDestinationService();
await seedDestination(service);
final wallet = await open(t, path: '/payout', destinations: service);
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
expect(
t
.widget<FilledButton>(find.byKey(const ValueKey('transfer-review')))
.onPressed,
isNull,
);
await selectPayout(t);
await click(t, find.byKey(const ValueKey('transfer-review')));
final d = (await service.list()).single;
await service.remove(d.id, d.metadataRevision, d.executionRevision);
await click(t, find.byKey(const ValueKey('transfer-confirm')));
expect(find.textContaining('Payout terms changed'), findsOneWidget);
expect(find.text('5'), findsOneWidget);
expect((await t.runAsync(wallet.fetch))!.operations, isEmpty);
},
);
for (final locale in ['en', 'ru']) {
testWidgets('all method forms fit $locale at 320px with large text', (
t,
) async {
t.view.physicalSize = const Size(320, 1100);
t.view.devicePixelRatio = 1;
t.platformDispatcher.textScaleFactorTestValue = 1.6;
addTearDown(t.view.resetPhysicalSize);
addTearDown(t.view.resetDevicePixelRatio);
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
await open(t, locale: locale);
await click(
t,
find
.text(
locale == 'en' ? 'Add payout method' : 'Добавить способ вывода',
)
.first,
);
for (final name
in locale == 'en'
? ['Crypto address', 'Bank card', 'Russian bank account']
: [
'Криптоадрес',
'Банковская карта',
'Российский банковский счёт',
]) {
await click(t, find.byType(DropdownButtonFormField<DestinationType>));
await click(t, find.text(name).last);
expect(t.takeException(), isNull);
}
});
}
testWidgets('semantic save outcomes contain no destination fields', (
t,
) async {
final analytics = DummyAnalyticsService();
await open(t, analytics: analytics);
await cryptoForm(t);
await click(t, find.byKey(const ValueKey('destination-continue')));
await t.pumpAndSettle();
final events = analytics.events
.where((e) => e.kind == JourneyKind.destination)
.toList();
expect(
events.any(
(e) =>
e.step == JourneyStep.destinationSave &&
e.signal == JourneySignal.simulated,
),
isTrue,
);
expect(
events.map((e) => e.properties.toString()).join(),
isNot(contains(testDestination.address)),
);
});
testWidgets(
'binding recovery uses secure capture and preserves the saved card',
(t) async {
final service = DummyDestinationService();
final created = await service.create(
const CardDestination(
capture: 'demo-card-recover',
brand: '',
last4: '',
expiryMonth: 0,
expiryYear: 0,
),
'Own card',
'creation-key-000001',
);
await open(t, destinations: service);
await click(t, find.text('Own card'));
await click(t, find.text('Update authorization'));
expect(find.byKey(const ValueKey('destination-nickname')), findsNothing);
await click(t, find.text('Demo card •••• 4242'));
await click(t, find.byKey(const ValueKey('destination-continue')));
final saved = await service.get(created.destinationId);
expect(saved.executionRevision, 2);
expect(saved.nickname, 'Own card');
expect((await service.list()).length, 1);
expect(
(await service.assess(
saved.input,
const PayoutContext('eur'),
)).eligible,
isTrue,
);
},
);
}
+109
View File
@@ -0,0 +1,109 @@
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';
const destinationLabel =
'Saved bank ending 1234 — Personal current account for daily transfers and reimbursements';
class LiveWallet extends DummyWalletService {
LiveWallet({this.hasMethod = true})
: super(DummyIdentityService(delay: Duration.zero), delay: Duration.zero);
final bool hasMethod;
String? received;
@override
bool get isSimulated => false;
@override
Future<WalletSnapshot> fetch() async => WalletSnapshot(
accounts: [
CurrencyAccount(
id: 'real-account',
currency: 'EUR',
precision: 2,
available: 7500,
payoutMethod: hasMethod ? 'method-ref' : '',
payoutLabel: hasMethod ? destinationLabel : '',
canFund: false,
),
],
operations: [],
);
@override
Future<Quote> quote(TransferRequest request) async {
received = request.recipient;
return Quote(
id: 'quote',
request: TransferRequest(
kind: request.kind,
source: request.source,
amount: request.amount,
recipient: destinationLabel,
),
fee: 100,
debit: request.amount + 100,
credit: request.amount,
expiresAt: DateTime.now().add(const Duration(minutes: 5)),
rateNumerator: 1,
rateDenominator: 1,
);
}
}
void main() {
for (final hasMethod in [true, false]) {
testWidgets(
'backend payout ${hasMethod ? 'reviews saved destination' : 'explains missing destination'} at narrow width',
(t) async {
t.view.physicalSize = const Size(320, 1100);
t.view.devicePixelRatio = 1;
t.platformDispatcher.textScaleFactorTestValue = 1.6;
t.platformDispatcher.defaultRouteNameTestValue = '/payout';
addTearDown(t.view.resetPhysicalSize);
addTearDown(t.view.resetDevicePixelRatio);
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
final wallet = LiveWallet(hasMethod: hasMethod);
await t.pumpWidget(SxchngeApp(walletService: wallet));
await t.pumpAndSettle();
expect(find.byKey(const ValueKey('transfer-recipient')), findsNothing);
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '20');
final review = find.byKey(const ValueKey('transfer-review'));
await t.ensureVisible(review);
if (hasMethod) {
expect(find.text(destinationLabel), findsOneWidget);
final choose = find.byKey(
const ValueKey('choose-configured-destination'),
);
expect(t.widget<FilledButton>(review).onPressed, isNull);
await t.ensureVisible(choose);
await t.tap(choose);
await t.pumpAndSettle();
await t.ensureVisible(review);
await t.tap(review);
await t.pumpAndSettle();
expect(wallet.received, 'method-ref');
expect(find.text(destinationLabel), findsOneWidget);
expect(
find.byKey(const ValueKey('transfer-confirm')),
findsOneWidget,
);
} else {
expect(t.widget<FilledButton>(review).onPressed, isNull);
expect(
find.text(
'No payout destination is set up for this account. Contact support to add one.',
),
findsOneWidget,
);
}
expect(t.takeException(), isNull);
await t.pumpWidget(const SizedBox.shrink());
},
);
}
}
+61
View File
@@ -42,6 +42,67 @@ Future<GoRouter> openRoute(
} }
void main() { void main() {
for (final language in ['en', 'ru']) {
testWidgets(
'verified link keeps success visible and prefills login ($language)',
(tester) async {
tester.platformDispatcher.defaultRouteNameTestValue =
'/verify-email?token=demo-valid';
addTearDown(tester.platformDispatcher.clearDefaultRouteNameTestValue);
await tester.pumpWidget(
SxchngeApp(
localeProvider: LocaleProvider(initialLocale: Locale(language)),
verificationService: const DummyVerificationService(
delay: Duration.zero,
verifiedEmail: 'private-alias@example.com',
),
),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('verification-verified')),
findsOneWidget,
);
expect(find.byKey(const ValueKey('email')), findsNothing);
final router = GoRouter.of(
tester.element(find.byType(VerificationPage)),
);
expect(router.routeInformationProvider.value.uri.path, '/verify-email');
await tester.ensureVisible(
find.byKey(const ValueKey('verification-continue')),
);
await tester.tap(find.byKey(const ValueKey('verification-continue')));
await tester.pumpAndSettle();
final email = tester.widget<TextFormField>(
find.byKey(const ValueKey('email')),
);
expect(email.controller!.text, 'private-alias@example.com');
final password = tester.state<EditableTextState>(
find.descendant(
of: find.byKey(const ValueKey('password')),
matching: find.byType(EditableText),
),
);
expect(password.currentTextEditingValue.text, isEmpty);
expect(password.widget.focusNode.hasFocus, isTrue);
expect(router.routeInformationProvider.value.uri.toString(), '/');
final restored = router.routeInformationParser.restoreRouteInformation(
router.routerDelegate.currentConfiguration,
)!;
expect(
restored.state.toString(),
isNot(contains('private-alias@example.com')),
);
expect(restored.state.toString(), isNot(contains('demo-valid')));
await tester.enterText(
find.byKey(const ValueKey('email')),
'changed@example.com',
);
expect(email.controller!.text, 'changed@example.com');
expect(find.byKey(const ValueKey('confirmation')), findsNothing);
},
);
}
testWidgets('unknown deep link redirects to canonical login URL', ( testWidgets('unknown deep link redirects to canonical login URL', (
tester, tester,
) async { ) async {
+54 -4
View File
@@ -6,9 +6,15 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart'; import 'package:sxchnge_auth/sxchnge_auth.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/main.dart';
import 'package:sxchnge_app/providers/locale.dart'; import 'package:sxchnge_app/providers/locale.dart';
import 'destination_helpers.dart';
Future<void> tapFactor(WidgetTester t, String key) async { Future<void> tapFactor(WidgetTester t, String key) async {
final finder = find.byKey(ValueKey(key)); final finder = find.byKey(ValueKey(key));
await t.ensureVisible(finder); await t.ensureVisible(finder);
@@ -31,8 +37,16 @@ Future<DummyTwoFactorService> openFactor(
delay: Duration.zero, delay: Duration.zero,
method: method, method: method,
); );
final wallet = DummyWalletService(
DummyIdentityService(delay: Duration.zero),
factors: service,
initialLocale: locale,
delay: Duration.zero,
);
await seedDestination(wallet.destinations);
await t.pumpWidget( await t.pumpWidget(
SxchngeApp( SxchngeApp(
walletService: wallet,
twoFactorService: service, twoFactorService: service,
analyticsService: analytics, analyticsService: analytics,
localeProvider: LocaleProvider(initialLocale: Locale(locale)), localeProvider: LocaleProvider(initialLocale: Locale(locale)),
@@ -134,13 +148,38 @@ void main() {
await signIn(t); await signIn(t);
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget); expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
expect(find.byKey(const ValueKey('preview-result')), findsNothing); expect(find.byKey(const ValueKey('preview-result')), findsNothing);
expect(
t.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect( expect(
analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal), analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal),
isEmpty, isEmpty,
); );
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '000000');
await tapFactor(t, 'two-factor-confirm');
expect(find.byKey(const ValueKey('two-factor-error')), findsOneWidget);
expect(
t.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456'); await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456');
await tapFactor(t, 'two-factor-confirm'); await tapFactor(t, 'two-factor-confirm');
expect(find.byKey(const ValueKey('account-eur')), findsOneWidget); expect(find.byKey(const ValueKey('account-eur')), findsOneWidget);
expect(
t.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
);
final login = analytics.events final login = analytics.events
.where((e) => e.kind == JourneyKind.login) .where((e) => e.kind == JourneyKind.login)
.toList(); .toList();
@@ -158,6 +197,20 @@ void main() {
await signIn(t); await signIn(t);
await tapFactor(t, 'two-factor-cancel'); await tapFactor(t, 'two-factor-cancel');
expect(find.byKey(const ValueKey('email')), findsOneWidget); expect(find.byKey(const ValueKey('email')), findsOneWidget);
expect(
t.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: false),
),
);
expect(
t.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect(find.byKey(const ValueKey('preview-result')), findsNothing); expect(find.byKey(const ValueKey('preview-result')), findsNothing);
expect( expect(
t t
@@ -174,10 +227,7 @@ void main() {
await openFactor(t, path: '/$kind', enabled: true); await openFactor(t, path: '/$kind', enabled: true);
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10'); await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
if (kind == 'payout') { if (kind == 'payout') {
await t.enterText( await selectPayout(t);
find.byKey(const ValueKey('transfer-recipient')),
'Sample recipient',
);
} }
await tapFactor(t, 'transfer-review'); await tapFactor(t, 'transfer-review');
await tapFactor(t, 'transfer-confirm'); await tapFactor(t, 'transfer-confirm');
+5 -8
View File
@@ -11,6 +11,8 @@ import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'package:sxchnge_app/main.dart'; import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart'; import 'package:sxchnge_app/providers/locale.dart';
import 'destination_helpers.dart';
Future<void> tap(WidgetTester t, Finder f) async { Future<void> tap(WidgetTester t, Finder f) async {
await t.ensureVisible(f); await t.ensureVisible(f);
await t.tap(f); await t.tap(f);
@@ -32,6 +34,7 @@ Future<DummyWalletService> open(
settlement: settlement, settlement: settlement,
initialLocale: locale, initialLocale: locale,
); );
await seedDestination(service.destinations);
await t.pumpWidget( await t.pumpWidget(
SxchngeApp( SxchngeApp(
identityService: identity, identityService: identity,
@@ -78,10 +81,7 @@ void main() {
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget); expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
await tap(t, find.text('Pay out').last); await tap(t, find.text('Pay out').last);
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5'); await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
await t.enterText( await selectPayout(t);
find.byKey(const ValueKey('transfer-recipient')),
'Sample recipient',
);
await tap(t, find.byKey(const ValueKey('transfer-review'))); await tap(t, find.byKey(const ValueKey('transfer-review')));
expect(find.text('Recipient receives'), findsOneWidget); expect(find.text('Recipient receives'), findsOneWidget);
await tap(t, find.byKey(const ValueKey('transfer-confirm'))); await tap(t, find.byKey(const ValueKey('transfer-confirm')));
@@ -101,10 +101,7 @@ void main() {
); );
s.failNextOperation = true; s.failNextOperation = true;
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10'); await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
await t.enterText( await selectPayout(t);
find.byKey(const ValueKey('transfer-recipient')),
'Sample recipient',
);
await tap(t, find.byKey(const ValueKey('transfer-review'))); await tap(t, find.byKey(const ValueKey('transfer-review')));
await tap(t, find.byKey(const ValueKey('transfer-confirm'))); await tap(t, find.byKey(const ValueKey('transfer-confirm')));
// Settlement is service-owned; refresh after the simulated processing period. // Settlement is service-owned; refresh after the simulated processing period.
@@ -11,8 +11,11 @@ enum JourneyKind {
exchange, exchange,
payout, payout,
security, security,
destination,
} }
enum DestinationResolution { created, duplicate }
enum JourneyStep { enum JourneyStep {
login, login,
signup, signup,
@@ -23,6 +26,10 @@ enum JourneyStep {
kycSelfie, kycSelfie,
kycSubmission, kycSubmission,
kycStatus, kycStatus,
destinationDraft,
destinationSave,
destinationReconciliation,
destinationBinding,
transferDraft, transferDraft,
transferQuote, transferQuote,
transferSubmission, transferSubmission,
@@ -78,6 +85,7 @@ class AnalyticsEvent {
required this.sequence, required this.sequence,
required this.elapsedMs, required this.elapsedMs,
this.step, this.step,
this.resolution,
this.operation, this.operation,
this.failure, this.failure,
this.exit, this.exit,
@@ -90,6 +98,7 @@ class AnalyticsEvent {
final JourneyKind kind; final JourneyKind kind;
final JourneySignal signal; final JourneySignal signal;
final JourneyStep? step; final JourneyStep? step;
final DestinationResolution? resolution;
final JourneyFailure? failure; final JourneyFailure? failure;
final JourneyExit? exit; final JourneyExit? exit;
final int sequence; final int sequence;
@@ -105,6 +114,7 @@ class AnalyticsEvent {
JourneyKind.exchange => 'exchange', JourneyKind.exchange => 'exchange',
JourneyKind.payout => 'payout', JourneyKind.payout => 'payout',
JourneyKind.security => 'security', JourneyKind.security => 'security',
JourneyKind.destination => 'destination',
_ => 'auth', _ => 'auth',
}; };
var stage = step == null ? 'journey' : _snake(step!.name); var stage = step == null ? 'journey' : _snake(step!.name);
@@ -123,6 +133,7 @@ class AnalyticsEvent {
'terminal': terminal, 'terminal': terminal,
'simulated': simulated, 'simulated': simulated,
if (step != null) 'step': _snake(step!.name), if (step != null) 'step': _snake(step!.name),
if (resolution != null) 'destination_resolution': resolution!.name,
if (operation != null) 'operation': operation!, if (operation != null) 'operation': operation!,
if (failure != null) 'failure': _snake(failure!.name), if (failure != null) 'failure': _snake(failure!.name),
if (exit != null) 'exit': _snake(exit!.name), if (exit != null) 'exit': _snake(exit!.name),
@@ -210,6 +221,7 @@ class JourneyRecorder {
JourneyFailure? failure, JourneyFailure? failure,
bool terminal = false, bool terminal = false,
bool cancelled = false, bool cancelled = false,
DestinationResolution? resolution,
}) { }) {
final journey = operation._journey; final journey = operation._journey;
if (_disposed || operation._finished || journey.closed) return; if (_disposed || operation._finished || journey.closed) return;
@@ -227,6 +239,7 @@ class JourneyRecorder {
? JourneySignal.simulated ? JourneySignal.simulated
: JourneySignal.succeeded, : JourneySignal.succeeded,
operation: operation, operation: operation,
resolution: resolution,
failure: failure, failure: failure,
terminal: complete, terminal: complete,
simulated: simulated || (complete && journey.simulated), simulated: simulated || (complete && journey.simulated),
@@ -258,6 +271,7 @@ class JourneyRecorder {
_Journey journey, _Journey journey,
JourneySignal signal, { JourneySignal signal, {
JourneyOperation? operation, JourneyOperation? operation,
DestinationResolution? resolution,
JourneyFailure? failure, JourneyFailure? failure,
JourneyExit? exit, JourneyExit? exit,
bool terminal = false, bool terminal = false,
@@ -271,6 +285,7 @@ class JourneyRecorder {
sequence: ++journey.sequence, sequence: ++journey.sequence,
elapsedMs: journey.time.elapsedMilliseconds, elapsedMs: journey.time.elapsedMilliseconds,
step: operation?.step, step: operation?.step,
resolution: resolution,
operation: operation?.number, operation: operation?.number,
failure: failure, failure: failure,
exit: exit, exit: exit,
@@ -6,6 +6,8 @@ class BackendDTO {
(jsonDecode(body) as Map<String, dynamic>)['data'] as Map<String, dynamic>, (jsonDecode(body) as Map<String, dynamic>)['data'] as Map<String, dynamic>,
); );
final Map<String, dynamic> data; final Map<String, dynamic> data;
bool get verified => data['verified'] == true;
String? get email => data['email'] as String?;
String get error => data['error'] as String? ?? 'unavailable'; String get error => data['error'] as String? ?? 'unavailable';
String get userId => String get userId =>
data['userId'] as String? ?? data['userId'] as String? ??
@@ -6,6 +6,7 @@ class AuthDTO {
} }
class VerificationDTO { class VerificationDTO {
const VerificationDTO({required this.simulated}); const VerificationDTO({required this.simulated, this.email});
final bool simulated; final bool simulated;
final String? email;
} }
@@ -5,6 +5,13 @@ import '../models/two_factor.dart';
import '../models/verification.dart'; import '../models/verification.dart';
abstract final class BackendMapper { abstract final class BackendMapper {
static VerificationResult activation(BackendDTO dto) {
if (!dto.verified) {
throw const VerificationException(VerificationError.internal);
}
return VerificationResult(isSimulated: false, verifiedEmail: dto.email);
}
static AuthResult signup(BackendDTO dto) { static AuthResult signup(BackendDTO dto) {
if (dto.userId.isEmpty) throw const FormatException('Missing user'); if (dto.userId.isEmpty) throw const FormatException('Missing user');
return AuthResult(userId: dto.userId, isSimulated: false); return AuthResult(userId: dto.userId, isSimulated: false);
@@ -9,5 +9,5 @@ abstract final class AuthMapper {
abstract final class VerificationMapper { abstract final class VerificationMapper {
static VerificationResult fromDTO(VerificationDTO dto) => static VerificationResult fromDTO(VerificationDTO dto) =>
VerificationResult(isSimulated: dto.simulated); VerificationResult(isSimulated: dto.simulated, verifiedEmail: dto.email);
} }
@@ -17,15 +17,22 @@ class VerificationException implements Exception {
} }
class VerificationResult { class VerificationResult {
const VerificationResult({required this.isSimulated}); const VerificationResult({required this.isSimulated, this.verifiedEmail});
final bool isSimulated; final bool isSimulated;
final String? verifiedEmail;
} }
class VerificationState { class VerificationState {
const VerificationState(this.status, {this.isSimulated = false, this.error}); const VerificationState(
this.status, {
this.isSimulated = false,
this.error,
this.result,
});
final VerificationStatus status; final VerificationStatus status;
final VerificationError? error; final VerificationError? error;
final bool isSimulated; final bool isSimulated;
final VerificationResult? result;
bool get isLoading => bool get isLoading =>
status == VerificationStatus.sending || status == VerificationStatus.sending ||
status == VerificationStatus.checking; status == VerificationStatus.checking;
@@ -35,7 +35,11 @@ class VerificationProvider extends ChangeNotifier {
try { try {
final result = await operation(); final result = await operation();
if (_disposed || generation != _generation) return; if (_disposed || generation != _generation) return;
_state = VerificationState(success, isSimulated: result.isSimulated); _state = VerificationState(
success,
isSimulated: result.isSimulated,
result: success == VerificationStatus.verified ? result : null,
);
} catch (error) { } catch (error) {
if (_disposed || generation != _generation) return; if (_disposed || generation != _generation) return;
final code = error is VerificationException final code = error is VerificationException
@@ -172,8 +172,10 @@ class BackendAuthServices
@override @override
Future<VerificationResult> confirm({required String token}) async { Future<VerificationResult> confirm({required String token}) async {
await _post('verification/confirm', {'token': token}, verification: true); final dto = await _post('verification/confirm', {
return VerificationMapper.fromDTO(const VerificationDTO(simulated: false)); 'token': token,
}, verification: true);
return BackendMapper.activation(dto);
} }
@override @override
@@ -19,11 +19,13 @@ class DummyVerificationService implements VerificationService, SimulationInfo {
const DummyVerificationService({ const DummyVerificationService({
this.delay = const Duration(milliseconds: 600), this.delay = const Duration(milliseconds: 600),
this.sendFailure, this.sendFailure,
this.verifiedEmail = 'alex@example.com',
}); });
@override @override
bool get isSimulated => true; bool get isSimulated => true;
final Duration delay; final Duration delay;
final VerificationError? sendFailure; final VerificationError? sendFailure;
final String verifiedEmail;
@override @override
Future<VerificationResult> send({ Future<VerificationResult> send({
@@ -41,7 +43,7 @@ class DummyVerificationService implements VerificationService, SimulationInfo {
switch (token) { switch (token) {
case 'demo-valid': case 'demo-valid':
return VerificationMapper.fromDTO( return VerificationMapper.fromDTO(
const VerificationDTO(simulated: true), VerificationDTO(simulated: true, email: verifiedEmail),
); );
case 'demo-expired': case 'demo-expired':
throw const VerificationException(VerificationError.expired); throw const VerificationException(VerificationError.expired);
@@ -10,6 +10,48 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
void main() { void main() {
http.Response response(Map<String, Object?> data, [int status = 202]) => http.Response response(Map<String, Object?> data, [int status = 202]) =>
http.Response(jsonEncode({'data': data}), status); http.Response(jsonEncode({'data': data}), status);
test(
'activation maps only a confirmed response to the verified email',
() async {
var confirmed = true;
final service = BackendAuthServices(
baseUri: Uri.parse('https://api.example/api/v1'),
clientId: 'web',
client: MockClient((request) async {
expect(request.url.path, '/api/v1/accounts/verification/confirm');
expect(jsonDecode(request.body), {'token': 'opaque-link'});
return response({
'verified': confirmed,
'email': 'alias@example.com',
});
}),
);
addTearDown(service.close);
final accepted = await service.confirm(token: 'opaque-link');
expect(accepted.verifiedEmail, 'alias@example.com');
expect(accepted.isSimulated, isFalse);
confirmed = false;
await expectLater(
service.confirm(token: 'opaque-link'),
throwsA(isA<VerificationException>()),
);
},
);
test(
'activation remains compatible with a verified response without email',
() async {
final service = BackendAuthServices(
baseUri: Uri.parse('https://api.example/api/v1'),
clientId: 'web',
client: MockClient((_) async => response({'verified': true})),
);
addTearDown(service.close);
expect(
(await service.confirm(token: 'opaque-link')).verifiedEmail,
isNull,
);
},
);
test( test(
'signup distinguishes support recovery and keeps the retry key', 'signup distinguishes support recovery and keeps the retry key',
() async { () async {
@@ -42,6 +42,12 @@ void main() {
addTearDown(provider.dispose); addTearDown(provider.dispose);
await provider.confirm(scenario.key); await provider.confirm(scenario.key);
expect(provider.state.status, scenario.value); expect(provider.state.status, scenario.value);
expect(
provider.state.result?.verifiedEmail,
scenario.value == VerificationStatus.verified
? 'alex@example.com'
: null,
);
expect( expect(
provider.state.isSimulated, provider.state.isSimulated,
scenario.value == VerificationStatus.verified, scenario.value == VerificationStatus.verified,
@@ -75,7 +81,23 @@ void main() {
service.pending.complete(const VerificationResult(isSimulated: true)); service.pending.complete(const VerificationResult(isSimulated: true));
await operation; await operation;
expect(provider.state.status, VerificationStatus.idle); expect(provider.state.status, VerificationStatus.idle);
expect(provider.state.result, isNull);
}); });
test(
'a later failed confirmation clears the previous verified identity',
() async {
final provider = VerificationProvider(
const DummyVerificationService(delay: Duration.zero),
);
addTearDown(provider.dispose);
await provider.confirm('demo-valid');
expect(provider.state.result?.verifiedEmail, 'alex@example.com');
final operation = provider.confirm('bad');
expect(provider.state.result, isNull);
await operation;
expect(provider.state.result, isNull);
},
);
test('dispose discards late exception', () async { test('dispose discards late exception', () async {
final service = PendingService(); final service = PendingService();
final provider = VerificationProvider(service); final provider = VerificationProvider(service);
+17 -2
View File
@@ -32,8 +32,9 @@ wallet state changes. The adapter reuses the KYC package's limit DTO and mapper.
## Integration boundary ## Integration boundary
Replace `WalletService` and `LimitsService` together with authenticated backend Backend mode now uses `BackendWalletService` and `WalletFactorService` with the
adapters. The backend must provide authorization, authoritative quotes/balances, authenticated BFF; `BackendLimitsService` supplies the reviewed platform projection.
See [payment provisioning and rollout](../../../docs/payments.md). The backend must provide authorization, authoritative quotes/balances,
atomic limits enforcement, durable idempotency and operation reconciliation. atomic limits enforcement, durable idempotency and operation reconciliation.
Add supported payment methods and validated recipients from that contract, rather Add supported payment methods and validated recipients from that contract, rather
than interpreting the demo input as production bank validation. Replace profile than interpreting the demo input as production bank validation. Replace profile
@@ -46,3 +47,17 @@ once on success/failure. Closing the app while pending does not count as draft
abandonment. No amounts, account IDs or recipient data enter events. All demo abandonment. No amounts, account IDs or recipient data enter events. All demo
signals are simulated. Cross-session outcomes require backend event delivery; signals are simulated. Cross-session outcomes require backend event delivery;
client-only analytics are not production accounting or conversion evidence. client-only analytics are not production accounting or conversion evidence.
## Saved destinations — #54
The injectable `DestinationService` adds canonical crypto, secure-card capture and
Russian bank models, saved/one-time input, independent metadata/execution revisions,
stable created/duplicate outcomes, contextual eligibility and management providers.
`DummyDestinationService` is customer-scoped and process-local. It has isolated
fixtures for directory/capture/recovery states, not live banking or card collection.
The demo wallet binds immutable destination snapshots to quotes and rechecks at its
synchronous acceptance boundary. Production must enforce those checks atomically
in the backend. Live mode currently advertises no new destination capabilities and
preserves explicit selection of the already provisioned wallet method. It never
uses a demo store. See [the UX/service contract](../../../docs/ux/payout-destinations.md)
for integration, security, retention, localization and acceptance requirements.
@@ -0,0 +1,49 @@
/// Local, explicit-label paste parsing. Raw pasted text is never retained.
class BankPaste {
BankPaste._(this.values, this.conflicts);
final Map<String, String> values;
final Set<String> conflicts;
static BankPaste parse(String text) {
final values = <String, String>{};
final conflicts = <String>{};
const labels = {
'бик': 'bik',
'bik': 'bik',
'счет': 'account',
'счёт': 'account',
'номер счета': 'account',
'номер счёта': 'account',
'account': 'account',
'account number': 'account',
'имя': 'firstName',
'first name': 'firstName',
'фамилия': 'lastName',
'last name': 'lastName',
'отчество': 'patronymic',
'patronymic': 'patronymic',
'получатель': 'holder',
'фио': 'holder',
'account holder': 'holder',
'банк': 'bank',
'bank': 'bank',
'корреспондентский счет': 'correspondent',
'корреспондентский счёт': 'correspondent',
'correspondent account': 'correspondent',
};
for (final line in text.split(RegExp(r'[\r\n]+'))) {
final match = RegExp(r'^\s*([^:=]+)\s*[:=]\s*(.+?)\s*$').firstMatch(line);
if (match == null) continue;
final key = labels[match[1]!.trim().toLowerCase()];
if (key == null) continue;
var value = match[2]!.trim();
if (['bik', 'account', 'correspondent'].contains(key))
value = value.replaceAll(RegExp(r'\s'), '');
if (values.containsKey(key) && values[key] != value) conflicts.add(key);
values[key] = value;
}
for (final key in conflicts) {
values.remove(key);
}
return BankPaste._(Map.unmodifiable(values), Set.unmodifiable(conflicts));
}
}
@@ -0,0 +1,614 @@
import 'dart:convert';
import 'key.dart';
import 'models.dart';
import 'service.dart';
import 'transport.dart';
/// Shared only by injected demo sessions. Never production persistence.
class DestinationMemory {
final records = <String, Map<String, SavedDestination>>{};
final attempts = <String, Map<String, (String, CreationResult)>>{};
final audit = <DestinationAudit>[];
final recoveries = <String, Map<String, (String, String)>>{};
final fingerprints = <String, Map<String, String?>>{};
}
/// Service-authoritative simulation. No raw card capture and no live rails.
class DummyDestinationService implements DestinationService {
DummyDestinationService({
this.customer = 'demo-customer',
DestinationMemory? memory,
DateTime Function()? now,
}) : memory = memory ?? DestinationMemory(),
now = now ?? DateTime.now;
final String customer;
final DestinationMemory memory;
final DateTime Function() now;
final availability = <String, PayoutEligibility>{};
int requirementsRevision = 1;
bool loseNextSaveResponse = false;
bool reusableCards = true, cardRecovery = true;
DestinationIssue? nextSaveError;
bool directoryUnavailable = false;
List<Bank> directory = [
const Bank(
bik: '044525225',
name: 'Demo Bank',
city: 'Moscow',
correspondent: '30101810400000000225',
revision: 'demo-1',
),
];
static const assets = [
CryptoAsset('demo:ethereum:native', 'ETH', 'eip155:1', 'Ethereum'),
CryptoAsset(
'demo:ethereum:usdc',
'USDC · Ethereum · demo token A',
'eip155:1',
'Ethereum',
),
CryptoAsset(
'demo:ethereum:usdc-alt',
'USDC · Ethereum · demo token B',
'eip155:1',
'Ethereum',
),
CryptoAsset('demo:polygon:usdc', 'USDC · Polygon', 'eip155:137', 'Polygon'),
CryptoAsset(
'demo:xrp:native',
'XRP',
'xrpl:mainnet',
'XRP Ledger',
memoRequired: true,
memoSupported: true,
),
];
Map<String, SavedDestination> get _records =>
memory.records.putIfAbsent(customer, () => {});
Map<String, (String, CreationResult)> get _attempts =>
memory.attempts.putIfAbsent(customer, () => {});
@override
bool get isSimulated => true;
@override
Future<DestinationCapabilities> capabilities() async =>
DestinationCapabilities(
types: DestinationType.values,
assets: assets,
reusableCards: true,
cardRecovery: true,
simulated: true,
);
@override
Future<List<SavedDestination>> list() async =>
_records.values.where((d) => !d.removed).toList();
SavedDestination _get(String id) {
final d = _records[id];
if (d == null) throw const DestinationException(DestinationIssue.removed);
return d;
}
@override
Future<SavedDestination> get(String id) async => _get(id);
void _audit(String reference, String action, String outcome) => memory.audit
.add(DestinationAudit(customer, reference, action, outcome, now()));
DestinationDetails _validate(DestinationDetails details) {
switch (details) {
case CryptoDestination d:
final asset = assets
.where((a) => a.id == d.assetId && a.networkId == d.networkId)
.firstOrNull;
if (asset == null)
throw const DestinationException(
DestinationIssue.invalid,
field: 'asset',
);
final address = d.address.trim();
final valid = asset.networkId.startsWith('eip155:')
? RegExp(r'^0x[0-9a-fA-F]{40}$').hasMatch(address)
: RegExp(r'^r[1-9A-HJ-NP-Za-km-z]{24,34}$').hasMatch(address);
if (!valid)
throw const DestinationException(
DestinationIssue.invalid,
field: 'address',
);
if (asset.memoRequired && !RegExp(r'^\d{1,10}$').hasMatch(d.memo))
throw const DestinationException(
DestinationIssue.invalid,
field: 'memo',
);
if (!asset.memoSupported && d.memo.isNotEmpty)
throw const DestinationException(
DestinationIssue.invalid,
field: 'memo',
);
return CryptoDestination(
assetId: asset.id,
networkId: asset.networkId,
address: address,
memo: d.memo,
assetLabel: asset.label,
networkLabel: asset.networkLabel,
);
case RussianBankDestination d:
final account = d.account.replaceAll(RegExp(r'\s'), '');
if (d.firstName.trim().isEmpty || d.lastName.trim().isEmpty)
throw const DestinationException(
DestinationIssue.invalid,
field: 'holder',
);
if (!RegExp(r'^\d{9}$').hasMatch(d.bank.bik) ||
!validBankAccount(account, d.bank.bik))
throw const DestinationException(
DestinationIssue.invalid,
field: 'account',
);
if (directoryUnavailable)
throw const DestinationException(
DestinationIssue.unavailable,
field: 'bank',
);
final bank = directory.where((b) => b.bik == d.bank.bik).firstOrNull;
if (bank == null)
throw const DestinationException(
DestinationIssue.invalid,
field: 'bank',
);
if (bank.correspondent != d.bank.correspondent)
throw const DestinationException(
DestinationIssue.bankChanged,
field: 'bank',
);
// Directory display values are authoritative; identity is never guessed.
return RussianBankDestination(
firstName: d.firstName.trim(),
lastName: d.lastName.trim(),
patronymic: d.patronymic.trim(),
bank: bank,
account: account,
);
case CardDestination d:
if (![
'demo-card-ready',
'demo-card-recover',
'demo-card-unknown',
].contains(d.capture))
throw const DestinationException(
DestinationIssue.invalid,
field: 'card',
);
// These are secure-collection fixtures, not user-entered card metadata.
return CardDestination(
capture: d.capture,
brand: 'Demo card',
last4: d.capture == 'demo-card-unknown' ? '0027' : '4242',
expiryMonth: 12,
expiryYear: now().year + 2,
);
}
}
@override
Future<DestinationValidation> validate(DestinationDetails details) async =>
DestinationValidation(DestinationMapper.clone(_validate(details)));
String? _identity(DestinationDetails details) => switch (details) {
CryptoDestination d => jsonEncode([
d.type.name,
d.assetId,
d.networkId,
d.networkId.startsWith('eip155:') ? d.address.toLowerCase() : d.address,
d.memo,
]),
RussianBankDestination d => jsonEncode([
d.type.name,
d.bank.bik,
d.account,
d.firstName,
d.lastName,
d.patronymic,
]),
CardDestination d =>
d.capture.isEmpty
? (memory.fingerprints[customer] ?? <String, String?>{})[d.identity]
: d.capture == 'demo-card-unknown'
? null
: 'demo-card-fingerprint:4242',
};
DestinationDetails _resolve(DestinationInput input) {
if (input is OneTimeDestination) return _validate(input.details);
final ref = input as SavedReference;
final d = _get(ref.id);
if (d.removed) throw const DestinationException(DestinationIssue.removed);
if (d.executionRevision != ref.executionRevision)
throw const DestinationException(DestinationIssue.conflict);
return d.details;
}
PayoutEligibility _assess(DestinationInput input, PayoutContext context) {
final d = _resolve(input);
if (input is SavedReference) {
final saved = _get(input.id);
if (saved.ownership.actionRequired)
return const PayoutEligibility(
EligibilityState.requiresAction,
reason: DestinationIssue.authorization,
);
final override = availability[input.id];
if (override != null) return override;
if (d is CardDestination) {
if (d.expiryYear < now().year ||
d.expiryYear == now().year && d.expiryMonth < now().month)
return const PayoutEligibility(
EligibilityState.requiresAction,
reason: DestinationIssue.expired,
);
final ready = saved.bindings.any(
(b) =>
b.reusable &&
b.state == BindingState.ready &&
(b.expiresAt == null || now().isBefore(b.expiresAt!)),
);
if (!ready) {
final recoverable =
cardRecovery && saved.bindings.any((b) => b.recoverable);
final temporary = saved.bindings.any(
(b) => b.state == BindingState.unavailable,
);
return PayoutEligibility(
recoverable
? EligibilityState.requiresAction
: temporary
? EligibilityState.temporarilyUnavailable
: EligibilityState.unsupported,
reason: recoverable
? DestinationIssue.authorization
: temporary
? DestinationIssue.unavailable
: DestinationIssue.unsupported,
);
}
}
}
if (context.source.isNotEmpty &&
!['eur', 'usd', 'gbp'].contains(context.source))
return const PayoutEligibility(
EligibilityState.unsupported,
reason: DestinationIssue.unsupported,
);
if (d is RussianBankDestination) {
if (directoryUnavailable)
return const PayoutEligibility(
EligibilityState.temporarilyUnavailable,
reason: DestinationIssue.unavailable,
);
final current = directory.where((b) => b.bik == d.bank.bik).firstOrNull;
if (current == null || !current.supported)
return const PayoutEligibility(
EligibilityState.unsupported,
reason: DestinationIssue.unsupported,
);
if (current.correspondent != d.bank.correspondent)
return const PayoutEligibility(
EligibilityState.requiresAction,
reason: DestinationIssue.bankChanged,
);
}
if (d is CardDestination && d.capture == 'demo-card-recover')
return const PayoutEligibility(
EligibilityState.requiresAction,
reason: DestinationIssue.authorization,
);
return PayoutEligibility(
EligibilityState.eligible,
canSave: d is! CardDestination || reusableCards || cardRecovery,
);
}
@override
Future<PayoutEligibility> assess(
DestinationInput input,
PayoutContext context,
) async => _assess(input, context);
@override
Future<CreationResult> create(
DestinationDetails details,
String nickname,
String idempotencyKey, {
String? replaces,
}) async {
if (idempotencyKey.length < 16 || nickname.length > 80)
throw const DestinationException(DestinationIssue.invalid);
final payload = jsonEncode([
DestinationMapper.details(details).value,
nickname,
replaces,
]);
final previous = _attempts[idempotencyKey];
if (previous != null) {
if (previous.$1 != payload)
throw const DestinationException(DestinationIssue.conflict);
return previous.$2;
}
final error = nextSaveError;
nextSaveError = null;
if (error != null) {
_audit(idempotencyKey, 'create', 'failed');
throw DestinationException(error);
}
if (replaces != null && _get(replaces).removed)
throw const DestinationException(DestinationIssue.removed);
final normalized = _validate(details);
if (normalized is CardDestination && !reusableCards && !cardRecovery)
throw const DestinationException(DestinationIssue.unsupported);
final identity = _identity(normalized);
final existing = identity == null
? null
: _records.values
.where((r) => !r.removed && _identity(r.details) == identity)
.firstOrNull;
final result = CreationResult(
existing == null ? SaveOutcome.created : SaveOutcome.duplicate,
existing?.id ?? destinationKey(),
);
// Synchronous mutation is the demo's linearization boundary; no await gap.
if (existing == null) {
_records[result.destinationId] = SavedDestination(
id: result.destinationId,
details: normalized is CardDestination
? CardDestination(
identity: result.destinationId,
brand: normalized.brand,
last4: normalized.last4,
expiryMonth: normalized.expiryMonth,
expiryYear: normalized.expiryYear,
)
: DestinationMapper.clone(normalized),
nickname: nickname.trim(),
bindings: normalized is CardDestination
? [
ProviderBinding(
reference: destinationKey(),
state:
!reusableCards ||
normalized.capture == 'demo-card-recover'
? BindingState.expired
: BindingState.ready,
reusable: reusableCards,
recoverable: cardRecovery,
),
]
: [],
);
}
if (existing == null && normalized is CardDestination)
memory.fingerprints.putIfAbsent(
customer,
() => {},
)[result.destinationId] = identity;
_attempts[idempotencyKey] = (payload, result);
_audit(
result.destinationId,
replaces == null ? 'create' : 'replacement',
result.outcome.name,
);
if (loseNextSaveResponse) {
loseNextSaveResponse = false;
throw const DestinationException(DestinationIssue.uncertain);
}
return result;
}
@override
Future<CreationResult?> creation(String idempotencyKey) async =>
_attempts[idempotencyKey]?.$2;
@override
Future<SavedDestination> rename(
String id,
String nickname,
int metadataRevision,
) async {
final d = _get(id);
if (d.removed) throw const DestinationException(DestinationIssue.removed);
if (d.metadataRevision != metadataRevision) {
_audit(id, 'rename', 'conflict');
throw const DestinationException(DestinationIssue.conflict);
}
if (nickname.length > 80)
throw const DestinationException(
DestinationIssue.invalid,
field: 'nickname',
);
final updated = SavedDestination(
id: d.id,
details: d.details,
nickname: nickname.trim(),
metadataRevision: d.metadataRevision + 1,
executionRevision: d.executionRevision,
bindings: d.bindings,
lastUsed: d.lastUsed,
ownership: d.ownership,
);
_records[id] = updated;
_audit(id, 'rename', 'succeeded');
return updated;
}
@override
Future<void> remove(
String id,
int metadataRevision,
int executionRevision,
) async {
final d = _get(id);
if (d.removed) return;
if (d.metadataRevision != metadataRevision ||
d.executionRevision != executionRevision) {
_audit(id, 'remove', 'conflict');
throw const DestinationException(DestinationIssue.conflict);
}
_records[id] = SavedDestination(
id: d.id,
details: d.details,
nickname: d.nickname,
metadataRevision: d.metadataRevision + 1,
executionRevision: d.executionRevision + 1,
removed: true,
bindings: d.bindings,
lastUsed: d.lastUsed,
ownership: d.ownership,
);
_audit(id, 'remove', 'succeeded');
}
@override
Future<List<Bank>> banks(String query, {Future<void>? abortTrigger}) async {
if (directoryUnavailable)
throw const DestinationException(DestinationIssue.unavailable);
final value = query.trim().toLowerCase();
if (RegExp(r'^\d+$').hasMatch(value) && value.length > 9)
throw const DestinationException(DestinationIssue.invalid, field: 'bik');
if (value.length < 2) return [];
return directory
.where(
(b) => value.length == 9 && RegExp(r'^\d+$').hasMatch(value)
? b.bik == value
: b.name.toLowerCase().contains(value) || b.bik.contains(value),
)
.toList();
}
DestinationDetails _snapshot(DestinationDetails details) =>
details is CardDestination
? CardDestination(
identity: details.identity,
brand: details.brand,
last4: details.last4,
expiryMonth: details.expiryMonth,
expiryYear: details.expiryYear,
)
: DestinationMapper.clone(details);
@override
Future<DestinationLease> prepare(
DestinationInput input,
PayoutContext context,
) async {
final eligibility = _assess(input, context);
if (!eligibility.eligible)
throw DestinationException(
eligibility.reason ?? DestinationIssue.unsupported,
);
return DestinationLease(
input: input,
snapshot: _snapshot(_resolve(input)),
context: context,
requirementsRevision: requirementsRevision,
expiresAt: now().add(const Duration(seconds: 60)),
);
}
/// Synchronous demo acceptance boundary; production enforcement belongs in BFF.
void accept(DestinationLease lease) {
if (!now().isBefore(lease.expiresAt))
throw const DestinationException(DestinationIssue.expired);
if (requirementsRevision != lease.requirementsRevision)
throw const DestinationException(DestinationIssue.conflict);
final result = _assess(lease.input, lease.context);
if (!result.eligible)
throw DestinationException(result.reason ?? DestinationIssue.unsupported);
if (lease.input case SavedReference ref) {
final d = _get(ref.id);
_records[d.id] = SavedDestination(
id: d.id,
details: d.details,
nickname: d.nickname,
metadataRevision: d.metadataRevision,
executionRevision: d.executionRevision,
bindings: d.bindings,
lastUsed: now(),
ownership: d.ownership,
);
}
}
@override
Future<SavedDestination> restoreCard(
String id,
int executionRevision,
CardDestination capture,
String idempotencyKey,
) async {
if (idempotencyKey.length < 16)
throw const DestinationException(DestinationIssue.invalid);
final payload = jsonEncode([
id,
executionRevision,
DestinationMapper.details(capture).value,
]);
final attempts = memory.recoveries.putIfAbsent(customer, () => {});
final previous = attempts[idempotencyKey];
if (previous != null) {
if (previous.$1 != payload)
throw const DestinationException(DestinationIssue.conflict);
return _get(previous.$2);
}
final d = _get(id);
if (d.removed) throw const DestinationException(DestinationIssue.removed);
if (d.executionRevision != executionRevision)
throw const DestinationException(DestinationIssue.conflict);
if (!cardRecovery || d.details is! CardDestination)
throw const DestinationException(DestinationIssue.unsupported);
final normalized = _validate(capture) as CardDestination;
final fingerprint = _identity(normalized);
if (fingerprint == null || fingerprint != _identity(d.details))
throw const DestinationException(DestinationIssue.replacementRequired);
if (capture.capture != 'demo-card-ready')
throw const DestinationException(DestinationIssue.authorization);
final updated = SavedDestination(
id: d.id,
details: CardDestination(
identity: (d.details as CardDestination).identity,
brand: normalized.brand,
last4: normalized.last4,
expiryMonth: normalized.expiryMonth,
expiryYear: normalized.expiryYear,
),
nickname: d.nickname,
metadataRevision: d.metadataRevision,
executionRevision: d.executionRevision + 1,
lastUsed: d.lastUsed,
ownership: d.ownership,
bindings: [
ProviderBinding(
reference: destinationKey(),
state: BindingState.ready,
reusable: true,
recoverable: true,
),
],
);
_records[id] = updated;
attempts[idempotencyKey] = (payload, id);
_audit(id, 'bindingRestored', 'succeeded');
return updated;
}
@override
void clearTransient() {
/* No transient server records survive method calls. */
}
}
bool validBankAccount(String account, String bik) {
if (!RegExp(r'^\d{20}$').hasMatch(account) ||
!RegExp(r'^\d{9}$').hasMatch(bik))
return false;
final digits = bik.substring(6) + account;
var sum = 0;
for (var i = 0; i < digits.length; i++) {
sum += int.parse(digits[i]) * [7, 1, 3][i % 3];
}
return sum % 10 == 0;
}
@@ -0,0 +1,276 @@
import 'package:flutter/foundation.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'key.dart';
import 'models.dart';
import 'service.dart';
class DestinationFormProvider extends ChangeNotifier {
DestinationFormProvider(
this.service,
this.analytics, {
this.context,
this.initial,
this.replacing,
this.restoring,
}) : recorder = JourneyRecorder(analytics),
details = initial {
if (context == null) _startJourney();
}
final DestinationService service;
final AnalyticsService analytics;
final JourneyRecorder recorder;
final PayoutContext? context;
final DestinationDetails? initial;
final String? replacing;
final SavedReference? restoring;
DestinationDetails? details;
String nickname = '';
bool save = false, busy = false, _disposed = false, uncertain = false;
DestinationIssue? error;
String? errorField;
CreationResult? saved;
PayoutEligibility? eligibility;
DestinationInput? result;
String? _key;
bool _creationStarted = false;
bool _journeyStarted = false;
void _startJourney() {
if (_journeyStarted) return;
_journeyStarted = true;
final op = recorder.begin(
JourneyKind.destination,
JourneyStep.destinationDraft,
simulated: service.isSimulated,
);
recorder.finish(op, simulated: service.isSimulated);
}
bool savingFailed = false;
bool get hasDraft => details != null || nickname.isNotEmpty;
void change(DestinationDetails? value) {
if (busy || uncertain) return;
details = value;
error = null;
errorField = null;
eligibility = null;
result = null;
saved = null;
_key = null;
_creationStarted = false;
notifyListeners();
}
void preferences({bool? save, String? nickname}) {
if (busy || uncertain) return;
if (save == true) _startJourney();
if (save == false && this.save) {
recorder.abandon(JourneyExit.modeChange);
_journeyStarted = false;
}
this.save = save ?? this.save;
this.nickname = nickname ?? this.nickname;
_key = null;
saved = null;
error = null;
notifyListeners();
}
Future<void> proceed({bool withoutSaving = false}) async {
if (busy || details == null) return;
if (withoutSaving && !uncertain) recorder.abandon(JourneyExit.modeChange);
if (uncertain && !withoutSaving) {
await reconcile();
return;
}
busy = true;
error = null;
errorField = null;
savingFailed = false;
if (!uncertain) _creationStarted = false;
notifyListeners();
try {
// Validation precedes both persistence and payout assessment.
final normalized = (await service.validate(details!)).details;
if (_disposed) return;
if (restoring != null) {
_key ??= destinationKey();
final bindingOperation = recorder.begin(
JourneyKind.destination,
JourneyStep.destinationBinding,
simulated: service.isSimulated,
);
final restored = await service.restoreCard(
restoring!.id,
restoring!.executionRevision,
normalized as CardDestination,
_key!,
);
recorder.finish(
bindingOperation,
terminal: true,
simulated: service.isSimulated,
);
if (!_disposed) result = restored.input;
} else if (context != null) {
eligibility = await service.assess(
OneTimeDestination(normalized),
context!,
);
if (_disposed) return;
}
if (restoring == null &&
(context == null || save) &&
!withoutSaving &&
saved == null) {
if (eligibility?.canSave == false)
throw const DestinationException(DestinationIssue.unsupported);
_key ??= destinationKey();
final op = recorder.begin(
JourneyKind.destination,
JourneyStep.destinationSave,
simulated: service.isSimulated,
);
_creationStarted = true;
try {
saved = await service.create(
normalized,
nickname,
_key!,
replaces: replacing,
);
_creationStarted = false;
if (_disposed) return;
recorder.finish(
op,
simulated: service.isSimulated,
terminal: true,
resolution: DestinationResolution.values.byName(
saved!.outcome.name,
),
);
} catch (e) {
savingFailed = true;
recorder.finish(
op,
failure: JourneyFailure.unavailable,
simulated: service.isSimulated,
);
rethrow;
}
}
if (restoring != null) {
// Restored bindings never create or rename a saved destination.
} else if (context == null) {
if (saved?.outcome == SaveOutcome.created)
result = SavedReference(
saved!.destinationId,
(await service.get(saved!.destinationId)).executionRevision,
);
} else if (eligibility!.eligible) {
if (withoutSaving || saved == null) {
result = OneTimeDestination(normalized);
} else if (saved!.outcome == SaveOutcome.created) {
final d = await service.get(saved!.destinationId);
result = d.input;
}
// Duplicates need explicit Use existing method, never another create.
}
} on DestinationException catch (e) {
error = e.issue;
errorField = e.field;
uncertain =
_creationStarted &&
[
DestinationIssue.uncertain,
DestinationIssue.unavailable,
].contains(e.issue);
} catch (_) {
error = DestinationIssue.unavailable;
uncertain = _creationStarted;
}
if (_disposed) return;
busy = false;
notifyListeners();
}
Future<void> useExisting() async {
if (busy || saved == null) return;
error = null;
busy = true;
notifyListeners();
try {
final d = await service.get(saved!.destinationId);
if (d.removed) throw const DestinationException(DestinationIssue.removed);
if (context != null)
eligibility = await service.assess(d.input, context!);
if (context == null || eligibility!.eligible) result = d.input;
} on DestinationException catch (e) {
error = e.issue;
} catch (_) {
error = DestinationIssue.unavailable;
}
if (_disposed) return;
busy = false;
notifyListeners();
}
Future<void> reconcile() async {
if (busy || _key == null) return;
busy = true;
notifyListeners();
final op = recorder.begin(
JourneyKind.destination,
JourneyStep.destinationReconciliation,
simulated: service.isSimulated,
);
try {
final outcome = await service.creation(_key!);
if (_disposed) return;
saved = outcome;
recorder.finish(
op,
simulated: service.isSimulated,
terminal: outcome != null,
resolution: outcome == null
? null
: DestinationResolution.values.byName(outcome.outcome.name),
);
uncertain = false;
_creationStarted = false;
error = null;
// Absence is an authoritative lookup result, not a timeout inference.
if (outcome?.outcome == SaveOutcome.created && context == null) {
final d = await service.get(outcome!.destinationId);
if (d.removed)
throw const DestinationException(DestinationIssue.removed);
result = d.input;
}
} on DestinationException catch (e) {
recorder.finish(
op,
simulated: service.isSimulated,
failure: JourneyFailure.unavailable,
);
error = e.issue;
} catch (_) {
recorder.finish(
op,
simulated: service.isSimulated,
failure: JourneyFailure.unavailable,
);
error = DestinationIssue.unavailable;
}
if (_disposed) return;
busy = false;
notifyListeners();
}
@override
void dispose() {
_disposed = true;
recorder.dispose(abandonActive: !uncertain);
super.dispose();
}
}
@@ -0,0 +1,7 @@
import 'dart:math';
/// An opaque attempt/record reference, never derived from destination details.
String destinationKey() => List.generate(
16,
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
@@ -0,0 +1,53 @@
import 'package:flutter/foundation.dart';
import 'models.dart';
import 'service.dart';
class DestinationManagementProvider extends ChangeNotifier {
DestinationManagementProvider(this.service, this.destination);
final DestinationService service;
SavedDestination destination;
DestinationIssue? error;
bool busy = false, removed = false, _disposed = false;
Future<void> rename(String nickname) => _run(() async {
destination = await service.rename(
destination.id,
nickname,
destination.metadataRevision,
);
});
Future<void> remove() => _run(() async {
await service.remove(
destination.id,
destination.metadataRevision,
destination.executionRevision,
);
removed = true;
});
Future<void> refresh() => _run(() async {
destination = await service.get(destination.id);
removed = destination.removed;
});
Future<void> _run(Future<void> Function() action) async {
if (busy) return;
busy = true;
error = null;
notifyListeners();
try {
await action();
} on DestinationException catch (e) {
error = e.issue;
} catch (_) {
error = DestinationIssue.unavailable;
}
if (_disposed) return;
busy = false;
notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
@@ -0,0 +1,255 @@
enum DestinationType { crypto, card, russianBank }
enum EligibilityState {
eligible,
temporarilyUnavailable,
requiresAction,
unsupported,
}
enum DestinationIssue {
invalid,
unavailable,
uncertain,
conflict,
removed,
expired,
bankChanged,
authorization,
unsupported,
duplicate,
replacementRequired,
}
enum SaveOutcome { created, duplicate }
enum BindingState { ready, expired, revoked, unavailable }
class DestinationException implements Exception {
const DestinationException(this.issue, {this.field});
final DestinationIssue issue;
final String? field;
}
sealed class DestinationDetails {
const DestinationDetails();
DestinationType get type;
String get display;
String get review;
}
class CryptoDestination extends DestinationDetails {
const CryptoDestination({
required this.assetId,
required this.networkId,
required this.address,
this.memo = '',
required this.assetLabel,
required this.networkLabel,
});
final String assetId, networkId, address, memo, assetLabel, networkLabel;
@override
DestinationType get type => DestinationType.crypto;
@override
String get display =>
'$assetLabel · $networkLabel · ${address.length > 14 ? '${address.substring(0, 6)}…${address.substring(address.length - 6)}' : address}';
@override
String get review =>
'$assetLabel · $networkLabel\n$address${memo.isEmpty ? '' : '\n$memo'}';
}
/// Secure capture references are short lived, not PAN/CVV or destination IDs.
class CardDestination extends DestinationDetails {
const CardDestination({
this.capture = '',
this.identity = '',
required this.brand,
required this.last4,
required this.expiryMonth,
required this.expiryYear,
});
final String capture, identity, brand, last4;
final int expiryMonth, expiryYear;
@override
DestinationType get type => DestinationType.card;
@override
String get display => '$brand · •••• $last4';
@override
String get review => display;
}
class RussianBankDestination extends DestinationDetails {
const RussianBankDestination({
required this.firstName,
required this.lastName,
this.patronymic = '',
required this.bank,
required this.account,
});
final String firstName, lastName, patronymic, account;
final Bank bank;
String get holder =>
[firstName, lastName, patronymic].where((s) => s.isNotEmpty).join(' ');
@override
DestinationType get type => DestinationType.russianBank;
@override
String get display =>
'${bank.name} · •••• ${account.length < 4 ? account : account.substring(account.length - 4)}';
@override
String get review =>
'$holder\n${bank.name}\n${bank.bik}\n$account\n${bank.correspondent}';
}
class Bank {
const Bank({
required this.bik,
required this.name,
required this.city,
required this.correspondent,
required this.revision,
this.supported = true,
});
final String bik, name, city, correspondent, revision;
final bool supported;
}
class CryptoAsset {
const CryptoAsset(
this.id,
this.label,
this.networkId,
this.networkLabel, {
this.memoRequired = false,
this.memoSupported = false,
});
final String id, label, networkId, networkLabel;
final bool memoRequired, memoSupported;
}
class ProviderBinding {
const ProviderBinding({
required this.reference,
required this.state,
required this.reusable,
this.recoverable = false,
this.expiresAt,
});
final String reference;
final BindingState state;
final bool reusable, recoverable;
final DateTime? expiresAt;
}
class OwnershipAssessment {
const OwnershipAssessment({
this.declaredOwn = true,
this.verified = false,
this.actionRequired = false,
});
final bool declaredOwn, verified, actionRequired;
}
class SavedDestination {
SavedDestination({
required this.id,
required this.details,
this.nickname = '',
this.metadataRevision = 1,
this.executionRevision = 1,
this.removed = false,
this.lastUsed,
this.ownership = const OwnershipAssessment(),
List<ProviderBinding> bindings = const [],
}) : bindings = List.unmodifiable(bindings);
final String id, nickname;
final DestinationDetails details;
final int metadataRevision, executionRevision;
final bool removed;
final DateTime? lastUsed;
final OwnershipAssessment ownership;
final List<ProviderBinding> bindings;
String get label => nickname.isEmpty ? details.display : nickname;
SavedReference get input => SavedReference(id, executionRevision);
}
sealed class DestinationInput {
const DestinationInput();
}
class SavedReference extends DestinationInput {
const SavedReference(this.id, this.executionRevision);
final String id;
final int executionRevision;
}
class OneTimeDestination extends DestinationInput {
const OneTimeDestination(this.details);
final DestinationDetails details;
}
class PayoutContext {
const PayoutContext(this.source, {this.amount});
final String source;
final int? amount;
}
class PayoutEligibility {
const PayoutEligibility(this.state, {this.reason, this.canSave = true});
final EligibilityState state;
final DestinationIssue? reason;
final bool canSave;
bool get eligible => state == EligibilityState.eligible;
}
class DestinationValidation {
const DestinationValidation(this.details);
final DestinationDetails details;
}
class DestinationCapabilities {
DestinationCapabilities({
List<DestinationType> types = const [],
List<CryptoAsset> assets = const [],
this.reusableCards = false,
this.cardRecovery = false,
this.simulated = false,
}) : types = List.unmodifiable(types),
assets = List.unmodifiable(assets);
final List<DestinationType> types;
final List<CryptoAsset> assets;
final bool reusableCards, cardRecovery, simulated;
}
/// Outcome identity never changes when a record is later renamed or removed.
class CreationResult {
const CreationResult(this.outcome, this.destinationId);
final SaveOutcome outcome;
final String destinationId;
}
class DestinationLease {
const DestinationLease({
required this.input,
required this.snapshot,
required this.context,
required this.requirementsRevision,
required this.expiresAt,
});
final DestinationInput input;
final DestinationDetails snapshot;
final PayoutContext context;
final int requirementsRevision;
final DateTime expiresAt;
}
class DestinationAudit {
const DestinationAudit(
this.actor,
this.reference,
this.action,
this.outcome,
this.at,
);
final String actor, reference, action, outcome;
final DateTime at;
}
@@ -0,0 +1,65 @@
import 'package:flutter/foundation.dart';
import 'models.dart';
import 'service.dart';
class DestinationsProvider extends ChangeNotifier {
DestinationsProvider(this.service);
final DestinationService service;
DestinationCapabilities? capabilities;
List<SavedDestination> methods = [];
bool loading = false, failed = false, _disposed = false;
int _revision = 0;
Future<void> refresh() async {
final revision = ++_revision;
loading = true;
failed = false;
notifyListeners();
try {
final caps = await service.capabilities();
final rows = await service.list();
if (_disposed || revision != _revision) return;
capabilities = caps;
methods = rows;
} catch (_) {
if (_disposed || revision != _revision) return;
failed = true;
}
if (_disposed) return;
loading = false;
notifyListeners();
}
List<SavedDestination> search(String query, DestinationType type) {
final q = query.trim().toLowerCase();
final rows = methods
.where(
(d) =>
d.details.type == type &&
'${d.label} ${d.details.display}'.toLowerCase().contains(q),
)
.toList();
rows.sort(
(a, b) => (b.lastUsed ?? DateTime(1970)).compareTo(
a.lastUsed ?? DateTime(1970),
),
);
return rows;
}
void reset() {
_revision++;
methods = [];
capabilities = null;
loading = failed = false;
service.clearTransient();
notifyListeners();
}
@override
void dispose() {
_disposed = true;
_revision++;
super.dispose();
}
}
@@ -0,0 +1,65 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'models.dart';
import 'service.dart';
class BankSearchProvider extends ChangeNotifier {
BankSearchProvider(this.service);
final DestinationService service;
Timer? _timer;
Completer<void>? _abort;
int _generation = 0;
bool _disposed = false, loading = false;
String query = '';
List<Bank> results = [];
DestinationIssue? error;
void search(String value) {
_timer?.cancel();
if (_abort != null && !_abort!.isCompleted) _abort!.complete();
_abort = null;
final generation = ++_generation;
query = value;
results = [];
error = null;
loading = false;
if (value.trim().length >= 2) {
_timer = Timer(
const Duration(milliseconds: 350),
() => _lookup(value, generation),
);
}
notifyListeners();
}
Future<void> _lookup(String value, int generation) async {
loading = true;
notifyListeners();
try {
final abort = Completer<void>();
_abort = abort;
final rows = await service.banks(value, abortTrigger: abort.future);
if (_disposed || generation != _generation) return;
results = rows;
if (rows.isEmpty) error = DestinationIssue.invalid;
} on DestinationException catch (e) {
if (!_disposed && generation == _generation) error = e.issue;
} catch (_) {
if (!_disposed && generation == _generation)
error = DestinationIssue.unavailable;
}
if (_disposed || generation != _generation) return;
loading = false;
notifyListeners();
}
@override
void dispose() {
_disposed = true;
_generation++;
_timer?.cancel();
if (_abort != null && !_abort!.isCompleted) _abort!.complete();
super.dispose();
}
}
@@ -0,0 +1,64 @@
import 'package:flutter/foundation.dart';
import 'models.dart';
import 'service.dart';
class DestinationSelectionProvider extends ChangeNotifier {
DestinationSelectionProvider(this.service, this.context);
final DestinationService service;
final PayoutContext context;
final assessments = <String, PayoutEligibility>{};
bool busy = false, _disposed = false;
DestinationIssue? error;
Future<void> assess(List<SavedDestination> methods) async {
for (final d in methods) {
try {
final assessment = await service.assess(d.input, context);
if (_disposed) return;
assessments[d.id] = assessment;
} on DestinationException catch (e) {
if (_disposed) return;
assessments[d.id] = PayoutEligibility(
EligibilityState.requiresAction,
reason: e.issue,
);
} catch (_) {
if (_disposed) return;
assessments[d.id] = const PayoutEligibility(
EligibilityState.temporarilyUnavailable,
reason: DestinationIssue.unavailable,
);
}
}
if (!_disposed) notifyListeners();
}
Future<SavedReference?> select(SavedDestination d) async {
if (busy) return null;
busy = true;
error = null;
notifyListeners();
SavedReference? result;
try {
final current = await service.get(d.id);
final a = await service.assess(current.input, context);
if (_disposed) return null;
assessments[d.id] = a;
if (a.eligible) result = current.input;
} on DestinationException catch (e) {
error = e.issue;
} catch (_) {
error = DestinationIssue.unavailable;
}
if (_disposed) return null;
busy = false;
notifyListeners();
return result;
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
@@ -0,0 +1,39 @@
import 'models.dart';
abstract interface class DestinationService {
bool get isSimulated;
Future<DestinationCapabilities> capabilities();
Future<List<SavedDestination>> list();
Future<SavedDestination> get(String id);
Future<DestinationValidation> validate(DestinationDetails details);
Future<PayoutEligibility> assess(
DestinationInput input,
PayoutContext context,
);
Future<CreationResult> create(
DestinationDetails details,
String nickname,
String idempotencyKey, {
String? replaces,
});
Future<CreationResult?> creation(String idempotencyKey);
Future<SavedDestination> rename(
String id,
String nickname,
int metadataRevision,
);
Future<void> remove(String id, int metadataRevision, int executionRevision);
Future<List<Bank>> banks(String query, {Future<void>? abortTrigger});
Future<DestinationLease> prepare(
DestinationInput input,
PayoutContext context,
);
Future<SavedDestination> restoreCard(
String id,
int executionRevision,
CardDestination capture,
String idempotencyKey,
);
void clearTransient();
}
@@ -0,0 +1,116 @@
import 'models.dart';
/// Local DTO boundary: transport fields never escape services into providers/UI.
class DestinationDTO {
const DestinationDTO(this.value);
final Map<String, Object?> value;
}
abstract final class DestinationMapper {
static DestinationDTO details(DestinationDetails value) =>
DestinationDTO(switch (value) {
CryptoDestination d => {
'type': 'crypto',
'assetId': d.assetId,
'networkId': d.networkId,
'address': d.address,
'memo': d.memo,
'assetLabel': d.assetLabel,
'networkLabel': d.networkLabel,
},
CardDestination d => {
'type': 'card',
'capture': d.capture,
'identity': d.identity,
'brand': d.brand,
'last4': d.last4,
'expiryMonth': d.expiryMonth,
'expiryYear': d.expiryYear,
},
RussianBankDestination d => {
'type': 'russianBank',
'firstName': d.firstName,
'lastName': d.lastName,
'patronymic': d.patronymic,
'account': d.account,
'bank': bank(d.bank),
},
});
static Map<String, Object?> bank(Bank b) => {
'bik': b.bik,
'name': b.name,
'city': b.city,
'correspondent': b.correspondent,
'revision': b.revision,
'supported': b.supported,
};
static Bank fromBank(Map<String, dynamic> v) => Bank(
bik: v['bik'] as String,
name: v['name'] as String,
city: v['city'] as String,
correspondent: v['correspondent'] as String,
revision: v['revision'] as String,
supported: v['supported'] as bool,
);
static DestinationDetails fromDTO(DestinationDTO dto) {
final d = dto.value;
return switch (d['type']) {
'crypto' => CryptoDestination(
assetId: d['assetId'] as String,
networkId: d['networkId'] as String,
address: d['address'] as String,
memo: d['memo'] as String,
assetLabel: d['assetLabel'] as String,
networkLabel: d['networkLabel'] as String,
),
'card' => CardDestination(
capture: d['capture'] as String,
identity: d['identity'] as String,
brand: d['brand'] as String,
last4: d['last4'] as String,
expiryMonth: d['expiryMonth'] as int,
expiryYear: d['expiryYear'] as int,
),
'russianBank' => RussianBankDestination(
firstName: d['firstName'] as String,
lastName: d['lastName'] as String,
patronymic: d['patronymic'] as String,
account: d['account'] as String,
bank: fromBank(Map<String, dynamic>.from(d['bank'] as Map)),
),
_ => throw const DestinationException(DestinationIssue.unsupported),
};
}
static DestinationDetails clone(DestinationDetails d) => fromDTO(details(d));
}
sealed class DestinationInputDTO {
const DestinationInputDTO();
}
class SavedInputDTO extends DestinationInputDTO {
const SavedInputDTO(this.id, this.executionRevision);
final String id;
final int executionRevision;
}
class OneTimeInputDTO extends DestinationInputDTO {
const OneTimeInputDTO(this.details);
final DestinationDTO details;
}
abstract final class DestinationInputMapper {
static DestinationInputDTO toDTO(DestinationInput input) => switch (input) {
SavedReference r => SavedInputDTO(r.id, r.executionRevision),
OneTimeDestination d => OneTimeInputDTO(
DestinationMapper.details(d.details),
),
};
static DestinationInput fromDTO(DestinationInputDTO dto) => switch (dto) {
SavedInputDTO d => SavedReference(d.id, d.executionRevision),
OneTimeInputDTO d => OneTimeDestination(
DestinationMapper.fromDTO(d.details),
),
};
}
@@ -0,0 +1,70 @@
import 'models.dart';
import 'service.dart';
/// Fail closed when the live backend has not supplied the destination contract.
/// Never substitutes a demo store for an authenticated live wallet.
class UnavailableDestinationService implements DestinationService {
const UnavailableDestinationService();
@override
bool get isSimulated => false;
@override
Future<DestinationCapabilities> capabilities() async =>
DestinationCapabilities();
@override
Future<List<SavedDestination>> list() async => [];
Never _unsupported() =>
throw const DestinationException(DestinationIssue.unsupported);
@override
Future<SavedDestination> get(String id) async => _unsupported();
@override
Future<DestinationValidation> validate(DestinationDetails details) async =>
_unsupported();
@override
Future<PayoutEligibility> assess(
DestinationInput input,
PayoutContext context,
) async => const PayoutEligibility(
EligibilityState.unsupported,
reason: DestinationIssue.unsupported,
canSave: false,
);
@override
Future<CreationResult> create(
DestinationDetails details,
String nickname,
String idempotencyKey, {
String? replaces,
}) async => _unsupported();
@override
Future<CreationResult?> creation(String idempotencyKey) async =>
_unsupported();
@override
Future<SavedDestination> rename(
String id,
String nickname,
int metadataRevision,
) async => _unsupported();
@override
Future<void> remove(
String id,
int metadataRevision,
int executionRevision,
) async => _unsupported();
@override
Future<List<Bank>> banks(String query, {Future<void>? abortTrigger}) async =>
_unsupported();
@override
Future<DestinationLease> prepare(
DestinationInput input,
PayoutContext context,
) async => _unsupported();
@override
Future<SavedDestination> restoreCard(
String id,
int executionRevision,
CardDestination capture,
String idempotencyKey,
) async => _unsupported();
@override
void clearTransient() {}
}
@@ -0,0 +1,52 @@
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../models/wallet.dart';
import '../transport/backend.dart';
import '../transport/wallet.dart';
abstract final class BackendMapper {
static Map<String, Object?> request(TransferRequest r) => {
'kind': r.kind.name,
'source': r.source,
'destination': r.destination ?? '',
'amount': r.amount,
'recipient': r.recipient,
};
static WalletSnapshot snapshot(BackendDTO d) => WalletSnapshot(
accounts: d.accounts.map(WalletMapper.account).toList(),
operations: d.operations.map(WalletMapper.operation).toList(),
);
static Quote quote(BackendDTO d) => WalletMapper.quote(d.quoteValue);
static WalletOperation? operation(BackendDTO d) => d.operationValue == null
? null
: WalletMapper.operation(d.operationValue!);
static AccountProfile profile(BackendDTO d) =>
WalletMapper.profile(ProfileDTO(d.locale));
static FactorChallenge challenge(
BackendDTO d,
FactorPurpose purpose,
String operation,
) {
final c = d.challenge;
return FactorChallenge(
id: c.id,
purpose: purpose,
operation: operation,
target: c.target,
expiresAt: c.expires,
resendAt: c.resend,
);
}
static WalletError failure(BackendDTO d) =>
WalletError.values.where((v) => v.name == d.error).firstOrNull ??
WalletError.unavailable;
static FactorError factorFailure(BackendDTO d) => switch (d.error) {
'factorInvalid' => FactorError.invalid,
'factorExpired' || 'expiredQuote' => FactorError.expired,
'factorLocked' => FactorError.locked,
'factorCooldown' => FactorError.cooldown,
'factorRequired' => FactorError.required,
_ => FactorError.unavailable,
};
}
@@ -1,3 +1,5 @@
import '../destinations/models.dart';
enum TransferKind { topUp, exchange, payout } enum TransferKind { topUp, exchange, payout }
enum OperationStatus { pending, completed, failed } enum OperationStatus { pending, completed, failed }
@@ -12,6 +14,7 @@ enum WalletError {
unsupported, unsupported,
notFound, notFound,
uncertain, uncertain,
destinationChanged,
} }
class WalletException implements Exception { class WalletException implements Exception {
@@ -25,9 +28,14 @@ class CurrencyAccount {
required this.currency, required this.currency,
required this.precision, required this.precision,
required this.available, required this.available,
this.payoutMethod = '',
this.payoutLabel = '',
this.canFund = true,
}); });
final String id, currency; final String id, currency;
final int precision, available; final int precision, available;
final String payoutMethod, payoutLabel;
final bool canFund;
/// Locale-aware decimal input, integer money. Grouping/exponents are rejected. /// Locale-aware decimal input, integer money. Grouping/exponents are rejected.
int? parse(String input) { int? parse(String input) {
@@ -59,12 +67,14 @@ class TransferRequest {
required this.amount, required this.amount,
this.destination, this.destination,
this.recipient = '', this.recipient = '',
this.payoutDestination,
}); });
final TransferKind kind; final TransferKind kind;
final String source; final String source;
final int amount; final int amount;
final String? destination; final String? destination;
final String recipient; final String recipient;
final DestinationInput? payoutDestination;
} }
class Quote { class Quote {
@@ -77,11 +87,15 @@ class Quote {
required this.expiresAt, required this.expiresAt,
required this.rateNumerator, required this.rateNumerator,
required this.rateDenominator, required this.rateDenominator,
this.payoutSnapshot,
this.payoutTerms,
}); });
final String id; final String id;
final TransferRequest request; final TransferRequest request;
final int fee, debit, credit, rateNumerator, rateDenominator; final int fee, debit, credit, rateNumerator, rateDenominator;
final DateTime expiresAt; final DateTime expiresAt;
final DestinationDetails? payoutSnapshot;
final PayoutTerms? payoutTerms;
} }
class WalletOperation { class WalletOperation {
@@ -111,3 +125,14 @@ class AccountProfile {
const AccountProfile(this.locale); const AccountProfile(this.locale);
final String locale; final String locale;
} }
class PayoutTerms {
const PayoutTerms(
this.amount,
this.precision,
this.assetLabel,
this.expectedSeconds,
);
final int amount, precision, expectedSeconds;
final String assetLabel;
}
@@ -6,6 +6,7 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart'; import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../destinations/models.dart';
import '../models/wallet.dart'; import '../models/wallet.dart';
import 'wallet.dart'; import 'wallet.dart';
@@ -19,6 +20,8 @@ class TransferProvider extends ChangeNotifier {
.firstOrNull .firstOrNull
?.id ?? ?.id ??
'usd'; 'usd';
wallet.addListener(_walletChanged);
_syncAccounts();
final op = recorder.begin( final op = recorder.begin(
journey, journey,
JourneyStep.transferDraft, JourneyStep.transferDraft,
@@ -26,12 +29,40 @@ class TransferProvider extends ChangeNotifier {
); );
recorder.finish(op, simulated: wallet.service.isSimulated); recorder.finish(op, simulated: wallet.service.isSimulated);
} }
void _syncAccounts() {
final accounts = wallet.data?.accounts;
if (accounts == null ||
accounts.isEmpty ||
busy ||
uncertain ||
quote != null)
return;
if (!accounts.any((a) => a.id == source)) source = accounts.first.id;
if (!accounts.any((a) => a.id == destination) || destination == source) {
destination = accounts.where((a) => a.id != source).firstOrNull?.id;
}
}
void _walletChanged() {
if (_disposed) return;
final previous = source;
_syncAccounts();
if (source != previous) {
payoutDestination = null;
payoutLabel = '';
recipient = '';
notifyListeners();
}
}
final WalletProvider wallet; final WalletProvider wallet;
final TransferKind kind; final TransferKind kind;
final JourneyRecorder recorder; final JourneyRecorder recorder;
String source; String source;
String? destination; String? destination;
String amount = '', recipient = ''; String amount = '', recipient = '';
DestinationInput? payoutDestination;
String payoutLabel = '';
Quote? quote; Quote? quote;
WalletOperation? result; WalletOperation? result;
WalletError? error; WalletError? error;
@@ -45,7 +76,8 @@ class TransferProvider extends ChangeNotifier {
TransferKind.exchange => JourneyKind.exchange, TransferKind.exchange => JourneyKind.exchange,
TransferKind.payout => JourneyKind.payout, TransferKind.payout => JourneyKind.payout,
}; };
bool get hasDraft => amount.isNotEmpty || recipient.isNotEmpty; bool get hasDraft =>
amount.isNotEmpty || payoutDestination != null || recipient.isNotEmpty;
bool get expired => bool get expired =>
quote != null && !DateTime.now().isBefore(quote!.expiresAt); quote != null && !DateTime.now().isBefore(quote!.expiresAt);
void change({ void change({
@@ -53,12 +85,22 @@ class TransferProvider extends ChangeNotifier {
String? destination, String? destination,
String? amount, String? amount,
String? recipient, String? recipient,
DestinationInput? payoutDestination,
String? payoutLabel,
}) { }) {
if (busy || uncertain) return; if (busy || uncertain) return;
if (source != null && source != this.source) {
this.payoutDestination = null;
this.payoutLabel = '';
recipient = '';
}
this.payoutDestination = payoutDestination ?? this.payoutDestination;
this.payoutLabel = payoutLabel ?? this.payoutLabel;
this.source = source ?? this.source; this.source = source ?? this.source;
this.destination = destination ?? this.destination; this.destination = destination ?? this.destination;
this.amount = amount ?? this.amount; this.amount = amount ?? this.amount;
this.recipient = recipient ?? this.recipient; this.recipient = recipient ?? this.recipient;
if (source != null) _syncAccounts();
quote = null; quote = null;
noSubmission = false; noSubmission = false;
error = null; error = null;
@@ -68,6 +110,36 @@ class TransferProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> selectPayout(DestinationInput input) async {
if (busy || uncertain || wallet.destinations == null) return;
busy = true;
error = null;
notifyListeners();
try {
final service = wallet.destinations!;
final assessment = await service.assess(
input,
PayoutContext(source, amount: wallet.account(source)?.parse(amount)),
);
if (!assessment.eligible)
throw const DestinationException(DestinationIssue.unsupported);
final details = input is OneTimeDestination
? input.details
: (await service.get((input as SavedReference).id)).details;
if (_disposed) return;
busy = false;
change(payoutDestination: input, payoutLabel: details.display);
} catch (_) {
if (_disposed) return;
payoutDestination = null;
payoutLabel = '';
error = WalletError.destinationChanged;
}
if (_disposed) return;
busy = false;
notifyListeners();
}
void edit() { void edit() {
if (!busy && !uncertain) { if (!busy && !uncertain) {
quote = null; quote = null;
@@ -105,6 +177,7 @@ class TransferProvider extends ChangeNotifier {
amount: money, amount: money,
destination: kind == TransferKind.exchange ? destination : null, destination: kind == TransferKind.exchange ? destination : null,
recipient: recipient.trim(), recipient: recipient.trim(),
payoutDestination: payoutDestination,
), ),
); );
if (_disposed || revision != _revision) return; if (_disposed || revision != _revision) return;
@@ -114,6 +187,10 @@ class TransferProvider extends ChangeNotifier {
} catch (e) { } catch (e) {
if (_disposed) return; if (_disposed) return;
error = _code(e); error = _code(e);
if (error == WalletError.destinationChanged) {
payoutDestination = null;
payoutLabel = '';
}
recorder.finish(op, failure: _failure(error!)); recorder.finish(op, failure: _failure(error!));
} }
if (_disposed) return; if (_disposed) return;
@@ -171,6 +248,7 @@ class TransferProvider extends ChangeNotifier {
error = null; error = null;
factorError = null; factorError = null;
notifyListeners(); notifyListeners();
_key ??= approval?.token;
_key ??= List.generate( _key ??= List.generate(
16, 16,
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'), (_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
@@ -196,6 +274,11 @@ class TransferProvider extends ChangeNotifier {
uncertain = false; uncertain = false;
} else { } else {
error = _code(e); error = _code(e);
if (error == WalletError.destinationChanged) {
quote = null;
payoutDestination = null;
payoutLabel = '';
}
} }
// All transport/unexpected failures are ambiguous after execution started. // All transport/unexpected failures are ambiguous after execution started.
uncertain = [ uncertain = [
@@ -218,6 +301,7 @@ class TransferProvider extends ChangeNotifier {
error = null; error = null;
factorError = null; factorError = null;
recipient = ''; recipient = '';
payoutDestination = null;
recorder.finish(op, simulated: wallet.service.isSimulated); recorder.finish(op, simulated: wallet.service.isSimulated);
wallet.track(value, recorder, journey); wallet.track(value, recorder, journey);
_handedOff = true; _handedOff = true;
@@ -266,6 +350,7 @@ class TransferProvider extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
_disposed = true; _disposed = true;
wallet.removeListener(_walletChanged);
recipient = ''; recipient = '';
if (!_handedOff) recorder.dispose(); if (!_handedOff) recorder.dispose();
super.dispose(); super.dispose();
@@ -6,12 +6,19 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart'; import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../destinations/service.dart';
import '../models/wallet.dart'; import '../models/wallet.dart';
import '../services/contracts.dart'; import '../services/contracts.dart';
class WalletProvider extends ChangeNotifier { class WalletProvider extends ChangeNotifier {
WalletProvider(this.service, this.analytics, {this.factors}); WalletProvider(
this.service,
this.analytics, {
this.factors,
this.destinations,
});
final WalletService service; final WalletService service;
final DestinationService? destinations;
final TwoFactorService? factors; final TwoFactorService? factors;
final AnalyticsService analytics; final AnalyticsService analytics;
WalletSnapshot? data; WalletSnapshot? data;
@@ -140,6 +147,21 @@ class WalletProvider extends ChangeNotifier {
return !saveFailed; return !saveFailed;
} }
/// Drop account-scoped state when the authenticated customer changes.
void reset() {
_revision++;
_poll?.cancel();
for (final pair in _settlements.values) {
pair.$1.dispose(abandonActive: false);
}
_settlements.clear();
data = null;
preferredLocale = null;
loading = failed = profileLoading = profileFailed = saving = saveFailed =
false;
notifyListeners();
}
@override @override
void dispose() { void dispose() {
_disposed = true; _disposed = true;
@@ -0,0 +1,135 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../mappers/backend.dart';
import '../models/wallet.dart';
import '../transport/backend.dart';
import 'contracts.dart';
import 'http/client.dart';
class BackendWalletService implements WalletService {
BackendWalletService({
required this.baseUri,
required this.accessToken,
http.Client? client,
}) : _client = client ?? createClient() {
if (!baseUri.hasAuthority ||
baseUri.hasQuery ||
baseUri.hasFragment ||
baseUri.userInfo.isNotEmpty ||
(baseUri.scheme != 'https' &&
!(baseUri.scheme == 'http' &&
['localhost', '127.0.0.1'].contains(baseUri.host))))
throw ArgumentError('Valid wallet API URL required');
}
final Uri baseUri;
final String? Function() accessToken;
final http.Client _client;
@override
bool get isSimulated => false;
Future<BackendDTO> send(
String path, {
Map<String, Object?>? body,
bool factor = false,
}) async {
final token = accessToken();
if (token == null) throw const WalletException(WalletError.unavailable);
final uri = baseUri.replace(
path:
'${baseUri.path.replaceFirst(RegExp(r"/$"), "")}/accounts/wallet$path',
);
final request = http.Request(body == null ? 'GET' : 'POST', uri)
..followRedirects = false;
request.headers.addAll({
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
if (kIsWeb) 'X-CSRF-Protection': '1',
});
if (body != null) request.body = jsonEncode(body);
try {
final response = await _client
.send(request)
.then(http.Response.fromStream)
.timeout(const Duration(seconds: 40));
if (token != accessToken())
throw const WalletException(WalletError.unavailable);
final dto = BackendDTO.decode(response.body);
if (response.statusCode < 200 ||
response.statusCode >= 300 ||
!dto.accepted) {
if (factor || dto.error.startsWith('factor'))
throw FactorException(BackendMapper.factorFailure(dto));
throw WalletException(BackendMapper.failure(dto));
}
return dto;
} on FactorException {
rethrow;
} on WalletException {
rethrow;
} catch (_) {
throw const WalletException(WalletError.unavailable);
}
}
Future<T> _mapped<T>(
Future<BackendDTO> dto,
T Function(BackendDTO) map,
) async {
try {
return map(await dto);
} on FactorException {
rethrow;
} on WalletException {
rethrow;
} catch (_) {
throw const WalletException(WalletError.unavailable);
}
}
@override
Future<WalletSnapshot> fetch() => _mapped(send(''), BackendMapper.snapshot);
@override
Future<Quote> quote(TransferRequest request) async {
if (request.payoutDestination != null)
throw const WalletException(WalletError.unsupported);
return _mapped(
send('/quote', body: BackendMapper.request(request)),
BackendMapper.quote,
);
}
@override
Future<WalletOperation> submit(
String quoteId,
String idempotencyKey, {
String? approval,
}) async {
final value = await _mapped(
send(
'/submit',
body: {'quoteId': quoteId, 'idempotencyKey': idempotencyKey},
),
BackendMapper.operation,
);
if (value == null) throw const WalletException(WalletError.uncertain);
return value;
}
@override
Future<WalletOperation?> findSubmission(String idempotencyKey) => _mapped(
send('/submission', body: {'idempotencyKey': idempotencyKey}),
BackendMapper.operation,
);
@override
Future<AccountProfile> profile() =>
_mapped(send('/profile'), BackendMapper.profile);
@override
Future<AccountProfile> saveLocale(String locale) async =>
throw const WalletException(WalletError.unsupported);
void close() => _client.close();
}
@@ -5,6 +5,9 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_kyc/limits_transport.dart'; import 'package:sxchnge_kyc/limits_transport.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import '../destinations/dummy.dart';
import '../destinations/models.dart';
import '../destinations/transport.dart';
import '../models/wallet.dart'; import '../models/wallet.dart';
import '../transport/wallet.dart'; import '../transport/wallet.dart';
import 'contracts.dart'; import 'contracts.dart';
@@ -14,13 +17,17 @@ class DummyWalletService implements WalletService {
DummyWalletService( DummyWalletService(
this.identity, { this.identity, {
this.factors, this.factors,
DummyDestinationService? destinations,
this.delay = const Duration(milliseconds: 350), this.delay = const Duration(milliseconds: 350),
this.settlement = const Duration(seconds: 2), this.settlement = const Duration(seconds: 2),
DateTime Function()? now, DateTime Function()? now,
String initialLocale = 'en', String initialLocale = 'en',
}) : now = now ?? DateTime.now, }) : destinations = destinations ?? DummyDestinationService(now: now),
now = now ?? DateTime.now,
_locale = initialLocale; _locale = initialLocale;
final IdentityService identity; final IdentityService identity;
final DummyDestinationService destinations;
final _destinationLeases = <String, DestinationLease>{};
final TwoFactorService? factors; final TwoFactorService? factors;
final Duration delay, settlement; final Duration delay, settlement;
final DateTime Function() now; final DateTime Function() now;
@@ -59,6 +66,10 @@ class DummyWalletService implements WalletService {
}; };
int _eur(int minor, String id) => (minor * _weight(id) + 1099) ~/ 1100; int _eur(int minor, String id) => (minor * _weight(id) + 1099) ~/ 1100;
void _settle() { void _settle() {
_quotes.removeWhere((_, quote) => !now().isBefore(quote.expires));
_destinationLeases.removeWhere(
(_, lease) => !now().isBefore(lease.expiresAt),
);
for (final o in _submissions.values) { for (final o in _submissions.values) {
if (o.status != 'pending' || now().isBefore(o.ready)) continue; if (o.status != 'pending' || now().isBefore(o.ready)) continue;
o.status = o.fail ? 'failed' : 'completed'; o.status = o.fail ? 'failed' : 'completed';
@@ -133,9 +144,19 @@ class DummyWalletService implements WalletService {
if (r.kind == TransferKind.exchange && if (r.kind == TransferKind.exchange &&
(r.source == r.destination || r.destination == null)) (r.source == r.destination || r.destination == null))
throw const WalletException(WalletError.unsupported); throw const WalletException(WalletError.unsupported);
if (r.kind == TransferKind.payout && DestinationLease? lease;
(r.recipient.trim().length < 6 || r.recipient.length > 140)) if (r.kind == TransferKind.payout) {
throw const WalletException(WalletError.invalidRecipient); if (r.payoutDestination == null)
throw const WalletException(WalletError.invalidRecipient);
try {
lease = await destinations.prepare(
r.payoutDestination!,
PayoutContext(r.source, amount: r.amount),
);
} on DestinationException {
throw const WalletException(WalletError.destinationChanged);
}
}
if (r.kind == TransferKind.exchange) _account(r.destination!); if (r.kind == TransferKind.exchange) _account(r.destination!);
final fee = switch (r.kind) { final fee = switch (r.kind) {
TransferKind.topUp => 0, TransferKind.topUp => 0,
@@ -146,19 +167,30 @@ class DummyWalletService implements WalletService {
final d = r.kind == TransferKind.exchange ? _weight(r.destination!) : 1; final d = r.kind == TransferKind.exchange ? _weight(r.destination!) : 1;
final q = QuoteDTO( final q = QuoteDTO(
'quote-${++_serial}', 'quote-${++_serial}',
WalletMapper.request(r), WalletMapper.request(r, includePayoutInput: false),
fee, fee,
r.kind == TransferKind.topUp ? r.amount : r.amount + fee, r.kind == TransferKind.topUp ? r.amount : r.amount + fee,
r.amount * n ~/ d, r.amount * n ~/ d,
now().add(const Duration(seconds: 60)), now().add(const Duration(seconds: 60)),
n, n,
d, d,
payoutTerms: lease == null
? null
: PayoutTermsDTO(r.amount, 2, switch (lease.snapshot) {
CryptoDestination d => d.assetLabel,
RussianBankDestination() => 'RUB',
CardDestination() => _account(r.source).currency,
}, settlement.inSeconds),
payoutSnapshot: lease == null
? null
: DestinationMapper.details(lease.snapshot),
); );
if (q.credit <= 0) throw const WalletException(WalletError.invalidAmount); if (q.credit <= 0) throw const WalletException(WalletError.invalidAmount);
final verified = await _verified(); final verified = await _verified();
_settle(); _settle();
_check(q, verified); _check(q, verified);
_quotes[q.id] = q; _quotes[q.id] = q;
if (lease != null) _destinationLeases[q.id] = lease;
return WalletMapper.quote(q); return WalletMapper.quote(q);
} }
@@ -193,6 +225,17 @@ class DummyWalletService implements WalletService {
if (_usedQuotes.contains(q.id)) if (_usedQuotes.contains(q.id))
throw const WalletException(WalletError.expiredQuote); throw const WalletException(WalletError.expiredQuote);
_check(q, verified); _check(q, verified);
final lease = _destinationLeases[q.id];
if (q.request.kind == 'payout' && lease == null)
throw const WalletException(WalletError.expiredQuote);
if (lease != null) {
try {
destinations.accept(lease);
} on DestinationException {
throw const WalletException(WalletError.destinationChanged);
}
}
// No asynchronous gap between destination acceptance and financial mutation.
_usedQuotes.add(q.id); _usedQuotes.add(q.id);
if (q.request.kind != 'topUp') if (q.request.kind != 'topUp')
_account(q.request.source).available -= q.debit; _account(q.request.source).available -= q.debit;
@@ -205,6 +248,7 @@ class DummyWalletService implements WalletService {
); );
failNextOperation = false; failNextOperation = false;
_submissions[idempotencyKey] = o; _submissions[idempotencyKey] = o;
_destinationLeases.remove(q.id);
if (loseNextResponse) { if (loseNextResponse) {
loseNextResponse = false; loseNextResponse = false;
throw const WalletException(WalletError.uncertain); throw const WalletException(WalletError.uncertain);
@@ -0,0 +1,149 @@
import 'dart:math';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../mappers/backend.dart';
import '../transport/backend.dart';
import 'backend.dart';
/// Composes authentication factors with quote-bound payment authorization.
/// Money codes authorize a durable BFF intent; raw verification proofs remain
/// server-side and cannot be reused as login grants.
class WalletFactorService implements TwoFactorService {
WalletFactorService(this.wallet, this.auth);
final BackendWalletService wallet;
final TwoFactorService auth;
final _challenges = <String, FactorChallenge>{};
final _keys = <String, String>{};
final _resends = <String, String>{};
String _key() => List.generate(
24,
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
String? _token;
@override
bool get isSimulated => false;
void _session({bool required = true}) {
final token = wallet.accessToken();
if (token != _token) {
_challenges.clear();
_keys.clear();
_resends.clear();
_token = token;
}
if (required && token == null)
throw const FactorException(FactorError.unavailable);
}
Future<T> _mapped<T>(
Future<BackendDTO> dto,
T Function(BackendDTO) map,
) async {
final token = wallet.accessToken();
try {
final value = await dto;
if (token != wallet.accessToken())
throw const FactorException(FactorError.unavailable);
return map(value);
} on FactorException {
rethrow;
} catch (_) {
throw const FactorException(FactorError.unavailable);
}
}
@override
Future<FactorChallenge?> begin(
FactorPurpose purpose,
String operation,
) async {
if (![
FactorPurpose.topUp,
FactorPurpose.exchange,
FactorPurpose.payout,
].contains(purpose))
return auth.begin(purpose, operation);
_session();
final c = await _mapped(
wallet.send('/challenge', body: {'quoteId': operation}, factor: true),
(d) => BackendMapper.challenge(d, purpose, operation),
);
_session();
_challenges[c.id] = c;
_keys.putIfAbsent(
operation,
() => List.generate(
24,
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
).join(),
);
return c;
}
@override
Future<FactorChallenge> resend(String challengeId) async {
_session(required: false);
final c = _challenges[challengeId];
if (c == null) return auth.resend(challengeId);
final result = await _mapped(
wallet.send(
'/resend',
body: {
'quoteId': c.operation,
'challengeId': c.id,
'idempotencyKey': _resends.putIfAbsent(c.id, _key),
},
factor: true,
),
(d) => BackendMapper.challenge(d, c.purpose, c.operation),
);
_session();
_resends.remove(c.id);
_challenges[result.id] = result;
return result;
}
@override
Future<FactorGrant> verify(String challengeId, String code) async {
_session(required: false);
final c = _challenges[challengeId];
if (c == null) return auth.verify(challengeId, code);
final key = _keys[c.operation]!;
return _mapped(
wallet.send(
'/verify',
body: {
'quoteId': c.operation,
'challengeId': c.id,
'code': code,
'idempotencyKey': key,
},
factor: true,
),
(d) {
if (d.key != key) throw const FactorException(FactorError.unavailable);
return FactorGrant(key);
},
);
}
@override
Future<FactorSettings> fetch() => auth.fetch();
@override
Future<FactorSettings> setEnabled(bool enabled, String grant) =>
auth.setEnabled(enabled, grant);
@override
Future<void> authorize(
FactorPurpose purpose,
String operation,
String? grant,
) async {
if ([
FactorPurpose.topUp,
FactorPurpose.exchange,
FactorPurpose.payout,
].contains(purpose))
throw const FactorException(FactorError.required);
await auth.authorize(purpose, operation, grant);
}
}
@@ -0,0 +1,4 @@
import 'package:http/browser_client.dart';
import 'package:http/http.dart' as http;
http.Client createClient() => BrowserClient()..withCredentials = true;
@@ -0,0 +1 @@
export 'native.dart' if (dart.library.js_interop) 'browser.dart';
@@ -0,0 +1,3 @@
import 'package:http/http.dart' as http;
http.Client createClient() => http.Client();
@@ -0,0 +1,78 @@
import 'dart:convert';
import 'wallet.dart';
/// Local BFF transport only; provider and widgets never parse response maps.
class BackendDTO {
BackendDTO.decode(String body) {
final envelope = jsonDecode(body) as Map<String, dynamic>;
accepted = envelope['status'] == 'success';
data = envelope['data'];
}
late final bool accepted;
late final dynamic data;
String get error =>
(data as Map<String, dynamic>?)?['error'] as String? ?? 'unavailable';
String get locale => (data as Map<String, dynamic>)['locale'] as String;
String get key => (data as Map<String, dynamic>)['idempotencyKey'] as String;
List<AccountDTO> get accounts =>
((data as Map<String, dynamic>)['accounts'] as List).map((v) {
final d = v as Map<String, dynamic>;
return AccountDTO(
d['id'] as String,
d['currency'] as String,
d['precision'] as int,
d['available'] as int,
payoutMethod: d['payoutMethod'] as String,
payoutLabel: d['payoutLabel'] as String,
canFund: d['canFund'] as bool,
);
}).toList();
List<OperationDTO> get operations =>
((data as Map<String, dynamic>)['operations'] as List)
.map((v) => operation(v as Map<String, dynamic>))
.toList();
QuoteDTO get quoteValue => quote(data as Map<String, dynamic>);
OperationDTO? get operationValue =>
data == null ? null : operation(data as Map<String, dynamic>);
static QuoteDTO quote(Map<String, dynamic> d) {
final r = d['request'] as Map<String, dynamic>;
return QuoteDTO(
d['id'] as String,
RequestDTO(
r['kind'] as String,
r['source'] as String,
(r['destination'] as String?)?.isEmpty == true
? null
: r['destination'] as String?,
r['amount'] as int,
r['recipient'] as String? ?? '',
),
d['fee'] as int,
d['debit'] as int,
d['credit'] as int,
DateTime.parse(d['expiresAt'] as String),
d['rateNumerator'] as int,
d['rateDenominator'] as int,
);
}
static OperationDTO operation(Map<String, dynamic> d) => OperationDTO(
d['id'] as String,
quote(d['quote'] as Map<String, dynamic>),
DateTime.parse(d['createdAt'] as String),
DateTime.parse(d['createdAt'] as String),
status: d['status'] as String,
);
ChallengeDTO get challenge => ChallengeDTO(data as Map<String, dynamic>);
}
class ChallengeDTO {
ChallengeDTO(Map<String, dynamic> d)
: id = d['challengeId'] as String,
target = d['target'] as String,
expires = DateTime.parse(d['expiresAt'] as String),
resend = DateTime.parse(d['resendAt'] as String);
final String id, target;
final DateTime expires, resend;
}
@@ -1,7 +1,18 @@
import '../destinations/transport.dart';
import '../models/wallet.dart'; import '../models/wallet.dart';
class AccountDTO { class AccountDTO {
AccountDTO(this.id, this.currency, this.precision, this.available); AccountDTO(
this.id,
this.currency,
this.precision,
this.available, {
this.payoutMethod = '',
this.payoutLabel = '',
this.canFund = true,
});
final String payoutMethod, payoutLabel;
final bool canFund;
final String id, currency; final String id, currency;
final int precision; final int precision;
int available; int available;
@@ -13,11 +24,13 @@ class RequestDTO {
this.source, this.source,
this.destination, this.destination,
this.amount, this.amount,
this.recipient, this.recipient, {
); this.payoutInput,
});
final String kind, source, recipient; final String kind, source, recipient;
final String? destination; final String? destination;
final int amount; final int amount;
final DestinationInputDTO? payoutInput;
} }
class QuoteDTO { class QuoteDTO {
@@ -29,12 +42,16 @@ class QuoteDTO {
this.credit, this.credit,
this.expires, this.expires,
this.numerator, this.numerator,
this.denominator, this.denominator, {
); this.payoutSnapshot,
this.payoutTerms,
});
final String id; final String id;
final RequestDTO request; final RequestDTO request;
final int fee, debit, credit, numerator, denominator; final int fee, debit, credit, numerator, denominator;
final DateTime expires; final DateTime expires;
final DestinationDTO? payoutSnapshot;
final PayoutTermsDTO? payoutTerms;
} }
class OperationDTO { class OperationDTO {
@@ -64,15 +81,32 @@ abstract final class WalletMapper {
currency: d.currency, currency: d.currency,
precision: d.precision, precision: d.precision,
available: d.available, available: d.available,
payoutMethod: d.payoutMethod,
payoutLabel: d.payoutLabel,
canFund: d.canFund,
);
static RequestDTO request(
TransferRequest r, {
bool includePayoutInput = true,
}) => RequestDTO(
r.kind.name,
r.source,
r.destination,
r.amount,
r.recipient,
payoutInput: !includePayoutInput || r.payoutDestination == null
? null
: DestinationInputMapper.toDTO(r.payoutDestination!),
); );
static RequestDTO request(TransferRequest r) =>
RequestDTO(r.kind.name, r.source, r.destination, r.amount, r.recipient);
static TransferRequest fromRequest(RequestDTO d) => TransferRequest( static TransferRequest fromRequest(RequestDTO d) => TransferRequest(
kind: TransferKind.values.byName(d.kind), kind: TransferKind.values.byName(d.kind),
source: d.source, source: d.source,
destination: d.destination, destination: d.destination,
amount: d.amount, amount: d.amount,
recipient: d.recipient, recipient: d.recipient,
payoutDestination: d.payoutInput == null
? null
: DestinationInputMapper.fromDTO(d.payoutInput!),
); );
static Quote quote(QuoteDTO d) => Quote( static Quote quote(QuoteDTO d) => Quote(
id: d.id, id: d.id,
@@ -83,6 +117,17 @@ abstract final class WalletMapper {
expiresAt: d.expires, expiresAt: d.expires,
rateNumerator: d.numerator, rateNumerator: d.numerator,
rateDenominator: d.denominator, rateDenominator: d.denominator,
payoutTerms: d.payoutTerms == null
? null
: PayoutTerms(
d.payoutTerms!.amount,
d.payoutTerms!.precision,
d.payoutTerms!.assetLabel,
d.payoutTerms!.expectedSeconds,
),
payoutSnapshot: d.payoutSnapshot == null
? null
: DestinationMapper.fromDTO(d.payoutSnapshot!),
); );
static WalletOperation operation(OperationDTO d) => WalletOperation( static WalletOperation operation(OperationDTO d) => WalletOperation(
id: d.id, id: d.id,
@@ -92,3 +137,14 @@ abstract final class WalletMapper {
); );
static AccountProfile profile(ProfileDTO dto) => AccountProfile(dto.locale); static AccountProfile profile(ProfileDTO dto) => AccountProfile(dto.locale);
} }
class PayoutTermsDTO {
const PayoutTermsDTO(
this.amount,
this.precision,
this.assetLabel,
this.expectedSeconds,
);
final int amount, precision, expectedSeconds;
final String assetLabel;
}
@@ -4,3 +4,17 @@ export 'src/providers/transfer.dart';
export 'src/services/contracts.dart'; export 'src/services/contracts.dart';
export 'src/services/dummy.dart'; export 'src/services/dummy.dart';
export 'src/providers/limits.dart'; export 'src/providers/limits.dart';
export 'src/services/backend.dart';
export 'src/services/factors.dart';
export 'src/destinations/models.dart';
export 'src/destinations/service.dart';
export 'src/destinations/dummy.dart';
export 'src/destinations/provider.dart';
export 'src/destinations/bank_paste.dart';
export 'src/destinations/unavailable.dart';
export 'src/destinations/form.dart';
export 'src/destinations/search.dart';
export 'src/destinations/selection.dart';
export 'src/destinations/management.dart';
export 'src/destinations/key.dart';
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more