59 changed files with 3042 additions and 54 deletions

No files matched your search

+7
View File
@@ -54,6 +54,9 @@ package-notifications-interface: generate-notifications
# Local product stack. Releases are resolved afresh, then pinned by digest. # Local product stack. Releases are resolved afresh, then pinned by digest.
COMPOSE = docker compose --project-directory . -f infra/local/compose.yml --env-file .local/dev/images.env COMPOSE = docker compose --project-directory . -f infra/local/compose.yml --env-file .local/dev/images.env
ifneq ($(wildcard .local/dev/sendico/settings.env),)
COMPOSE += -f infra/local/sendico/compose.yml
endif
.PHONY: build up down logs ps test-dev .PHONY: build up down logs ps test-dev
build: build:
sh infra/local/tools.sh build sh infra/local/tools.sh build
@@ -92,3 +95,7 @@ email:
test-dev-tooling: test-dev-tooling:
sh infra/local/tools.sh test sh infra/local/tools.sh test
.PHONY: sendico
sendico:
sh infra/local/tools.sh sendico
+11 -9
View File
@@ -9,11 +9,11 @@ The consumer app includes responsive login/signup, English and Russian
localization, email verification and recovery screens, and URL-based navigation localization, email verification and recovery screens, and URL-based navigation
with `go_router`. Its interface uses the shared theme's colors and typography. with `go_router`. Its interface uses the shared theme's colors and typography.
**Authentication, email verification and analytics use dummy services.** No real The default preview uses dummy services. Configuring the existing backend URL
account or session is created, no email is delivered, and no analytics data is and client ID selects authenticated BFF adapters for auth, identity, limits and
transmitted by those app services. The backend now contains locally owned copies wallet quotations/payment execution. Optional Sendico configuration and dedicated
of Sendico’s BFF runtime, auth/session stack and account service. The app is not customer financial resources are required for real operations; analytics still
connected to that backend or to Sendico yet. defaults to an in-memory sink. See [payment setup and rollout](docs/payments.md).
## Repository structure ## Repository structure
@@ -256,7 +256,9 @@ gaps and delivery phases. Selected Sendico code has been adapted with recorded
[provenance](backend/PROVENANCE.md); the projects share no runtime modules. [provenance](backend/PROVENANCE.md); the projects share no runtime modules.
Run `make test-backend`, `make lint-backend` or `make run-backend` from the root. Run `make test-backend`, `make lint-backend` or `make run-backend` from the root.
The copied runtime installs local authentication transport and initializes its The runtime installs local authentication, verification delivery, identity and
account service. Signup/profile endpoints, verification delivery/completion, wallet transports. Public Sendico adapters support independent customer facts,
Flutter HTTP adapters and live Sendico operations remain to be wired. See the reviewed identity/limits and quotation/execution through the BFF. Deployment and
[backend run guide](backend/README.md) for local MongoDB configuration. financial-resource provisioning are separate; backend account-language saving
and additional funding/recipient onboarding remain follow-up work. See the
[backend run guide](backend/README.md) and [payment integration](docs/payments.md).
+7
View File
@@ -43,3 +43,10 @@ contracts; local customers retain no platform tenancy. See
`go test -race ./...` and `go vet ./...` validate this module. Tests require Docker `go test -race ./...` and `go vet ./...` validate this module. Tests require Docker
for disposable Mongo replica sets. Root `make test` and `make lint-backend` also for disposable Mongo replica sets. Root `make test` and `make lint-backend` also
validate `shared`. See [source provenance](../PROVENANCE.md). validate `shared`. See [source provenance](../PROVENANCE.md).
## Customer quotations and payments
With `api.sendico`, runtime composes the customer-scoped payment service and
public HTTP provider adapter. Flutter backend mode selects this path automatically;
missing provider configuration never returns demo money. See
[contracts, provisioning, authorization and rollout](../../docs/payments.md).
+2
View File
@@ -7,6 +7,7 @@ import (
"git.sendico.io/tech/sxchnge/product/auth/storage" "git.sendico.io/tech/sxchnge/product/auth/storage"
"git.sendico.io/tech/sxchnge/product/customers" "git.sendico.io/tech/sxchnge/product/customers"
"git.sendico.io/tech/sxchnge/product/identity" "git.sendico.io/tech/sxchnge/product/identity"
"git.sendico.io/tech/sxchnge/product/payments"
"git.sendico.io/tech/sxchnge/product/twofactor" "git.sendico.io/tech/sxchnge/product/twofactor"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -19,6 +20,7 @@ type Dependencies struct {
Auth *flow.Service Auth *flow.Service
Factors *twofactor.Service Factors *twofactor.Service
Identity *identity.Service Identity *identity.Service
Payments *payments.Service
Token middleware.TokenConfig Token middleware.TokenConfig
Signature middleware.Signature Signature middleware.Signature
LoginLimits middleware.LoginRateLimits LoginLimits middleware.LoginRateLimits
+2
View File
@@ -6,6 +6,7 @@ import (
auth "git.sendico.io/tech/sxchnge/product/auth/http/v1" auth "git.sendico.io/tech/sxchnge/product/auth/http/v1"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/public" "git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
identity "git.sendico.io/tech/sxchnge/product/identity/http/v1" identity "git.sendico.io/tech/sxchnge/product/identity/http/v1"
paymenthttp "git.sendico.io/tech/sxchnge/product/payments/http/v1"
factors "git.sendico.io/tech/sxchnge/product/twofactor/http/v1" factors "git.sendico.io/tech/sxchnge/product/twofactor/http/v1"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
) )
@@ -24,5 +25,6 @@ func Install(scope api.Scope, deps *api.Dependencies) error {
factors.Install(routes, deps.Factors, scope.Logger) factors.Install(routes, deps.Factors, scope.Logger)
} }
identity.Install(routes, deps.Identity, scope.Logger) identity.Install(routes, deps.Identity, scope.Logger)
paymenthttp.Install(routes, deps.Payments, scope.Logger)
return nil return nil
} }
+9
View File
@@ -10,6 +10,7 @@ import (
"git.sendico.io/tech/sxchnge/product/customers" "git.sendico.io/tech/sxchnge/product/customers"
"git.sendico.io/tech/sxchnge/product/identity" "git.sendico.io/tech/sxchnge/product/identity"
"git.sendico.io/tech/sxchnge/product/notifications" "git.sendico.io/tech/sxchnge/product/notifications"
"git.sendico.io/tech/sxchnge/product/payments"
"git.sendico.io/tech/sxchnge/product/providers/sendico" "git.sendico.io/tech/sxchnge/product/providers/sendico"
"git.sendico.io/tech/sxchnge/product/twofactor" "git.sendico.io/tech/sxchnge/product/twofactor"
"git.sendico.io/tech/sxchnge/product/twofactor/delivery" "git.sendico.io/tech/sxchnge/product/twofactor/delivery"
@@ -69,6 +70,11 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
} }
deps.Identity = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("identity")} deps.Identity = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("identity")}
deps.Auth.Facts = deps.Identity deps.Auth.Facts = deps.Identity
paymentStore, err := payments.OpenStore(db)
if err != nil {
return nil, nil, err
}
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
logger.Info("Sendico sign-up facts integration enabled") logger.Info("Sendico sign-up facts integration enabled")
} }
records, err := factorstore.Open(logger, db) records, err := factorstore.Open(logger, db)
@@ -80,6 +86,9 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
return nil, nil, err return nil, nil, err
} }
} }
if deps.Payments != nil {
deps.Payments.Factors = deps.Factors
}
if config.Sendico != nil && deps.Auth == nil { if config.Sendico != nil && deps.Auth == nil {
return nil, nil, merrors.InvalidArgument("Sendico sign-up facts require notifications configuration") return nil, nil, merrors.InvalidArgument("Sendico sign-up facts require notifications configuration")
} }
+10
View File
@@ -147,3 +147,13 @@ func (s *Service) SubmitVerification(ctx context.Context, a *customer.Account, k
s.Logger.Info("Identity proofs accepted for review", zap.String("account_ref", a.ID.Hex()), zap.String("submission_ref", v.SubmissionID)) s.Logger.Info("Identity proofs accepted for review", zap.String("account_ref", a.ID.Hex()), zap.String("submission_ref", v.SubmissionID))
return v, nil return v, nil
} }
// Subject resolves the authenticated customer's confirmed provider binding.
// Only server-side services may use this resource; clients cannot select it.
func (s *Service) Subject(ctx context.Context, a *customer.Account) (string, error) {
b, err := s.binding(ctx, a)
if err != nil {
return "", err
}
return b.SubjectRef, nil
}
+84
View File
@@ -0,0 +1,84 @@
package paymenthttp
import (
"git.sendico.io/tech/sxchnge/product/payments"
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
"time"
)
type requestDTO struct {
Kind string `json:"kind"`
Source string `json:"source"`
Destination string `json:"destination"`
Recipient string `json:"recipient"`
Amount int64 `json:"amount"`
}
func (d requestDTO) model() payments.Request {
return payments.Request{Kind: d.Kind, Source: d.Source, Destination: d.Destination, Recipient: d.Recipient, Amount: d.Amount}
}
func fromRequest(r payments.Request) requestDTO {
return requestDTO{r.Kind, r.Source, r.Destination, r.Recipient, r.Amount}
}
type quoteDTO struct {
ID string `json:"id"`
Request requestDTO `json:"request"`
Fee int64 `json:"fee"`
Debit int64 `json:"debit"`
Credit int64 `json:"credit"`
ExpiresAt time.Time `json:"expiresAt"`
Numerator int64 `json:"rateNumerator"`
Denominator int64 `json:"rateDenominator"`
}
func fromQuote(q payments.Quote) quoteDTO {
return quoteDTO{q.ID, fromRequest(q.Request), q.Fee, q.Debit, q.Credit, q.ExpiresAt, q.RateNumerator, q.RateDenominator}
}
type operationDTO struct {
ID string `json:"id"`
Quote quoteDTO `json:"quote"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
}
func fromOperation(o payments.Operation) operationDTO {
return operationDTO{o.ID, fromQuote(o.Quote), o.Status, o.CreatedAt}
}
type accountDTO struct {
ID string `json:"id"`
Currency string `json:"currency"`
Precision int `json:"precision"`
Available int64 `json:"available"`
PayoutMethod string `json:"payoutMethod"`
PayoutLabel string `json:"payoutLabel"`
CanFund bool `json:"canFund"`
}
type snapshotDTO struct {
Accounts []accountDTO `json:"accounts"`
Operations []operationDTO `json:"operations"`
}
func fromSnapshot(s payments.Snapshot) snapshotDTO {
v := snapshotDTO{Accounts: []accountDTO{}, Operations: []operationDTO{}}
for _, a := range s.Accounts {
v.Accounts = append(v.Accounts, accountDTO{a.ID, a.Currency, a.Precision, a.Available, a.PayoutMethod, a.PayoutLabel, a.FundingMethod != ""})
}
for i := len(s.Operations) - 1; i >= 0; i-- {
v.Operations = append(v.Operations, fromOperation(s.Operations[i]))
}
return v
}
type challengeDTO struct {
ID string `json:"challengeId"`
Target string `json:"target"`
ExpiresAt time.Time `json:"expiresAt"`
ResendAt time.Time `json:"resendAt"`
}
func fromChallenge(c factor.Challenge) challengeDTO {
return challengeDTO{c.ID, c.Target, c.ExpiresAt, c.ResendAt}
}
+164
View File
@@ -0,0 +1,164 @@
package paymenthttp
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/payments"
verification "git.sendico.io/tech/sxchnge/product/verification/store"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/http/response"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
mservice "git.sendico.io/tech/sxchnge/shared/service"
)
type Registrar interface {
AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc)
}
type router struct {
service *payments.Service
logger mlogger.Logger
}
func Install(reg Registrar, s *payments.Service, logger mlogger.Logger) {
h := &router{s, logger.Named("payment_http")}
reg.AccountHandler(mservice.Accounts, "/wallet/profile", api.Get, h.profile)
reg.AccountHandler(mservice.Accounts, "/wallet", api.Get, h.fetch)
reg.AccountHandler(mservice.Accounts, "/wallet/quote", api.Post, h.quote)
reg.AccountHandler(mservice.Accounts, "/wallet/submit", api.Post, h.submit)
reg.AccountHandler(mservice.Accounts, "/wallet/submission", api.Post, h.reconcile)
reg.AccountHandler(mservice.Accounts, "/wallet/challenge", api.Post, h.challenge)
reg.AccountHandler(mservice.Accounts, "/wallet/resend", api.Post, h.resend)
reg.AccountHandler(mservice.Accounts, "/wallet/verify", api.Post, h.verify)
}
func decode(r *http.Request, dst any) error {
d := json.NewDecoder(io.LimitReader(r.Body, 8193))
d.DisallowUnknownFields()
if d.Decode(dst) != nil {
return payments.InvalidAmount
}
var extra any
if d.Decode(&extra) != io.EOF {
return payments.InvalidAmount
}
return nil
}
func (h *router) result(data any, err error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if err == nil {
response.Ok(h.logger, data)(w, r)
return
}
code := "unavailable"
status := http.StatusServiceUnavailable
var f payments.Failure
if errors.As(err, &f) {
code = string(f)
if f != payments.Unavailable && f != payments.Uncertain {
status = http.StatusBadRequest
}
}
switch {
case errors.Is(err, verification.ErrTokenExpired):
code = "factorExpired"
status = 400
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
code = "factorLocked"
status = 400
case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed), errors.Is(err, merrors.ErrUnauthorized):
code = "factorInvalid"
status = 400
}
if throttle, ok := verification.Throttle(err); ok {
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(throttle.RetryAfter.Seconds()))))
code = "factorCooldown"
status = 429
}
response.Error(h.logger, mservice.Accounts, status, code, "Unable to complete wallet operation")(w, r)
}
}
func (h *router) fetch(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
v, e := h.service.Fetch(r.Context(), a)
return h.result(fromSnapshot(v), e)
}
func (h *router) quote(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d requestDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Quote(r.Context(), a, d.model())
return h.result(fromQuote(v), e)
}
type actionDTO struct {
Quote string `json:"quoteId"`
Key string `json:"idempotencyKey"`
Challenge string `json:"challengeId"`
Code string `json:"code"`
}
func (h *router) submit(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Submit(r.Context(), a, d.Quote, d.Key)
return h.result(fromOperation(v), e)
}
func (h *router) reconcile(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Reconcile(r.Context(), a, d.Key)
if v == nil {
return h.result(nil, e)
}
return h.result(fromOperation(*v), e)
}
func requestIP(r *http.Request) string {
if ip := ipguard.ClientIP(r); ip != nil {
return ip.String()
}
return "unresolved"
}
func (h *router) challenge(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Challenge(r.Context(), a, d.Quote, requestIP(r))
return h.result(fromChallenge(v), e)
}
func (h *router) resend(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Resend(r.Context(), a, d.Quote, d.Challenge, d.Key, requestIP(r))
return h.result(fromChallenge(v), e)
}
func (h *router) verify(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
e := h.service.Authorize(r.Context(), a, d.Quote, d.Challenge, d.Code, d.Key)
return h.result(struct {
Key string `json:"idempotencyKey"`
}{d.Key}, e)
}
func (h *router) profile(_ *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
return h.result(struct {
Locale string `json:"locale"`
}{a.Locale}, nil)
}
@@ -0,0 +1,36 @@
package paymenthttp
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func TestUnconfiguredWalletCannotReturnDemoFunds(t *testing.T) {
h := &router{logger: zap.NewNop()}
a := &customer.Account{}
a.ID = bson.NewObjectID()
request := httptest.NewRequest(http.MethodGet, "/accounts/wallet", nil)
recorder := httptest.NewRecorder()
h.fetch(request, a, nil)(recorder, request)
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
require.Equal(t, "no-store", recorder.Header().Get("Cache-Control"))
require.NotContains(t, recorder.Body.String(), "7500")
}
func TestQuoteRejectsUnknownFieldsAndTrailingRequests(t *testing.T) {
h := &router{logger: zap.NewNop()}
a := &customer.Account{}
a.ID = bson.NewObjectID()
for _, body := range []string{`{"kind":"payout","subject":"foreign"}`, `{"amount":10} {"amount":20}`, `{"amount":1.2}`} {
request := httptest.NewRequest(http.MethodPost, "/accounts/wallet/quote", strings.NewReader(body))
recorder := httptest.NewRecorder()
h.quote(request, a, nil)(recorder, request)
require.Equal(t, http.StatusBadRequest, recorder.Code)
}
}
+83
View File
@@ -0,0 +1,83 @@
// Package payments owns customer quotation ownership and durable execution intent.
// Money and settlement remain authoritative in the provider.
package payments
import (
"context"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/shared/database/record"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Account struct {
ID, Currency string
Precision int
Available int64
PayoutMethod, PayoutLabel, FundingMethod string
}
type Request struct {
Kind, Source, Destination, Recipient string
Amount int64
}
type Quote struct {
ID string
Request Request
Fee, Debit, Credit int64
ExpiresAt time.Time
RateNumerator, RateDenominator int64
}
type Operation struct {
ID string
Quote Quote
Status string
CreatedAt time.Time
}
type Snapshot struct {
Accounts []Account
Operations []Operation
}
type Record struct {
storable.Base `bson:",inline"`
Customer bson.ObjectID `bson:"customer"`
Namespace string `bson:"namespace"`
Subject string `bson:"subject"`
Quote Quote `bson:"quote"`
ProviderQuote string `bson:"providerQuote"`
Key string `bson:"key"`
Payment string `bson:"payment"`
}
func (*Record) Collection() string { return "payment_quotes" }
type Provider interface {
Namespace() string
Accounts(context.Context, string) ([]Account, error)
Quote(context.Context, string, Request, []Account) (Quote, error)
Execute(context.Context, string, string) (RemoteOperation, error)
Payment(context.Context, string) (RemoteOperation, error)
}
type RemoteOperation struct {
ID, QuoteRef, Status string
CreatedAt time.Time
}
type Identity interface {
Subject(context.Context, *customer.Account) (string, error)
}
type Failure string
func (e Failure) Error() string { return string(e) }
const (
Unavailable Failure = "unavailable"
InvalidAmount Failure = "invalidAmount"
InvalidRecipient Failure = "invalidRecipient"
InsufficientFunds Failure = "insufficientFunds"
LimitExceeded Failure = "limitExceeded"
ExpiredQuote Failure = "expiredQuote"
Unsupported Failure = "unsupported"
NotFound Failure = "notFound"
Uncertain Failure = "uncertain"
FactorRequired Failure = "factorRequired"
)
+237
View File
@@ -0,0 +1,237 @@
package payments
import (
"context"
"errors"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/twofactor"
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
verification "git.sendico.io/tech/sxchnge/product/verification/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type Service struct {
Store Store
Provider Provider
Identity Identity
Factors twofactor.API
Logger mlogger.Logger
}
func (s *Service) ready() error {
if s == nil || s.Store == nil || s.Provider == nil || s.Identity == nil || s.Factors == nil {
return Unavailable
}
return nil
}
func (s *Service) record(ctx context.Context, a *customer.Account, id string) (*Record, error) {
if err := s.ready(); err != nil {
return nil, err
}
if a == nil || a.ID.IsZero() {
return nil, NotFound
}
ref, err := bson.ObjectIDFromHex(id)
if err != nil {
return nil, NotFound
}
r, err := s.Store.Get(ctx, ref, a.ID, s.Provider.Namespace())
if errors.Is(err, merrors.ErrNoData) {
return nil, NotFound
}
if err != nil {
return nil, err
}
subject, err := s.Identity.Subject(ctx, a)
if err != nil {
return nil, err
}
if subject != r.Subject {
return nil, Unavailable
}
return r, nil
}
func (s *Service) Quote(ctx context.Context, a *customer.Account, request Request) (Quote, error) {
if err := s.ready(); err != nil {
return Quote{}, err
}
subject, err := s.Identity.Subject(ctx, a)
if err != nil {
return Quote{}, err
}
accounts, err := s.Provider.Accounts(ctx, subject)
if err != nil {
return Quote{}, err
}
q, err := s.Provider.Quote(ctx, subject, request, accounts)
if err != nil {
return Quote{}, err
}
providerRef := q.ID
q.ID = bson.NewObjectID().Hex()
r := &Record{Customer: a.ID, Namespace: s.Provider.Namespace(), Subject: subject, Quote: q, ProviderQuote: providerRef}
r.SetID(mustID(q.ID))
if err = s.Store.Create(ctx, r); err != nil {
return Quote{}, err
}
s.Logger.Info("Customer quotation stored", zap.String("quote_ref", q.ID), zap.String("kind", request.Kind))
return q, nil
}
func mustID(id string) bson.ObjectID { value, _ := bson.ObjectIDFromHex(id); return value }
func operationScope(r *Record) factor.Scope {
return factor.Scope{Purpose: verification.PurposePayment, ContextRef: r.Quote.ID}
}
func (s *Service) Challenge(ctx context.Context, a *customer.Account, id, ip string) (factor.Challenge, error) {
r, err := s.record(ctx, a, id)
if err != nil {
return factor.Challenge{}, err
}
if !time.Now().Before(r.Quote.ExpiresAt) || r.Key != "" {
return factor.Challenge{}, ExpiredQuote
}
return s.Factors.Begin(ctx, factor.Request{Subject: a.ID, Scope: operationScope(r), Deadline: r.Quote.ExpiresAt, IdempotencyKey: "payment:" + r.Quote.ID, IP: ip})
}
func (s *Service) Resend(ctx context.Context, a *customer.Account, id, challenge, key, ip string) (factor.Challenge, error) {
r, err := s.record(ctx, a, id)
if err != nil {
return factor.Challenge{}, err
}
if !time.Now().Before(r.Quote.ExpiresAt) || r.Key != "" {
return factor.Challenge{}, ExpiredQuote
}
if !validKey(key) {
return factor.Challenge{}, InvalidAmount
}
return s.Factors.Resend(ctx, factor.Request{Subject: a.ID, Scope: operationScope(r), Deadline: r.Quote.ExpiresAt, IdempotencyKey: key, IP: ip}, challenge)
}
func validKey(key string) bool {
if len(key) < 16 || len(key) > 128 {
return false
}
for _, c := range key {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
return false
}
}
return true
}
// Authorize consumes the challenge atomically with a durable execution intent.
// No upstream I/O occurs inside the transaction. Replaying the same authorization
// recovers a lost response without reusing the one-use verification code.
func (s *Service) Authorize(ctx context.Context, a *customer.Account, id, challenge, code, key string) error {
r, err := s.record(ctx, a, id)
if err != nil {
return err
}
if !validKey(key) {
return InvalidAmount
}
if r.Key != "" {
if r.Key == key {
return nil
}
return ExpiredQuote
}
return s.Factors.Complete(ctx, a.ID, operationScope(r), challenge, code, func(tx context.Context) error {
if !time.Now().Before(r.Quote.ExpiresAt) {
return ExpiredQuote
}
return s.Store.Authorize(tx, r, key)
})
}
func (s *Service) execute(ctx context.Context, r *Record) (Operation, error) {
var remote RemoteOperation
var err error
if r.Payment != "" {
remote, err = s.Provider.Payment(ctx, r.Payment)
} else {
remote, err = s.Provider.Execute(ctx, r.ProviderQuote, "sxchnge:payment:"+r.ID.Hex())
}
if err != nil {
s.Logger.Warn("Payment execution needs reconciliation", zap.String("quote_ref", r.Quote.ID))
return Operation{}, merrors.OpaqueWrap(Uncertain, err, "provider payment outcome unknown")
}
if remote.QuoteRef != r.ProviderQuote || remote.ID == "" || (r.Payment != "" && r.Payment != remote.ID) {
return Operation{}, Uncertain
}
if r.Payment == "" {
if err = s.Store.Attach(ctx, r, remote.ID); err != nil {
return Operation{}, merrors.OpaqueWrap(Uncertain, err, "persist provider payment reference")
}
}
s.Logger.Info("Payment execution reconciled", zap.String("quote_ref", r.Quote.ID), zap.String("payment_ref", remote.ID), zap.String("state", remote.Status))
return Operation{ID: r.Quote.ID, Quote: r.Quote, Status: remote.Status, CreatedAt: remote.CreatedAt}, nil
}
func (s *Service) Submit(ctx context.Context, a *customer.Account, id, key string) (Operation, error) {
r, err := s.record(ctx, a, id)
if err != nil {
return Operation{}, err
}
if !validKey(key) || r.Key == "" || r.Key != key {
return Operation{}, FactorRequired
}
return s.execute(ctx, r)
}
// Reconcile replays the already authorized durable intent with the same provider
// key. A timeout or an empty upstream list is never treated as proof of absence.
func (s *Service) Reconcile(ctx context.Context, a *customer.Account, key string) (*Operation, error) {
if err := s.ready(); err != nil {
return nil, err
}
if !validKey(key) {
return nil, NotFound
}
r, err := s.Store.ByKey(ctx, a.ID, s.Provider.Namespace(), key)
if errors.Is(err, merrors.ErrNoData) {
return nil, nil
}
if err != nil {
return nil, err
}
r, err = s.record(ctx, a, r.ID.Hex())
if err != nil {
return nil, err
}
op, err := s.execute(ctx, r)
return &op, err
}
func (s *Service) Fetch(ctx context.Context, a *customer.Account) (Snapshot, error) {
if err := s.ready(); err != nil {
return Snapshot{}, err
}
subject, err := s.Identity.Subject(ctx, a)
if err != nil {
return Snapshot{}, err
}
rows, err := s.Store.List(ctx, a.ID, s.Provider.Namespace())
if err != nil {
return Snapshot{}, err
}
result := Snapshot{Operations: []Operation{}}
for _, r := range rows {
if r.Subject != subject {
return Snapshot{}, Unavailable
}
op, err := s.execute(ctx, &r)
if errors.Is(err, Uncertain) {
// A durable authorization with an unknown provider outcome remains
// pending. It must not hide unrelated balances or report failure.
op = Operation{ID: r.Quote.ID, Quote: r.Quote, Status: "pending", CreatedAt: r.CreatedAt}
} else if err != nil {
return Snapshot{}, err
}
result.Operations = append(result.Operations, op)
}
result.Accounts, err = s.Provider.Accounts(ctx, subject)
if err != nil {
return Snapshot{}, err
}
return result, nil
}
+189
View File
@@ -0,0 +1,189 @@
package payments
import (
"context"
"sync"
"testing"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type memoryStore struct {
mu sync.Mutex
records map[bson.ObjectID]Record
}
func (s *memoryStore) Create(_ context.Context, r *Record) error {
s.mu.Lock()
defer s.mu.Unlock()
s.records[r.ID] = *r
return nil
}
func (s *memoryStore) Get(_ context.Context, id, customer bson.ObjectID, namespace string) (*Record, error) {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.records[id]
if !ok || r.Customer != customer || r.Namespace != namespace {
return nil, merrors.ErrNoData
}
return &r, nil
}
func (s *memoryStore) ByKey(_ context.Context, customer bson.ObjectID, namespace, key string) (*Record, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, r := range s.records {
if r.Customer == customer && r.Namespace == namespace && r.Key == key {
return &r, nil
}
}
return nil, merrors.ErrNoData
}
func (s *memoryStore) List(_ context.Context, customer bson.ObjectID, namespace string) ([]Record, error) {
s.mu.Lock()
defer s.mu.Unlock()
rows := []Record{}
for _, r := range s.records {
if r.Customer == customer && r.Namespace == namespace && r.Key != "" {
rows = append(rows, r)
}
}
return rows, nil
}
func (s *memoryStore) Authorize(_ context.Context, r *Record, key string) error {
s.mu.Lock()
defer s.mu.Unlock()
current := s.records[r.ID]
if current.Key != "" {
return Uncertain
}
current.Key = key
s.records[r.ID] = current
return nil
}
func (s *memoryStore) Attach(_ context.Context, r *Record, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
current := s.records[r.ID]
current.Payment = id
s.records[r.ID] = current
return nil
}
type provider struct {
mu sync.Mutex
accepted int
lost bool
status string
key string
namespace string
}
func (p *provider) Namespace() string { return p.namespace }
func (p *provider) Accounts(context.Context, string) ([]Account, error) {
return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil
}
func (p *provider) Quote(_ context.Context, _ string, r Request, _ []Account) (Quote, error) {
return Quote{ID: "provider-quote", Request: r, ExpiresAt: time.Now().Add(time.Minute)}, nil
}
func (p *provider) Execute(_ context.Context, quote, key string) (RemoteOperation, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.key == "" {
p.accepted++
p.key = key
}
if p.key != key {
return RemoteOperation{}, Unavailable
}
if p.lost {
p.lost = false
return RemoteOperation{}, Unavailable
}
return RemoteOperation{ID: "payment", QuoteRef: quote, Status: p.status, CreatedAt: time.Now()}, nil
}
func (p *provider) Payment(context.Context, string) (RemoteOperation, error) {
return RemoteOperation{ID: "payment", QuoteRef: "provider-quote", Status: p.status, CreatedAt: time.Now()}, nil
}
type identity struct{}
func (identity) Subject(context.Context, *customer.Account) (string, error) { return "subject", nil }
type factors struct {
calls int
subject bson.ObjectID
scope factor.Scope
}
func (f *factors) Begin(_ context.Context, r factor.Request) (factor.Challenge, error) {
f.subject = r.Subject
f.scope = r.Scope
return factor.Challenge{ID: "challenge"}, nil
}
func (f *factors) Resend(ctx context.Context, r factor.Request, _ string) (factor.Challenge, error) {
return f.Begin(ctx, r)
}
func (f *factors) Complete(ctx context.Context, subject bson.ObjectID, scope factor.Scope, id, code string, action func(context.Context) error) error {
if subject != f.subject || scope != f.scope || id != "challenge" || code != "123456" {
return FactorRequired
}
f.calls++
return action(ctx)
}
func TestCustomerExecutionOwnershipAndLostResponse(t *testing.T) {
ctx := context.Background()
a := &customer.Account{}
a.ID = bson.NewObjectID()
other := &customer.Account{}
other.ID = bson.NewObjectID()
store := &memoryStore{records: map[bson.ObjectID]Record{}}
p := &provider{lost: true, status: "pending", namespace: "provider"}
f := &factors{}
s := &Service{Store: store, Provider: p, Identity: identity{}, Factors: f, Logger: zap.NewNop()}
q, err := s.Quote(ctx, a, Request{Kind: "payout", Source: "ledger", Amount: 1000})
require.NoError(t, err)
_, err = s.Submit(ctx, a, q.ID, "unapproved-key-123")
require.ErrorIs(t, err, FactorRequired)
require.Zero(t, p.accepted)
_, err = s.Challenge(ctx, other, q.ID, "")
require.ErrorIs(t, err, NotFound)
_, err = s.Challenge(ctx, a, q.ID, "")
require.NoError(t, err)
require.ErrorIs(t, s.Authorize(ctx, a, q.ID, "challenge", "bad-code", "authorized-key-123"), FactorRequired)
require.NoError(t, s.Authorize(ctx, a, q.ID, "challenge", "123456", "authorized-key-123"))
require.NoError(t, s.Authorize(ctx, a, q.ID, "challenge", "123456", "authorized-key-123"))
require.Equal(t, 1, f.calls)
_, err = s.Submit(ctx, a, q.ID, "authorized-key-123")
require.ErrorIs(t, err, Uncertain)
op, err := s.Reconcile(ctx, other, "authorized-key-123")
require.NoError(t, err)
require.Nil(t, op)
op, err = s.Reconcile(ctx, a, "authorized-key-123")
require.NoError(t, err)
require.Equal(t, "pending", op.Status)
require.Equal(t, 1, p.accepted)
p.status = "completed"
snapshot, err := s.Fetch(ctx, a)
require.NoError(t, err)
require.Equal(t, "completed", snapshot.Operations[0].Status)
require.ErrorIs(t, s.Authorize(ctx, a, q.ID, "challenge", "123456", "different-key-1234"), ExpiredQuote)
// A changed provider namespace cannot execute the old quote.
p.namespace = "rotated-provider"
_, err = s.Submit(ctx, a, q.ID, "authorized-key-123")
require.ErrorIs(t, err, NotFound)
}
func TestExpiredQuoteCannotAuthorize(t *testing.T) {
a := &customer.Account{}
a.ID = bson.NewObjectID()
r := Record{Customer: a.ID, Namespace: "p", Subject: "subject", Quote: Quote{ID: bson.NewObjectID().Hex(), ExpiresAt: time.Now().Add(-time.Second)}}
r.SetID(mustID(r.Quote.ID))
s := &Service{Store: &memoryStore{records: map[bson.ObjectID]Record{r.ID: r}}, Provider: &provider{namespace: "p"}, Identity: identity{}, Factors: &factors{}, Logger: zap.NewNop()}
_, err := s.Challenge(context.Background(), a, r.Quote.ID, "")
require.ErrorIs(t, err, ExpiredQuote)
}
+88
View File
@@ -0,0 +1,88 @@
package payments
import (
"context"
db "git.sendico.io/tech/sxchnge/shared/database"
deps "git.sendico.io/tech/sxchnge/shared/database/dependencies"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/source"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Store interface {
Create(context.Context, *Record) error
Get(context.Context, bson.ObjectID, bson.ObjectID, string) (*Record, error)
ByKey(context.Context, bson.ObjectID, string, string) (*Record, error)
List(context.Context, bson.ObjectID, string) ([]Record, error)
Authorize(context.Context, *Record, string) error
Attach(context.Context, *Record, string) error
}
type mongoStore struct{ records repository.Repository }
func OpenStore(factory db.Factory) (Store, error) {
d, err := deps.ResolveDependencies(factory)
if err != nil {
return nil, err
}
r := repository.CreateRepository(source.Majority(d.Database), "payment_quotes")
err = r.CreateIndex(&ri.Definition{Name: "customer_execution_key", Unique: true, Keys: []ri.Key{{Field: "customer", Sort: ri.Asc}, {Field: "namespace", Sort: ri.Asc}, {Field: "key", Sort: ri.Asc}}, PartialFilter: repository.Query().Comparison(repository.Field("key"), builder.Gt, "")})
if err != nil {
return nil, err
}
if err = r.CreateIndex(&ri.Definition{Name: "provider_quote_unique", Unique: true, Keys: []ri.Key{{Field: "namespace", Sort: ri.Asc}, {Field: "providerQuote", Sort: ri.Asc}}, PartialFilter: repository.Query().Comparison(repository.Field("providerQuote"), builder.Gt, "")}); err != nil {
return nil, err
}
return &mongoStore{r}, nil
}
func scope(customer bson.ObjectID, namespace string) repository.FilterQuery {
return repository.Query().Filter(repository.Field("customer"), customer).Filter(repository.Field("namespace"), namespace)
}
func (s *mongoStore) Create(ctx context.Context, r *Record) error {
return s.records.Insert(ctx, r, nil)
}
func (s *mongoStore) Get(ctx context.Context, id, customer bson.ObjectID, namespace string) (*Record, error) {
r := &Record{}
err := s.records.FindOneByFilter(ctx, scope(customer, namespace).Filter(repository.IDField(), id), r)
return r, err
}
func (s *mongoStore) ByKey(ctx context.Context, customer bson.ObjectID, namespace, key string) (*Record, error) {
r := &Record{}
err := s.records.FindOneByFilter(ctx, scope(customer, namespace).Filter(repository.Field("key"), key), r)
return r, err
}
func (s *mongoStore) List(ctx context.Context, customer bson.ObjectID, namespace string) ([]Record, error) {
var rows []Record
err := s.records.FindAllByFilter(ctx, scope(customer, namespace).Comparison(repository.Field("key"), builder.Gt, "").Sort(repository.Field("createdAt"), true), &rows)
return rows, err
}
func (s *mongoStore) Authorize(ctx context.Context, r *Record, key string) error {
matched, err := s.records.PatchOneByFilter(ctx, scope(r.Customer, r.Namespace).Filter(repository.IDField(), r.ID).Filter(repository.Field("key"), ""), repository.Patch().Set(repository.Field("key"), key), &Record{})
if err != nil {
return err
}
if !matched {
return Uncertain
}
return nil
}
func (s *mongoStore) Attach(ctx context.Context, r *Record, id string) error {
matched, err := s.records.PatchOneByFilter(ctx, scope(r.Customer, r.Namespace).Filter(repository.IDField(), r.ID).Filter(repository.Field("payment"), ""), repository.Patch().Set(repository.Field("payment"), id), &Record{})
if err != nil {
return err
}
if matched {
return nil
}
current, err := s.Get(ctx, r.ID, r.Customer, r.Namespace)
if err != nil {
return err
}
if current.Payment != id {
return Uncertain
}
return nil
}
+66
View File
@@ -0,0 +1,66 @@
package payments
import (
"context"
"net/url"
"testing"
"time"
db "git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func TestDurableAuthorizationTransactionAndCustomerIsolation(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx)
require.NoError(t, err)
u, err := url.Parse(uri)
require.NoError(t, err)
q := u.Query()
q.Set("directConnection", "true")
u.RawQuery = q.Encode()
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "payments"}})
require.NoError(t, err)
t.Cleanup(factory.CloseConnection)
store, err := OpenStore(factory)
require.NoError(t, err)
customer := bson.NewObjectID()
r := &Record{Customer: customer, Namespace: "provider", Subject: "subject"}
r.SetID(bson.NewObjectID())
require.NoError(t, store.Create(ctx, r))
_, err = store.Get(ctx, r.ID, bson.NewObjectID(), r.Namespace)
require.ErrorIs(t, err, merrors.ErrNoData)
_, err = factory.TransactionFactory().CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
require.NoError(t, store.Authorize(tx, r, "authorized-key-123"))
return nil, Uncertain
})
require.ErrorIs(t, err, Uncertain)
stored, err := store.Get(ctx, r.ID, customer, r.Namespace)
require.NoError(t, err)
require.Empty(t, stored.Key)
require.NoError(t, store.Authorize(ctx, r, "authorized-key-123"))
require.ErrorIs(t, store.Authorize(ctx, r, "other-key-123456"), Uncertain)
another := &Record{Customer: customer, Namespace: r.Namespace}
another.SetID(bson.NewObjectID())
require.NoError(t, store.Create(ctx, another))
require.ErrorIs(t, store.Authorize(ctx, another, "authorized-key-123"), merrors.ErrDataConflict)
require.NoError(t, store.Attach(ctx, r, "payment-1"))
require.NoError(t, store.Attach(ctx, r, "payment-1"))
require.ErrorIs(t, store.Attach(ctx, r, "different-payment"), Uncertain)
reopened, err := OpenStore(factory)
require.NoError(t, err)
stored, err = reopened.ByKey(ctx, customer, r.Namespace, "authorized-key-123")
require.NoError(t, err)
require.Equal(t, "payment-1", stored.Payment)
_, err = reopened.ByKey(ctx, customer, "other-provider", "authorized-key-123")
require.ErrorIs(t, err, merrors.ErrNoData)
}
@@ -0,0 +1,110 @@
package sendico
import (
"context"
"net/http"
"git.sendico.io/tech/sxchnge/product/payments"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Accounts exposes only dedicated, organization-owned customer ledgers tagged by
// trusted platform provisioning. The integration login's personal/pooled ledgers
// never become customer balances. Method bindings are provisioned on that ledger.
func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Account, error) {
if !validRef(subject) {
return nil, payments.Unavailable
}
var currencies struct {
Rows []currencyDTO `json:"currencies"`
}
if err := c.paymentRequest(ctx, http.MethodGet, "/payments/fx/currencies/"+c.config.OrganizationID, nil, &currencies); err != nil {
return nil, err
}
scales := map[string]int{}
for _, row := range currencies.Rows {
if row.Scale != nil && *row.Scale >= 0 && *row.Scale <= 8 {
scales[row.Code] = *row.Scale
}
}
var data struct {
Accounts []accountDTO `json:"accounts"`
}
root := "/ledger_accounts/" + c.config.OrganizationID
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
return nil, err
}
accounts := []payments.Account{}
for _, row := range data.Accounts {
if row.Metadata["sxchnge_subject_ref"] != subject {
continue
}
if !validRef(row.ID) || row.ScopeRef != c.config.OrganizationID || row.Owner != "" || row.Status != "ACCOUNT_STATUS_ACTIVE" || row.Type != "ACCOUNT_TYPE_ASSET" || row.LedgerRole != "ACCOUNT_ROLE_OPERATING" {
return nil, payments.Unavailable
}
scale, ok := scales[row.Currency]
if !ok {
return nil, payments.Unsupported
}
var balance struct {
Value balanceDTO `json:"balance"`
}
if err := c.paymentRequest(ctx, http.MethodGet, root+"/"+row.ID+"/balance", nil, &balance); err != nil {
return nil, err
}
account, err := mapAccount(row, balance.Value, scale)
if err != nil {
return nil, err
}
accounts = append(accounts, account)
}
return accounts, nil
}
func (c *Client) Quote(ctx context.Context, subject string, r payments.Request, accounts []payments.Account) (payments.Quote, error) {
mapped, err := mapIntent(subject, r, accounts)
if err != nil {
return payments.Quote{}, err
}
var response struct {
Quote paymentQuoteDTO `json:"quote"`
Subject string `json:"externalSubjectRef"`
}
err = c.paymentRequest(ctx, http.MethodPost, "/payments/quote/"+c.config.OrganizationID, quoteRequestDTO{Key: "sxchnge:quote:" + bson.NewObjectID().Hex(), Intent: mapped.wire}, &response)
if err != nil {
return payments.Quote{}, err
}
if response.Subject != subject {
return payments.Quote{}, payments.Unavailable
}
return mapPaymentQuote(response.Quote, mapped.request, mapped.source, mapped.destination)
}
func (c *Client) Execute(ctx context.Context, quote, key string) (payments.RemoteOperation, error) {
if !safePaymentRef(quote) {
return payments.RemoteOperation{}, payments.Unavailable
}
var result struct {
Payment paymentDTO `json:"payment"`
}
payload := struct {
Quote string `json:"quoteRef"`
Key string `json:"idempotencyKey"`
}{quote, key}
err := c.paymentRequest(ctx, http.MethodPost, "/payments/by-quote/"+c.config.OrganizationID, payload, &result)
if err != nil {
return payments.RemoteOperation{}, err
}
return mapPayment(result.Payment)
}
func (c *Client) Payment(ctx context.Context, id string) (payments.RemoteOperation, error) {
if !safePaymentRef(id) {
return payments.RemoteOperation{}, payments.Unavailable
}
var result struct {
Payment paymentDTO `json:"payment"`
}
err := c.paymentRequest(ctx, http.MethodGet, "/payments/by-ref/"+id, nil, &result)
if err != nil {
return payments.RemoteOperation{}, err
}
return mapPayment(result.Payment)
}
@@ -0,0 +1,211 @@
package sendico
import (
"math/big"
"strconv"
"strings"
"time"
"git.sendico.io/tech/sxchnge/product/payments"
)
const maxMinor int64 = 9007199254740991
func safePaymentRef(value string) bool {
if len(value) == 0 || len(value) > 200 {
return false
}
for _, c := range value {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == ':') {
return false
}
}
return true
}
// Decimal parsing never rounds provider values or converts money through floats.
func minorMoney(m moneyDTO, currency string, scale int) (int64, error) {
if m.Currency != currency || scale < 0 || scale > 8 {
return 0, payments.Unavailable
}
parts := strings.Split(m.Amount, ".")
if len(parts) > 2 || parts[0] == "" {
return 0, payments.Unavailable
}
digits := parts[0]
if len(parts) == 2 {
if parts[1] == "" || len(parts[1]) > scale {
return 0, payments.Unavailable
}
digits += parts[1] + strings.Repeat("0", scale-len(parts[1]))
} else {
digits += strings.Repeat("0", scale)
}
for _, r := range digits {
if r < '0' || r > '9' {
return 0, payments.Unavailable
}
}
value, err := strconv.ParseInt(digits, 10, 64)
if err != nil || value > maxMinor {
return 0, payments.Unavailable
}
return value, nil
}
func majorMoney(minor int64, scale int) string {
digits := strconv.FormatInt(minor, 10)
if scale == 0 {
return digits
}
if len(digits) <= scale {
digits = strings.Repeat("0", scale+1-len(digits)) + digits
}
return digits[:len(digits)-scale] + "." + digits[len(digits)-scale:]
}
func mapPaymentQuote(dto paymentQuoteDTO, r payments.Request, source, destination payments.Account) (payments.Quote, error) {
if !dto.Executable || dto.State != "executable" {
switch dto.BlockReason {
case "limit_blocked":
return payments.Quote{}, payments.LimitExceeded
case "amount_too_small", "amount_too_large":
return payments.Quote{}, payments.InvalidAmount
default:
return payments.Quote{}, payments.Unsupported
}
}
if !safePaymentRef(dto.ID) || !dto.Expires.After(time.Now()) {
return payments.Quote{}, payments.ExpiredQuote
}
debit, err := minorMoney(dto.Amounts.Debit, source.Currency, source.Precision)
if err != nil {
return payments.Quote{}, err
}
principal, err := minorMoney(dto.Amounts.Principal, source.Currency, source.Precision)
if err != nil {
return payments.Quote{}, err
}
credit, err := minorMoney(dto.Amounts.Credit, destination.Currency, destination.Precision)
if err != nil {
return payments.Quote{}, err
}
if principal != r.Amount || debit < principal || credit <= 0 {
return payments.Quote{}, payments.Unavailable
}
if r.Kind != "topUp" && debit > source.Available {
return payments.Quote{}, payments.InsufficientFunds
}
num, den := big.NewInt(credit), big.NewInt(principal)
num.Mul(num, new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(source.Precision)), nil))
den.Mul(den, new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(destination.Precision)), nil))
gcd := new(big.Int).GCD(nil, nil, num, den)
num.Quo(num, gcd)
den.Quo(den, gcd)
if !num.IsInt64() || !den.IsInt64() || num.Int64() > maxMinor || den.Int64() > maxMinor {
return payments.Quote{}, payments.Unsupported
}
return payments.Quote{ID: dto.ID, Request: r, Fee: debit - principal, Debit: debit, Credit: credit, ExpiresAt: dto.Expires, RateNumerator: num.Int64(), RateDenominator: den.Int64()}, nil
}
func mapPayment(dto paymentDTO) (payments.RemoteOperation, error) {
if !safePaymentRef(dto.ID) || !safePaymentRef(dto.Quote.ID) || dto.Created.IsZero() {
return payments.RemoteOperation{}, payments.Unavailable
}
status := "pending"
switch dto.State {
case "orchestration_state_settled":
status = "completed"
case "orchestration_state_failed":
status = "failed"
case "orchestration_state_created", "orchestration_state_executing", "orchestration_state_needs_attention":
default:
return payments.RemoteOperation{}, payments.Unavailable
}
return payments.RemoteOperation{ID: dto.ID, QuoteRef: dto.Quote.ID, Status: status, CreatedAt: dto.Created}, nil
}
func accountByID(accounts []payments.Account, id string) (payments.Account, error) {
for _, a := range accounts {
if a.ID == id {
return a, nil
}
}
return payments.Account{}, payments.NotFound
}
func ledgerEndpoint(id string) endpointDTO {
return endpointDTO{Type: "ledger", Data: map[string]string{"account_ref": id}}
}
func methodEndpoint(id string) endpointDTO {
return endpointDTO{Type: "paymentMethod", Data: map[string]string{"ref": id}}
}
type mappedIntent struct {
wire paymentIntentDTO
request payments.Request
source, destination payments.Account
}
func mapIntent(subject string, r payments.Request, accounts []payments.Account) (mappedIntent, error) {
source, err := accountByID(accounts, r.Source)
if err != nil {
return mappedIntent{}, err
}
if r.Amount <= 0 || r.Amount > maxMinor {
return mappedIntent{}, payments.InvalidAmount
}
intent := paymentIntentDTO{Subject: subject, Source: ledgerEndpoint(source.ID), Amount: moneyDTO{Amount: majorMoney(r.Amount, source.Precision), Currency: source.Currency}, FeeTreatment: "add_to_source"}
destination := source
switch r.Kind {
case "payout":
r.Destination = ""
if source.PayoutMethod == "" || r.Recipient != source.PayoutMethod {
return mappedIntent{}, payments.InvalidRecipient
}
intent.Kind = "payout"
intent.Destination = methodEndpoint(source.PayoutMethod)
// Store the safe provisioned display label, never present an opaque method ID.
r.Recipient = source.PayoutLabel
case "exchange":
r.Recipient = ""
destination, err = accountByID(accounts, r.Destination)
if err != nil {
return mappedIntent{}, err
}
if source.ID == destination.ID || source.Currency == destination.Currency {
return mappedIntent{}, payments.Unsupported
}
intent.Kind = "fx_conversion"
intent.Destination = ledgerEndpoint(destination.ID)
case "topUp":
r.Destination = ""
r.Recipient = ""
if source.FundingMethod == "" {
return mappedIntent{}, payments.Unsupported
}
intent.Kind = "internal_transfer"
intent.Source = methodEndpoint(source.FundingMethod)
intent.Destination = ledgerEndpoint(source.ID)
default:
return mappedIntent{}, payments.Unsupported
}
return mappedIntent{intent, r, source, destination}, nil
}
func mapAccount(row accountDTO, balance balanceDTO, scale int) (payments.Account, error) {
if balance.ID != row.ID {
return payments.Account{}, payments.Unavailable
}
minor, err := minorMoney(balance.Money, row.Currency, scale)
if err != nil {
return payments.Account{}, err
}
account := payments.Account{ID: row.ID, Currency: row.Currency, Precision: scale, Available: minor, PayoutMethod: row.Metadata["sxchnge_payout_method_ref"], PayoutLabel: row.Metadata["sxchnge_payout_label"], FundingMethod: row.Metadata["sxchnge_funding_method_ref"]}
for _, ref := range []string{account.PayoutMethod, account.FundingMethod} {
if ref != "" && !validRef(ref) {
return payments.Account{}, payments.Unavailable
}
}
if account.PayoutMethod != "" && (strings.TrimSpace(account.PayoutLabel) == "" || len(account.PayoutLabel) > 140) {
return payments.Account{}, payments.Unavailable
}
return account, nil
}
@@ -0,0 +1,103 @@
package sendico
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.sendico.io/tech/sxchnge/product/payments"
"github.com/stretchr/testify/require"
)
func TestPaymentPublicWireAndExactMoney(t *testing.T) {
ctx := context.Background()
ledger := "507f1f77bcf86cd799439015"
method := "507f1f77bcf86cd799439016"
calls := 0
acknowledge := true
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NotEmpty(t, r.Header.Get("Authorization"))
var result any
switch r.URL.Path {
case "/api/v1/payments/fx/currencies/" + org:
result = map[string]any{"currencies": []any{map[string]any{"code": "EUR", "accountingScale": 2}}}
case "/api/v1/ledger_accounts/" + org:
result = map[string]any{"accounts": []any{
map[string]any{"ledgerAccountRef": source, "organizationRef": org, "ownerRef": source, "currency": "EUR", "accountType": "ACCOUNT_TYPE_ASSET", "role": "ACCOUNT_ROLE_OPERATING", "status": "ACCOUNT_STATUS_ACTIVE"},
map[string]any{"ledgerAccountRef": ledger, "organizationRef": org, "currency": "EUR", "accountType": "ACCOUNT_TYPE_ASSET", "role": "ACCOUNT_ROLE_OPERATING", "status": "ACCOUNT_STATUS_ACTIVE", "metadata": map[string]string{"sxchnge_subject_ref": subject, "sxchnge_payout_method_ref": method, "sxchnge_payout_label": "Saved bank ending 1234"}},
}}
case "/api/v1/ledger_accounts/" + org + "/" + ledger + "/balance":
result = map[string]any{"balance": map[string]any{"ledgerAccountRef": ledger, "balance": moneyDTO{"75.00", "EUR"}}}
case "/api/v1/payments/quote/" + org:
var request quoteRequestDTO
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
require.Equal(t, subject, request.Intent.Subject)
require.Equal(t, "20.01", request.Intent.Amount.Amount)
require.Equal(t, ledger, request.Intent.Source.Data["account_ref"])
require.Equal(t, method, request.Intent.Destination.Data["ref"])
confirmedSubject := subject
if !acknowledge {
confirmedSubject = ""
}
result = map[string]any{"externalSubjectRef": confirmedSubject, "quote": paymentQuoteDTO{ID: "quote:1", State: "executable", Executable: true, Expires: time.Now().Add(time.Minute), Amounts: quoteAmountsDTO{Principal: moneyDTO{"20.01", "EUR"}, Debit: moneyDTO{"21.01", "EUR"}, Credit: moneyDTO{"20.01", "EUR"}}}}
case "/api/v1/payments/by-quote/" + org:
calls++
var payload map[string]string
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
require.Equal(t, "stable-execution", payload["idempotencyKey"])
require.Equal(t, "quote:1", payload["quoteRef"])
result = map[string]any{"payment": paymentDTO{ID: "payment:1", State: "orchestration_state_needs_attention", Quote: paymentQuoteDTO{ID: "quote:1"}, Created: time.Now()}}
case "/api/v1/payments/by-ref/payment:1":
result = map[string]any{"payment": paymentDTO{ID: "payment:1", State: "orchestration_state_settled", Quote: paymentQuoteDTO{ID: "quote:1"}, Created: time.Now()}}
default:
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(404)
return
}
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"status": "success", "data": result}))
}))
defer server.Close()
client, err := Open(config(t, server.URL))
require.NoError(t, err)
accounts, err := client.Accounts(ctx, subject)
require.NoError(t, err)
require.Len(t, accounts, 1)
require.EqualValues(t, 7500, accounts[0].Available)
q, err := client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: ledger, Amount: 2001, Recipient: method}, accounts)
require.NoError(t, err)
require.EqualValues(t, 100, q.Fee)
require.EqualValues(t, 2101, q.Debit)
require.Equal(t, "Saved bank ending 1234", q.Request.Recipient)
_, err = client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: source, Amount: 2001, Recipient: method}, accounts)
require.ErrorIs(t, err, payments.NotFound)
_, err = client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: ledger, Amount: 2001, Recipient: "foreign-method"}, accounts)
require.ErrorIs(t, err, payments.InvalidRecipient)
acknowledge = false
_, err = client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: ledger, Amount: 2001, Recipient: method}, accounts)
require.ErrorIs(t, err, payments.Unavailable)
acknowledge = true
op, err := client.Execute(ctx, q.ID, "stable-execution")
require.NoError(t, err)
require.Equal(t, "pending", op.Status)
require.Equal(t, 1, calls)
op, err = client.Payment(ctx, op.ID)
require.NoError(t, err)
require.Equal(t, "completed", op.Status)
}
func TestPaymentMappingRejectsUnsafeOrUnexecutableValues(t *testing.T) {
for _, amount := range []string{"1e3", "0.001", "-2", "NaN", "90071992547409.92", ""} {
_, err := minorMoney(moneyDTO{amount, "EUR"}, "EUR", 2)
require.Error(t, err, amount)
}
_, err := minorMoney(moneyDTO{"10.00", "USD"}, "EUR", 2)
require.Error(t, err)
_, err = mapPayment(paymentDTO{ID: "p", State: "new_unknown_state", Quote: paymentQuoteDTO{ID: "q"}, Created: time.Now()})
require.Error(t, err)
_, err = mapPaymentQuote(paymentQuoteDTO{State: "blocked", BlockReason: "limit_blocked"}, payments.Request{}, payments.Account{}, payments.Account{})
require.ErrorIs(t, err, payments.LimitExceeded)
_, err = mapPaymentQuote(paymentQuoteDTO{State: "executable", Executable: false}, payments.Request{}, payments.Account{}, payments.Account{})
require.Error(t, err)
}
@@ -0,0 +1,123 @@
package sendico
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"time"
"git.sendico.io/tech/sxchnge/product/payments"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
)
type moneyDTO struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}
type endpointDTO struct {
Type string `json:"type"`
Data map[string]string `json:"data"`
}
// Explicit wire fields avoid coupling local domain models to provider JSON.
type quoteRequestDTO struct {
Key string `json:"idempotencyKey"`
Intent paymentIntentDTO `json:"intent"`
}
type paymentIntentDTO struct {
Kind string `json:"kind"`
Source endpointDTO `json:"source"`
Destination endpointDTO `json:"destination"`
Amount moneyDTO `json:"amount"`
Subject string `json:"external_subject_ref"`
FeeTreatment string `json:"fee_treatment"`
}
type quoteAmountsDTO struct {
Principal moneyDTO `json:"sourcePrincipal"`
Debit moneyDTO `json:"sourceDebitTotal"`
Credit moneyDTO `json:"destinationSettlement"`
}
type paymentQuoteDTO struct {
ID string `json:"quoteRef"`
State string `json:"state"`
Executable bool `json:"executable"`
BlockReason string `json:"blockReason"`
Expires time.Time `json:"expiresAt"`
Amounts quoteAmountsDTO `json:"amounts"`
}
type paymentDTO struct {
ID string `json:"paymentRef"`
State string `json:"state"`
Quote paymentQuoteDTO `json:"lastQuote"`
Created time.Time `json:"createdAt"`
}
type accountDTO struct {
ID string `json:"ledgerAccountRef"`
ScopeRef string `json:"organizationRef"`
Owner string `json:"ownerRef"`
Currency string `json:"currency"`
Type string `json:"accountType"`
LedgerRole string `json:"role"`
Status string `json:"status"`
Metadata map[string]string `json:"metadata"`
}
type currencyDTO struct {
Code string `json:"code"`
Scale *int `json:"accountingScale"`
}
type balanceDTO struct {
ID string `json:"ledgerAccountRef"`
Money moneyDTO `json:"balance"`
}
func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any) error {
token, err := c.token()
if err != nil {
return err
}
var body []byte
if payload != nil {
body, err = json.Marshal(payload)
if err != nil {
return merrors.OpaqueWrap(payments.Unavailable, err, "encode Sendico payment request")
}
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, bytes.NewReader(body))
if err != nil {
return merrors.OpaqueWrap(payments.Unavailable, err, "create Sendico payment request")
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
client := *c.http
client.Timeout = 30 * time.Second
res, err := client.Do(req)
if err != nil {
return merrors.OpaqueWrap(payments.Unavailable, err, "Sendico payment request failed")
}
defer func() { _ = res.Body.Close() }()
// Never forward upstream errors, credentials, or arbitrary error bodies.
if res.StatusCode != http.StatusOK {
return payments.Unavailable
}
var envelope struct {
Status string `json:"status"`
Data json.RawMessage `json:"data"`
}
d := json.NewDecoder(io.LimitReader(res.Body, 4*1024*1024+1))
if err = d.Decode(&envelope); err != nil {
return merrors.OpaqueWrap(payments.Unavailable, err, "decode Sendico payment envelope")
}
if envelope.Status != "success" {
return payments.Unavailable
}
var extra any
if d.Decode(&extra) != io.EOF {
return payments.Unavailable
}
if err = json.Unmarshal(envelope.Data, dst); err != nil {
return merrors.OpaqueWrap(payments.Unavailable, err, "decode Sendico payment payload")
}
return nil
}
+1 -1
View File
@@ -87,7 +87,7 @@ func validScope(scope model.Scope) bool {
return false return false
} }
switch scope.Purpose { switch scope.Purpose {
case vmodel.PurposeLogin, vmodel.PurposePayout, vmodel.PurposeFactorPreference: case vmodel.PurposeLogin, vmodel.PurposePayment, vmodel.PurposePayout, vmodel.PurposeFactorPreference:
return true return true
} }
return false return false
@@ -21,6 +21,7 @@ const (
PurposeFactorPreference VerificationPurpose = "factor_preference" PurposeFactorPreference VerificationPurpose = "factor_preference"
PurposeFactorProof VerificationPurpose = "factor_proof" PurposeFactorProof VerificationPurpose = "factor_proof"
PurposePayout VerificationPurpose = "payout" PurposePayout VerificationPurpose = "payout"
PurposePayment VerificationPurpose = "payment"
) )
func VPToString(code VerificationPurpose) string { func VPToString(code VerificationPurpose) string {
@@ -45,6 +46,8 @@ func VPFromString(s string) (VerificationPurpose, error) {
return PurposeFactorPreference, nil return PurposeFactorPreference, nil
case string(PurposeFactorProof): case string(PurposeFactorProof):
return PurposeFactorProof, nil return PurposeFactorProof, nil
case string(PurposePayment):
return PurposePayment, nil
case string(PurposePayout): case string(PurposePayout):
return PurposePayout, nil return PurposePayout, nil
default: default:
+41
View File
@@ -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 and assignment. Apply #2886 before #2887 and deploy the combined Sendico result
before this SXCHNGE flow. The public proof/limits contract is unchanged; this before this SXCHNGE flow. The public proof/limits contract is unchanged; this
SXCHNGE follow-up changes documentation only and requires consistency/diff checks. SXCHNGE follow-up changes documentation only and requires consistency/diff checks.
## Sendico quotations and payment execution — task #50
[Task #50](https://git.sendico.io/tech/sxchnge/issues/50), branch `payments-50`,
connects backend wallet mode through the authenticated customer BFF to Sendico's
public account, quotation, by-quote execution and payment-status APIs. It is
stacked on `identity-verification-48` / PR #49; merge #47 → #49 first.
The separate public-provider prerequisite is
[Sendico PR #2897](https://git.sendico.io/tech/sendico/pulls/2897), task #2896.
It validates independent customer context and selects the reviewed ACCOUNT limits
resource rather than the integration login or an unassigned CUSTOMER policy.
Deploy its Product Edge and Quotation/Orchestrator consumers together before
SXCHNGE. The BFF requires an exact customer-context acknowledgement from quotes.
The contribution adds durable customer/namespace/subject-bound quotation ownership,
transactional payment-factor authorization, unique quote/execution keys, stable
provider replay, payment-reference reconciliation and authoritative status polling.
Live UI uses provisioned destinations, accurate quote expiry, explicit missing
method/loading/error/pending states and long labels at narrow widths. It clears
account state on customer changes. DTO/mapper/service/model/provider boundaries,
shared database wrappers, existing credentials and explicit demo mode are retained.
Validation passed: `make test`, `make lint-backend`, `make test-frontend` after the
final UI review, changed-Dart format verification, and app/wallet `flutter analyze`.
The final frontend run passed 21 wallet and 81 app tests, plus the unchanged shared
package suites. Tests include synthetic public HTTP, Mongo transaction rollback,
unique-key/customer isolation, lost-response reconciliation, factor retry and
session replacement. Sendico's full backend test/lint targets also passed.
[Payment contracts, provisioning, UX measurement and limitations](payments.md)
document rollout. Dedicated customer ledger/method bindings and platform access
must be provisioned through existing operator APIs; this work does not run live
payments or deploy either repository. Generic ambiguous rejections remain pending
for operational reconciliation. Cross-session analytics retention, additional
funding/recipient onboarding, backend locale saving and external history ingestion
remain separate capabilities. Unrelated notifications runtime changes are excluded.
Delivered in [PR #51](https://git.sendico.io/tech/sxchnge/pulls/51), targeting
`identity-verification-48`. Merge order is #47 → #49 → #51; deploy Sendico #2897
before SXCHNGE #51. Both implementation branches are pushed and remain unmerged.
+2
View File
@@ -1,5 +1,7 @@
# Local development # Local development
For the connected Sendico stand, see [local Sendico integration](local-sendico.md).
Task [#42](https://git.sendico.io/tech/sxchnge/issues/42), Task [#42](https://git.sendico.io/tech/sxchnge/issues/42),
[PR #43](https://git.sendico.io/tech/sxchnge/pulls/43), branch `dev-env-42`. [PR #43](https://git.sendico.io/tech/sxchnge/pulls/43), branch `dev-env-42`.
+158
View File
@@ -0,0 +1,158 @@
# Local Sendico integration
Task [#52](https://git.sendico.io/tech/sxchnge/issues/52), branch `local-stand-52`,
based on `payments-50` / [PR #51](https://git.sendico.io/tech/sxchnge/pulls/51).
The local Sendico checkout uses `payments-2896` /
[PR #2897](https://git.sendico.io/tech/sendico/pulls/2897). Merge the prerequisites
before this infrastructure PR. No PR is merged by local startup.
## Running the configured stands
Start Sendico first from its checkout using its documented Make workflow. Its
Product API must respond at `http://localhost:8080/api/v1/health`. Then, here:
```sh
make build
make up
make test-dev
```
Open [SXCHNGE](https://localhost:5126),
[Sendico Product](http://localhost:3000), and
[Sendico Backoffice](http://localhost:4000).
SXCHNGE's Caddy CA is exported to `.local/dev/ca.pem`; trust that public CA in
the browser/OS if necessary. Bootstrap does not modify the system trust store.
The optional integration is selected by ignored `.local/dev/sendico/settings.env`.
Make and the containerized bootstrap both include
`infra/local/sendico/compose.yml` whenever that file exists. Standalone SXCHNGE
still uses six services. The connected stack adds one `sendico` session worker.
`make ps`, `make logs`, and `make down` include it automatically.
## Identity and configuration
The configured local stand has a dedicated `SXCHNGE Local` organization, active
integration account and account-bound confidential API client. The account is
an organization member with organization read/update, ledger read, payment
create/read, and recipient create/read/update permissions. Customer subjects
remain independent from that account. No pooled balances, customer funds or
payout destinations are seeded by this setup. Existing stand data is retained.
This identity was created through a one-time local administrative bootstrap;
runtime traffic and session creation use only the public HTTP API.
For another stand, an operator must first provision the organization, account,
client and grants in Sendico. Resolve actual IDs from that stand; do not copy
another environment's references. Initialize SXCHNGE's Vault using `make up`
before configuring the integration. Supply this JSON privately on stdin:
```json
{
"organization_ref": "<organization object ID>",
"source_account_ref": "<integration account object ID>",
"client_id": "<confidential client ID>",
"device_id": "sxchnge-local-bff",
"login": "<integration login>",
"password": "<integration password>",
"client_secret": "<confidential client secret>"
}
```
```sh
# Save operator input outside tracked files, with mode 0600. Never echo it.
make sendico < /private/path/sendico-input.json
make up
make test-dev
```
`make sendico` validates references and identifiers, stores only login/password/
client_secret at Vault `kv/services/bff/sendico`, and writes the non-secret client,
device, organization and source references into ignored local settings. The BFF
runtime is generated from `infra/local/bff/runtime.yml` with the existing
`api.sendico` configuration. Re-run configuration after changing that base YAML.
Remove the temporary input file once credentials have been stored in Vault.
No Sendico configuration parameters or application transport rules change.
## Connection and renewal
The session worker shares BFF's network namespace and binds a private loopback
TCP bridge at `127.0.0.1:18081`, forwarding to
`host.docker.internal:8080`. This uses the local stand's existing public HTTP API
port; it requires a local Docker engine with `host.docker.internal` support, as
provided by Docker Desktop. The bridge publishes no new host port. The adapter's
existing HTTPS requirement and loopback-only HTTP exception remain intact.
The worker authenticates to Vault with its own AppRole, reads exactly
`services/bff/sendico`, and revokes its five-minute Vault token after reading.
It cannot read Mongo credentials, JWT signing keys, NATS auth or administrator
material. Its initial login uses Sendico's public `/accounts/login/api` grant,
validates the access token's source account, full-session status and expiry, and
atomically publishes the token in a volume mounted read-only by BFF. Credentials
and response bodies stay in private tmpfs files and never appear in logs or
command arguments. Flutter receives no platform credentials.
Successful login repeats every 20 minutes before the one-hour access token
expires. A failure retains the prior token, logs one generic actionable message,
and retries after five minutes. The worker becomes unhealthy after 25 minutes
without renewal or when the upstream health endpoint fails; the adapter
separately rejects expired or mismatched tokens.
BFF waits for a recent token at startup. Following credential changes, restart
the worker with `make up` followed by the Compose restart command below, or wait
for its next renewal. Never change the integration account on an existing
financial environment without reviewing resource and idempotency bindings.
```sh
docker compose --project-directory . -f infra/local/compose.yml \
-f infra/local/sendico/compose.yml --env-file .local/dev/images.env restart sendico
```
`make test-dev` checks seven healthy services, verified browser TLS, the Vault
layout and least-privilege identities, Mongo transactions/lease rotation, and
authenticated Sendico currency and ledger reads through the same loopback
connection and token used by BFF. It sends no email and executes no payment.
An empty wallet is expected until dedicated subject-bound currency ledgers and
saved methods are provisioned as described in [payment provisioning](payments.md).
## Recovery
The existing stand had application processes stopped inside still-running Air
containers after a NATS disconnect and Mongo replica-state interruption. Restore
discovery first, then Treasury and the other service announcers. Wait until each
stage is registered before restarting dependent services: billing fees and
Ledger, then Limits/gateways/quotation/receiving routes, then payment orchestrator,
compliance assessment and Product BFF. The Sendico Makefile provides its ordered
backend startup workflow. Inspect current logs and health instead of assuming a
running development container has a live application process. Do not delete
volumes or reset financial data to recover service registration.
If an old Caddy leaf certificate expires while the stack is stopped, restart
frontend after startup so Caddy issues a fresh certificate. Preserve `caddy-data`
and the established CA. A subsequent verified TLS check must succeed.
## Acceptance and operator UX review
Validated on 2026-10-16: `make build`, `make up`, `make test-dev-tooling`,
`make test-dev-infra`, `make test-dev`, and `git diff --check` passed. Both product
sites, Sendico Backoffice and both API health endpoints returned HTTP 200;
SXCHNGE's browser endpoint was checked with its exported CA. All Sendico services
with health checks were healthy. Forced worker restart issued a different valid
same-account token and preserved successful authenticated currency/ledger reads.
The existing notification sender edit remains outside this task's commit.
The setup journey starts with the operator's Make invocation and completes when
Compose confirms service readiness; `make test-dev` additionally confirms the
authenticated integration. Existing command exit codes and Compose progress /
health states distinguish configuration, dependency, authentication and readiness
failures. A renewed-session log records credential recovery, while stale renewal
or upstream outage changes container health. Each new operator invocation is a
new attempt; worker retries remain within that process's lifetime. Command wall
time measures setup duration. Interrupted commands are incomplete attempts, not
successful launches. These are local operator signals, with no retained analytics
or cross-invocation conversion/abandonment reporting added.
The UX review covered missing bootstrap material, malformed operator input,
authentication failure, retained retry context, stale TLS recovery, successful
startup and confirmed API access. Commands reuse existing Make/Compose patterns,
errors give a recovery action, and success follows readiness. No secret values
appear in terminal feedback. Product layouts, focus and screen-reader behavior
are unchanged; browser interaction and funded payment settlement are not claimed
by HTTP readiness checks. No email or payment was sent during validation.
+168
View File
@@ -0,0 +1,168 @@
# Customer quotations and payment execution
Task [SXCHNGE #50](https://git.sendico.io/tech/sxchnge/issues/50), branch
`payments-50`, based on `identity-verification-48` / PR #49. Provider prerequisite:
[Sendico #2896](https://git.sendico.io/tech/sendico/issues/2896).
## Boundary and behavior
The authenticated app uses `BackendWalletService` whenever backend authentication
is configured. Explicit service injection remains available for tests; demo mode
continues to use the isolated in-memory wallet. Missing provider configuration
returns an unavailable wallet, never demonstration balances or fake settlements.
The path is Flutter DTO → mapper → service → domain model → provider → UI, then
SXCHNGE's authenticated customer BFF → Sendico public HTTP. The existing confirmed
identity binding resolves each signed-in customer to an independent subject.
Neither a browser-selected organization nor an integration-login-owned balance
is used as that customer's financial resource.
Consumed public endpoints, below the existing configured API prefix:
- `GET /payments/fx/currencies/{organization}` for accounting scales;
- `GET /ledger_accounts/{organization}` and
`GET /ledger_accounts/{organization}/{ledger}/balance`;
- `POST /payments/quote/{organization}` with `external_subject_ref`;
- `POST /payments/by-quote/{organization}`;
- `GET /payments/by-ref/{payment}` for authoritative status.
The adapter requires the exact `externalSubjectRef` acknowledgement on the quote
response. An old deployment that ignores the new request field cannot silently
produce an acceptable integration-principal quote. Public provider DTOs and
mappers stay inside the adapter. No Sendico modules, databases, gRPC clients or
message bus are imported by SXCHNGE.
Amounts are parsed and formatted as exact decimal strings at the provider
boundary and represented as integer minor units locally. Unknown scales, values
beyond the browser's exact integer range, excess fractional precision, unknown
payment states, unexecutable quotes and mismatched payment/quote references fail
closed. Source principal, total debit and net destination credit come from the
quote; rates account for different currency precisions. Only same-currency saved
payout/funding methods are currently represented by the wallet review model.
Exchange connects two dedicated currency ledgers belonging to the same customer.
## Provisioning and rollout
Merge/deploy the identity prerequisites first: SXCHNGE #47 → #49 and the Sendico
identity-proof/limits-assignment work documented in `identity-verification.md`.
Deploy the Sendico #2896 Product Edge and the Quotation/Orchestrator builds that
consume the changed shared payment-storage Limits adapter together. Then deploy
SXCHNGE #50. The Edge acknowledgement is a compatibility guard; it does not replace
coordinated deployment of the platform's financial services.
Reuse `api.sendico` and its existing Vault-rendered token file. Do not put provider
credentials or organization/source settings in Flutter. Provision the integration
account's organization membership, payment-create/read, ledger-account/balance
read and the permissions needed to resolve its stored payment methods. Verify
those permissions in the intended environment before enabling real operations.
Through Sendico's existing operator APIs, provision one dedicated unowned
organization ledger per customer currency, with active `asset` / `operating`
semantics, no negative balance and metadata:
| Ledger metadata | Meaning |
| --- | --- |
| `sxchnge_subject_ref` | Confirmed independent subject reference from sign-up |
| `sxchnge_payout_method_ref` | Optional prevalidated same-currency saved payout method |
| `sxchnge_payout_label` | Safe customer-visible destination label, e.g. bank plus masked suffix |
| `sxchnge_funding_method_ref` | Optional same-currency saved funding source supported by the platform route |
These are financial-resource bindings maintained by trusted provisioning, not
client choices or new runtime configuration. Do not tag a pooled/integration-login
ledger as a customer ledger. Do not place full bank/card details or secret values
in labels or metadata. The BFF checks subject, organization, account type, role,
status and owner on each account projection/quotation. It accepts only the bound
saved payout method and replaces its opaque reference with the safe label in the
reviewed quote. No arbitrary recipient identifier or free-text bank input can
be executed from the live UI. Missing bindings have an explanatory disabled
review action. Funding uses a quote for the provisioned stored source; it does
not mint money or treat demo top-ups as real deposits. Additional destinations,
recipient onboarding and other funding instructions remain separate features.
An empty set of provisioned ledgers is an empty wallet. Existing organization
payments and pooled balances are never imported into customer history. This
integration exposes the durable operations initiated through this BFF, with live
provider reconciliation; independent inbound deposits/history ingestion and
pagination are not added by this contribution. Ledger balances are posted ledger
projections; execution-time solvency and holds remain the platform's authority.
## Authorization, idempotency and recovery
Local routes are under `/accounts/wallet`: snapshot GET, `/profile` GET,
`/quote`, `/challenge`, `/resend`, `/verify`, `/submit` and `/submission` POST.
All use the existing full authenticated-account wrapper, access token and browser
CSRF guard. Pending-login credentials cannot access them. Local quote references
are random BFF IDs; provider quote/payment references are not accepted as user
ownership evidence.
`payment_quotes` stores customer, adapter namespace, confirmed subject, immutable
quote, upstream quote reference, authorized execution key and eventual payment
reference. A unique partial index enforces customer/namespace/key uniqueness.
The existing second-factor service uses the dedicated `payment` purpose and
exact local quote context. Successful code verification consumes its one-use
proof in the same local Mongo transaction that records execution authorization.
No external I/O runs in that transaction and raw factor proofs never leave the
server. Verification response retries retain their key and recover committed
intent. Resend uses a separate stable-per-retry issuance key.
Submit/reconciliation replays `sxchnge:payment:<local-quote-id>` at Sendico.
A quote cannot be authorized under a second execution key. A network failure or
lost provider response remains uncertain; it never permits execution with a new
provider key. Once the provider payment reference is attached, refresh uses GET.
Authorization is durable: reopening the wallet reconciles authorized intents even
if the original client never received verification/submission success. Unknown
outcomes remain pending and do not hide unrelated balances. `needs_attention`
also stays pending; only `settled`/`failed` become terminal UI outcomes.
Generic upstream errors cannot prove that no payment exists. An unresolved
pre-execution rejection (including an expired quote after durable authorization)
therefore requires operational reconciliation rather than automatic release or a
new payment. Inspect the local quote/provider reference and platform audit trail;
never delete/rekey a record to bypass uncertainty. The UI's live copy does not
promise a debit, refund or completion from mere acceptance. Only local drafts,
not durable authorized intents, count as abandoned when a user leaves.
The adapter rejects changed-session HTTP responses, and the app clears wallet
snapshots/journeys when the authenticated customer changes. Account-language
reads use the authenticated customer. Backend language saving is deliberately
unsupported and reports failure; the previous demo setting is not presented as
persisted backend state.
## UX, observation and validation
The existing typed transfer journey recorder measures draft → quotation →
second-factor challenge/verification → submission → authoritative settlement.
Attempts/retries share their client journey; accepted operations finish once
when the wallet observes a terminal platform status. An uncertain result is not
success. Leaving an unresolved asynchronous operation is not draft abandonment.
Use the existing per-attempt timestamps for durations and a 30-minute inactivity
window for unfinished local drafts; server-authorized pending operations remain
outside that abandonment denominator. Compare draft-to-quote and quote-to-submit
drop-off, intent-to-terminal completion/failure and time to terminal outcome.
The existing recorder provides in-memory client evidence only: deployment of a
retained analytics sink and backend event delivery is needed for cross-session
conversion/settlement measurement. No amounts, recipients, bank labels, codes or
credentials enter analytics. Structured server logs use quote/payment references
and bounded states; operational reconciliation uses the durable database record.
Tests cover public wire requests, exact money, source/destination isolation,
blocked/unknown states, factor errors and retry keys, changed-session responses,
durable authorization rollback/uniqueness, lost-response reconciliation and
customer/provider-namespace isolation. Widget checks exercise saved destinations
and the missing-destination state at 320px with 1.6× text, alongside the existing
EN/RU workspace flows. Default Flutter keyboard/focus semantics are retained;
full browser/screen-reader acceptance and live funded settlement are deployment
checks, not claimed by synthetic tests. No live payments or deployment are run.
Validation passed after review: `make test` (backend race tests and all frontend
suites), `make lint-backend`, a subsequent `make test-frontend` after the final
long-label UI adjustment, changed-Dart format verification, and `flutter analyze`
in `frontend/app` and `frontend/packages/sxchnge_wallet`. The final frontend run
passed 21 wallet, 20 KYC, 7 analytics, 40 auth, 3 theme, 3 theme-example and 81 app
tests. The provider prerequisite passed Sendico's full backend test/lint targets.
Delivery: [SXCHNGE PR #51](https://git.sendico.io/tech/sxchnge/pulls/51), stacked
on PR #49. Merge #47 → #49 → #51. The separate
[Sendico provider PR #2897](https://git.sendico.io/tech/sendico/pulls/2897) must be
deployed before enabling this consumer. Both implementation branches are pushed;
merge, deployment and real financial-resource provisioning remain separate.
+9 -1
View File
@@ -15,7 +15,15 @@ flutter run -d chrome
A responsive login/sign-up preview with a themed brand panel, email and password validation, password visibility controls, keyboard submission, and inline feedback. Sign-up additionally asks for a name and matching password confirmation. The 12-character sign-up minimum is a UI preview policy and must be aligned with the backend when authentication is integrated. A responsive login/sign-up preview with a themed brand panel, email and password validation, password visibility controls, keyboard submission, and inline feedback. Sign-up additionally asks for a name and matching password confirmation. The 12-character sign-up minimum is a UI preview policy and must be aligned with the backend when authentication is integrated.
No authentication endpoint is configured. Submission validates locally and calls the shared dummy authentication services; it never creates an account, starts a session, sends credentials, or persists them. Passwords are cleared when switching modes. Password managers/autofill integration is deferred until real authentication exists. LoginProvider and SignupProvider own loading/results/errors; SxchngeApp accepts injected loginService and signupService implementations. Dummy completion is explicitly labeled and does not establish a real session. Without backend configuration the app uses dummy services. The existing
`SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID` select authenticated backend auth,
identity, limits and wallet adapters. Wallet operations use Sendico through the
BFF after the provider prerequisites and dedicated customer resource bindings
are provisioned; they do not use demo balances in backend mode. LoginProvider,
SignupProvider and shared wallet providers own loading/results/errors.
SxchngeApp retains explicit service injection for tests and previews. See
[quotation/payment integration](../../docs/payments.md) for setup, supported
funding/destinations, authorization, reconciliation and rollout.
The interface uses `ColorScheme` and `TextTheme` roles from `sxchnge_theme`; the desktop brand panel installs the package's dark theme locally. Entrance animation respects reduced-motion preferences. The public website remains separate in `website/`. The interface uses `ColorScheme` and `TextTheme` roles from `sxchnge_theme`; the desktop brand panel installs the package's dark theme locally. Entrance animation respects reduced-motion preferences. The public website remains separate in `website/`.
+13 -1
View File
@@ -277,5 +277,17 @@
"kycOperationMaximum": "Per operation maximum", "kycOperationMaximum": "Per operation maximum",
"kycErrorAccess": "Verification is unavailable for this account. Contact support if this continues.", "kycErrorAccess": "Verification is unavailable for this account. Contact support if this continues.",
"kycUpgradePendingTitle": "Limits upgrade pending", "kycUpgradePendingTitle": "Limits upgrade pending",
"kycUpgradePendingBody": "Your identity review is approved. We are waiting for confirmation of the limits upgrade. You do not need to submit your photos again." "kycUpgradePendingBody": "Your identity review is approved. We are waiting for confirmation of the limits upgrade. You do not need to submit your photos again.",
"wNoPayoutMethod": "No payout destination is set up for this account. Contact support to add one.",
"wNoFundingMethod": "No funding method is set up for this account. Contact support for funding instructions.",
"wLivePending": "Your payment is awaiting a final outcome. Refresh to see its latest status and balance.",
"wLiveFailed": "Your payment did not complete. Refresh your accounts to see the current available balance.",
"wLiveQuoteNotice": "Quote expires in {seconds} seconds.",
"@wLiveQuoteNotice": {
"placeholders": {
"seconds": {
"type": "int"
}
}
}
} }
+13 -1
View File
@@ -277,5 +277,17 @@
"kycOperationMaximum": "Максимум на операцию", "kycOperationMaximum": "Максимум на операцию",
"kycErrorAccess": "Проверка недоступна для этого аккаунта. Если это повторяется, обратитесь в поддержку.", "kycErrorAccess": "Проверка недоступна для этого аккаунта. Если это повторяется, обратитесь в поддержку.",
"kycUpgradePendingTitle": "Ожидается повышение лимитов", "kycUpgradePendingTitle": "Ожидается повышение лимитов",
"kycUpgradePendingBody": "Проверка личности одобрена. Ожидается подтверждение повышения лимитов. Отправлять фото повторно не нужно." "kycUpgradePendingBody": "Проверка личности одобрена. Ожидается подтверждение повышения лимитов. Отправлять фото повторно не нужно.",
"wNoPayoutMethod": "Для этого счёта не настроен способ вывода. Обратитесь в поддержку.",
"wNoFundingMethod": "Для этого счёта не настроен способ пополнения. Обратитесь в поддержку за инструкциями.",
"wLivePending": "Ожидается окончательный результат платежа. Обновите страницу, чтобы проверить статус и баланс.",
"wLiveFailed": "Платёж не завершён. Обновите счета, чтобы проверить доступный баланс.",
"wLiveQuoteNotice": "Предложение истекает через {seconds} сек.",
"@wLiveQuoteNotice": {
"placeholders": {
"seconds": {
"type": "int"
}
}
}
} }
+24 -6
View File
@@ -63,8 +63,11 @@ class _SxchngeAppState extends State<SxchngeApp> {
late final _locale = widget.localeProvider ?? LocaleProvider(); late final _locale = widget.localeProvider ?? LocaleProvider();
late final _analytics = widget.analyticsService ?? DummyAnalyticsService(); late final _analytics = widget.analyticsService ?? DummyAnalyticsService();
late final _factors = late final _authFactors =
widget.backend ?? widget.twoFactorService ?? DummyTwoFactorService(); widget.backend ?? widget.twoFactorService ?? DummyTwoFactorService();
late final _factors = _walletService is BackendWalletService
? WalletFactorService(_walletService, _authFactors)
: _authFactors;
late final _security = SecurityProvider(_factors, _analytics); late final _security = SecurityProvider(_factors, _analytics);
late final _identity = late final _identity =
widget.identityService ?? widget.identityService ??
@@ -76,11 +79,16 @@ class _SxchngeAppState extends State<SxchngeApp> {
)); ));
late final _walletService = late final _walletService =
widget.walletService ?? widget.walletService ??
DummyWalletService( (widget.backend != null
_identity, ? BackendWalletService(
initialLocale: _locale.locale.languageCode, baseUri: widget.backend!.baseUri,
factors: _factors, accessToken: () => widget.backend!.session?.accessToken,
); )
: DummyWalletService(
_identity,
initialLocale: _locale.locale.languageCode,
factors: _authFactors,
));
late final _wallet = WalletProvider( late final _wallet = WalletProvider(
_walletService, _walletService,
_analytics, _analytics,
@@ -109,14 +117,21 @@ class _SxchngeAppState extends State<SxchngeApp> {
: () => widget.backend!.session != null, : () => widget.backend!.session != null,
)..routeInformationProvider.addListener(_routeChanged); )..routeInformationProvider.addListener(_routeChanged);
String _previousPath = '/'; String _previousPath = '/';
String? _walletCustomer;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_previousPath = _router.routeInformationProvider.value.uri.path; _previousPath = _router.routeInformationProvider.value.uri.path;
_walletCustomer = widget.backend?.session?.userId;
} }
void _routeChanged() { void _routeChanged() {
final customer = widget.backend?.session?.userId;
if (customer != _walletCustomer) {
_walletCustomer = customer;
_wallet.reset();
}
final path = _router.routeInformationProvider.value.uri.path; final path = _router.routeInformationProvider.value.uri.path;
if (path == '/' && _previousPath != '/' && _previousPath != '/two-factor') { if (path == '/' && _previousPath != '/' && _previousPath != '/two-factor') {
_journeys.abandon(JourneyExit.navigation); _journeys.abandon(JourneyExit.navigation);
@@ -130,6 +145,9 @@ class _SxchngeAppState extends State<SxchngeApp> {
_router.dispose(); _router.dispose();
_journeys.dispose(); _journeys.dispose();
_wallet.dispose(); _wallet.dispose();
if (_walletService is BackendWalletService) {
_walletService.close();
}
_security.dispose(); _security.dispose();
if (_identity is BackendIdentityService) { if (_identity is BackendIdentityService) {
_identity.close(); _identity.close();
+19 -11
View File
@@ -28,17 +28,25 @@ String statusText(AppLocalizations l, OperationStatus s) => switch (s) {
OperationStatus.completed => l.wCompleted, OperationStatus.completed => l.wCompleted,
OperationStatus.failed => l.wFailed, OperationStatus.failed => l.wFailed,
}; };
String fundsText(AppLocalizations l, WalletOperation o) => String fundsText(
switch ((o.status, o.quote.request.kind)) { AppLocalizations l,
(OperationStatus.pending, TransferKind.topUp) => l.wPendingTopUp, WalletOperation o, {
(OperationStatus.pending, TransferKind.exchange) => l.wPendingExchange, bool simulated = true,
(OperationStatus.pending, TransferKind.payout) => l.wPendingPayout, }) => !simulated && o.status == OperationStatus.pending
(OperationStatus.completed, TransferKind.topUp) => l.wCompleteTopUp, ? l.wLivePending
(OperationStatus.completed, TransferKind.exchange) => l.wCompleteExchange, : !simulated && o.status == OperationStatus.failed
(OperationStatus.completed, TransferKind.payout) => l.wCompletePayout, ? l.wLiveFailed
(OperationStatus.failed, TransferKind.topUp) => l.wFailedTopUp, : switch ((o.status, o.quote.request.kind)) {
_ => l.wFailedOutgoing, (OperationStatus.pending, TransferKind.topUp) => l.wPendingTopUp,
}; (OperationStatus.pending, TransferKind.exchange) => l.wPendingExchange,
(OperationStatus.pending, TransferKind.payout) => l.wPendingPayout,
(OperationStatus.completed, TransferKind.topUp) => l.wCompleteTopUp,
(OperationStatus.completed, TransferKind.exchange) =>
l.wCompleteExchange,
(OperationStatus.completed, TransferKind.payout) => l.wCompletePayout,
(OperationStatus.failed, TransferKind.topUp) => l.wFailedTopUp,
_ => l.wFailedOutgoing,
};
String walletError(AppLocalizations l, WalletError e) => switch (e) { String walletError(AppLocalizations l, WalletError e) => switch (e) {
WalletError.invalidAmount => l.wInvalidAmount, WalletError.invalidAmount => l.wInvalidAmount,
WalletError.insufficientFunds => l.wInsufficient, WalletError.insufficientFunds => l.wInsufficient,
+1 -1
View File
@@ -97,7 +97,7 @@ class _OperationPageState extends State<OperationPage> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
fundsText(l, o), fundsText(l, o, simulated: p.service.isSimulated),
style: TextStyle( style: TextStyle(
color: o.status == OperationStatus.failed color: o.status == OperationStatus.failed
? c.onErrorContainer ? c.onErrorContainer
+45 -10
View File
@@ -133,6 +133,15 @@ class TransferPageState extends State<TransferPage> {
final t = Theme.of(context); final t = Theme.of(context);
final accounts = wallet.data?.accounts ?? []; final accounts = wallet.data?.accounts ?? [];
final source = wallet.account(p.source); final source = wallet.account(p.source);
final live = !wallet.service.isSimulated;
final 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( return SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Center( child: Center(
@@ -207,7 +216,15 @@ class TransferPageState extends State<TransferPage> {
] else if (p.quote != null) ...[ ] else if (p.quote != null) ...[
TransferSummary(quote: p.quote!), TransferSummary(quote: p.quote!),
const SizedBox(height: 16), const SizedBox(height: 16),
Text(p.expired ? l.wExpired : l.wQuoteNotice), Text(
p.expired
? l.wExpired
: live
? l.wLiveQuoteNotice(
expiresIn < 0 ? 0 : expiresIn,
)
: l.wQuoteNotice,
),
const SizedBox(height: 20), const SizedBox(height: 20),
if (p.expired) if (p.expired)
FilledButton( FilledButton(
@@ -299,21 +316,39 @@ class TransferPageState extends State<TransferPage> {
), ),
if (widget.kind == TransferKind.payout) ...[ if (widget.kind == TransferKind.payout) ...[
const SizedBox(height: 20), const SizedBox(height: 20),
TextField( if (live && source?.payoutMethod.isNotEmpty == true)
key: const ValueKey('transfer-recipient'), InputDecorator(
controller: _recipient, decoration: InputDecoration(
enabled: !p.busy, labelText: l.wRecipient,
decoration: InputDecoration( ),
labelText: l.wRecipient, child: Text(source!.payoutLabel),
hintText: l.wRecipientHint,
), ),
onChanged: (v) => p.change(recipient: v), if (!live)
TextField(
key: const ValueKey('transfer-recipient'),
controller: _recipient,
enabled: !p.busy,
decoration: InputDecoration(
labelText: l.wRecipient,
hintText: l.wRecipientHint,
),
onChanged: (v) => p.change(recipient: v),
),
],
if (unavailableMethod) ...[
const SizedBox(height: 12),
Text(
widget.kind == TransferKind.payout
? l.wNoPayoutMethod
: l.wNoFundingMethod,
), ),
], ],
const SizedBox(height: 28), const SizedBox(height: 28),
FilledButton( FilledButton(
key: const ValueKey('transfer-review'), key: const ValueKey('transfer-review'),
onPressed: p.busy ? null : p.review, onPressed: p.busy || unavailableMethod
? null
: p.review,
child: Text(l.wReview), child: Text(l.wReview),
), ),
if (p.error == WalletError.limitExceeded) if (p.error == WalletError.limitExceeded)
+101
View File
@@ -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());
},
);
}
}
+3 -2
View File
@@ -32,8 +32,9 @@ wallet state changes. The adapter reuses the KYC package's limit DTO and mapper.
## Integration boundary ## Integration boundary
Replace `WalletService` and `LimitsService` together with authenticated backend Backend mode now uses `BackendWalletService` and `WalletFactorService` with the
adapters. The backend must provide authorization, authoritative quotes/balances, authenticated BFF; `BackendLimitsService` supplies the reviewed platform projection.
See [payment provisioning and rollout](../../../docs/payments.md). The backend must provide authorization, authoritative quotes/balances,
atomic limits enforcement, durable idempotency and operation reconciliation. atomic limits enforcement, durable idempotency and operation reconciliation.
Add supported payment methods and validated recipients from that contract, rather Add supported payment methods and validated recipients from that contract, rather
than interpreting the demo input as production bank validation. Replace profile than interpreting the demo input as production bank validation. Replace profile
@@ -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.currency,
required this.precision, required this.precision,
required this.available, required this.available,
this.payoutMethod = '',
this.payoutLabel = '',
this.canFund = true,
}); });
final String id, currency; final String id, currency;
final int precision, available; final int precision, available;
final String payoutMethod, payoutLabel;
final bool canFund;
/// Locale-aware decimal input, integer money. Grouping/exponents are rejected. /// Locale-aware decimal input, integer money. Grouping/exponents are rejected.
int? parse(String input) { int? parse(String input) {
@@ -19,6 +19,8 @@ class TransferProvider extends ChangeNotifier {
.firstOrNull .firstOrNull
?.id ?? ?.id ??
'usd'; 'usd';
wallet.addListener(_walletChanged);
_syncAccounts();
final op = recorder.begin( final op = recorder.begin(
journey, journey,
JourneyStep.transferDraft, JourneyStep.transferDraft,
@@ -26,6 +28,30 @@ class TransferProvider extends ChangeNotifier {
); );
recorder.finish(op, simulated: wallet.service.isSimulated); recorder.finish(op, simulated: wallet.service.isSimulated);
} }
void _syncAccounts() {
final accounts = wallet.data?.accounts;
if (accounts == null ||
accounts.isEmpty ||
busy ||
uncertain ||
quote != null)
return;
if (!accounts.any((a) => a.id == source)) source = accounts.first.id;
if (!accounts.any((a) => a.id == destination) || destination == source) {
destination = accounts.where((a) => a.id != source).firstOrNull?.id;
}
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 WalletProvider wallet;
final TransferKind kind; final TransferKind kind;
final JourneyRecorder recorder; final JourneyRecorder recorder;
@@ -45,7 +71,8 @@ class TransferProvider extends ChangeNotifier {
TransferKind.exchange => JourneyKind.exchange, TransferKind.exchange => JourneyKind.exchange,
TransferKind.payout => JourneyKind.payout, TransferKind.payout => JourneyKind.payout,
}; };
bool get hasDraft => amount.isNotEmpty || recipient.isNotEmpty; bool get hasDraft =>
amount.isNotEmpty || (wallet.service.isSimulated && recipient.isNotEmpty);
bool get expired => bool get expired =>
quote != null && !DateTime.now().isBefore(quote!.expiresAt); quote != null && !DateTime.now().isBefore(quote!.expiresAt);
void change({ void change({
@@ -59,6 +86,7 @@ class TransferProvider extends ChangeNotifier {
this.destination = destination ?? this.destination; this.destination = destination ?? this.destination;
this.amount = amount ?? this.amount; this.amount = amount ?? this.amount;
this.recipient = recipient ?? this.recipient; this.recipient = recipient ?? this.recipient;
if (source != null) _syncAccounts();
quote = null; quote = null;
noSubmission = false; noSubmission = false;
error = null; error = null;
@@ -171,6 +199,7 @@ class TransferProvider extends ChangeNotifier {
error = null; error = null;
factorError = null; factorError = null;
notifyListeners(); notifyListeners();
_key ??= approval?.token;
_key ??= List.generate( _key ??= List.generate(
16, 16,
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'), (_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
@@ -266,6 +295,7 @@ class TransferProvider extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
_disposed = true; _disposed = true;
wallet.removeListener(_walletChanged);
recipient = ''; recipient = '';
if (!_handedOff) recorder.dispose(); if (!_handedOff) recorder.dispose();
super.dispose(); super.dispose();
@@ -140,6 +140,21 @@ class WalletProvider extends ChangeNotifier {
return !saveFailed; return !saveFailed;
} }
/// Drop account-scoped state when the authenticated customer changes.
void reset() {
_revision++;
_poll?.cancel();
for (final pair in _settlements.values) {
pair.$1.dispose(abandonActive: false);
}
_settlements.clear();
data = null;
preferredLocale = null;
loading = failed = profileLoading = profileFailed = saving = saveFailed =
false;
notifyListeners();
}
@override @override
void dispose() { void dispose() {
_disposed = true; _disposed = true;
@@ -0,0 +1,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'; import '../models/wallet.dart';
class AccountDTO { class AccountDTO {
AccountDTO(this.id, this.currency, this.precision, this.available); AccountDTO(
this.id,
this.currency,
this.precision,
this.available, {
this.payoutMethod = '',
this.payoutLabel = '',
this.canFund = true,
});
final String payoutMethod, payoutLabel;
final bool canFund;
final String id, currency; final String id, currency;
final int precision; final int precision;
int available; int available;
@@ -64,6 +74,9 @@ abstract final class WalletMapper {
currency: d.currency, currency: d.currency,
precision: d.precision, precision: d.precision,
available: d.available, available: d.available,
payoutMethod: d.payoutMethod,
payoutLabel: d.payoutLabel,
canFund: d.canFund,
); );
static RequestDTO request(TransferRequest r) => static RequestDTO request(TransferRequest r) =>
RequestDTO(r.kind.name, r.source, r.destination, r.amount, r.recipient); 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/contracts.dart';
export 'src/services/dummy.dart'; export 'src/services/dummy.dart';
export 'src/providers/limits.dart'; export 'src/providers/limits.dart';
export 'src/services/backend.dart';
export 'src/services/factors.dart';
@@ -6,6 +6,7 @@ environment:
sdk: '>=3.13.0 <4.0.0' sdk: '>=3.13.0 <4.0.0'
flutter: '>=3.47.0' flutter: '>=3.47.0'
dependencies: dependencies:
http: ^1.6.0
flutter: flutter:
sdk: flutter sdk: flutter
sxchnge_auth: 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);
});
}
+1 -1
View File
@@ -1,3 +1,3 @@
ARG DOCKER_IMAGE ARG DOCKER_IMAGE
FROM ${DOCKER_IMAGE} FROM ${DOCKER_IMAGE}
RUN apk add --no-cache curl jq git openssl util-linux shellcheck RUN apk add --no-cache curl jq git openssl util-linux shellcheck socat
+11 -4
View File
@@ -54,24 +54,31 @@ provision_mongo() {
{ cat "$WORK/admin.js"; cat infra/local/mongo/mongo-client.js; } | mongo >/dev/null || fail 'Browser client provisioning failed' { cat "$WORK/admin.js"; cat infra/local/mongo/mongo-client.js; } | mongo >/dev/null || fail 'Browser client provisioning failed'
} }
provision_agents() { provision_agents() {
for service in bff notifications nats; do services='bff notifications nats'
if [ -f "$STATE/sendico/settings.env" ]; then services="$services sendico"; fi
for service in $services; do
directory="$STATE/agents/$service" directory="$STATE/agents/$service"
mkdir -p "$directory"; chmod 755 "$directory" mkdir -p "$directory"; chmod 755 "$directory"
case "$service" in case "$service" in
bff) paths='services/bff/jwt';; bff) paths='services/bff/jwt';;
notifications) paths='infrastructure/nats/auth services/notifications/email/resend';; notifications) paths='infrastructure/nats/auth services/notifications/email/resend';;
nats) paths=infrastructure/nats/auth;; nats) paths=infrastructure/nats/auth;;
sendico) paths=services/bff/sendico;;
esac esac
: > "$WORK/policy" : > "$WORK/policy"
for path in $paths; do printf 'path "kv/data/%s" { capabilities = ["read"] }\n' "$path" >> "$WORK/policy"; done for path in $paths; do printf 'path "kv/data/%s" { capabilities = ["read"] }\n' "$path" >> "$WORK/policy"; done
if [ "$service" != nats ]; then printf 'path "database/creds/%s" { capabilities = ["read"] }\n' "$service" >> "$WORK/policy"; fi case "$service" in bff|notifications) printf 'path "database/creds/%s" { capabilities = ["read"] }\n' "$service" >> "$WORK/policy";; esac
jq -n --rawfile policy "$WORK/policy" '{policy:$policy}' | vault "sys/policies/acl/$service" POST >/dev/null jq -n --rawfile policy "$WORK/policy" '{policy:$policy}' | vault "sys/policies/acl/$service" POST >/dev/null
jq -n --arg service "$service" '{token_policies:[$service],token_period:"1h",secret_id_ttl:"0",secret_id_num_uses:0}' | vault "auth/approle/role/$service" POST >/dev/null if [ "$service" = sendico ]; then
printf '{"token_policies":["sendico"],"token_ttl":"5m","token_max_ttl":"5m","secret_id_ttl":"0","secret_id_num_uses":0}' | vault "auth/approle/role/$service" POST >/dev/null
else
jq -n --arg service "$service" '{token_policies:[$service],token_period:"1h",secret_id_ttl:"0",secret_id_num_uses:0}' | vault "auth/approle/role/$service" POST >/dev/null
fi
vault "auth/approle/role/$service/role-id" | jq -r '.data.role_id' | write "$directory/role-id" 444 vault "auth/approle/role/$service/role-id" | jq -r '.data.role_id' | write "$directory/role-id" 444
if [ ! -f "$directory/secret-id" ]; then if [ ! -f "$directory/secret-id" ]; then
printf '{}' | vault "auth/approle/role/$service/secret-id" POST | jq -r '.data.secret_id' | write "$directory/secret-id" 444 printf '{}' | vault "auth/approle/role/$service/secret-id" POST | jq -r '.data.secret_id' | write "$directory/secret-id" 444
fi fi
if ! cmp -s "infra/local/vault/$service.hcl" "$directory/config.hcl"; then if [ "$service" != sendico ] && ! cmp -s "infra/local/vault/$service.hcl" "$directory/config.hcl"; then
cat "infra/local/vault/$service.hcl" | write "$directory/config.hcl" 444 cat "infra/local/vault/$service.hcl" | write "$directory/config.hcl" 444
if compose ps --status running --services "$service" | grep -qx "$service"; then if compose ps --status running --services "$service" | grep -qx "$service"; then
compose up -d --no-deps --force-recreate --wait --wait-timeout 120 "$service" compose up -d --no-deps --force-recreate --wait --wait-timeout 120 "$service"
+7 -1
View File
@@ -14,7 +14,13 @@ trap cleanup EXIT
trap 'exit 130' INT trap 'exit 130' INT
trap 'exit 143' TERM trap 'exit 143' TERM
fail() { echo "$*" >&2; exit 1; } fail() { echo "$*" >&2; exit 1; }
compose() { docker compose --project-directory . -f infra/local/compose.yml --env-file "$STATE/images.env" "$@"; } compose() {
if [ -f "$STATE/sendico/settings.env" ]; then
docker compose --project-directory . -f infra/local/compose.yml -f infra/local/sendico/compose.yml --env-file "$STATE/images.env" "$@"
else
docker compose --project-directory . -f infra/local/compose.yml --env-file "$STATE/images.env" "$@"
fi
}
write() { write() {
target=$1; mode=${2:-600} target=$1; mode=${2:-600}
mkdir -p "$(dirname "$target")" mkdir -p "$(dirname "$target")"
+1
View File
@@ -7,6 +7,7 @@ case "${1:-}" in
images) . infra/local/scripts/images.sh; images;; images) . infra/local/scripts/images.sh; images;;
up) . infra/local/scripts/bootstrap.sh; up;; up) . infra/local/scripts/bootstrap.sh; up;;
email) . infra/local/scripts/bootstrap.sh; email;; email) . infra/local/scripts/bootstrap.sh; email;;
sendico) . infra/local/scripts/bootstrap.sh; . infra/local/scripts/sendico.sh; configure_sendico;;
verify|verify-infra) . infra/local/scripts/verify.sh; existing; infrastructure; if [ "$1" = verify ]; then product; fi;; verify|verify-infra) . infra/local/scripts/verify.sh; existing; infrastructure; if [ "$1" = verify ]; then product; fi;;
rotation) . infra/local/scripts/verify.sh; . infra/local/scripts/rotation.sh; existing; rotation;; rotation) . infra/local/scripts/verify.sh; . infra/local/scripts/rotation.sh; existing; rotation;;
test) sh infra/local/scripts/test.sh;; test) sh infra/local/scripts/test.sh;;
+20
View File
@@ -0,0 +1,20 @@
# Operator input is JSON on stdin. Only credential values are stored in Vault.
configure_sendico() {
existing
cat > "$WORK/input"
jq -e '
(.organization_ref|test("^[0-9a-f]{24}$") and .!="000000000000000000000000") and
(.source_account_ref|test("^[0-9a-f]{24}$") and .!="000000000000000000000000") and
([.client_id,.device_id,.login,.password,.client_secret] | all(.[]; type=="string" and test("\\S"))) and
([.client_id,.device_id] | all(.[]; test("^[A-Za-z0-9._-]+$")))
' "$WORK/input" >/dev/null || fail 'Invalid Sendico operator input'
jq '{login,password,client_secret}' "$WORK/input" | put services/bff/sendico
mkdir -p "$STATE/sendico"
jq -r '"SENDICO_ORGANIZATION_REF="+.organization_ref+"\nSENDICO_SOURCE_ACCOUNT_REF="+.source_account_ref+"\nSENDICO_CLIENT_ID="+.client_id+"\nSENDICO_DEVICE_ID="+.device_id' "$WORK/input" | write "$STATE/sendico/settings.env"
{
cat infra/local/bff/runtime.yml
jq -r '"\n sendico:\n api_url: http://127.0.0.1:18081/api/v1\n organization_ref: "+.organization_ref+"\n source_account_ref: "+.source_account_ref+"\n token_file: /run/sendico/access-token"' "$WORK/input"
} | write "$STATE/sendico/runtime.yml" 644
provision_agents
echo 'Local Sendico connection configured. Run make up.'
}
+8
View File
@@ -4,4 +4,12 @@ umask 077
if [ "$SXCHNGE_SERVICE" = notifications ]; then if [ "$SXCHNGE_SERVICE" = notifications ]; then
exec service -config.file=/etc/sxchnge/notifications.yml exec service -config.file=/etc/sxchnge/notifications.yml
fi fi
if [ "${SXCHNGE_SENDICO_LOCAL:-}" = 1 ]; then
attempt=0
until [ -s /run/sendico/access-token ] && [ "$(($(date +%s) - $(stat -c %Y /run/sendico/access-token)))" -lt 1500 ]; do
attempt=$((attempt+1))
[ "$attempt" -lt 120 ] || { echo 'Local Sendico session did not become ready' >&2; exit 1; }
sleep 2
done
fi
exec service -config.file=/etc/sxchnge/bff.yml exec service -config.file=/etc/sxchnge/bff.yml
+21
View File
@@ -4,6 +4,7 @@ set -o pipefail
# Shell syntax and static analysis cover the actual container shell scripts. # Shell syntax and static analysis cover the actual container shell scripts.
shellcheck -s ash -x -S warning infra/local/scripts/dispatch.sh infra/local/scripts/test.sh shellcheck -s ash -x -S warning infra/local/scripts/dispatch.sh infra/local/scripts/test.sh
shellcheck -x -S warning infra/local/tools.sh infra/local/scripts/start-service.sh infra/local/mongo/start.sh infra/local/nats/start.sh infra/local/vault/start.sh shellcheck -x -S warning infra/local/tools.sh infra/local/scripts/start-service.sh infra/local/mongo/start.sh infra/local/nats/start.sh infra/local/vault/start.sh
shellcheck -s ash -x -S warning infra/local/sendico/session.sh infra/local/sendico/check.sh
. infra/local/scripts/common.sh . infra/local/scripts/common.sh
. infra/local/scripts/images.sh . infra/local/scripts/images.sh
. infra/local/scripts/bootstrap.sh . infra/local/scripts/bootstrap.sh
@@ -93,3 +94,23 @@ retire_secrets
retire_secrets retire_secrets
jq -e '.=={"infrastructure/nats/auth":true}' "$WORK/retirement" >/dev/null jq -e '.=={"infrastructure/nats/auth":true}' "$WORK/retirement" >/dev/null
echo 'PASS: obsolete caller secrets retired idempotently; NATS authentication preserved' echo 'PASS: obsolete caller secrets retired idempotently; NATS authentication preserved'
# Local integration configuration keeps credentials out of runtime settings and
# rejects malformed operator inputs before mutating Vault or service identities.
. infra/local/scripts/sendico.sh
STATE="$WORK/sendico-state"; mkdir "$STATE"
existing() { :; }
put() { cat > "$WORK/sendico-credentials"; }
provision_agents() { touch "$WORK/sendico-agents"; }
printf '{}' > "$WORK/sendico-invalid"
if (configure_sendico < "$WORK/sendico-invalid") >/dev/null 2>&1; then fail 'Invalid Sendico configuration accepted'; fi
[ ! -e "$WORK/sendico-credentials" ] || fail 'Invalid Sendico input mutated credentials'
cat > "$WORK/sendico-input" <<'JSON'
{"organization_ref":"000000000000000000000001","source_account_ref":"000000000000000000000002","client_id":"fixture.client","device_id":"fixture-device","login":"fixture@example.test","password":"fixture-password","client_secret":"fixture-secret"}
JSON
configure_sendico < "$WORK/sendico-input" >/dev/null
jq -e 'keys==["client_secret","login","password"]' "$WORK/sendico-credentials" >/dev/null
[ -f "$WORK/sendico-agents" ] || fail 'Sendico identity was not provisioned'
if grep -Eq 'fixture-secret|fixture-password|fixture@example' "$STATE/sendico/settings.env" "$STATE/sendico/runtime.yml"; then fail 'Credential leaked into runtime configuration'; fi
grep -q 'source_account_ref: 000000000000000000000002' "$STATE/sendico/runtime.yml"
grep -q 'api_url: http://127.0.0.1:18081/api/v1' "$STATE/sendico/runtime.yml"
echo 'PASS: Sendico operator input validation, secret separation and local runtime configuration'
+14 -3
View File
@@ -2,11 +2,16 @@ policies() (
root_token=$VAULT_TOKEN root_token=$VAULT_TOKEN
identity='' identity=''
trap 'if [ -n "$identity" ]; then printf "{}" | VAULT_TOKEN=$identity vault auth/token/revoke-self POST >/dev/null; fi' EXIT trap 'if [ -n "$identity" ]; then printf "{}" | VAULT_TOKEN=$identity vault auth/token/revoke-self POST >/dev/null; fi' EXIT
for service in bff notifications nats; do services='bff notifications nats'
if [ -f "$STATE/sendico/settings.env" ]; then services="$services sendico"; fi
for service in $services; do
directory="$STATE/agents/$service" directory="$STATE/agents/$service"
login=$(jq -n --rawfile role "$directory/role-id" --rawfile secret "$directory/secret-id" '{role_id:($role|rtrimstr("\n")),secret_id:($secret|rtrimstr("\n"))}' | vault auth/approle/login POST) login=$(jq -n --rawfile role "$directory/role-id" --rawfile secret "$directory/secret-id" '{role_id:($role|rtrimstr("\n")),secret_id:($secret|rtrimstr("\n"))}' | vault auth/approle/login POST)
identity=$(printf '%s' "$login" | jq -er '.auth.client_token') identity=$(printf '%s' "$login" | jq -er '.auth.client_token')
printf '%s' "$login" | jq '{token:.auth.client_token,paths:["kv/data/bootstrap/mongo","kv/data/infrastructure/mongo/bootstrap","sys/init","kv/data/bff","kv/data/caller","kv/data/services/notifications/callers/bff","kv/data/email","kv/data/nats","kv/data/tls"]}' | vault sys/capabilities POST | jq -e '.data | all(.[]; .==["deny"])' >/dev/null || fail 'Service can access bootstrap credentials' printf '%s' "$login" | jq '{token:.auth.client_token,paths:["kv/data/bootstrap/mongo","kv/data/infrastructure/mongo/bootstrap","sys/init","kv/data/bff","kv/data/caller","kv/data/services/notifications/callers/bff","kv/data/email","kv/data/nats","kv/data/tls"]}' | vault sys/capabilities POST | jq -e '.data | all(.[]; .==["deny"])' >/dev/null || fail 'Service can access bootstrap credentials'
if [ "$service" = sendico ]; then
printf '%s' "$login" | jq '{token:.auth.client_token,paths:["database/creds/bff","database/creds/notifications","kv/data/services/bff/jwt","kv/data/infrastructure/nats/auth"]}' | vault sys/capabilities POST | jq -e '.data | all(.[]; .==["deny"])' >/dev/null || fail 'Sendico session identity has unrelated secret access'
fi
VAULT_TOKEN=$(printf '%s' "$login" | jq -er '.auth.client_token') VAULT_TOKEN=$(printf '%s' "$login" | jq -er '.auth.client_token')
printf '{}' | vault auth/token/revoke-self POST >/dev/null printf '{}' | vault auth/token/revoke-self POST >/dev/null
VAULT_TOKEN=$root_token VAULT_TOKEN=$root_token
@@ -44,12 +49,18 @@ infrastructure() {
# Resolve through Docker DNS while verifying the certificate for localhost. # Resolve through Docker DNS while verifying the certificate for localhost.
https() { curl -sS --max-time 5 --cacert "$STATE/ca.pem" --connect-to localhost:5126:frontend:8443 "$@"; } https() { curl -sS --max-time 5 --cacert "$STATE/ca.pem" --connect-to localhost:5126:frontend:8443 "$@"; }
product() { product() {
compose ps --format json | jq -se 'length==6 and all(.[];.Health=="healthy")' >/dev/null || fail 'Expected six healthy services' expected=6
if [ -f "$STATE/sendico/settings.env" ]; then expected=7; fi
compose ps --format json | jq -se --argjson expected "$expected" 'length==$expected and all(.[];.Health=="healthy")' >/dev/null || fail "Expected $expected healthy services"
https -f https://localhost:5126/ >/dev/null https -f https://localhost:5126/ >/dev/null
https -f https://localhost:5126/api/v1/health >/dev/null https -f https://localhost:5126/api/v1/health >/dev/null
echo 'PASS: six healthy services, frontend and proxied BFF use verified TLS; no email sent' if [ "$expected" = 7 ]; then compose exec -T sendico sh /etc/sendico/check.sh; fi
echo "PASS: $expected healthy services, frontend and proxied BFF use verified TLS; no email sent"
} }
layout() { layout() {
if [ -f "$STATE/sendico/settings.env" ]; then
kv services/bff/sendico | jq -e 'keys==["client_secret","login","password"]' >/dev/null || fail 'Sendico secret contains non-secret configuration'
fi
kv services/notifications/email/resend | jq -e 'keys==["api_key"]' >/dev/null || fail 'Non-secret email configuration remains in Vault' kv services/notifications/email/resend | jq -e 'keys==["api_key"]' >/dev/null || fail 'Non-secret email configuration remains in Vault'
for old in bootstrap/mongo bff caller services/notifications/callers/bff email nats tls frontend-tls notifications-tls; do for old in bootstrap/mongo bff caller services/notifications/callers/bff email nats tls frontend-tls notifications-tls; do
[ "$(vault "kv/metadata/$old" GET yes)" = null ] || fail "Obsolete KV entry remains: $old" [ "$(vault "kv/metadata/$old" GET yes)" = null ] || fail "Obsolete KV entry remains: $old"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
umask 077
check=$(mktemp -d /private/check.XXXXXX)
trap 'rm -rf "$check"' EXIT
printf 'Authorization: Bearer %s\n' "$(cat /run/sendico/access-token)" > "$check/header"
for path in "payments/fx/currencies/$SENDICO_ORGANIZATION_REF" "ledger_accounts/$SENDICO_ORGANIZATION_REF"; do
curl -fsS --max-time 15 -H "@$check/header" "http://127.0.0.1:18081/api/v1/$path" > "$check/response" 2>/dev/null || { echo 'Local Sendico authenticated API check failed' >&2; exit 1; }
jq -e '.status=="success"' "$check/response" >/dev/null
done
echo 'PASS: local Sendico authenticated currency and ledger APIs are accessible'
+30
View File
@@ -0,0 +1,30 @@
# Enabled by make sendico; settings contain no credentials.
services:
bff:
environment:
SXCHNGE_SENDICO_LOCAL: '1'
volumes:
- ./.local/dev/sendico/runtime.yml:/etc/sxchnge/bff.yml:ro
- sendico-session:/run/sendico:ro
sendico:
image: sxchnge-local/tools:dev
restart: unless-stopped
# Keep the adapter's loopback-only HTTP rule; forward to the public stand API.
network_mode: service:bff
entrypoint: [/bin/sh, /etc/sendico/session.sh]
env_file: ./.local/dev/sendico/settings.env
volumes:
- ./infra/local/sendico/session.sh:/etc/sendico/session.sh:ro
- ./infra/local/sendico/check.sh:/etc/sendico/check.sh:ro
- ./.local/dev/agents/sendico:/etc/agent:ro
- sendico-session:/run/sendico
tmpfs: [/private:mode=0700]
healthcheck:
test: [CMD-SHELL, 'test -f /private/ready && test $$(($$(date +%s) - $$(stat -c %Y /private/ready))) -lt 1500 && curl -fsS --max-time 2 http://127.0.0.1:18081/api/v1/health >/dev/null']
interval: 10s
timeout: 3s
retries: 30
volumes:
sendico-session:
+45
View File
@@ -0,0 +1,45 @@
#!/bin/sh
set -eu
umask 077
mkdir -p /private /run/sendico
chmod 755 /run/sendico
cd /private
socat TCP4-LISTEN:18081,bind=127.0.0.1,reuseaddr,fork TCP4:host.docker.internal:8080 &
bridge=$!
trap 'kill "$bridge" 2>/dev/null || true' EXIT
trap 'exit 0' INT TERM
session() {
jq -n --rawfile role /etc/agent/role-id --rawfile secret /etc/agent/secret-id \
'{role_id:($role|rtrimstr("\n")),secret_id:($secret|rtrimstr("\n"))}' > login.json
curl -fsS --max-time 15 -H 'Content-Type: application/json' --data-binary @login.json \
http://vault:8200/v1/auth/approle/login > vault.json 2>/dev/null || return 1
jq -er '"X-Vault-Token: "+.auth.client_token' vault.json > vault.header || return 1
curl -fsS --max-time 15 -H @vault.header http://vault:8200/v1/kv/data/services/bff/sendico > credentials.json 2>/dev/null || return 1
jq -e --arg client "$SENDICO_CLIENT_ID" --arg device "$SENDICO_DEVICE_ID" \
'.data.data | {login:{login,password},clientSecret:.client_secret,clientId:$client,deviceId:$device}' credentials.json > request.json || return 1
curl -fsS --max-time 15 -H @vault.header -X POST http://vault:8200/v1/auth/token/revoke-self >/dev/null 2>&1 || return 1
rm -f vault.header vault.json credentials.json login.json
curl -fsS --max-time 20 -H 'Content-Type: application/json' --data-binary @request.json \
http://127.0.0.1:18081/api/v1/accounts/login/api > response.json 2>/dev/null || return 1
rm -f request.json
jq -er '.data.accessToken.token' response.json > token 2>/dev/null || return 1
jq -e --arg source "$SENDICO_SOURCE_ACCOUNT_REF" \
'.data.accessToken.token | split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson | .id==$source and .pending!=true and .exp>(now+1500)' response.json >/dev/null 2>&1 || return 1
cp token /run/sendico/access-token.new
chmod 444 /run/sendico/access-token.new
mv /run/sendico/access-token.new /run/sendico/access-token
rm -f token response.json
touch ready
}
while :; do
if session; then
echo 'Local Sendico session ready; next renewal in 20 minutes.'
sleep 1200 & wait $!
else
echo 'Local Sendico session unavailable; check stand health and integration credentials. Retrying in 5 minutes.' >&2
rm -f request.json response.json token vault.header vault.json credentials.json login.json
sleep 300 & wait $!
fi
done