diff --git a/README.md b/README.md index df96951..f75bfeb 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ The consumer app includes responsive login/signup, English and Russian localization, email verification and recovery screens, and URL-based navigation with `go_router`. Its interface uses the shared theme's colors and typography. -**Authentication, email verification and analytics use dummy services.** No real -account or session is created, no email is delivered, and no analytics data is -transmitted by those app services. The backend now contains locally owned copies -of Sendico’s BFF runtime, auth/session stack and account service. The app is not -connected to that backend or to Sendico yet. +The default preview uses dummy services. Configuring the existing backend URL +and client ID selects authenticated BFF adapters for auth, identity, limits and +wallet quotations/payment execution. Optional Sendico configuration and dedicated +customer financial resources are required for real operations; analytics still +defaults to an in-memory sink. See [payment setup and rollout](docs/payments.md). ## 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. Run `make test-backend`, `make lint-backend` or `make run-backend` from the root. -The copied runtime installs local authentication transport and initializes its -account service. Signup/profile endpoints, verification delivery/completion, -Flutter HTTP adapters and live Sendico operations remain to be wired. See the -[backend run guide](backend/README.md) for local MongoDB configuration. +The runtime installs local authentication, verification delivery, identity and +wallet transports. Public Sendico adapters support independent customer facts, +reviewed identity/limits and quotation/execution through the BFF. Deployment and +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). diff --git a/backend/product/README.md b/backend/product/README.md index 78680fb..f8c7db4 100644 --- a/backend/product/README.md +++ b/backend/product/README.md @@ -43,3 +43,10 @@ contracts; local customers retain no platform tenancy. See `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 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). diff --git a/backend/product/app/api/api.go b/backend/product/app/api/api.go index 324124a..a8d5414 100644 --- a/backend/product/app/api/api.go +++ b/backend/product/app/api/api.go @@ -7,6 +7,7 @@ import ( "git.sendico.io/tech/sxchnge/product/auth/storage" "git.sendico.io/tech/sxchnge/product/customers" "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/shared/logging" "github.com/go-chi/chi/v5" @@ -19,6 +20,7 @@ type Dependencies struct { Auth *flow.Service Factors *twofactor.Service Identity *identity.Service + Payments *payments.Service Token middleware.TokenConfig Signature middleware.Signature LoginLimits middleware.LoginRateLimits diff --git a/backend/product/app/api/v1/install.go b/backend/product/app/api/v1/install.go index 054ecf9..f6b9aee 100644 --- a/backend/product/app/api/v1/install.go +++ b/backend/product/app/api/v1/install.go @@ -6,6 +6,7 @@ import ( auth "git.sendico.io/tech/sxchnge/product/auth/http/v1" "git.sendico.io/tech/sxchnge/product/auth/http/v1/public" 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" "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) } identity.Install(routes, deps.Identity, scope.Logger) + paymenthttp.Install(routes, deps.Payments, scope.Logger) return nil } diff --git a/backend/product/app/runtime/services.go b/backend/product/app/runtime/services.go index af4d021..5e33af0 100644 --- a/backend/product/app/runtime/services.go +++ b/backend/product/app/runtime/services.go @@ -10,6 +10,7 @@ import ( "git.sendico.io/tech/sxchnge/product/customers" "git.sendico.io/tech/sxchnge/product/identity" "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/twofactor" "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.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") } records, err := factorstore.Open(logger, db) @@ -80,6 +86,9 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf return nil, nil, err } } + if deps.Payments != nil { + deps.Payments.Factors = deps.Factors + } if config.Sendico != nil && deps.Auth == nil { return nil, nil, merrors.InvalidArgument("Sendico sign-up facts require notifications configuration") } diff --git a/backend/product/identity/verification.go b/backend/product/identity/verification.go index f393f56..b87bd1f 100644 --- a/backend/product/identity/verification.go +++ b/backend/product/identity/verification.go @@ -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)) 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 +} diff --git a/backend/product/payments/http/v1/dto.go b/backend/product/payments/http/v1/dto.go new file mode 100644 index 0000000..af161a2 --- /dev/null +++ b/backend/product/payments/http/v1/dto.go @@ -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} +} diff --git a/backend/product/payments/http/v1/router.go b/backend/product/payments/http/v1/router.go new file mode 100644 index 0000000..1a012c5 --- /dev/null +++ b/backend/product/payments/http/v1/router.go @@ -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) +} diff --git a/backend/product/payments/http/v1/router_test.go b/backend/product/payments/http/v1/router_test.go new file mode 100644 index 0000000..1adebe4 --- /dev/null +++ b/backend/product/payments/http/v1/router_test.go @@ -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) + } +} diff --git a/backend/product/payments/model.go b/backend/product/payments/model.go new file mode 100644 index 0000000..d063cf5 --- /dev/null +++ b/backend/product/payments/model.go @@ -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" +) diff --git a/backend/product/payments/service.go b/backend/product/payments/service.go new file mode 100644 index 0000000..7b79333 --- /dev/null +++ b/backend/product/payments/service.go @@ -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 +} diff --git a/backend/product/payments/service_test.go b/backend/product/payments/service_test.go new file mode 100644 index 0000000..a3d78a9 --- /dev/null +++ b/backend/product/payments/service_test.go @@ -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) +} diff --git a/backend/product/payments/store.go b/backend/product/payments/store.go new file mode 100644 index 0000000..0168699 --- /dev/null +++ b/backend/product/payments/store.go @@ -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 +} diff --git a/backend/product/payments/store_test.go b/backend/product/payments/store_test.go new file mode 100644 index 0000000..ce1ca54 --- /dev/null +++ b/backend/product/payments/store_test.go @@ -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) +} diff --git a/backend/product/providers/sendico/payments.go b/backend/product/providers/sendico/payments.go new file mode 100644 index 0000000..85c6def --- /dev/null +++ b/backend/product/providers/sendico/payments.go @@ -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, ¤cies); 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) +} diff --git a/backend/product/providers/sendico/payments_mapper.go b/backend/product/providers/sendico/payments_mapper.go new file mode 100644 index 0000000..22cec7f --- /dev/null +++ b/backend/product/providers/sendico/payments_mapper.go @@ -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 +} diff --git a/backend/product/providers/sendico/payments_test.go b/backend/product/providers/sendico/payments_test.go new file mode 100644 index 0000000..98310b4 --- /dev/null +++ b/backend/product/providers/sendico/payments_test.go @@ -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) +} diff --git a/backend/product/providers/sendico/payments_transport.go b/backend/product/providers/sendico/payments_transport.go new file mode 100644 index 0000000..63e70db --- /dev/null +++ b/backend/product/providers/sendico/payments_transport.go @@ -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 +} diff --git a/backend/product/twofactor/service.go b/backend/product/twofactor/service.go index 61d5f16..25a2012 100644 --- a/backend/product/twofactor/service.go +++ b/backend/product/twofactor/service.go @@ -87,7 +87,7 @@ func validScope(scope model.Scope) bool { return false } switch scope.Purpose { - case vmodel.PurposeLogin, vmodel.PurposePayout, vmodel.PurposeFactorPreference: + case vmodel.PurposeLogin, vmodel.PurposePayment, vmodel.PurposePayout, vmodel.PurposeFactorPreference: return true } return false diff --git a/backend/product/verification/model/verificaton.go b/backend/product/verification/model/verificaton.go index cfa997b..f1e5040 100644 --- a/backend/product/verification/model/verificaton.go +++ b/backend/product/verification/model/verificaton.go @@ -21,6 +21,7 @@ const ( PurposeFactorPreference VerificationPurpose = "factor_preference" PurposeFactorProof VerificationPurpose = "factor_proof" PurposePayout VerificationPurpose = "payout" + PurposePayment VerificationPurpose = "payment" ) func VPToString(code VerificationPurpose) string { @@ -45,6 +46,8 @@ func VPFromString(s string) (VerificationPurpose, error) { return PurposeFactorPreference, nil case string(PurposeFactorProof): return PurposeFactorProof, nil + case string(PurposePayment): + return PurposePayment, nil case string(PurposePayout): return PurposePayout, nil default: diff --git a/docs/implementation.md b/docs/implementation.md index 8c0e080..82906d0 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -927,3 +927,40 @@ routes proof cases to Identity proof review while blocking generic login linking and assignment. Apply #2886 before #2887 and deploy the combined Sendico result before this SXCHNGE flow. The public proof/limits contract is unchanged; this 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. diff --git a/docs/payments.md b/docs/payments.md new file mode 100644 index 0000000..f18d1a4 --- /dev/null +++ b/docs/payments.md @@ -0,0 +1,162 @@ +# 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:` 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. diff --git a/frontend/app/README.md b/frontend/app/README.md index b50050b..9403cf9 100644 --- a/frontend/app/README.md +++ b/frontend/app/README.md @@ -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. -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/`. diff --git a/frontend/app/lib/l10n/en.arb b/frontend/app/lib/l10n/en.arb index ecd9547..f2497a1 100644 --- a/frontend/app/lib/l10n/en.arb +++ b/frontend/app/lib/l10n/en.arb @@ -277,5 +277,17 @@ "kycOperationMaximum": "Per operation maximum", "kycErrorAccess": "Verification is unavailable for this account. Contact support if this continues.", "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" + } + } + } } diff --git a/frontend/app/lib/l10n/ru.arb b/frontend/app/lib/l10n/ru.arb index 382a241..fa172e2 100644 --- a/frontend/app/lib/l10n/ru.arb +++ b/frontend/app/lib/l10n/ru.arb @@ -277,5 +277,17 @@ "kycOperationMaximum": "Максимум на операцию", "kycErrorAccess": "Проверка недоступна для этого аккаунта. Если это повторяется, обратитесь в поддержку.", "kycUpgradePendingTitle": "Ожидается повышение лимитов", - "kycUpgradePendingBody": "Проверка личности одобрена. Ожидается подтверждение повышения лимитов. Отправлять фото повторно не нужно." + "kycUpgradePendingBody": "Проверка личности одобрена. Ожидается подтверждение повышения лимитов. Отправлять фото повторно не нужно.", + "wNoPayoutMethod": "Для этого счёта не настроен способ вывода. Обратитесь в поддержку.", + "wNoFundingMethod": "Для этого счёта не настроен способ пополнения. Обратитесь в поддержку за инструкциями.", + "wLivePending": "Ожидается окончательный результат платежа. Обновите страницу, чтобы проверить статус и баланс.", + "wLiveFailed": "Платёж не завершён. Обновите счета, чтобы проверить доступный баланс.", + "wLiveQuoteNotice": "Предложение истекает через {seconds} сек.", + "@wLiveQuoteNotice": { + "placeholders": { + "seconds": { + "type": "int" + } + } + } } diff --git a/frontend/app/lib/main.dart b/frontend/app/lib/main.dart index 51aad25..fe2f172 100644 --- a/frontend/app/lib/main.dart +++ b/frontend/app/lib/main.dart @@ -63,8 +63,11 @@ class _SxchngeAppState extends State { late final _locale = widget.localeProvider ?? LocaleProvider(); late final _analytics = widget.analyticsService ?? DummyAnalyticsService(); - late final _factors = + late final _authFactors = widget.backend ?? widget.twoFactorService ?? DummyTwoFactorService(); + late final _factors = _walletService is BackendWalletService + ? WalletFactorService(_walletService, _authFactors) + : _authFactors; late final _security = SecurityProvider(_factors, _analytics); late final _identity = widget.identityService ?? @@ -76,11 +79,16 @@ class _SxchngeAppState extends State { )); late final _walletService = widget.walletService ?? - DummyWalletService( - _identity, - initialLocale: _locale.locale.languageCode, - factors: _factors, - ); + (widget.backend != null + ? BackendWalletService( + baseUri: widget.backend!.baseUri, + accessToken: () => widget.backend!.session?.accessToken, + ) + : DummyWalletService( + _identity, + initialLocale: _locale.locale.languageCode, + factors: _authFactors, + )); late final _wallet = WalletProvider( _walletService, _analytics, @@ -109,14 +117,21 @@ class _SxchngeAppState extends State { : () => widget.backend!.session != null, )..routeInformationProvider.addListener(_routeChanged); String _previousPath = '/'; + String? _walletCustomer; @override void initState() { super.initState(); _previousPath = _router.routeInformationProvider.value.uri.path; + _walletCustomer = widget.backend?.session?.userId; } void _routeChanged() { + final customer = widget.backend?.session?.userId; + if (customer != _walletCustomer) { + _walletCustomer = customer; + _wallet.reset(); + } final path = _router.routeInformationProvider.value.uri.path; if (path == '/' && _previousPath != '/' && _previousPath != '/two-factor') { _journeys.abandon(JourneyExit.navigation); @@ -130,6 +145,9 @@ class _SxchngeAppState extends State { _router.dispose(); _journeys.dispose(); _wallet.dispose(); + if (_walletService is BackendWalletService) { + _walletService.close(); + } _security.dispose(); if (_identity is BackendIdentityService) { _identity.close(); diff --git a/frontend/app/lib/workspace/copy.dart b/frontend/app/lib/workspace/copy.dart index bfe3001..061e490 100644 --- a/frontend/app/lib/workspace/copy.dart +++ b/frontend/app/lib/workspace/copy.dart @@ -28,17 +28,25 @@ String statusText(AppLocalizations l, OperationStatus s) => switch (s) { OperationStatus.completed => l.wCompleted, OperationStatus.failed => l.wFailed, }; -String fundsText(AppLocalizations l, WalletOperation o) => - switch ((o.status, o.quote.request.kind)) { - (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 fundsText( + AppLocalizations l, + WalletOperation o, { + bool simulated = true, +}) => !simulated && o.status == OperationStatus.pending + ? l.wLivePending + : !simulated && o.status == OperationStatus.failed + ? l.wLiveFailed + : switch ((o.status, o.quote.request.kind)) { + (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) { WalletError.invalidAmount => l.wInvalidAmount, WalletError.insufficientFunds => l.wInsufficient, diff --git a/frontend/app/lib/workspace/operation.dart b/frontend/app/lib/workspace/operation.dart index 79ce917..bb33abb 100644 --- a/frontend/app/lib/workspace/operation.dart +++ b/frontend/app/lib/workspace/operation.dart @@ -97,7 +97,7 @@ class _OperationPageState extends State { ), const SizedBox(height: 16), Text( - fundsText(l, o), + fundsText(l, o, simulated: p.service.isSimulated), style: TextStyle( color: o.status == OperationStatus.failed ? c.onErrorContainer diff --git a/frontend/app/lib/workspace/transfer.dart b/frontend/app/lib/workspace/transfer.dart index c7f0bd9..6136eb6 100644 --- a/frontend/app/lib/workspace/transfer.dart +++ b/frontend/app/lib/workspace/transfer.dart @@ -133,6 +133,15 @@ class TransferPageState extends State { final t = Theme.of(context); final accounts = wallet.data?.accounts ?? []; final source = wallet.account(p.source); + final live = !wallet.service.isSimulated; + final expiresIn = + p.quote?.expiresAt.difference(DateTime.now()).inSeconds ?? 0; + final unavailableMethod = + live && + ((widget.kind == TransferKind.payout && + (source?.payoutMethod.isEmpty ?? true)) || + (widget.kind == TransferKind.topUp && + !(source?.canFund ?? false))); return SingleChildScrollView( padding: const EdgeInsets.all(24), child: Center( @@ -207,7 +216,15 @@ class TransferPageState extends State { ] else if (p.quote != null) ...[ TransferSummary(quote: p.quote!), 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), if (p.expired) FilledButton( @@ -299,21 +316,39 @@ class TransferPageState extends State { ), if (widget.kind == TransferKind.payout) ...[ const SizedBox(height: 20), - TextField( - key: const ValueKey('transfer-recipient'), - controller: _recipient, - enabled: !p.busy, - decoration: InputDecoration( - labelText: l.wRecipient, - hintText: l.wRecipientHint, + if (live && source?.payoutMethod.isNotEmpty == true) + InputDecorator( + decoration: InputDecoration( + labelText: l.wRecipient, + ), + child: Text(source!.payoutLabel), ), - onChanged: (v) => p.change(recipient: v), + if (!live) + TextField( + key: const ValueKey('transfer-recipient'), + controller: _recipient, + enabled: !p.busy, + decoration: InputDecoration( + labelText: l.wRecipient, + hintText: l.wRecipientHint, + ), + 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), FilledButton( key: const ValueKey('transfer-review'), - onPressed: p.busy ? null : p.review, + onPressed: p.busy || unavailableMethod + ? null + : p.review, child: Text(l.wReview), ), if (p.error == WalletError.limitExceeded) diff --git a/frontend/app/test/payout_backend_test.dart b/frontend/app/test/payout_backend_test.dart new file mode 100644 index 0000000..d2869ff --- /dev/null +++ b/frontend/app/test/payout_backend_test.dart @@ -0,0 +1,101 @@ +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 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(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); + 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(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()); + }, + ); + } +} diff --git a/frontend/packages/sxchnge_wallet/README.md b/frontend/packages/sxchnge_wallet/README.md index 17a7fcc..ddaa252 100644 --- a/frontend/packages/sxchnge_wallet/README.md +++ b/frontend/packages/sxchnge_wallet/README.md @@ -32,8 +32,9 @@ wallet state changes. The adapter reuses the KYC package's limit DTO and mapper. ## Integration boundary -Replace `WalletService` and `LimitsService` together with authenticated backend -adapters. The backend must provide authorization, authoritative quotes/balances, +Backend mode now uses `BackendWalletService` and `WalletFactorService` with the +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. Add supported payment methods and validated recipients from that contract, rather than interpreting the demo input as production bank validation. Replace profile diff --git a/frontend/packages/sxchnge_wallet/lib/src/mappers/backend.dart b/frontend/packages/sxchnge_wallet/lib/src/mappers/backend.dart new file mode 100644 index 0000000..1c52782 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/mappers/backend.dart @@ -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 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, + }; +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/models/wallet.dart b/frontend/packages/sxchnge_wallet/lib/src/models/wallet.dart index 7548ea8..e968aad 100644 --- a/frontend/packages/sxchnge_wallet/lib/src/models/wallet.dart +++ b/frontend/packages/sxchnge_wallet/lib/src/models/wallet.dart @@ -25,9 +25,14 @@ class CurrencyAccount { required this.currency, required this.precision, required this.available, + this.payoutMethod = '', + this.payoutLabel = '', + this.canFund = true, }); final String id, currency; final int precision, available; + final String payoutMethod, payoutLabel; + final bool canFund; /// Locale-aware decimal input, integer money. Grouping/exponents are rejected. int? parse(String input) { diff --git a/frontend/packages/sxchnge_wallet/lib/src/providers/transfer.dart b/frontend/packages/sxchnge_wallet/lib/src/providers/transfer.dart index 0a9549a..dcd5481 100644 --- a/frontend/packages/sxchnge_wallet/lib/src/providers/transfer.dart +++ b/frontend/packages/sxchnge_wallet/lib/src/providers/transfer.dart @@ -19,6 +19,8 @@ class TransferProvider extends ChangeNotifier { .firstOrNull ?.id ?? 'usd'; + wallet.addListener(_walletChanged); + _syncAccounts(); final op = recorder.begin( journey, JourneyStep.transferDraft, @@ -26,6 +28,30 @@ class TransferProvider extends ChangeNotifier { ); 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; + } + if (!wallet.service.isSimulated && kind == TransferKind.payout) { + recipient = wallet.account(source)?.payoutMethod ?? ''; + } + } + + void _walletChanged() { + if (_disposed) return; + final previous = source; + _syncAccounts(); + if (source != previous) notifyListeners(); + } + final WalletProvider wallet; final TransferKind kind; final JourneyRecorder recorder; @@ -45,7 +71,8 @@ class TransferProvider extends ChangeNotifier { TransferKind.exchange => JourneyKind.exchange, TransferKind.payout => JourneyKind.payout, }; - bool get hasDraft => amount.isNotEmpty || recipient.isNotEmpty; + bool get hasDraft => + amount.isNotEmpty || (wallet.service.isSimulated && recipient.isNotEmpty); bool get expired => quote != null && !DateTime.now().isBefore(quote!.expiresAt); void change({ @@ -59,6 +86,7 @@ class TransferProvider extends ChangeNotifier { this.destination = destination ?? this.destination; this.amount = amount ?? this.amount; this.recipient = recipient ?? this.recipient; + if (source != null) _syncAccounts(); quote = null; noSubmission = false; error = null; @@ -171,6 +199,7 @@ class TransferProvider extends ChangeNotifier { error = null; factorError = null; notifyListeners(); + _key ??= approval?.token; _key ??= List.generate( 16, (_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'), @@ -266,6 +295,7 @@ class TransferProvider extends ChangeNotifier { @override void dispose() { _disposed = true; + wallet.removeListener(_walletChanged); recipient = ''; if (!_handedOff) recorder.dispose(); super.dispose(); diff --git a/frontend/packages/sxchnge_wallet/lib/src/providers/wallet.dart b/frontend/packages/sxchnge_wallet/lib/src/providers/wallet.dart index ad4a878..6fa8d89 100644 --- a/frontend/packages/sxchnge_wallet/lib/src/providers/wallet.dart +++ b/frontend/packages/sxchnge_wallet/lib/src/providers/wallet.dart @@ -140,6 +140,21 @@ class WalletProvider extends ChangeNotifier { 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 void dispose() { _disposed = true; diff --git a/frontend/packages/sxchnge_wallet/lib/src/services/backend.dart b/frontend/packages/sxchnge_wallet/lib/src/services/backend.dart new file mode 100644 index 0000000..415412b --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/services/backend.dart @@ -0,0 +1,130 @@ +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 send( + String path, { + Map? 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 _mapped( + Future 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 fetch() => _mapped(send(''), BackendMapper.snapshot); + @override + Future quote(TransferRequest request) => _mapped( + send('/quote', body: BackendMapper.request(request)), + BackendMapper.quote, + ); + @override + Future 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 findSubmission(String idempotencyKey) => _mapped( + send('/submission', body: {'idempotencyKey': idempotencyKey}), + BackendMapper.operation, + ); + @override + Future profile() => + _mapped(send('/profile'), BackendMapper.profile); + @override + Future saveLocale(String locale) async => + throw const WalletException(WalletError.unsupported); + void close() => _client.close(); +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/services/factors.dart b/frontend/packages/sxchnge_wallet/lib/src/services/factors.dart new file mode 100644 index 0000000..d7d2adc --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/services/factors.dart @@ -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 = {}; + final _keys = {}; + final _resends = {}; + 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 _mapped( + Future 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 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 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 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 fetch() => auth.fetch(); + @override + Future setEnabled(bool enabled, String grant) => + auth.setEnabled(enabled, grant); + @override + Future 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); + } +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/services/http/browser.dart b/frontend/packages/sxchnge_wallet/lib/src/services/http/browser.dart new file mode 100644 index 0000000..b2e1cd3 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/services/http/browser.dart @@ -0,0 +1,4 @@ +import 'package:http/browser_client.dart'; +import 'package:http/http.dart' as http; + +http.Client createClient() => BrowserClient()..withCredentials = true; diff --git a/frontend/packages/sxchnge_wallet/lib/src/services/http/client.dart b/frontend/packages/sxchnge_wallet/lib/src/services/http/client.dart new file mode 100644 index 0000000..e4e6ca5 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/services/http/client.dart @@ -0,0 +1 @@ +export 'native.dart' if (dart.library.js_interop) 'browser.dart'; diff --git a/frontend/packages/sxchnge_wallet/lib/src/services/http/native.dart b/frontend/packages/sxchnge_wallet/lib/src/services/http/native.dart new file mode 100644 index 0000000..190dfaa --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/services/http/native.dart @@ -0,0 +1,3 @@ +import 'package:http/http.dart' as http; + +http.Client createClient() => http.Client(); diff --git a/frontend/packages/sxchnge_wallet/lib/src/transport/backend.dart b/frontend/packages/sxchnge_wallet/lib/src/transport/backend.dart new file mode 100644 index 0000000..c29aa71 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/transport/backend.dart @@ -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; + accepted = envelope['status'] == 'success'; + data = envelope['data']; + } + late final bool accepted; + late final dynamic data; + String get error => + (data as Map?)?['error'] as String? ?? 'unavailable'; + String get locale => (data as Map)['locale'] as String; + String get key => (data as Map)['idempotencyKey'] as String; + List get accounts => + ((data as Map)['accounts'] as List).map((v) { + final d = v as Map; + 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 get operations => + ((data as Map)['operations'] as List) + .map((v) => operation(v as Map)) + .toList(); + QuoteDTO get quoteValue => quote(data as Map); + OperationDTO? get operationValue => + data == null ? null : operation(data as Map); + static QuoteDTO quote(Map d) { + final r = d['request'] as Map; + 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 d) => OperationDTO( + d['id'] as String, + quote(d['quote'] as Map), + DateTime.parse(d['createdAt'] as String), + DateTime.parse(d['createdAt'] as String), + status: d['status'] as String, + ); + ChallengeDTO get challenge => ChallengeDTO(data as Map); +} + +class ChallengeDTO { + ChallengeDTO(Map 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; +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/transport/wallet.dart b/frontend/packages/sxchnge_wallet/lib/src/transport/wallet.dart index 445b496..e49f6f5 100644 --- a/frontend/packages/sxchnge_wallet/lib/src/transport/wallet.dart +++ b/frontend/packages/sxchnge_wallet/lib/src/transport/wallet.dart @@ -1,7 +1,17 @@ import '../models/wallet.dart'; 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 int precision; int available; @@ -64,6 +74,9 @@ abstract final class WalletMapper { currency: d.currency, precision: d.precision, available: d.available, + payoutMethod: d.payoutMethod, + payoutLabel: d.payoutLabel, + canFund: d.canFund, ); static RequestDTO request(TransferRequest r) => RequestDTO(r.kind.name, r.source, r.destination, r.amount, r.recipient); diff --git a/frontend/packages/sxchnge_wallet/lib/sxchnge_wallet.dart b/frontend/packages/sxchnge_wallet/lib/sxchnge_wallet.dart index bbc4d41..eddac5d 100644 --- a/frontend/packages/sxchnge_wallet/lib/sxchnge_wallet.dart +++ b/frontend/packages/sxchnge_wallet/lib/sxchnge_wallet.dart @@ -4,3 +4,5 @@ export 'src/providers/transfer.dart'; export 'src/services/contracts.dart'; export 'src/services/dummy.dart'; export 'src/providers/limits.dart'; +export 'src/services/backend.dart'; +export 'src/services/factors.dart'; diff --git a/frontend/packages/sxchnge_wallet/pubspec.yaml b/frontend/packages/sxchnge_wallet/pubspec.yaml index b4cee5d..0352f0b 100644 --- a/frontend/packages/sxchnge_wallet/pubspec.yaml +++ b/frontend/packages/sxchnge_wallet/pubspec.yaml @@ -6,6 +6,7 @@ environment: sdk: '>=3.13.0 <4.0.0' flutter: '>=3.47.0' dependencies: + http: ^1.6.0 flutter: sdk: flutter sxchnge_auth: diff --git a/frontend/packages/sxchnge_wallet/test/backend_test.dart b/frontend/packages/sxchnge_wallet/test/backend_test.dart new file mode 100644 index 0000000..cc92808 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/test/backend_test.dart @@ -0,0 +1,245 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:sxchnge_analytics/sxchnge_analytics.dart'; + +import 'package:sxchnge_auth/sxchnge_auth.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +Map quoteData() => { + 'id': 'local-quote', + 'request': { + 'kind': 'payout', + 'source': 'ledger', + 'destination': '', + 'recipient': 'Saved bank ending 1234', + 'amount': 2001, + }, + 'fee': 100, + 'debit': 2101, + 'credit': 2001, + 'expiresAt': DateTime.now() + .add(const Duration(minutes: 5)) + .toUtc() + .toIso8601String(), + 'rateNumerator': 1, + 'rateDenominator': 1, +}; +Map operationData(String status) => { + 'id': 'local-quote', + 'quote': quoteData(), + 'status': status, + 'createdAt': DateTime.now().toUtc().toIso8601String(), +}; +http.Response success(Object? data) => + http.Response(jsonEncode({'status': 'success', 'data': data}), 200); +void main() { + test('backend wallet maps accounts, authoritative quote, execution and reconciliation', () async { + final requests = []; + final service = BackendWalletService( + baseUri: Uri.parse('https://bff.example/api/v1'), + accessToken: () => 'session', + client: MockClient((r) async { + requests.add(r); + expect(r.headers['Authorization'], 'Bearer session'); + expect(r.followRedirects, false); + switch (r.url.path) { + case '/api/v1/accounts/wallet': + return success({ + 'accounts': [ + { + 'id': 'ledger', + 'currency': 'EUR', + 'precision': 2, + 'available': 7500, + 'payoutMethod': 'method', + 'payoutLabel': 'Saved bank ending 1234', + 'canFund': false, + }, + ], + 'operations': [], + }); + case '/api/v1/accounts/wallet/quote': + expect(jsonDecode(r.body), { + 'kind': 'payout', + 'source': 'ledger', + 'destination': '', + 'amount': 2001, + 'recipient': 'method', + }); + return success(quoteData()); + case '/api/v1/accounts/wallet/submit': + expect(jsonDecode(r.body), { + 'quoteId': 'local-quote', + 'idempotencyKey': 'authorized-key', + }); + return success(operationData('pending')); + case '/api/v1/accounts/wallet/submission': + return success(operationData('completed')); + default: + return success({'locale': 'ru'}); + } + }), + ); + addTearDown(service.close); + expect(service.isSimulated, false); + final data = await service.fetch(); + expect(data.accounts.single.available, 7500); + expect(data.accounts.single.payoutLabel, 'Saved bank ending 1234'); + final q = await service.quote( + const TransferRequest( + kind: TransferKind.payout, + source: 'ledger', + recipient: 'method', + amount: 2001, + ), + ); + expect(q.debit, 2101); + expect(q.request.recipient, 'Saved bank ending 1234'); + expect( + (await service.submit(q.id, 'authorized-key')).status, + OperationStatus.pending, + ); + expect( + (await service.findSubmission('authorized-key'))!.status, + OperationStatus.completed, + ); + expect((await service.profile()).locale, 'ru'); + expect(requests.length, 5); + }); + test('session replacement rejects late responses and never returns stale balances', () async { + String? token = 'one'; + final response = Completer(); + final service = BackendWalletService( + baseUri: Uri.parse('https://bff.example'), + accessToken: () => token, + client: MockClient((_) => response.future), + ); + addTearDown(service.close); + final result = service.fetch(); + token = 'two'; + response.complete(success({'accounts': [], 'operations': []})); + await expectLater(result, throwsA(isA())); + token = null; + await expectLater(service.fetch(), throwsA(isA())); + }); + test( + 'malformed or unsuccessful responses cannot report payment success', + () async { + for (final response in [ + http.Response('broken', 200), + http.Response('{"status":"error","data":{"error":"uncertain"}}', 503), + success(null), + ]) { + final service = BackendWalletService( + baseUri: Uri.parse('https://bff.example'), + accessToken: () => 'session', + client: MockClient((_) async => response), + ); + await expectLater( + service.submit('quote', 'key'), + throwsA(isA()), + ); + service.close(); + } + }, + ); + test( + 'payment factor keeps authorization and resend keys stable across retry', + () async { + final bodies = >[]; + var fail = true; + final wallet = BackendWalletService( + baseUri: Uri.parse('https://bff.example'), + accessToken: () => 'session', + client: MockClient((r) async { + final body = jsonDecode(r.body) as Map; + bodies.add(body); + if (r.url.path.endsWith('/verify')) { + if (fail) { + fail = false; + return http.Response( + '{"status":"error","data":{"error":"factorInvalid"}}', + 400, + ); + } + return success({'idempotencyKey': body['idempotencyKey']}); + } + return success({ + 'challengeId': 'challenge', + 'target': 'a***@example.com', + 'expiresAt': DateTime.now() + .add(const Duration(minutes: 1)) + .toUtc() + .toIso8601String(), + 'resendAt': DateTime.now().toUtc().toIso8601String(), + }); + }), + ); + addTearDown(wallet.close); + final factor = WalletFactorService(wallet, DummyTwoFactorService()); + final challenge = (await factor.begin(FactorPurpose.payout, 'quote'))!; + await expectLater( + factor.verify(challenge.id, 'bad'), + throwsA( + isA().having( + (e) => e.code, + 'code', + FactorError.invalid, + ), + ), + ); + final grant = await factor.verify(challenge.id, '123456'); + expect(grant.token, bodies[1]['idempotencyKey']); + expect(bodies[1]['idempotencyKey'], bodies[2]['idempotencyKey']); + await factor.resend(challenge.id); + expect(bodies.last['idempotencyKey'], isNotEmpty); + }, + ); + test('provider waits for real account IDs and submits with the authorized intent key', () async { + final service = BackendWalletService( + baseUri: Uri.parse('https://bff.example'), + accessToken: () => 'session', + client: MockClient((r) async { + if (r.url.path.endsWith('/quote')) return success(quoteData()); + if (r.url.path.endsWith('/submit')) { + expect(jsonDecode(r.body)['idempotencyKey'], 'approved-intent-key'); + return success(operationData('completed')); + } + return success({ + 'accounts': [ + { + 'id': 'ledger', + 'currency': 'EUR', + 'precision': 2, + 'available': 7500, + 'payoutMethod': 'method', + 'payoutLabel': 'Saved bank ending 1234', + 'canFund': false, + }, + ], + 'operations': [], + }); + }), + ); + addTearDown(service.close); + final wallet = WalletProvider(service, DummyAnalyticsService()); + addTearDown(wallet.dispose); + final draft = TransferProvider(wallet, TransferKind.payout); + addTearDown(draft.dispose); + await wallet.refresh(); + expect(draft.source, 'ledger'); + expect(draft.recipient, 'method'); + expect(draft.hasDraft, false); + draft.change(amount: '20.01'); + await draft.review(); + await draft.submit(approval: const FactorGrant('approved-intent-key')); + expect(draft.result, isNotNull); + }); +}