Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34d8ef18d1 | ||
|
|
7a6510d1a8 |
No files matched your search
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package paymenthttp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/payments"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||
mservice "git.sendico.io/tech/sxchnge/shared/service"
|
||||
)
|
||||
|
||||
type Registrar interface {
|
||||
AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc)
|
||||
}
|
||||
type router struct {
|
||||
service *payments.Service
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func Install(reg Registrar, s *payments.Service, logger mlogger.Logger) {
|
||||
h := &router{s, logger.Named("payment_http")}
|
||||
reg.AccountHandler(mservice.Accounts, "/wallet/profile", api.Get, h.profile)
|
||||
reg.AccountHandler(mservice.Accounts, "/wallet", api.Get, h.fetch)
|
||||
reg.AccountHandler(mservice.Accounts, "/wallet/quote", api.Post, h.quote)
|
||||
reg.AccountHandler(mservice.Accounts, "/wallet/submit", api.Post, h.submit)
|
||||
reg.AccountHandler(mservice.Accounts, "/wallet/submission", api.Post, h.reconcile)
|
||||
reg.AccountHandler(mservice.Accounts, "/wallet/challenge", api.Post, h.challenge)
|
||||
reg.AccountHandler(mservice.Accounts, "/wallet/resend", api.Post, h.resend)
|
||||
reg.AccountHandler(mservice.Accounts, "/wallet/verify", api.Post, h.verify)
|
||||
}
|
||||
func decode(r *http.Request, dst any) error {
|
||||
d := json.NewDecoder(io.LimitReader(r.Body, 8193))
|
||||
d.DisallowUnknownFields()
|
||||
if d.Decode(dst) != nil {
|
||||
return payments.InvalidAmount
|
||||
}
|
||||
var extra any
|
||||
if d.Decode(&extra) != io.EOF {
|
||||
return payments.InvalidAmount
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (h *router) result(data any, err error) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err == nil {
|
||||
response.Ok(h.logger, data)(w, r)
|
||||
return
|
||||
}
|
||||
code := "unavailable"
|
||||
status := http.StatusServiceUnavailable
|
||||
var f payments.Failure
|
||||
if errors.As(err, &f) {
|
||||
code = string(f)
|
||||
if f != payments.Unavailable && f != payments.Uncertain {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, verification.ErrTokenExpired):
|
||||
code = "factorExpired"
|
||||
status = 400
|
||||
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
|
||||
code = "factorLocked"
|
||||
status = 400
|
||||
case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed), errors.Is(err, merrors.ErrUnauthorized):
|
||||
code = "factorInvalid"
|
||||
status = 400
|
||||
}
|
||||
if throttle, ok := verification.Throttle(err); ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(throttle.RetryAfter.Seconds()))))
|
||||
code = "factorCooldown"
|
||||
status = 429
|
||||
}
|
||||
response.Error(h.logger, mservice.Accounts, status, code, "Unable to complete wallet operation")(w, r)
|
||||
}
|
||||
}
|
||||
func (h *router) fetch(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
v, e := h.service.Fetch(r.Context(), a)
|
||||
return h.result(fromSnapshot(v), e)
|
||||
}
|
||||
func (h *router) quote(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var d requestDTO
|
||||
if e := decode(r, &d); e != nil {
|
||||
return h.result(nil, e)
|
||||
}
|
||||
v, e := h.service.Quote(r.Context(), a, d.model())
|
||||
return h.result(fromQuote(v), e)
|
||||
}
|
||||
|
||||
type actionDTO struct {
|
||||
Quote string `json:"quoteId"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
Challenge string `json:"challengeId"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func (h *router) submit(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var d actionDTO
|
||||
if e := decode(r, &d); e != nil {
|
||||
return h.result(nil, e)
|
||||
}
|
||||
v, e := h.service.Submit(r.Context(), a, d.Quote, d.Key)
|
||||
return h.result(fromOperation(v), e)
|
||||
}
|
||||
func (h *router) reconcile(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var d actionDTO
|
||||
if e := decode(r, &d); e != nil {
|
||||
return h.result(nil, e)
|
||||
}
|
||||
v, e := h.service.Reconcile(r.Context(), a, d.Key)
|
||||
if v == nil {
|
||||
return h.result(nil, e)
|
||||
}
|
||||
return h.result(fromOperation(*v), e)
|
||||
}
|
||||
func requestIP(r *http.Request) string {
|
||||
if ip := ipguard.ClientIP(r); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
return "unresolved"
|
||||
}
|
||||
func (h *router) challenge(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var d actionDTO
|
||||
if e := decode(r, &d); e != nil {
|
||||
return h.result(nil, e)
|
||||
}
|
||||
v, e := h.service.Challenge(r.Context(), a, d.Quote, requestIP(r))
|
||||
return h.result(fromChallenge(v), e)
|
||||
}
|
||||
func (h *router) resend(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var d actionDTO
|
||||
if e := decode(r, &d); e != nil {
|
||||
return h.result(nil, e)
|
||||
}
|
||||
v, e := h.service.Resend(r.Context(), a, d.Quote, d.Challenge, d.Key, requestIP(r))
|
||||
return h.result(fromChallenge(v), e)
|
||||
}
|
||||
func (h *router) verify(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var d actionDTO
|
||||
if e := decode(r, &d); e != nil {
|
||||
return h.result(nil, e)
|
||||
}
|
||||
e := h.service.Authorize(r.Context(), a, d.Quote, d.Challenge, d.Code, d.Key)
|
||||
return h.result(struct {
|
||||
Key string `json:"idempotencyKey"`
|
||||
}{d.Key}, e)
|
||||
}
|
||||
|
||||
func (h *router) profile(_ *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
return h.result(struct {
|
||||
Locale string `json:"locale"`
|
||||
}{a.Locale}, nil)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package paymenthttp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestUnconfiguredWalletCannotReturnDemoFunds(t *testing.T) {
|
||||
h := &router{logger: zap.NewNop()}
|
||||
a := &customer.Account{}
|
||||
a.ID = bson.NewObjectID()
|
||||
request := httptest.NewRequest(http.MethodGet, "/accounts/wallet", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
h.fetch(request, a, nil)(recorder, request)
|
||||
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
|
||||
require.Equal(t, "no-store", recorder.Header().Get("Cache-Control"))
|
||||
require.NotContains(t, recorder.Body.String(), "7500")
|
||||
}
|
||||
func TestQuoteRejectsUnknownFieldsAndTrailingRequests(t *testing.T) {
|
||||
h := &router{logger: zap.NewNop()}
|
||||
a := &customer.Account{}
|
||||
a.ID = bson.NewObjectID()
|
||||
for _, body := range []string{`{"kind":"payout","subject":"foreign"}`, `{"amount":10} {"amount":20}`, `{"amount":1.2}`} {
|
||||
request := httptest.NewRequest(http.MethodPost, "/accounts/wallet/quote", strings.NewReader(body))
|
||||
recorder := httptest.NewRecorder()
|
||||
h.quote(request, a, nil)(recorder, request)
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestDurableAuthorizationTransactionAndCustomerIsolation(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
|
||||
uri, err := container.ConnectionString(ctx)
|
||||
require.NoError(t, err)
|
||||
u, err := url.Parse(uri)
|
||||
require.NoError(t, err)
|
||||
q := u.Query()
|
||||
q.Set("directConnection", "true")
|
||||
u.RawQuery = q.Encode()
|
||||
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "payments"}})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(factory.CloseConnection)
|
||||
store, err := OpenStore(factory)
|
||||
require.NoError(t, err)
|
||||
customer := bson.NewObjectID()
|
||||
r := &Record{Customer: customer, Namespace: "provider", Subject: "subject"}
|
||||
r.SetID(bson.NewObjectID())
|
||||
require.NoError(t, store.Create(ctx, r))
|
||||
_, err = store.Get(ctx, r.ID, bson.NewObjectID(), r.Namespace)
|
||||
require.ErrorIs(t, err, merrors.ErrNoData)
|
||||
_, err = factory.TransactionFactory().CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
||||
require.NoError(t, store.Authorize(tx, r, "authorized-key-123"))
|
||||
return nil, Uncertain
|
||||
})
|
||||
require.ErrorIs(t, err, Uncertain)
|
||||
stored, err := store.Get(ctx, r.ID, customer, r.Namespace)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, stored.Key)
|
||||
require.NoError(t, store.Authorize(ctx, r, "authorized-key-123"))
|
||||
require.ErrorIs(t, store.Authorize(ctx, r, "other-key-123456"), Uncertain)
|
||||
another := &Record{Customer: customer, Namespace: r.Namespace}
|
||||
another.SetID(bson.NewObjectID())
|
||||
require.NoError(t, store.Create(ctx, another))
|
||||
require.ErrorIs(t, store.Authorize(ctx, another, "authorized-key-123"), merrors.ErrDataConflict)
|
||||
require.NoError(t, store.Attach(ctx, r, "payment-1"))
|
||||
require.NoError(t, store.Attach(ctx, r, "payment-1"))
|
||||
require.ErrorIs(t, store.Attach(ctx, r, "different-payment"), Uncertain)
|
||||
reopened, err := OpenStore(factory)
|
||||
require.NoError(t, err)
|
||||
stored, err = reopened.ByKey(ctx, customer, r.Namespace, "authorized-key-123")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "payment-1", stored.Payment)
|
||||
_, err = reopened.ByKey(ctx, customer, "other-provider", "authorized-key-123")
|
||||
require.ErrorIs(t, err, merrors.ErrNoData)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package sendico
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/payments"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Accounts exposes only dedicated, organization-owned customer ledgers tagged by
|
||||
// trusted platform provisioning. The integration login's personal/pooled ledgers
|
||||
// never become customer balances. Method bindings are provisioned on that ledger.
|
||||
func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Account, error) {
|
||||
if !validRef(subject) {
|
||||
return nil, payments.Unavailable
|
||||
}
|
||||
var currencies struct {
|
||||
Rows []currencyDTO `json:"currencies"`
|
||||
}
|
||||
if err := c.paymentRequest(ctx, http.MethodGet, "/payments/fx/currencies/"+c.config.OrganizationID, nil, ¤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)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package sendico
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/payments"
|
||||
)
|
||||
|
||||
const maxMinor int64 = 9007199254740991
|
||||
|
||||
func safePaymentRef(value string) bool {
|
||||
if len(value) == 0 || len(value) > 200 {
|
||||
return false
|
||||
}
|
||||
for _, c := range value {
|
||||
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == ':') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Decimal parsing never rounds provider values or converts money through floats.
|
||||
func minorMoney(m moneyDTO, currency string, scale int) (int64, error) {
|
||||
if m.Currency != currency || scale < 0 || scale > 8 {
|
||||
return 0, payments.Unavailable
|
||||
}
|
||||
parts := strings.Split(m.Amount, ".")
|
||||
if len(parts) > 2 || parts[0] == "" {
|
||||
return 0, payments.Unavailable
|
||||
}
|
||||
digits := parts[0]
|
||||
if len(parts) == 2 {
|
||||
if parts[1] == "" || len(parts[1]) > scale {
|
||||
return 0, payments.Unavailable
|
||||
}
|
||||
digits += parts[1] + strings.Repeat("0", scale-len(parts[1]))
|
||||
} else {
|
||||
digits += strings.Repeat("0", scale)
|
||||
}
|
||||
for _, r := range digits {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, payments.Unavailable
|
||||
}
|
||||
}
|
||||
value, err := strconv.ParseInt(digits, 10, 64)
|
||||
if err != nil || value > maxMinor {
|
||||
return 0, payments.Unavailable
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
func majorMoney(minor int64, scale int) string {
|
||||
digits := strconv.FormatInt(minor, 10)
|
||||
if scale == 0 {
|
||||
return digits
|
||||
}
|
||||
if len(digits) <= scale {
|
||||
digits = strings.Repeat("0", scale+1-len(digits)) + digits
|
||||
}
|
||||
return digits[:len(digits)-scale] + "." + digits[len(digits)-scale:]
|
||||
}
|
||||
func mapPaymentQuote(dto paymentQuoteDTO, r payments.Request, source, destination payments.Account) (payments.Quote, error) {
|
||||
if !dto.Executable || dto.State != "executable" {
|
||||
switch dto.BlockReason {
|
||||
case "limit_blocked":
|
||||
return payments.Quote{}, payments.LimitExceeded
|
||||
case "amount_too_small", "amount_too_large":
|
||||
return payments.Quote{}, payments.InvalidAmount
|
||||
default:
|
||||
return payments.Quote{}, payments.Unsupported
|
||||
}
|
||||
}
|
||||
if !safePaymentRef(dto.ID) || !dto.Expires.After(time.Now()) {
|
||||
return payments.Quote{}, payments.ExpiredQuote
|
||||
}
|
||||
debit, err := minorMoney(dto.Amounts.Debit, source.Currency, source.Precision)
|
||||
if err != nil {
|
||||
return payments.Quote{}, err
|
||||
}
|
||||
principal, err := minorMoney(dto.Amounts.Principal, source.Currency, source.Precision)
|
||||
if err != nil {
|
||||
return payments.Quote{}, err
|
||||
}
|
||||
credit, err := minorMoney(dto.Amounts.Credit, destination.Currency, destination.Precision)
|
||||
if err != nil {
|
||||
return payments.Quote{}, err
|
||||
}
|
||||
if principal != r.Amount || debit < principal || credit <= 0 {
|
||||
return payments.Quote{}, payments.Unavailable
|
||||
}
|
||||
if r.Kind != "topUp" && debit > source.Available {
|
||||
return payments.Quote{}, payments.InsufficientFunds
|
||||
}
|
||||
num, den := big.NewInt(credit), big.NewInt(principal)
|
||||
num.Mul(num, new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(source.Precision)), nil))
|
||||
den.Mul(den, new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(destination.Precision)), nil))
|
||||
gcd := new(big.Int).GCD(nil, nil, num, den)
|
||||
num.Quo(num, gcd)
|
||||
den.Quo(den, gcd)
|
||||
if !num.IsInt64() || !den.IsInt64() || num.Int64() > maxMinor || den.Int64() > maxMinor {
|
||||
return payments.Quote{}, payments.Unsupported
|
||||
}
|
||||
return payments.Quote{ID: dto.ID, Request: r, Fee: debit - principal, Debit: debit, Credit: credit, ExpiresAt: dto.Expires, RateNumerator: num.Int64(), RateDenominator: den.Int64()}, nil
|
||||
}
|
||||
func mapPayment(dto paymentDTO) (payments.RemoteOperation, error) {
|
||||
if !safePaymentRef(dto.ID) || !safePaymentRef(dto.Quote.ID) || dto.Created.IsZero() {
|
||||
return payments.RemoteOperation{}, payments.Unavailable
|
||||
}
|
||||
status := "pending"
|
||||
switch dto.State {
|
||||
case "orchestration_state_settled":
|
||||
status = "completed"
|
||||
case "orchestration_state_failed":
|
||||
status = "failed"
|
||||
case "orchestration_state_created", "orchestration_state_executing", "orchestration_state_needs_attention":
|
||||
default:
|
||||
return payments.RemoteOperation{}, payments.Unavailable
|
||||
}
|
||||
return payments.RemoteOperation{ID: dto.ID, QuoteRef: dto.Quote.ID, Status: status, CreatedAt: dto.Created}, nil
|
||||
}
|
||||
|
||||
func accountByID(accounts []payments.Account, id string) (payments.Account, error) {
|
||||
for _, a := range accounts {
|
||||
if a.ID == id {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
return payments.Account{}, payments.NotFound
|
||||
}
|
||||
func ledgerEndpoint(id string) endpointDTO {
|
||||
return endpointDTO{Type: "ledger", Data: map[string]string{"account_ref": id}}
|
||||
}
|
||||
func methodEndpoint(id string) endpointDTO {
|
||||
return endpointDTO{Type: "paymentMethod", Data: map[string]string{"ref": id}}
|
||||
}
|
||||
|
||||
type mappedIntent struct {
|
||||
wire paymentIntentDTO
|
||||
request payments.Request
|
||||
source, destination payments.Account
|
||||
}
|
||||
|
||||
func mapIntent(subject string, r payments.Request, accounts []payments.Account) (mappedIntent, error) {
|
||||
source, err := accountByID(accounts, r.Source)
|
||||
if err != nil {
|
||||
return mappedIntent{}, err
|
||||
}
|
||||
if r.Amount <= 0 || r.Amount > maxMinor {
|
||||
return mappedIntent{}, payments.InvalidAmount
|
||||
}
|
||||
intent := paymentIntentDTO{Subject: subject, Source: ledgerEndpoint(source.ID), Amount: moneyDTO{Amount: majorMoney(r.Amount, source.Precision), Currency: source.Currency}, FeeTreatment: "add_to_source"}
|
||||
destination := source
|
||||
switch r.Kind {
|
||||
case "payout":
|
||||
r.Destination = ""
|
||||
if source.PayoutMethod == "" || r.Recipient != source.PayoutMethod {
|
||||
return mappedIntent{}, payments.InvalidRecipient
|
||||
}
|
||||
intent.Kind = "payout"
|
||||
intent.Destination = methodEndpoint(source.PayoutMethod)
|
||||
// Store the safe provisioned display label, never present an opaque method ID.
|
||||
r.Recipient = source.PayoutLabel
|
||||
case "exchange":
|
||||
r.Recipient = ""
|
||||
destination, err = accountByID(accounts, r.Destination)
|
||||
if err != nil {
|
||||
return mappedIntent{}, err
|
||||
}
|
||||
if source.ID == destination.ID || source.Currency == destination.Currency {
|
||||
return mappedIntent{}, payments.Unsupported
|
||||
}
|
||||
intent.Kind = "fx_conversion"
|
||||
intent.Destination = ledgerEndpoint(destination.ID)
|
||||
case "topUp":
|
||||
r.Destination = ""
|
||||
r.Recipient = ""
|
||||
if source.FundingMethod == "" {
|
||||
return mappedIntent{}, payments.Unsupported
|
||||
}
|
||||
intent.Kind = "internal_transfer"
|
||||
intent.Source = methodEndpoint(source.FundingMethod)
|
||||
intent.Destination = ledgerEndpoint(source.ID)
|
||||
default:
|
||||
return mappedIntent{}, payments.Unsupported
|
||||
}
|
||||
|
||||
return mappedIntent{intent, r, source, destination}, nil
|
||||
}
|
||||
|
||||
func mapAccount(row accountDTO, balance balanceDTO, scale int) (payments.Account, error) {
|
||||
if balance.ID != row.ID {
|
||||
return payments.Account{}, payments.Unavailable
|
||||
}
|
||||
minor, err := minorMoney(balance.Money, row.Currency, scale)
|
||||
if err != nil {
|
||||
return payments.Account{}, err
|
||||
}
|
||||
account := payments.Account{ID: row.ID, Currency: row.Currency, Precision: scale, Available: minor, PayoutMethod: row.Metadata["sxchnge_payout_method_ref"], PayoutLabel: row.Metadata["sxchnge_payout_label"], FundingMethod: row.Metadata["sxchnge_funding_method_ref"]}
|
||||
for _, ref := range []string{account.PayoutMethod, account.FundingMethod} {
|
||||
if ref != "" && !validRef(ref) {
|
||||
return payments.Account{}, payments.Unavailable
|
||||
}
|
||||
}
|
||||
if account.PayoutMethod != "" && (strings.TrimSpace(account.PayoutLabel) == "" || len(account.PayoutLabel) > 140) {
|
||||
return payments.Account{}, payments.Unavailable
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package sendico
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/payments"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPaymentPublicWireAndExactMoney(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ledger := "507f1f77bcf86cd799439015"
|
||||
method := "507f1f77bcf86cd799439016"
|
||||
calls := 0
|
||||
acknowledge := true
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.NotEmpty(t, r.Header.Get("Authorization"))
|
||||
var result any
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/payments/fx/currencies/" + org:
|
||||
result = map[string]any{"currencies": []any{map[string]any{"code": "EUR", "accountingScale": 2}}}
|
||||
case "/api/v1/ledger_accounts/" + org:
|
||||
result = map[string]any{"accounts": []any{
|
||||
map[string]any{"ledgerAccountRef": source, "organizationRef": org, "ownerRef": source, "currency": "EUR", "accountType": "ACCOUNT_TYPE_ASSET", "role": "ACCOUNT_ROLE_OPERATING", "status": "ACCOUNT_STATUS_ACTIVE"},
|
||||
map[string]any{"ledgerAccountRef": ledger, "organizationRef": org, "currency": "EUR", "accountType": "ACCOUNT_TYPE_ASSET", "role": "ACCOUNT_ROLE_OPERATING", "status": "ACCOUNT_STATUS_ACTIVE", "metadata": map[string]string{"sxchnge_subject_ref": subject, "sxchnge_payout_method_ref": method, "sxchnge_payout_label": "Saved bank ending 1234"}},
|
||||
}}
|
||||
case "/api/v1/ledger_accounts/" + org + "/" + ledger + "/balance":
|
||||
result = map[string]any{"balance": map[string]any{"ledgerAccountRef": ledger, "balance": moneyDTO{"75.00", "EUR"}}}
|
||||
case "/api/v1/payments/quote/" + org:
|
||||
var request quoteRequestDTO
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
|
||||
require.Equal(t, subject, request.Intent.Subject)
|
||||
require.Equal(t, "20.01", request.Intent.Amount.Amount)
|
||||
require.Equal(t, ledger, request.Intent.Source.Data["account_ref"])
|
||||
require.Equal(t, method, request.Intent.Destination.Data["ref"])
|
||||
confirmedSubject := subject
|
||||
if !acknowledge {
|
||||
confirmedSubject = ""
|
||||
}
|
||||
result = map[string]any{"externalSubjectRef": confirmedSubject, "quote": paymentQuoteDTO{ID: "quote:1", State: "executable", Executable: true, Expires: time.Now().Add(time.Minute), Amounts: quoteAmountsDTO{Principal: moneyDTO{"20.01", "EUR"}, Debit: moneyDTO{"21.01", "EUR"}, Credit: moneyDTO{"20.01", "EUR"}}}}
|
||||
case "/api/v1/payments/by-quote/" + org:
|
||||
calls++
|
||||
var payload map[string]string
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
|
||||
require.Equal(t, "stable-execution", payload["idempotencyKey"])
|
||||
require.Equal(t, "quote:1", payload["quoteRef"])
|
||||
result = map[string]any{"payment": paymentDTO{ID: "payment:1", State: "orchestration_state_needs_attention", Quote: paymentQuoteDTO{ID: "quote:1"}, Created: time.Now()}}
|
||||
case "/api/v1/payments/by-ref/payment:1":
|
||||
result = map[string]any{"payment": paymentDTO{ID: "payment:1", State: "orchestration_state_settled", Quote: paymentQuoteDTO{ID: "quote:1"}, Created: time.Now()}}
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"status": "success", "data": result}))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := Open(config(t, server.URL))
|
||||
require.NoError(t, err)
|
||||
accounts, err := client.Accounts(ctx, subject)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, accounts, 1)
|
||||
require.EqualValues(t, 7500, accounts[0].Available)
|
||||
q, err := client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: ledger, Amount: 2001, Recipient: method}, accounts)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 100, q.Fee)
|
||||
require.EqualValues(t, 2101, q.Debit)
|
||||
require.Equal(t, "Saved bank ending 1234", q.Request.Recipient)
|
||||
_, err = client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: source, Amount: 2001, Recipient: method}, accounts)
|
||||
require.ErrorIs(t, err, payments.NotFound)
|
||||
_, err = client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: ledger, Amount: 2001, Recipient: "foreign-method"}, accounts)
|
||||
require.ErrorIs(t, err, payments.InvalidRecipient)
|
||||
acknowledge = false
|
||||
_, err = client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: ledger, Amount: 2001, Recipient: method}, accounts)
|
||||
require.ErrorIs(t, err, payments.Unavailable)
|
||||
acknowledge = true
|
||||
op, err := client.Execute(ctx, q.ID, "stable-execution")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "pending", op.Status)
|
||||
require.Equal(t, 1, calls)
|
||||
op, err = client.Payment(ctx, op.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "completed", op.Status)
|
||||
}
|
||||
func TestPaymentMappingRejectsUnsafeOrUnexecutableValues(t *testing.T) {
|
||||
for _, amount := range []string{"1e3", "0.001", "-2", "NaN", "90071992547409.92", ""} {
|
||||
_, err := minorMoney(moneyDTO{amount, "EUR"}, "EUR", 2)
|
||||
require.Error(t, err, amount)
|
||||
}
|
||||
_, err := minorMoney(moneyDTO{"10.00", "USD"}, "EUR", 2)
|
||||
require.Error(t, err)
|
||||
_, err = mapPayment(paymentDTO{ID: "p", State: "new_unknown_state", Quote: paymentQuoteDTO{ID: "q"}, Created: time.Now()})
|
||||
require.Error(t, err)
|
||||
_, err = mapPaymentQuote(paymentQuoteDTO{State: "blocked", BlockReason: "limit_blocked"}, payments.Request{}, payments.Account{}, payments.Account{})
|
||||
require.ErrorIs(t, err, payments.LimitExceeded)
|
||||
_, err = mapPaymentQuote(paymentQuoteDTO{State: "executable", Executable: false}, payments.Request{}, payments.Account{}, payments.Account{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package sendico
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/payments"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
type moneyDTO struct {
|
||||
Amount string `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
type endpointDTO struct {
|
||||
Type string `json:"type"`
|
||||
Data map[string]string `json:"data"`
|
||||
}
|
||||
|
||||
// Explicit wire fields avoid coupling local domain models to provider JSON.
|
||||
type quoteRequestDTO struct {
|
||||
Key string `json:"idempotencyKey"`
|
||||
Intent paymentIntentDTO `json:"intent"`
|
||||
}
|
||||
type paymentIntentDTO struct {
|
||||
Kind string `json:"kind"`
|
||||
Source endpointDTO `json:"source"`
|
||||
Destination endpointDTO `json:"destination"`
|
||||
Amount moneyDTO `json:"amount"`
|
||||
Subject string `json:"external_subject_ref"`
|
||||
FeeTreatment string `json:"fee_treatment"`
|
||||
}
|
||||
type quoteAmountsDTO struct {
|
||||
Principal moneyDTO `json:"sourcePrincipal"`
|
||||
Debit moneyDTO `json:"sourceDebitTotal"`
|
||||
Credit moneyDTO `json:"destinationSettlement"`
|
||||
}
|
||||
type paymentQuoteDTO struct {
|
||||
ID string `json:"quoteRef"`
|
||||
State string `json:"state"`
|
||||
Executable bool `json:"executable"`
|
||||
BlockReason string `json:"blockReason"`
|
||||
Expires time.Time `json:"expiresAt"`
|
||||
Amounts quoteAmountsDTO `json:"amounts"`
|
||||
}
|
||||
type paymentDTO struct {
|
||||
ID string `json:"paymentRef"`
|
||||
State string `json:"state"`
|
||||
Quote paymentQuoteDTO `json:"lastQuote"`
|
||||
Created time.Time `json:"createdAt"`
|
||||
}
|
||||
type accountDTO struct {
|
||||
ID string `json:"ledgerAccountRef"`
|
||||
ScopeRef string `json:"organizationRef"`
|
||||
Owner string `json:"ownerRef"`
|
||||
Currency string `json:"currency"`
|
||||
Type string `json:"accountType"`
|
||||
LedgerRole string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
type currencyDTO struct {
|
||||
Code string `json:"code"`
|
||||
Scale *int `json:"accountingScale"`
|
||||
}
|
||||
type balanceDTO struct {
|
||||
ID string `json:"ledgerAccountRef"`
|
||||
Money moneyDTO `json:"balance"`
|
||||
}
|
||||
|
||||
func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any) error {
|
||||
token, err := c.token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var body []byte
|
||||
if payload != nil {
|
||||
body, err = json.Marshal(payload)
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(payments.Unavailable, err, "encode Sendico payment request")
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.base+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(payments.Unavailable, err, "create Sendico payment request")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := *c.http
|
||||
client.Timeout = 30 * time.Second
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(payments.Unavailable, err, "Sendico payment request failed")
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
// Never forward upstream errors, credentials, or arbitrary error bodies.
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return payments.Unavailable
|
||||
}
|
||||
var envelope struct {
|
||||
Status string `json:"status"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
d := json.NewDecoder(io.LimitReader(res.Body, 4*1024*1024+1))
|
||||
if err = d.Decode(&envelope); err != nil {
|
||||
return merrors.OpaqueWrap(payments.Unavailable, err, "decode Sendico payment envelope")
|
||||
}
|
||||
if envelope.Status != "success" {
|
||||
return payments.Unavailable
|
||||
}
|
||||
var extra any
|
||||
if d.Decode(&extra) != io.EOF {
|
||||
return payments.Unavailable
|
||||
}
|
||||
if err = json.Unmarshal(envelope.Data, dst); err != nil {
|
||||
return merrors.OpaqueWrap(payments.Unavailable, err, "decode Sendico payment payload")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -927,3 +927,44 @@ 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.
|
||||
|
||||
Delivered in [PR #51](https://git.sendico.io/tech/sxchnge/pulls/51), targeting
|
||||
`identity-verification-48`. Merge order is #47 → #49 → #51; deploy Sendico #2897
|
||||
before SXCHNGE #51. Both implementation branches are pushed and remain unmerged.
|
||||
@@ -0,0 +1,168 @@
|
||||
# Customer quotations and payment execution
|
||||
|
||||
Task [SXCHNGE #50](https://git.sendico.io/tech/sxchnge/issues/50), branch
|
||||
`payments-50`, based on `identity-verification-48` / PR #49. Provider prerequisite:
|
||||
[Sendico #2896](https://git.sendico.io/tech/sendico/issues/2896).
|
||||
|
||||
## Boundary and behavior
|
||||
|
||||
The authenticated app uses `BackendWalletService` whenever backend authentication
|
||||
is configured. Explicit service injection remains available for tests; demo mode
|
||||
continues to use the isolated in-memory wallet. Missing provider configuration
|
||||
returns an unavailable wallet, never demonstration balances or fake settlements.
|
||||
|
||||
The path is Flutter DTO → mapper → service → domain model → provider → UI, then
|
||||
SXCHNGE's authenticated customer BFF → Sendico public HTTP. The existing confirmed
|
||||
identity binding resolves each signed-in customer to an independent subject.
|
||||
Neither a browser-selected organization nor an integration-login-owned balance
|
||||
is used as that customer's financial resource.
|
||||
|
||||
Consumed public endpoints, below the existing configured API prefix:
|
||||
|
||||
- `GET /payments/fx/currencies/{organization}` for accounting scales;
|
||||
- `GET /ledger_accounts/{organization}` and
|
||||
`GET /ledger_accounts/{organization}/{ledger}/balance`;
|
||||
- `POST /payments/quote/{organization}` with `external_subject_ref`;
|
||||
- `POST /payments/by-quote/{organization}`;
|
||||
- `GET /payments/by-ref/{payment}` for authoritative status.
|
||||
|
||||
The adapter requires the exact `externalSubjectRef` acknowledgement on the quote
|
||||
response. An old deployment that ignores the new request field cannot silently
|
||||
produce an acceptable integration-principal quote. Public provider DTOs and
|
||||
mappers stay inside the adapter. No Sendico modules, databases, gRPC clients or
|
||||
message bus are imported by SXCHNGE.
|
||||
|
||||
Amounts are parsed and formatted as exact decimal strings at the provider
|
||||
boundary and represented as integer minor units locally. Unknown scales, values
|
||||
beyond the browser's exact integer range, excess fractional precision, unknown
|
||||
payment states, unexecutable quotes and mismatched payment/quote references fail
|
||||
closed. Source principal, total debit and net destination credit come from the
|
||||
quote; rates account for different currency precisions. Only same-currency saved
|
||||
payout/funding methods are currently represented by the wallet review model.
|
||||
Exchange connects two dedicated currency ledgers belonging to the same customer.
|
||||
|
||||
## Provisioning and rollout
|
||||
|
||||
Merge/deploy the identity prerequisites first: SXCHNGE #47 → #49 and the Sendico
|
||||
identity-proof/limits-assignment work documented in `identity-verification.md`.
|
||||
Deploy the Sendico #2896 Product Edge and the Quotation/Orchestrator builds that
|
||||
consume the changed shared payment-storage Limits adapter together. Then deploy
|
||||
SXCHNGE #50. The Edge acknowledgement is a compatibility guard; it does not replace
|
||||
coordinated deployment of the platform's financial services.
|
||||
|
||||
Reuse `api.sendico` and its existing Vault-rendered token file. Do not put provider
|
||||
credentials or organization/source settings in Flutter. Provision the integration
|
||||
account's organization membership, payment-create/read, ledger-account/balance
|
||||
read and the permissions needed to resolve its stored payment methods. Verify
|
||||
those permissions in the intended environment before enabling real operations.
|
||||
|
||||
Through Sendico's existing operator APIs, provision one dedicated unowned
|
||||
organization ledger per customer currency, with active `asset` / `operating`
|
||||
semantics, no negative balance and metadata:
|
||||
|
||||
| Ledger metadata | Meaning |
|
||||
| --- | --- |
|
||||
| `sxchnge_subject_ref` | Confirmed independent subject reference from sign-up |
|
||||
| `sxchnge_payout_method_ref` | Optional prevalidated same-currency saved payout method |
|
||||
| `sxchnge_payout_label` | Safe customer-visible destination label, e.g. bank plus masked suffix |
|
||||
| `sxchnge_funding_method_ref` | Optional same-currency saved funding source supported by the platform route |
|
||||
|
||||
These are financial-resource bindings maintained by trusted provisioning, not
|
||||
client choices or new runtime configuration. Do not tag a pooled/integration-login
|
||||
ledger as a customer ledger. Do not place full bank/card details or secret values
|
||||
in labels or metadata. The BFF checks subject, organization, account type, role,
|
||||
status and owner on each account projection/quotation. It accepts only the bound
|
||||
saved payout method and replaces its opaque reference with the safe label in the
|
||||
reviewed quote. No arbitrary recipient identifier or free-text bank input can
|
||||
be executed from the live UI. Missing bindings have an explanatory disabled
|
||||
review action. Funding uses a quote for the provisioned stored source; it does
|
||||
not mint money or treat demo top-ups as real deposits. Additional destinations,
|
||||
recipient onboarding and other funding instructions remain separate features.
|
||||
|
||||
An empty set of provisioned ledgers is an empty wallet. Existing organization
|
||||
payments and pooled balances are never imported into customer history. This
|
||||
integration exposes the durable operations initiated through this BFF, with live
|
||||
provider reconciliation; independent inbound deposits/history ingestion and
|
||||
pagination are not added by this contribution. Ledger balances are posted ledger
|
||||
projections; execution-time solvency and holds remain the platform's authority.
|
||||
|
||||
## Authorization, idempotency and recovery
|
||||
|
||||
Local routes are under `/accounts/wallet`: snapshot GET, `/profile` GET,
|
||||
`/quote`, `/challenge`, `/resend`, `/verify`, `/submit` and `/submission` POST.
|
||||
All use the existing full authenticated-account wrapper, access token and browser
|
||||
CSRF guard. Pending-login credentials cannot access them. Local quote references
|
||||
are random BFF IDs; provider quote/payment references are not accepted as user
|
||||
ownership evidence.
|
||||
|
||||
`payment_quotes` stores customer, adapter namespace, confirmed subject, immutable
|
||||
quote, upstream quote reference, authorized execution key and eventual payment
|
||||
reference. A unique partial index enforces customer/namespace/key uniqueness.
|
||||
The existing second-factor service uses the dedicated `payment` purpose and
|
||||
exact local quote context. Successful code verification consumes its one-use
|
||||
proof in the same local Mongo transaction that records execution authorization.
|
||||
No external I/O runs in that transaction and raw factor proofs never leave the
|
||||
server. Verification response retries retain their key and recover committed
|
||||
intent. Resend uses a separate stable-per-retry issuance key.
|
||||
|
||||
Submit/reconciliation replays `sxchnge:payment:<local-quote-id>` at Sendico.
|
||||
A quote cannot be authorized under a second execution key. A network failure or
|
||||
lost provider response remains uncertain; it never permits execution with a new
|
||||
provider key. Once the provider payment reference is attached, refresh uses GET.
|
||||
Authorization is durable: reopening the wallet reconciles authorized intents even
|
||||
if the original client never received verification/submission success. Unknown
|
||||
outcomes remain pending and do not hide unrelated balances. `needs_attention`
|
||||
also stays pending; only `settled`/`failed` become terminal UI outcomes.
|
||||
|
||||
Generic upstream errors cannot prove that no payment exists. An unresolved
|
||||
pre-execution rejection (including an expired quote after durable authorization)
|
||||
therefore requires operational reconciliation rather than automatic release or a
|
||||
new payment. Inspect the local quote/provider reference and platform audit trail;
|
||||
never delete/rekey a record to bypass uncertainty. The UI's live copy does not
|
||||
promise a debit, refund or completion from mere acceptance. Only local drafts,
|
||||
not durable authorized intents, count as abandoned when a user leaves.
|
||||
|
||||
The adapter rejects changed-session HTTP responses, and the app clears wallet
|
||||
snapshots/journeys when the authenticated customer changes. Account-language
|
||||
reads use the authenticated customer. Backend language saving is deliberately
|
||||
unsupported and reports failure; the previous demo setting is not presented as
|
||||
persisted backend state.
|
||||
|
||||
## UX, observation and validation
|
||||
|
||||
The existing typed transfer journey recorder measures draft → quotation →
|
||||
second-factor challenge/verification → submission → authoritative settlement.
|
||||
Attempts/retries share their client journey; accepted operations finish once
|
||||
when the wallet observes a terminal platform status. An uncertain result is not
|
||||
success. Leaving an unresolved asynchronous operation is not draft abandonment.
|
||||
Use the existing per-attempt timestamps for durations and a 30-minute inactivity
|
||||
window for unfinished local drafts; server-authorized pending operations remain
|
||||
outside that abandonment denominator. Compare draft-to-quote and quote-to-submit
|
||||
drop-off, intent-to-terminal completion/failure and time to terminal outcome.
|
||||
The existing recorder provides in-memory client evidence only: deployment of a
|
||||
retained analytics sink and backend event delivery is needed for cross-session
|
||||
conversion/settlement measurement. No amounts, recipients, bank labels, codes or
|
||||
credentials enter analytics. Structured server logs use quote/payment references
|
||||
and bounded states; operational reconciliation uses the durable database record.
|
||||
|
||||
Tests cover public wire requests, exact money, source/destination isolation,
|
||||
blocked/unknown states, factor errors and retry keys, changed-session responses,
|
||||
durable authorization rollback/uniqueness, lost-response reconciliation and
|
||||
customer/provider-namespace isolation. Widget checks exercise saved destinations
|
||||
and the missing-destination state at 320px with 1.6× text, alongside the existing
|
||||
EN/RU workspace flows. Default Flutter keyboard/focus semantics are retained;
|
||||
full browser/screen-reader acceptance and live funded settlement are deployment
|
||||
checks, not claimed by synthetic tests. No live payments or deployment are run.
|
||||
|
||||
Validation passed after review: `make test` (backend race tests and all frontend
|
||||
suites), `make lint-backend`, a subsequent `make test-frontend` after the final
|
||||
long-label UI adjustment, changed-Dart format verification, and `flutter analyze`
|
||||
in `frontend/app` and `frontend/packages/sxchnge_wallet`. The final frontend run
|
||||
passed 21 wallet, 20 KYC, 7 analytics, 40 auth, 3 theme, 3 theme-example and 81 app
|
||||
tests. The provider prerequisite passed Sendico's full backend test/lint targets.
|
||||
|
||||
Delivery: [SXCHNGE PR #51](https://git.sendico.io/tech/sxchnge/pulls/51), stacked
|
||||
on PR #49. Merge #47 → #49 → #51. The separate
|
||||
[Sendico provider PR #2897](https://git.sendico.io/tech/sendico/pulls/2897) must be
|
||||
deployed before enabling this consumer. Both implementation branches are pushed;
|
||||
merge, deployment and real financial-resource provisioning remain separate.
|
||||
@@ -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/`.
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,5 +277,17 @@
|
||||
"kycOperationMaximum": "Максимум на операцию",
|
||||
"kycErrorAccess": "Проверка недоступна для этого аккаунта. Если это повторяется, обратитесь в поддержку.",
|
||||
"kycUpgradePendingTitle": "Ожидается повышение лимитов",
|
||||
"kycUpgradePendingBody": "Проверка личности одобрена. Ожидается подтверждение повышения лимитов. Отправлять фото повторно не нужно."
|
||||
"kycUpgradePendingBody": "Проверка личности одобрена. Ожидается подтверждение повышения лимитов. Отправлять фото повторно не нужно.",
|
||||
"wNoPayoutMethod": "Для этого счёта не настроен способ вывода. Обратитесь в поддержку.",
|
||||
"wNoFundingMethod": "Для этого счёта не настроен способ пополнения. Обратитесь в поддержку за инструкциями.",
|
||||
"wLivePending": "Ожидается окончательный результат платежа. Обновите страницу, чтобы проверить статус и баланс.",
|
||||
"wLiveFailed": "Платёж не завершён. Обновите счета, чтобы проверить доступный баланс.",
|
||||
"wLiveQuoteNotice": "Предложение истекает через {seconds} сек.",
|
||||
"@wLiveQuoteNotice": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,8 +63,11 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
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<SxchngeApp> {
|
||||
));
|
||||
late final _walletService =
|
||||
widget.walletService ??
|
||||
DummyWalletService(
|
||||
(widget.backend != null
|
||||
? BackendWalletService(
|
||||
baseUri: widget.backend!.baseUri,
|
||||
accessToken: () => widget.backend!.session?.accessToken,
|
||||
)
|
||||
: DummyWalletService(
|
||||
_identity,
|
||||
initialLocale: _locale.locale.languageCode,
|
||||
factors: _factors,
|
||||
);
|
||||
factors: _authFactors,
|
||||
));
|
||||
late final _wallet = WalletProvider(
|
||||
_walletService,
|
||||
_analytics,
|
||||
@@ -109,14 +117,21 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
: () => 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<SxchngeApp> {
|
||||
_router.dispose();
|
||||
_journeys.dispose();
|
||||
_wallet.dispose();
|
||||
if (_walletService is BackendWalletService) {
|
||||
_walletService.close();
|
||||
}
|
||||
_security.dispose();
|
||||
if (_identity is BackendIdentityService) {
|
||||
_identity.close();
|
||||
|
||||
@@ -28,13 +28,21 @@ 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)) {
|
||||
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.exchange) =>
|
||||
l.wCompleteExchange,
|
||||
(OperationStatus.completed, TransferKind.payout) => l.wCompletePayout,
|
||||
(OperationStatus.failed, TransferKind.topUp) => l.wFailedTopUp,
|
||||
_ => l.wFailedOutgoing,
|
||||
|
||||
@@ -97,7 +97,7 @@ class _OperationPageState extends State<OperationPage> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
fundsText(l, o),
|
||||
fundsText(l, o, simulated: p.service.isSimulated),
|
||||
style: TextStyle(
|
||||
color: o.status == OperationStatus.failed
|
||||
? c.onErrorContainer
|
||||
|
||||
@@ -133,6 +133,15 @@ class TransferPageState extends State<TransferPage> {
|
||||
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<TransferPage> {
|
||||
] 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,6 +316,14 @@ class TransferPageState extends State<TransferPage> {
|
||||
),
|
||||
if (widget.kind == TransferKind.payout) ...[
|
||||
const SizedBox(height: 20),
|
||||
if (live && source?.payoutMethod.isNotEmpty == true)
|
||||
InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: l.wRecipient,
|
||||
),
|
||||
child: Text(source!.payoutLabel),
|
||||
),
|
||||
if (!live)
|
||||
TextField(
|
||||
key: const ValueKey('transfer-recipient'),
|
||||
controller: _recipient,
|
||||
@@ -310,10 +335,20 @@ class TransferPageState extends State<TransferPage> {
|
||||
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)
|
||||
|
||||
@@ -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<WalletSnapshot> fetch() async => WalletSnapshot(
|
||||
accounts: [
|
||||
CurrencyAccount(
|
||||
id: 'real-account',
|
||||
currency: 'EUR',
|
||||
precision: 2,
|
||||
available: 7500,
|
||||
payoutMethod: hasMethod ? 'method-ref' : '',
|
||||
payoutLabel: hasMethod ? destinationLabel : '',
|
||||
canFund: false,
|
||||
),
|
||||
],
|
||||
operations: [],
|
||||
);
|
||||
@override
|
||||
Future<Quote> quote(TransferRequest request) async {
|
||||
received = request.recipient;
|
||||
return Quote(
|
||||
id: 'quote',
|
||||
request: TransferRequest(
|
||||
kind: request.kind,
|
||||
source: request.source,
|
||||
amount: request.amount,
|
||||
recipient: destinationLabel,
|
||||
),
|
||||
fee: 100,
|
||||
debit: request.amount + 100,
|
||||
credit: request.amount,
|
||||
expiresAt: DateTime.now().add(const Duration(minutes: 5)),
|
||||
rateNumerator: 1,
|
||||
rateDenominator: 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final hasMethod in [true, false]) {
|
||||
testWidgets(
|
||||
'backend payout ${hasMethod ? 'reviews saved destination' : 'explains missing destination'} at narrow width',
|
||||
(t) async {
|
||||
t.view.physicalSize = const Size(320, 1100);
|
||||
t.view.devicePixelRatio = 1;
|
||||
t.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||
t.platformDispatcher.defaultRouteNameTestValue = '/payout';
|
||||
addTearDown(t.view.resetPhysicalSize);
|
||||
addTearDown(t.view.resetDevicePixelRatio);
|
||||
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||
final wallet = LiveWallet(hasMethod: hasMethod);
|
||||
await t.pumpWidget(SxchngeApp(walletService: wallet));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('transfer-recipient')), findsNothing);
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '20');
|
||||
final review = find.byKey(const ValueKey('transfer-review'));
|
||||
await t.ensureVisible(review);
|
||||
if (hasMethod) {
|
||||
expect(find.text(destinationLabel), findsOneWidget);
|
||||
await t.tap(review);
|
||||
await t.pumpAndSettle();
|
||||
expect(wallet.received, 'method-ref');
|
||||
expect(find.text(destinationLabel), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('transfer-confirm')),
|
||||
findsOneWidget,
|
||||
);
|
||||
} else {
|
||||
expect(t.widget<FilledButton>(review).onPressed, isNull);
|
||||
expect(
|
||||
find.text(
|
||||
'No payout destination is set up for this account. Contact support to add one.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
}
|
||||
expect(t.takeException(), isNull);
|
||||
await t.pumpWidget(const SizedBox.shrink());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import '../models/wallet.dart';
|
||||
import '../transport/backend.dart';
|
||||
import '../transport/wallet.dart';
|
||||
|
||||
abstract final class BackendMapper {
|
||||
static Map<String, Object?> request(TransferRequest r) => {
|
||||
'kind': r.kind.name,
|
||||
'source': r.source,
|
||||
'destination': r.destination ?? '',
|
||||
'amount': r.amount,
|
||||
'recipient': r.recipient,
|
||||
};
|
||||
static WalletSnapshot snapshot(BackendDTO d) => WalletSnapshot(
|
||||
accounts: d.accounts.map(WalletMapper.account).toList(),
|
||||
operations: d.operations.map(WalletMapper.operation).toList(),
|
||||
);
|
||||
static Quote quote(BackendDTO d) => WalletMapper.quote(d.quoteValue);
|
||||
static WalletOperation? operation(BackendDTO d) => d.operationValue == null
|
||||
? null
|
||||
: WalletMapper.operation(d.operationValue!);
|
||||
static AccountProfile profile(BackendDTO d) =>
|
||||
WalletMapper.profile(ProfileDTO(d.locale));
|
||||
static FactorChallenge challenge(
|
||||
BackendDTO d,
|
||||
FactorPurpose purpose,
|
||||
String operation,
|
||||
) {
|
||||
final c = d.challenge;
|
||||
return FactorChallenge(
|
||||
id: c.id,
|
||||
purpose: purpose,
|
||||
operation: operation,
|
||||
target: c.target,
|
||||
expiresAt: c.expires,
|
||||
resendAt: c.resend,
|
||||
);
|
||||
}
|
||||
|
||||
static WalletError failure(BackendDTO d) =>
|
||||
WalletError.values.where((v) => v.name == d.error).firstOrNull ??
|
||||
WalletError.unavailable;
|
||||
static FactorError factorFailure(BackendDTO d) => switch (d.error) {
|
||||
'factorInvalid' => FactorError.invalid,
|
||||
'factorExpired' || 'expiredQuote' => FactorError.expired,
|
||||
'factorLocked' => FactorError.locked,
|
||||
'factorCooldown' => FactorError.cooldown,
|
||||
'factorRequired' => FactorError.required,
|
||||
_ => FactorError.unavailable,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<BackendDTO> send(
|
||||
String path, {
|
||||
Map<String, Object?>? body,
|
||||
bool factor = false,
|
||||
}) async {
|
||||
final token = accessToken();
|
||||
if (token == null) throw const WalletException(WalletError.unavailable);
|
||||
final uri = baseUri.replace(
|
||||
path:
|
||||
'${baseUri.path.replaceFirst(RegExp(r"/$"), "")}/accounts/wallet$path',
|
||||
);
|
||||
final request = http.Request(body == null ? 'GET' : 'POST', uri)
|
||||
..followRedirects = false;
|
||||
request.headers.addAll({
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
if (kIsWeb) 'X-CSRF-Protection': '1',
|
||||
});
|
||||
if (body != null) request.body = jsonEncode(body);
|
||||
try {
|
||||
final response = await _client
|
||||
.send(request)
|
||||
.then(http.Response.fromStream)
|
||||
.timeout(const Duration(seconds: 40));
|
||||
if (token != accessToken())
|
||||
throw const WalletException(WalletError.unavailable);
|
||||
final dto = BackendDTO.decode(response.body);
|
||||
if (response.statusCode < 200 ||
|
||||
response.statusCode >= 300 ||
|
||||
!dto.accepted) {
|
||||
if (factor || dto.error.startsWith('factor'))
|
||||
throw FactorException(BackendMapper.factorFailure(dto));
|
||||
throw WalletException(BackendMapper.failure(dto));
|
||||
}
|
||||
return dto;
|
||||
} on FactorException {
|
||||
rethrow;
|
||||
} on WalletException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
throw const WalletException(WalletError.unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> _mapped<T>(
|
||||
Future<BackendDTO> dto,
|
||||
T Function(BackendDTO) map,
|
||||
) async {
|
||||
try {
|
||||
return map(await dto);
|
||||
} on FactorException {
|
||||
rethrow;
|
||||
} on WalletException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
throw const WalletException(WalletError.unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WalletSnapshot> fetch() => _mapped(send(''), BackendMapper.snapshot);
|
||||
@override
|
||||
Future<Quote> quote(TransferRequest request) => _mapped(
|
||||
send('/quote', body: BackendMapper.request(request)),
|
||||
BackendMapper.quote,
|
||||
);
|
||||
@override
|
||||
Future<WalletOperation> submit(
|
||||
String quoteId,
|
||||
String idempotencyKey, {
|
||||
String? approval,
|
||||
}) async {
|
||||
final value = await _mapped(
|
||||
send(
|
||||
'/submit',
|
||||
body: {'quoteId': quoteId, 'idempotencyKey': idempotencyKey},
|
||||
),
|
||||
BackendMapper.operation,
|
||||
);
|
||||
if (value == null) throw const WalletException(WalletError.uncertain);
|
||||
return value;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WalletOperation?> findSubmission(String idempotencyKey) => _mapped(
|
||||
send('/submission', body: {'idempotencyKey': idempotencyKey}),
|
||||
BackendMapper.operation,
|
||||
);
|
||||
@override
|
||||
Future<AccountProfile> profile() =>
|
||||
_mapped(send('/profile'), BackendMapper.profile);
|
||||
@override
|
||||
Future<AccountProfile> saveLocale(String locale) async =>
|
||||
throw const WalletException(WalletError.unsupported);
|
||||
void close() => _client.close();
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import '../mappers/backend.dart';
|
||||
import '../transport/backend.dart';
|
||||
import 'backend.dart';
|
||||
|
||||
/// Composes authentication factors with quote-bound payment authorization.
|
||||
/// Money codes authorize a durable BFF intent; raw verification proofs remain
|
||||
/// server-side and cannot be reused as login grants.
|
||||
class WalletFactorService implements TwoFactorService {
|
||||
WalletFactorService(this.wallet, this.auth);
|
||||
final BackendWalletService wallet;
|
||||
final TwoFactorService auth;
|
||||
final _challenges = <String, FactorChallenge>{};
|
||||
final _keys = <String, String>{};
|
||||
final _resends = <String, String>{};
|
||||
String _key() => List.generate(
|
||||
24,
|
||||
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||
).join();
|
||||
String? _token;
|
||||
@override
|
||||
bool get isSimulated => false;
|
||||
void _session({bool required = true}) {
|
||||
final token = wallet.accessToken();
|
||||
if (token != _token) {
|
||||
_challenges.clear();
|
||||
_keys.clear();
|
||||
_resends.clear();
|
||||
_token = token;
|
||||
}
|
||||
if (required && token == null)
|
||||
throw const FactorException(FactorError.unavailable);
|
||||
}
|
||||
|
||||
Future<T> _mapped<T>(
|
||||
Future<BackendDTO> dto,
|
||||
T Function(BackendDTO) map,
|
||||
) async {
|
||||
final token = wallet.accessToken();
|
||||
try {
|
||||
final value = await dto;
|
||||
if (token != wallet.accessToken())
|
||||
throw const FactorException(FactorError.unavailable);
|
||||
return map(value);
|
||||
} on FactorException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
throw const FactorException(FactorError.unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FactorChallenge?> begin(
|
||||
FactorPurpose purpose,
|
||||
String operation,
|
||||
) async {
|
||||
if (![
|
||||
FactorPurpose.topUp,
|
||||
FactorPurpose.exchange,
|
||||
FactorPurpose.payout,
|
||||
].contains(purpose))
|
||||
return auth.begin(purpose, operation);
|
||||
_session();
|
||||
final c = await _mapped(
|
||||
wallet.send('/challenge', body: {'quoteId': operation}, factor: true),
|
||||
(d) => BackendMapper.challenge(d, purpose, operation),
|
||||
);
|
||||
_session();
|
||||
_challenges[c.id] = c;
|
||||
_keys.putIfAbsent(
|
||||
operation,
|
||||
() => List.generate(
|
||||
24,
|
||||
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||
).join(),
|
||||
);
|
||||
return c;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FactorChallenge> resend(String challengeId) async {
|
||||
_session(required: false);
|
||||
final c = _challenges[challengeId];
|
||||
if (c == null) return auth.resend(challengeId);
|
||||
final result = await _mapped(
|
||||
wallet.send(
|
||||
'/resend',
|
||||
body: {
|
||||
'quoteId': c.operation,
|
||||
'challengeId': c.id,
|
||||
'idempotencyKey': _resends.putIfAbsent(c.id, _key),
|
||||
},
|
||||
factor: true,
|
||||
),
|
||||
(d) => BackendMapper.challenge(d, c.purpose, c.operation),
|
||||
);
|
||||
_session();
|
||||
_resends.remove(c.id);
|
||||
_challenges[result.id] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FactorGrant> verify(String challengeId, String code) async {
|
||||
_session(required: false);
|
||||
final c = _challenges[challengeId];
|
||||
if (c == null) return auth.verify(challengeId, code);
|
||||
final key = _keys[c.operation]!;
|
||||
return _mapped(
|
||||
wallet.send(
|
||||
'/verify',
|
||||
body: {
|
||||
'quoteId': c.operation,
|
||||
'challengeId': c.id,
|
||||
'code': code,
|
||||
'idempotencyKey': key,
|
||||
},
|
||||
factor: true,
|
||||
),
|
||||
(d) {
|
||||
if (d.key != key) throw const FactorException(FactorError.unavailable);
|
||||
return FactorGrant(key);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FactorSettings> fetch() => auth.fetch();
|
||||
@override
|
||||
Future<FactorSettings> setEnabled(bool enabled, String grant) =>
|
||||
auth.setEnabled(enabled, grant);
|
||||
@override
|
||||
Future<void> authorize(
|
||||
FactorPurpose purpose,
|
||||
String operation,
|
||||
String? grant,
|
||||
) async {
|
||||
if ([
|
||||
FactorPurpose.topUp,
|
||||
FactorPurpose.exchange,
|
||||
FactorPurpose.payout,
|
||||
].contains(purpose))
|
||||
throw const FactorException(FactorError.required);
|
||||
await auth.authorize(purpose, operation, grant);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import 'package:http/browser_client.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
http.Client createClient() => BrowserClient()..withCredentials = true;
|
||||
@@ -0,0 +1 @@
|
||||
export 'native.dart' if (dart.library.js_interop) 'browser.dart';
|
||||
@@ -0,0 +1,3 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
http.Client createClient() => http.Client();
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'wallet.dart';
|
||||
|
||||
/// Local BFF transport only; provider and widgets never parse response maps.
|
||||
class BackendDTO {
|
||||
BackendDTO.decode(String body) {
|
||||
final envelope = jsonDecode(body) as Map<String, dynamic>;
|
||||
accepted = envelope['status'] == 'success';
|
||||
data = envelope['data'];
|
||||
}
|
||||
late final bool accepted;
|
||||
late final dynamic data;
|
||||
String get error =>
|
||||
(data as Map<String, dynamic>?)?['error'] as String? ?? 'unavailable';
|
||||
String get locale => (data as Map<String, dynamic>)['locale'] as String;
|
||||
String get key => (data as Map<String, dynamic>)['idempotencyKey'] as String;
|
||||
List<AccountDTO> get accounts =>
|
||||
((data as Map<String, dynamic>)['accounts'] as List).map((v) {
|
||||
final d = v as Map<String, dynamic>;
|
||||
return AccountDTO(
|
||||
d['id'] as String,
|
||||
d['currency'] as String,
|
||||
d['precision'] as int,
|
||||
d['available'] as int,
|
||||
payoutMethod: d['payoutMethod'] as String,
|
||||
payoutLabel: d['payoutLabel'] as String,
|
||||
canFund: d['canFund'] as bool,
|
||||
);
|
||||
}).toList();
|
||||
List<OperationDTO> get operations =>
|
||||
((data as Map<String, dynamic>)['operations'] as List)
|
||||
.map((v) => operation(v as Map<String, dynamic>))
|
||||
.toList();
|
||||
QuoteDTO get quoteValue => quote(data as Map<String, dynamic>);
|
||||
OperationDTO? get operationValue =>
|
||||
data == null ? null : operation(data as Map<String, dynamic>);
|
||||
static QuoteDTO quote(Map<String, dynamic> d) {
|
||||
final r = d['request'] as Map<String, dynamic>;
|
||||
return QuoteDTO(
|
||||
d['id'] as String,
|
||||
RequestDTO(
|
||||
r['kind'] as String,
|
||||
r['source'] as String,
|
||||
(r['destination'] as String?)?.isEmpty == true
|
||||
? null
|
||||
: r['destination'] as String?,
|
||||
r['amount'] as int,
|
||||
r['recipient'] as String? ?? '',
|
||||
),
|
||||
d['fee'] as int,
|
||||
d['debit'] as int,
|
||||
d['credit'] as int,
|
||||
DateTime.parse(d['expiresAt'] as String),
|
||||
d['rateNumerator'] as int,
|
||||
d['rateDenominator'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
static OperationDTO operation(Map<String, dynamic> d) => OperationDTO(
|
||||
d['id'] as String,
|
||||
quote(d['quote'] as Map<String, dynamic>),
|
||||
DateTime.parse(d['createdAt'] as String),
|
||||
DateTime.parse(d['createdAt'] as String),
|
||||
status: d['status'] as String,
|
||||
);
|
||||
ChallengeDTO get challenge => ChallengeDTO(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
class ChallengeDTO {
|
||||
ChallengeDTO(Map<String, dynamic> d)
|
||||
: id = d['challengeId'] as String,
|
||||
target = d['target'] as String,
|
||||
expires = DateTime.parse(d['expiresAt'] as String),
|
||||
resend = DateTime.parse(d['resendAt'] as String);
|
||||
final String id, target;
|
||||
final DateTime expires, resend;
|
||||
}
|
||||
@@ -1,7 +1,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);
|
||||
|
||||
@@ -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';
|
||||
@@ -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:
|
||||
|
||||
@@ -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<String, Object?> 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<String, Object?> 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 = <http.Request>[];
|
||||
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': <Object>[],
|
||||
});
|
||||
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<http.Response>();
|
||||
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<WalletException>()));
|
||||
token = null;
|
||||
await expectLater(service.fetch(), throwsA(isA<WalletException>()));
|
||||
});
|
||||
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<WalletException>()),
|
||||
);
|
||||
service.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
test(
|
||||
'payment factor keeps authorization and resend keys stable across retry',
|
||||
() async {
|
||||
final bodies = <Map<String, dynamic>>[];
|
||||
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<String, dynamic>;
|
||||
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<FactorException>().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': <Object>[],
|
||||
});
|
||||
}),
|
||||
);
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user