Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5689876ff | ||
|
|
34d8ef18d1 | ||
|
|
7a6510d1a8 | ||
|
|
5d67c25c8e | ||
|
|
b303285b8b | ||
|
|
1354551ae8 | ||
|
|
7bf8b8087a | ||
|
|
a584c8189a | ||
|
|
30f0455122 |
No files matched your search
@@ -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
|
||||||
@@ -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).
|
||||||
@@ -33,12 +33,20 @@ API versions are installed explicitly by the product composition. See
|
|||||||
mounts, shared lifecycle and adding a future version. Customer profile endpoint
|
mounts, shared lifecycle and adding a future version. Customer profile endpoint
|
||||||
wiring remains follow-up work.
|
wiring remains follow-up work.
|
||||||
|
|
||||||
Sendico integration is not wired. Its organization context belongs only to the
|
Optional `api.sendico` connects sign-up to the public individual registry and
|
||||||
future public HTTP adapter. There is no unused public API snapshot in this module;
|
immutable Facts intake. The adapter owns organization context and consumed wire
|
||||||
copy only the wire definitions consumed by an implemented adapter.
|
contracts; local customers retain no platform tenancy. See
|
||||||
|
[configuration, recovery and measurement](../../docs/signup-facts.md).
|
||||||
|
|
||||||
## Validate
|
## Validate
|
||||||
|
|
||||||
`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).
|
||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||||
"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/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"
|
||||||
@@ -17,6 +19,8 @@ type Dependencies struct {
|
|||||||
Customers accountservice.AccountService
|
Customers accountservice.AccountService
|
||||||
Auth *flow.Service
|
Auth *flow.Service
|
||||||
Factors *twofactor.Service
|
Factors *twofactor.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
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package api
|
|||||||
import (
|
import (
|
||||||
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
|
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
|
||||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
||||||
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
|
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
Sendico *sendico.Config `yaml:"sendico"`
|
||||||
// nil preserves the legacy v1 mount; an explicit empty list installs no API.
|
// nil preserves the legacy v1 mount; an explicit empty list installs no API.
|
||||||
Versions []Mount `yaml:"versions"`
|
Versions []Mount `yaml:"versions"`
|
||||||
Notifications *notifications.Config `yaml:"notifications"`
|
Notifications *notifications.Config `yaml:"notifications"`
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||||
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"
|
||||||
|
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"
|
||||||
)
|
)
|
||||||
@@ -22,5 +24,7 @@ func Install(scope api.Scope, deps *api.Dependencies) error {
|
|||||||
if deps.Factors != nil {
|
if deps.Factors != nil {
|
||||||
factors.Install(routes, deps.Factors, scope.Logger)
|
factors.Install(routes, deps.Factors, scope.Logger)
|
||||||
}
|
}
|
||||||
|
identity.Install(routes, deps.Identity, scope.Logger)
|
||||||
|
paymenthttp.Install(routes, deps.Payments, scope.Logger)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -2,16 +2,20 @@ package runtime_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||||
"git.sendico.io/tech/sxchnge/product/app/profile"
|
"git.sendico.io/tech/sxchnge/product/app/profile"
|
||||||
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
|
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
||||||
"git.sendico.io/tech/sxchnge/shared/database"
|
"git.sendico.io/tech/sxchnge/shared/database"
|
||||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -61,4 +65,22 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
|||||||
result = httptest.NewRecorder()
|
result = httptest.NewRecorder()
|
||||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
|
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
|
||||||
require.Equal(t, http.StatusBadRequest, result.Code)
|
require.Equal(t, http.StatusBadRequest, result.Code)
|
||||||
|
// Enabling the adapter validates credentials, composes once, and does not
|
||||||
|
// register any provider resource until a customer starts sign-up.
|
||||||
|
const source = "507f1f77bcf86cd799439011"
|
||||||
|
claims, err := json.Marshal(map[string]any{"id": source, "exp": time.Now().Add(time.Hour).Unix()})
|
||||||
|
require.NoError(t, err)
|
||||||
|
file := filepath.Join(t.TempDir(), "credential")
|
||||||
|
require.NoError(t, os.WriteFile(file, []byte("header."+base64.RawURLEncoding.EncodeToString(claims)+".signature"), 0600))
|
||||||
|
example.API.Sendico = &sendico.Config{APIURL: "https://api.example/api/v1", OrganizationID: source, SourceID: source, TokenFile: file}
|
||||||
|
dependencies, finish, err := sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, dependencies.Auth.Facts)
|
||||||
|
require.NoError(t, finish(ctx))
|
||||||
|
example.API.Sendico.TokenFile = ""
|
||||||
|
_, _, err = sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
|
||||||
|
require.Error(t, err)
|
||||||
|
example.API.Notifications = nil
|
||||||
|
_, _, err = sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
|
||||||
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
@@ -8,11 +8,15 @@ import (
|
|||||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||||
"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/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/twofactor"
|
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||||
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
||||||
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
||||||
"git.sendico.io/tech/sxchnge/shared/database"
|
"git.sendico.io/tech/sxchnge/shared/database"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -55,6 +59,24 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
deps.Auth.Sender = client
|
deps.Auth.Sender = client
|
||||||
|
if config.Sendico != nil {
|
||||||
|
provider, err := sendico.Open(*config.Sendico)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
bindings, err := identity.OpenStore(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
deps.Identity = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("identity")}
|
||||||
|
deps.Auth.Facts = deps.Identity
|
||||||
|
paymentStore, err := payments.OpenStore(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
|
||||||
|
logger.Info("Sendico sign-up facts integration enabled")
|
||||||
|
}
|
||||||
records, err := factorstore.Open(logger, db)
|
records, err := factorstore.Open(logger, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -64,6 +86,12 @@ 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 {
|
||||||
|
return nil, nil, merrors.InvalidArgument("Sendico sign-up facts require notifications configuration")
|
||||||
|
}
|
||||||
logger.Info("Product dependencies initialized")
|
logger.Info("Product dependencies initialized")
|
||||||
return deps, cleanup, nil
|
return deps, cleanup, nil
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,13 @@ import (
|
|||||||
|
|
||||||
const Cooldown = time.Minute
|
const Cooldown = time.Minute
|
||||||
|
|
||||||
|
type SignupFacts interface {
|
||||||
|
Ensure(context.Context, *customer.Account) error
|
||||||
|
Ready(context.Context, bson.ObjectID) error
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
|
Facts SignupFacts
|
||||||
Accounts account.DB
|
Accounts account.DB
|
||||||
Customers accountservice.AccountService
|
Customers accountservice.AccountService
|
||||||
Tokens verification.DB
|
Tokens verification.DB
|
||||||
@@ -85,20 +91,25 @@ func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key strin
|
|||||||
if err = s.Limit(ctx, ip, email); err != nil {
|
if err = s.Limit(ctx, ip, email); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
raw, err := s.Customers.CreateAccount(ctx, a)
|
raw, err := s.Customers.CreateAccountWithActivationKey(ctx, a, s.activationKey(a.Login, key))
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
existing, lookup := s.Accounts.GetByEmail(ctx, email)
|
existing, lookup := s.Accounts.GetByEmail(ctx, email)
|
||||||
if lookup != nil {
|
if lookup != nil {
|
||||||
return nil, lookup
|
return nil, lookup
|
||||||
}
|
}
|
||||||
// A retry after a queue failure may resume only with the original password.
|
// A retry after a queue failure may resume only with the original password.
|
||||||
if existing.Status != customer.AccountPendingVerification || !existing.MatchPassword(a.Password) {
|
if existing.Status != customer.AccountPendingVerification || existing.IsArchived() || !existing.MatchPassword(a.Password) {
|
||||||
return nil, merrors.DataConflict("email already registered")
|
return nil, merrors.DataConflict("email already registered")
|
||||||
}
|
}
|
||||||
a = existing
|
a = existing
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if s.Facts != nil {
|
||||||
|
if err := s.Facts.Ensure(ctx, a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
if raw != "" {
|
if raw != "" {
|
||||||
u, _ := url.Parse(s.ActivationURL)
|
u, _ := url.Parse(s.ActivationURL)
|
||||||
q := u.Query()
|
q := u.Query()
|
||||||
@@ -132,14 +143,22 @@ func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) e
|
|||||||
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if s.Facts != nil {
|
||||||
|
if err := s.Facts.Ensure(ctx, a); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
_, err = s.sendActivation(ctx, a, key)
|
_, err = s.sendActivation(ctx, a, key)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
func (s *Service) activationKey(email, key string) string {
|
||||||
|
return s.key(email, string(verificationmodel.PurposeAccountActivation), "", key)
|
||||||
|
}
|
||||||
func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key string) (time.Time, error) {
|
func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key string) (time.Time, error) {
|
||||||
if strings.TrimSpace(key) == "" || len(key) > 128 {
|
if strings.TrimSpace(key) == "" || len(key) > 128 {
|
||||||
return time.Time{}, merrors.InvalidArgument("idempotency key required")
|
return time.Time{}, merrors.InvalidArgument("idempotency key required")
|
||||||
}
|
}
|
||||||
secretKey := s.key(a.ID.Hex(), string(verificationmodel.PurposeAccountActivation), "", key)
|
secretKey := s.activationKey(a.Login, key)
|
||||||
req := verification.NewLinkRequest(a.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24 * time.Hour).WithCooldown(Cooldown).WithIdempotencyKey(secretKey)
|
req := verification.NewLinkRequest(a.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24 * time.Hour).WithCooldown(Cooldown).WithIdempotencyKey(secretKey)
|
||||||
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC()))
|
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC()))
|
||||||
result, err := s.Tokens.CreateWithResult(ctx, req)
|
result, err := s.Tokens.CreateWithResult(ctx, req)
|
||||||
@@ -176,6 +195,11 @@ func (s *Service) Activate(ctx context.Context, raw string) error {
|
|||||||
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
||||||
return nil, merrors.Unauthorized("account cannot be activated")
|
return nil, merrors.Unauthorized("account cannot be activated")
|
||||||
}
|
}
|
||||||
|
if s.Facts != nil {
|
||||||
|
if err := s.Facts.Ready(tx, a.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
|
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func TestIssueRetriesSamePayloadAndBindsServerSecret(t *testing.T) {
|
|||||||
require.Equal(t, sender.messages[0], sender.messages[1])
|
require.Equal(t, sender.messages[0], sender.messages[1])
|
||||||
require.Equal(t, "ru", sender.messages[0].Locale)
|
require.Equal(t, "ru", sender.messages[0].Locale)
|
||||||
service.Secret = []byte(strings.Repeat("b", 32))
|
service.Secret = []byte(strings.Repeat("b", 32))
|
||||||
require.NotEqual(t, key, service.key(a.ID.Hex(), "account_activation", "client-key"))
|
require.NotEqual(t, key, service.activationKey(a.Login, "client-key"))
|
||||||
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
|
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
|
||||||
_, err = service.sendActivation(context.Background(), a, "expired")
|
_, err = service.sendActivation(context.Background(), a, "expired")
|
||||||
require.ErrorIs(t, err, verification.ErrTokenExpired)
|
require.ErrorIs(t, err, verification.ErrTokenExpired)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package public
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFactsAssistanceHasTypedPrivacySafeResponse(t *testing.T) {
|
||||||
|
router := &PublicRouter{logger: zap.NewNop(), service: "accounts"}
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/signup", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.flowError(merrors.FailedPrecondition("private upstream detail"))(response, request)
|
||||||
|
require.Equal(t, http.StatusServiceUnavailable, response.Code)
|
||||||
|
require.Equal(t, "no-store", response.Header().Get("Cache-Control"))
|
||||||
|
var envelope struct {
|
||||||
|
Data struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &envelope))
|
||||||
|
require.Equal(t, "signup_attention_required", envelope.Data.Error)
|
||||||
|
require.NotContains(t, response.Body.String(), "private upstream detail")
|
||||||
|
}
|
||||||
@@ -72,6 +72,8 @@ func (pr *PublicRouter) flowError(err error) http.HandlerFunc {
|
|||||||
return pr.loginRateLimited(details.RetryAfter)
|
return pr.loginRateLimited(details.RetryAfter)
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
|
case errors.Is(err, merrors.ErrFailedPrecondition):
|
||||||
|
return response.Error(pr.logger, pr.service, http.StatusServiceUnavailable, "signup_attention_required", "We could not finish setting up your account. Please contact support.")
|
||||||
case errors.Is(err, verification.ErrTokenExpired):
|
case errors.Is(err, verification.ErrTokenExpired):
|
||||||
return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email")
|
return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email")
|
||||||
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
|
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
||||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||||
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||||
@@ -26,6 +27,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,6 +36,38 @@ type capturedMail struct {
|
|||||||
fail bool
|
fail bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type signupProvider struct {
|
||||||
|
registrations, submissions int
|
||||||
|
fail bool
|
||||||
|
last identity.Facts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*signupProvider) Namespace() string { return "test-provider" }
|
||||||
|
func (p *signupProvider) Register(context.Context, string) (string, error) {
|
||||||
|
p.registrations++
|
||||||
|
return "subject", nil
|
||||||
|
}
|
||||||
|
func (p *signupProvider) Submit(_ context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) {
|
||||||
|
p.submissions++
|
||||||
|
p.last = facts
|
||||||
|
if p.fail {
|
||||||
|
return identity.Receipt{}, merrors.ServiceUnavailable("provider unavailable")
|
||||||
|
}
|
||||||
|
return identity.Receipt{SubjectRef: subject, SubmissionRef: "submission", AcceptedAt: time.Now().UTC()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type activationGuard struct {
|
||||||
|
flow.SignupFacts
|
||||||
|
block bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *activationGuard) Ready(ctx context.Context, id bson.ObjectID) error {
|
||||||
|
if g.block {
|
||||||
|
return merrors.ServiceUnavailable("intake receipt unavailable")
|
||||||
|
}
|
||||||
|
return g.SignupFacts.Ready(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
||||||
m.messages = append(m.messages, r)
|
m.messages = append(m.messages, r)
|
||||||
if m.fail {
|
if m.fail {
|
||||||
@@ -64,6 +98,10 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
mail := &capturedMail{}
|
mail := &capturedMail{}
|
||||||
service := &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: factory.TransactionFactory(), Attempts: stores.LoginAttempts, Sender: mail, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
|
service := &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: factory.TransactionFactory(), Attempts: stores.LoginAttempts, Sender: mail, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
|
||||||
require.NoError(t, service.Validate())
|
require.NoError(t, service.Validate())
|
||||||
|
bindings, err := identity.OpenStore(factory)
|
||||||
|
require.NoError(t, err)
|
||||||
|
provider := &signupProvider{fail: true}
|
||||||
|
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()}
|
||||||
// Keep the real verification/customer stores and capture only session writes.
|
// Keep the real verification/customer stores and capture only session writes.
|
||||||
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
||||||
records, err := factorstore.Open(zap.NewNop(), factory)
|
records, err := factorstore.Open(zap.NewNop(), factory)
|
||||||
@@ -83,7 +121,22 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
signup := map[string]any{"name": "Customer", "email": "USER@example.com", "password": " correct password ", "locale": "ru", "idempotencyKey": "signup-one"}
|
signup := map[string]any{"name": "Customer", "email": "USER@example.com", "password": " correct password ", "locale": "ru", "idempotencyKey": "signup-one"}
|
||||||
w := post("signup", signup)
|
w := post("signup", signup)
|
||||||
|
require.Equal(t, 503, w.Code, w.Body.String())
|
||||||
|
require.Empty(t, mail.messages)
|
||||||
|
require.Equal(t, 1, provider.registrations)
|
||||||
|
require.Equal(t, 503, post("verification/send", map[string]string{"email": "user@example.com", "idempotencyKey": "failed-resend"}).Code)
|
||||||
|
require.Empty(t, mail.messages)
|
||||||
|
// Only the original password can resume a pending local customer.
|
||||||
|
signup["password"] = "different password"
|
||||||
|
require.Equal(t, 400, post("signup", signup).Code)
|
||||||
|
signup["password"] = " correct password "
|
||||||
|
signup["name"] = "Changed retry name"
|
||||||
|
provider.fail = false
|
||||||
|
w = post("signup", signup)
|
||||||
require.Equal(t, 202, w.Code, w.Body.String())
|
require.Equal(t, 202, w.Code, w.Body.String())
|
||||||
|
require.Equal(t, 1, provider.registrations)
|
||||||
|
require.Equal(t, "Customer", provider.last.FullName)
|
||||||
|
require.Equal(t, "user@example.com", provider.last.Email)
|
||||||
require.Len(t, mail.messages, 1)
|
require.Len(t, mail.messages, 1)
|
||||||
require.Equal(t, "auth.activation", mail.messages[0].Template)
|
require.Equal(t, "auth.activation", mail.messages[0].Template)
|
||||||
require.Equal(t, "user@example.com", mail.messages[0].Email)
|
require.Equal(t, "user@example.com", mail.messages[0].Email)
|
||||||
@@ -95,9 +148,18 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
a, err := stores.Accounts.GetByEmail(ctx, "user@example.com")
|
a, err := stores.Accounts.GetByEmail(ctx, "user@example.com")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.False(t, a.IsActive())
|
require.False(t, a.IsActive())
|
||||||
|
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
|
||||||
|
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
||||||
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
||||||
require.Equal(t, 403, post("login", login).Code)
|
require.Equal(t, 403, post("login", login).Code)
|
||||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).Code)
|
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).Code)
|
||||||
|
guard := &activationGuard{SignupFacts: service.Facts, block: true}
|
||||||
|
service.Facts = guard
|
||||||
|
require.Equal(t, 503, post("verification/confirm", map[string]string{"token": activation}).Code)
|
||||||
|
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, a.IsActive())
|
||||||
|
guard.block = false
|
||||||
w = post("verification/confirm", map[string]string{"token": activation})
|
w = post("verification/confirm", map[string]string{"token": activation})
|
||||||
require.Equal(t, 202, w.Code, w.Body.String())
|
require.Equal(t, 202, w.Code, w.Body.String())
|
||||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code)
|
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code)
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ database:
|
|||||||
uri_env: SXCHNGE_MONGO_URI
|
uri_env: SXCHNGE_MONGO_URI
|
||||||
database: sxchnge
|
database: sxchnge
|
||||||
api:
|
api:
|
||||||
|
# Optional public Sendico facts integration. Enable with provisioned values;
|
||||||
|
# omit this block to retain local-only sign-up. See docs/signup-facts.md.
|
||||||
|
# sendico:
|
||||||
|
# api_url: https://sendico.example/api/v1
|
||||||
|
# organization_ref: <sxchnge-organization-object-id>
|
||||||
|
# source_account_ref: <integration-account-object-id>
|
||||||
|
# token_file: /run/sxchnge/sendico-token
|
||||||
# Optional: omit for v1 at SXCHNGE_API_ENDPOINT; [] disables application APIs.
|
# Optional: omit for v1 at SXCHNGE_API_ENDPOINT; [] disables application APIs.
|
||||||
# versions:
|
# versions:
|
||||||
# - version: v1
|
# - version: v1
|
||||||
|
|||||||
@@ -119,6 +119,17 @@ func (s *service) CreateAccount(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
acct *customermodel.Account,
|
acct *customermodel.Account,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
|
return s.createAccount(ctx, acct, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) CreateAccountWithActivationKey(ctx context.Context, acct *customermodel.Account, key string) (string, error) {
|
||||||
|
if len(key) < 32 || len(key) > 128 {
|
||||||
|
return "", merrors.InvalidArgument("server activation key required")
|
||||||
|
}
|
||||||
|
return s.createAccount(ctx, acct, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) createAccount(ctx context.Context, acct *customermodel.Account, key string) (string, error) {
|
||||||
if acct == nil {
|
if acct == nil {
|
||||||
return "", merrors.InvalidArgument("account is required")
|
return "", merrors.InvalidArgument("account is required")
|
||||||
}
|
}
|
||||||
@@ -137,7 +148,7 @@ func (s *service) CreateAccount(
|
|||||||
if err := s.accountDB.Create(tx, saved); err != nil {
|
if err := s.accountDB.Create(tx, saved); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.VerifyAccount(tx, saved)
|
return s.vdb.Create(tx, verification.NewLinkRequest(saved.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24*time.Hour).WithIdempotencyKey(key))
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ type AccountService interface {
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
acct *customermodel.Account,
|
acct *customermodel.Account,
|
||||||
) (verificationToken string, err error)
|
) (verificationToken string, err error)
|
||||||
|
// CreateAccountWithActivationKey preserves atomic creation and lets the auth
|
||||||
|
// service replay an unsent initial token. The key must be server-secret-derived.
|
||||||
|
CreateAccountWithActivationKey(context.Context, *customermodel.Account, string) (string, error)
|
||||||
|
|
||||||
VerifyAccount(
|
VerifyAccount(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package identityhttp
|
||||||
|
|
||||||
|
import "git.sendico.io/tech/sxchnge/product/identity"
|
||||||
|
|
||||||
|
type profileDTO struct {
|
||||||
|
FullName string `json:"fullName"`
|
||||||
|
DateOfBirth string `json:"dateOfBirth"`
|
||||||
|
ResidenceCountry string `json:"residenceCountry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p profileDTO) model() identity.Profile {
|
||||||
|
return identity.Profile{FullName: p.FullName, DateOfBirth: p.DateOfBirth, ResidenceCountry: p.ResidenceCountry}
|
||||||
|
}
|
||||||
|
|
||||||
|
type verificationDTO struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
UpgradePending bool `json:"upgradePending"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
SubmissionID string `json:"submissionId,omitempty"`
|
||||||
|
Profile profileDTO `json:"profile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapVerification(v identity.Verification) verificationDTO {
|
||||||
|
return verificationDTO{Status: v.Status, UpgradePending: v.UpgradePending, Reason: v.Reason, SubmissionID: v.SubmissionID, Profile: profileDTO{FullName: v.Profile.FullName, DateOfBirth: v.Profile.DateOfBirth, ResidenceCountry: v.Profile.ResidenceCountry}}
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitDTO struct {
|
||||||
|
Operation string `json:"operation"`
|
||||||
|
Period string `json:"period"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Cap string `json:"cap"`
|
||||||
|
Remaining string `json:"remaining"`
|
||||||
|
Used string `json:"used"`
|
||||||
|
Reserved string `json:"reserved"`
|
||||||
|
}
|
||||||
|
type limitsDTO struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
ProfileRef string `json:"profileRef"`
|
||||||
|
Entries []limitDTO `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapLimits(v identity.Limits) limitsDTO {
|
||||||
|
entries := make([]limitDTO, 0, len(v.Entries))
|
||||||
|
for _, r := range v.Entries {
|
||||||
|
entries = append(entries, limitDTO{Operation: r.Operation, Period: r.Period, Currency: r.Currency, Cap: r.Cap, Remaining: r.Remaining, Used: r.Used, Reserved: r.Reserved})
|
||||||
|
}
|
||||||
|
return limitsDTO{Tier: v.Tier, ProfileRef: v.ProfileRef, Entries: entries}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package identityhttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
|
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 *identity.Service
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func Install(reg Registrar, service *identity.Service, logger mlogger.Logger) {
|
||||||
|
h := &router{service, logger.Named("identity_http")}
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/identity", api.Get, h.get)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/identity", api.Post, h.submit)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/limits", api.Get, h.limits)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *router) limits(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
v, err := h.service.Limits(r.Context(), a)
|
||||||
|
return h.result(mapLimits(v), err)
|
||||||
|
}
|
||||||
|
func (h *router) result(data any, err error) http.HandlerFunc {
|
||||||
|
if err == nil {
|
||||||
|
return response.Ok(h.logger, data)
|
||||||
|
}
|
||||||
|
if throttle, ok := merrors.ThrottleDetailsFrom(err); ok {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(throttle.RetryAfter.Seconds()))))
|
||||||
|
response.TooManyRequests(h.logger, mservice.Accounts, "Wait before retrying identity submission")(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, merrors.ErrInvalidArg):
|
||||||
|
return response.BadRequest(h.logger, mservice.Accounts, "invalid_request", "Check profile and use readable JPEG/PNG photos, 600×600 minimum, 10 MiB maximum each")
|
||||||
|
case errors.Is(err, merrors.ErrDataConflict):
|
||||||
|
return response.DataConflict(h.logger, mservice.Accounts, "Identity submission changed; refresh its status")
|
||||||
|
case errors.Is(err, merrors.ErrAccessDenied):
|
||||||
|
return response.AccessDenied(h.logger, mservice.Accounts, "Identity submission is unavailable")
|
||||||
|
default:
|
||||||
|
return response.Error(h.logger, mservice.Accounts, http.StatusServiceUnavailable, "unavailable", "Identity verification is temporarily unavailable; retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (h *router) get(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
v, err := h.service.Verification(r.Context(), a)
|
||||||
|
return h.result(mapVerification(v), err)
|
||||||
|
}
|
||||||
|
func (h *router) submit(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
// Read multipart as a bounded stream. Never spill identity documents to disk.
|
||||||
|
r.Body = http.MaxBytesReader(nil, r.Body, 2*identity.MaxPhotoBytes+16384)
|
||||||
|
reader, err := r.MultipartReader()
|
||||||
|
if err != nil {
|
||||||
|
return h.result(nil, merrors.InvalidArgument("multipart required"))
|
||||||
|
}
|
||||||
|
fields := map[string][]byte{}
|
||||||
|
for {
|
||||||
|
part, readErr := reader.NextPart()
|
||||||
|
if readErr == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return h.result(nil, merrors.InvalidArgument("invalid multipart"))
|
||||||
|
}
|
||||||
|
name := part.FormName()
|
||||||
|
limit := int64(8192)
|
||||||
|
switch name {
|
||||||
|
case "document", "selfie":
|
||||||
|
limit = identity.MaxPhotoBytes
|
||||||
|
case "profile", "idempotencyKey":
|
||||||
|
default:
|
||||||
|
_ = part.Close()
|
||||||
|
return h.result(nil, merrors.InvalidArgument("unexpected field"))
|
||||||
|
}
|
||||||
|
if _, exists := fields[name]; exists {
|
||||||
|
_ = part.Close()
|
||||||
|
return h.result(nil, merrors.InvalidArgument("duplicate field"))
|
||||||
|
}
|
||||||
|
data, readErr := io.ReadAll(io.LimitReader(part, limit+1))
|
||||||
|
_ = part.Close()
|
||||||
|
if readErr != nil || int64(len(data)) > limit {
|
||||||
|
return h.result(nil, merrors.InvalidArgument("field too large"))
|
||||||
|
}
|
||||||
|
fields[name] = data
|
||||||
|
}
|
||||||
|
var p profileDTO
|
||||||
|
if err = json.Unmarshal(fields["profile"], &p); err != nil {
|
||||||
|
return h.result(nil, merrors.InvalidArgument("profile required"))
|
||||||
|
}
|
||||||
|
v, err := h.service.SubmitVerification(r.Context(), a, string(fields["idempotencyKey"]), p.model(), fields["document"], fields["selfie"])
|
||||||
|
return h.result(mapVerification(v), err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package identityhttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestThrottleDoesNotBecomeGenericUnavailable(t *testing.T) {
|
||||||
|
h := router{logger: zap.NewNop()}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("POST", "/accounts/identity", nil)
|
||||||
|
h.result(nil, merrors.WithThrottle(merrors.ErrServiceUnavailable, "identity", 120*time.Second, "temporary limit"))(w, r)
|
||||||
|
require.Equal(t, 429, w.Code)
|
||||||
|
require.Equal(t, "120", w.Header().Get("Retry-After"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var requests = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_signup_facts_total", Help: "Sign-up facts handoff attempts; acceptance is not verification."}, []string{"outcome"})
|
||||||
|
var duration = promauto.NewHistogram(prometheus.HistogramOpts{Name: "sxchnge_signup_facts_duration_seconds", Help: "Sign-up facts handoff latency including retries."})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Package identity owns the durable sign-up facts handoff, not KYC decisions.
|
||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/database/record"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Facts is a snapshot of local sign-up assertions. It never contains credentials.
|
||||||
|
type Facts struct {
|
||||||
|
FullName string `bson:"fullName"`
|
||||||
|
Email string `bson:"email"`
|
||||||
|
CustomerRef string `bson:"customerRef"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Receipt struct {
|
||||||
|
SubmissionRef string `bson:"submissionRef"`
|
||||||
|
SubjectRef string `bson:"subjectRef"`
|
||||||
|
AcceptedAt time.Time `bson:"acceptedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binding uses the local customer ID as its primary key. Namespace is an opaque
|
||||||
|
// adapter configuration fingerprint; no platform tenancy enters the customer model.
|
||||||
|
type Binding struct {
|
||||||
|
storable.Base `bson:",inline"`
|
||||||
|
Namespace string `bson:"namespace"`
|
||||||
|
RegistrationRequest string `bson:"registrationRequest"`
|
||||||
|
Facts Facts `bson:"facts"`
|
||||||
|
Registering bool `bson:"registering"`
|
||||||
|
RegisteringAt time.Time `bson:"registeringAt"`
|
||||||
|
SubjectRef string `bson:"subjectRef"`
|
||||||
|
Receipt *Receipt `bson:"receipt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Binding) Collection() string { return "signup_facts" }
|
||||||
|
|
||||||
|
func sourceRef(id bson.ObjectID) string { return "sxchnge:signup:" + id.Hex() }
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MaxPhotoBytes = 10 << 20
|
||||||
|
|
||||||
|
// ValidatePhoto repeats client checks at the trust boundary and bounds decoder
|
||||||
|
// memory before decoding pixels. File names and client MIME types are ignored.
|
||||||
|
func ValidatePhoto(data []byte) error {
|
||||||
|
if len(data) == 0 || len(data) > MaxPhotoBytes {
|
||||||
|
return merrors.InvalidArgument("photo must be between 1 byte and 10 MiB")
|
||||||
|
}
|
||||||
|
config, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||||
|
if err != nil || (format != "jpeg" && format != "png") {
|
||||||
|
return merrors.InvalidArgument("readable JPEG or PNG required")
|
||||||
|
}
|
||||||
|
if config.Width < 600 || config.Height < 600 || int64(config.Width)*int64(config.Height) > 40000000 {
|
||||||
|
return merrors.InvalidArgument("photo dimensions must be at least 600 by 600 and at most 40 megapixels")
|
||||||
|
}
|
||||||
|
if _, _, err = image.Decode(bytes.NewReader(data)); err != nil {
|
||||||
|
return merrors.InvalidArgument("readable JPEG or PNG required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/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 Provider interface {
|
||||||
|
Namespace() string
|
||||||
|
Register(context.Context, string) (string, error)
|
||||||
|
Submit(context.Context, string, string, Facts) (Receipt, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
Store Store
|
||||||
|
Provider Provider
|
||||||
|
Logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func unavailable() error { return merrors.ServiceUnavailable("sign-up facts unavailable") }
|
||||||
|
|
||||||
|
// Ensure completes only after a provider receipt has been persisted. Registration
|
||||||
|
// has no idempotency contract, so its durable claim is never automatically expired.
|
||||||
|
func (s *Service) Ensure(ctx context.Context, a *customer.Account) (result error) {
|
||||||
|
if a == nil || a.ID.IsZero() {
|
||||||
|
return merrors.InvalidArgument("customer required")
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
|
requestRef := ""
|
||||||
|
outcome := "unavailable"
|
||||||
|
defer func() {
|
||||||
|
requests.WithLabelValues(outcome).Inc()
|
||||||
|
duration.Observe(time.Since(started).Seconds())
|
||||||
|
fields := []zap.Field{zap.String("account_ref", a.ID.Hex()), zap.String("request_ref", requestRef), zap.String("outcome", outcome)}
|
||||||
|
if result != nil {
|
||||||
|
s.Logger.Warn("Sign-up facts handoff incomplete", fields...)
|
||||||
|
} else {
|
||||||
|
s.Logger.Info("Sign-up facts handoff completed", fields...)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
namespace := s.Provider.Namespace()
|
||||||
|
b, err := s.Store.Get(ctx, a.ID)
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
b = &Binding{Namespace: namespace, RegistrationRequest: bson.NewObjectID().Hex(), Facts: Facts{FullName: a.Name, Email: a.Login, CustomerRef: a.ID.Hex()}}
|
||||||
|
b.SetID(a.ID)
|
||||||
|
if err = s.Store.Create(ctx, b); err != nil {
|
||||||
|
if !errors.Is(err, merrors.ErrDataConflict) {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "persist sign-up facts")
|
||||||
|
}
|
||||||
|
b, err = s.Store.Get(ctx, a.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "load sign-up facts")
|
||||||
|
}
|
||||||
|
requestRef = b.RegistrationRequest
|
||||||
|
if b.Namespace != namespace {
|
||||||
|
outcome = "attention_required"
|
||||||
|
return merrors.FailedPrecondition("sign-up provider binding changed")
|
||||||
|
}
|
||||||
|
if b.Receipt != nil {
|
||||||
|
outcome = "replayed"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if b.SubjectRef == "" {
|
||||||
|
claimed, err := s.Store.Claim(ctx, a.ID, namespace)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "claim individual registration")
|
||||||
|
}
|
||||||
|
if !claimed {
|
||||||
|
// Another request may have completed while this request was claiming.
|
||||||
|
b, err = s.Store.Get(ctx, a.ID)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "reload sign-up facts")
|
||||||
|
}
|
||||||
|
if b.SubjectRef == "" {
|
||||||
|
if b.RegisteringAt.Add(30 * time.Second).After(time.Now()) {
|
||||||
|
return unavailable()
|
||||||
|
}
|
||||||
|
outcome = "attention_required"
|
||||||
|
return merrors.FailedPrecondition("individual registration requires reconciliation")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
subject, err := s.Provider.Register(ctx, b.RegistrationRequest)
|
||||||
|
if err != nil {
|
||||||
|
outcome = "attention_required"
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "individual registration requires reconciliation")
|
||||||
|
}
|
||||||
|
// Persist even if the browser left while Sendico accepted registration.
|
||||||
|
saveCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||||
|
err = s.Store.Bind(saveCtx, a.ID, namespace, subject)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
outcome = "attention_required"
|
||||||
|
s.Logger.Warn("Registered individual binding needs reconciliation", zap.String("account_ref", a.ID.Hex()), zap.String("subject_ref", subject))
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "persist individual binding")
|
||||||
|
}
|
||||||
|
b.SubjectRef = subject
|
||||||
|
}
|
||||||
|
}
|
||||||
|
receipt, err := s.Provider.Submit(ctx, b.SubjectRef, sourceRef(a.ID), b.Facts)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "submit sign-up facts")
|
||||||
|
}
|
||||||
|
if receipt.SubjectRef != b.SubjectRef || receipt.SubmissionRef == "" || receipt.AcceptedAt.IsZero() {
|
||||||
|
return unavailable()
|
||||||
|
}
|
||||||
|
if err = s.Store.Accept(ctx, a.ID, namespace, receipt); err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "persist facts receipt")
|
||||||
|
}
|
||||||
|
outcome = "accepted"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready checks confirmed intake without external I/O inside activation transactions.
|
||||||
|
func (s *Service) Ready(ctx context.Context, id bson.ObjectID) error {
|
||||||
|
b, err := s.Store.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "load facts receipt")
|
||||||
|
}
|
||||||
|
if b.Namespace != s.Provider.Namespace() || b.Receipt == nil {
|
||||||
|
return unavailable()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/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"
|
||||||
|
"go.uber.org/zap/zaptest/observer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type memoryStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
rows map[bson.ObjectID]Binding
|
||||||
|
bindErr, acceptErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memoryStore) Get(_ context.Context, id bson.ObjectID) (*Binding, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
b, ok := s.rows[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.NoData("missing")
|
||||||
|
}
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
func (s *memoryStore) Create(_ context.Context, b *Binding) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if _, ok := s.rows[b.ID]; ok {
|
||||||
|
return merrors.DataConflict("duplicate")
|
||||||
|
}
|
||||||
|
s.rows[b.ID] = *b
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *memoryStore) Claim(_ context.Context, id bson.ObjectID, namespace string) (bool, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
b := s.rows[id]
|
||||||
|
if b.Namespace != namespace || b.Registering || b.SubjectRef != "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
b.Registering = true
|
||||||
|
b.RegisteringAt = time.Now()
|
||||||
|
s.rows[id] = b
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
func (s *memoryStore) Bind(_ context.Context, id bson.ObjectID, namespace, subject string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.bindErr != nil {
|
||||||
|
return s.bindErr
|
||||||
|
}
|
||||||
|
b := s.rows[id]
|
||||||
|
b.SubjectRef = subject
|
||||||
|
b.Registering = false
|
||||||
|
s.rows[id] = b
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *memoryStore) Accept(_ context.Context, id bson.ObjectID, namespace string, r Receipt) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.acceptErr != nil {
|
||||||
|
return s.acceptErr
|
||||||
|
}
|
||||||
|
b := s.rows[id]
|
||||||
|
b.Receipt = &r
|
||||||
|
s.rows[id] = b
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type providerStub struct {
|
||||||
|
registrations, submissions int
|
||||||
|
registerErr, submitErr error
|
||||||
|
snapshots []Facts
|
||||||
|
sources []string
|
||||||
|
entered, release chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*providerStub) Namespace() string { return "provider-1" }
|
||||||
|
func (p *providerStub) Register(context.Context, string) (string, error) {
|
||||||
|
p.registrations++
|
||||||
|
if p.entered != nil {
|
||||||
|
close(p.entered)
|
||||||
|
<-p.release
|
||||||
|
}
|
||||||
|
return "subject-1", p.registerErr
|
||||||
|
}
|
||||||
|
func (p *providerStub) Submit(_ context.Context, subject, source string, f Facts) (Receipt, error) {
|
||||||
|
p.submissions++
|
||||||
|
p.snapshots = append(p.snapshots, f)
|
||||||
|
p.sources = append(p.sources, source)
|
||||||
|
return Receipt{SubjectRef: subject, SubmissionRef: "submission-1", AcceptedAt: time.Now()}, p.submitErr
|
||||||
|
}
|
||||||
|
func fixture() (*Service, *memoryStore, *providerStub, *customer.Account) {
|
||||||
|
store := &memoryStore{rows: map[bson.ObjectID]Binding{}}
|
||||||
|
provider := &providerStub{}
|
||||||
|
a := &customer.Account{}
|
||||||
|
a.ID = bson.NewObjectID()
|
||||||
|
a.Name = "Ada Lovelace"
|
||||||
|
a.Login = "ada@example.com"
|
||||||
|
a.Password = "never transmit"
|
||||||
|
return &Service{Store: store, Provider: provider, Logger: zap.NewNop()}, store, provider, a
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntakeRetryUsesPersistedSubjectSnapshotAndStableSource(t *testing.T) {
|
||||||
|
s, store, p, a := fixture()
|
||||||
|
p.submitErr = merrors.ServiceUnavailable("upstream")
|
||||||
|
require.Error(t, s.Ensure(context.Background(), a))
|
||||||
|
require.Error(t, s.Ready(context.Background(), a.ID))
|
||||||
|
require.Equal(t, 1, p.registrations)
|
||||||
|
b, err := store.Get(context.Background(), a.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "subject-1", b.SubjectRef)
|
||||||
|
require.Nil(t, b.Receipt)
|
||||||
|
a.Name = "changed retry"
|
||||||
|
p.submitErr = nil
|
||||||
|
require.NoError(t, s.Ensure(context.Background(), a))
|
||||||
|
require.NoError(t, s.Ready(context.Background(), a.ID))
|
||||||
|
require.Equal(t, 1, p.registrations)
|
||||||
|
require.Equal(t, 2, p.submissions)
|
||||||
|
require.Equal(t, p.snapshots[0], p.snapshots[1])
|
||||||
|
require.Equal(t, "Ada Lovelace", p.snapshots[1].FullName)
|
||||||
|
require.Equal(t, p.sources[0], p.sources[1])
|
||||||
|
require.NoError(t, s.Ensure(context.Background(), a))
|
||||||
|
require.Equal(t, 2, p.submissions)
|
||||||
|
other := a.Copy()
|
||||||
|
other.ID = bson.NewObjectID()
|
||||||
|
require.Error(t, s.Ready(context.Background(), other.ID))
|
||||||
|
}
|
||||||
|
func TestLostAcceptanceReceiptIsSafelyReplayed(t *testing.T) {
|
||||||
|
s, store, p, a := fixture()
|
||||||
|
store.acceptErr = merrors.ServiceUnavailable("storage")
|
||||||
|
require.Error(t, s.Ensure(context.Background(), a))
|
||||||
|
store.acceptErr = nil
|
||||||
|
require.NoError(t, s.Ensure(context.Background(), a))
|
||||||
|
require.Equal(t, 1, p.registrations)
|
||||||
|
require.Equal(t, 2, p.submissions)
|
||||||
|
require.Equal(t, p.sources[0], p.sources[1])
|
||||||
|
}
|
||||||
|
func TestAmbiguousRegistrationNeverAutomaticallyRegistersAgain(t *testing.T) {
|
||||||
|
for _, bindFailure := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "request", true: "binding"}[bindFailure], func(t *testing.T) {
|
||||||
|
s, store, p, a := fixture()
|
||||||
|
if bindFailure {
|
||||||
|
store.bindErr = merrors.ServiceUnavailable("storage")
|
||||||
|
} else {
|
||||||
|
p.registerErr = merrors.ServiceUnavailable("lost response")
|
||||||
|
}
|
||||||
|
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
|
||||||
|
b := store.rows[a.ID]
|
||||||
|
b.RegisteringAt = time.Now().Add(-time.Minute)
|
||||||
|
store.rows[a.ID] = b
|
||||||
|
store.bindErr = nil
|
||||||
|
p.registerErr = nil
|
||||||
|
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
|
||||||
|
require.Equal(t, 1, p.registrations)
|
||||||
|
require.Zero(t, p.submissions)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestConcurrentRequestCannotRegisterSameCustomerTwice(t *testing.T) {
|
||||||
|
s, _, p, a := fixture()
|
||||||
|
p.entered = make(chan struct{})
|
||||||
|
p.release = make(chan struct{})
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- s.Ensure(context.Background(), a) }()
|
||||||
|
<-p.entered
|
||||||
|
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrServiceUnavailable)
|
||||||
|
close(p.release)
|
||||||
|
require.NoError(t, <-done)
|
||||||
|
require.Equal(t, 1, p.registrations)
|
||||||
|
require.NoError(t, s.Ensure(context.Background(), a))
|
||||||
|
require.Equal(t, 1, p.submissions)
|
||||||
|
}
|
||||||
|
func TestNamespaceChangeFailsClosed(t *testing.T) {
|
||||||
|
s, store, p, a := fixture()
|
||||||
|
require.NoError(t, s.Ensure(context.Background(), a))
|
||||||
|
b := store.rows[a.ID]
|
||||||
|
b.Namespace = "other-account-or-organization"
|
||||||
|
store.rows[a.ID] = b
|
||||||
|
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
|
||||||
|
require.Error(t, s.Ready(context.Background(), a.ID))
|
||||||
|
require.Equal(t, 1, p.submissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandoffLogsExcludeAssertionsAndCredentials(t *testing.T) {
|
||||||
|
s, _, p, a := fixture()
|
||||||
|
core, logs := observer.New(zap.InfoLevel)
|
||||||
|
s.Logger = zap.New(core)
|
||||||
|
p.submitErr = merrors.ServiceUnavailable(a.Login + " " + a.Password)
|
||||||
|
require.Error(t, s.Ensure(context.Background(), a))
|
||||||
|
p.submitErr = nil
|
||||||
|
require.NoError(t, s.Ensure(context.Background(), a))
|
||||||
|
require.Len(t, logs.All(), 2)
|
||||||
|
for _, entry := range logs.All() {
|
||||||
|
require.NotContains(t, entry.Message, a.Login)
|
||||||
|
require.NotContains(t, entry.Message, a.Name)
|
||||||
|
fields := entry.ContextMap()
|
||||||
|
require.Equal(t, a.ID.Hex(), fields["account_ref"])
|
||||||
|
require.NotEmpty(t, fields["request_ref"])
|
||||||
|
for _, value := range fields {
|
||||||
|
require.NotEqual(t, a.Login, value)
|
||||||
|
require.NotEqual(t, a.Name, value)
|
||||||
|
require.NotEqual(t, a.Password, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Get(context.Context, bson.ObjectID) (*Binding, error)
|
||||||
|
Create(context.Context, *Binding) error
|
||||||
|
Claim(context.Context, bson.ObjectID, string) (bool, error)
|
||||||
|
Bind(context.Context, bson.ObjectID, string, string) error
|
||||||
|
Accept(context.Context, bson.ObjectID, string, Receipt) 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
|
||||||
|
}
|
||||||
|
records := repository.CreateRepository(source.Majority(d.Database), "signup_facts")
|
||||||
|
if err = records.CreateIndex(&ri.Definition{Name: "provider_subject_unique", Unique: true, Keys: []ri.Key{{Field: "namespace", Sort: ri.Asc}, {Field: "subjectRef", Sort: ri.Asc}}, PartialFilter: repository.Query().Comparison(repository.Field("subjectRef"), builder.Gt, "")}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &mongoStore{records: records}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mongoStore) Get(ctx context.Context, id bson.ObjectID) (*Binding, error) {
|
||||||
|
b := &Binding{}
|
||||||
|
err := s.records.Get(ctx, id, b)
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
|
func (s *mongoStore) Create(ctx context.Context, b *Binding) error {
|
||||||
|
return s.records.Insert(ctx, b, nil)
|
||||||
|
}
|
||||||
|
func scoped(id bson.ObjectID, namespace string) repository.FilterQuery {
|
||||||
|
return repository.IDFilter(id).Filter(repository.Field("namespace"), namespace)
|
||||||
|
}
|
||||||
|
func (s *mongoStore) Claim(ctx context.Context, id bson.ObjectID, namespace string) (bool, error) {
|
||||||
|
query := scoped(id, namespace).Filter(repository.Field("registering"), false).Filter(repository.Field("subjectRef"), "")
|
||||||
|
return s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("registering"), true).Set(repository.Field("registeringAt"), time.Now().UTC()), &Binding{})
|
||||||
|
}
|
||||||
|
func (s *mongoStore) Bind(ctx context.Context, id bson.ObjectID, namespace, subject string) error {
|
||||||
|
query := scoped(id, namespace).Filter(repository.Field("registering"), true).Filter(repository.Field("subjectRef"), "")
|
||||||
|
matched, err := s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("subjectRef"), subject).Set(repository.Field("registering"), false), &Binding{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
return unavailable()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *mongoStore) Accept(ctx context.Context, id bson.ObjectID, namespace string, receipt Receipt) error {
|
||||||
|
query := scoped(id, namespace).Filter(repository.Field("subjectRef"), receipt.SubjectRef)
|
||||||
|
matched, err := s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("receipt"), receipt), &Binding{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
return unavailable()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"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 TestDurableBindingClaimsAndIsolation(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")
|
||||||
|
// The handoff store must override unsafe caller defaults before claiming
|
||||||
|
// ownership of a non-idempotent external operation.
|
||||||
|
q.Set("w", "0")
|
||||||
|
q.Set("readPreference", "secondary")
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "facts"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(factory.CloseConnection)
|
||||||
|
store, err := OpenStore(factory)
|
||||||
|
require.NoError(t, err)
|
||||||
|
b := &Binding{Namespace: "configured-source", Facts: Facts{FullName: "Ada", Email: "ada@example.com"}}
|
||||||
|
b.SetID(bson.NewObjectID())
|
||||||
|
require.NoError(t, store.Create(ctx, b))
|
||||||
|
require.ErrorIs(t, store.Create(ctx, b), merrors.ErrDataConflict)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var wins atomic.Int32
|
||||||
|
for range 12 {
|
||||||
|
wg.Go(func() {
|
||||||
|
claimed, err := store.Claim(ctx, b.ID, b.Namespace)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if claimed {
|
||||||
|
wins.Add(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
require.EqualValues(t, 1, wins.Load())
|
||||||
|
stored, err := store.Get(ctx, b.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, stored.Registering)
|
||||||
|
require.False(t, stored.RegisteringAt.IsZero())
|
||||||
|
require.Error(t, store.Bind(ctx, b.ID, "other-source", "subject"))
|
||||||
|
require.NoError(t, store.Bind(ctx, b.ID, b.Namespace, "subject"))
|
||||||
|
duplicate := &Binding{Namespace: b.Namespace}
|
||||||
|
duplicate.SetID(bson.NewObjectID())
|
||||||
|
require.NoError(t, store.Create(ctx, duplicate))
|
||||||
|
claimedDuplicate, err := store.Claim(ctx, duplicate.ID, duplicate.Namespace)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, claimedDuplicate)
|
||||||
|
require.ErrorIs(t, store.Bind(ctx, duplicate.ID, duplicate.Namespace, "subject"), merrors.ErrDataConflict)
|
||||||
|
require.Error(t, store.Bind(ctx, b.ID, b.Namespace, "different-subject"))
|
||||||
|
receipt := Receipt{SubjectRef: "other-subject", SubmissionRef: "submission", AcceptedAt: time.Now().UTC().Truncate(time.Millisecond)}
|
||||||
|
require.Error(t, store.Accept(ctx, b.ID, b.Namespace, receipt))
|
||||||
|
receipt.SubjectRef = "subject"
|
||||||
|
require.NoError(t, store.Accept(ctx, b.ID, b.Namespace, receipt))
|
||||||
|
// A separately opened store sees the durable receipt and cannot reclaim registration.
|
||||||
|
reopened, err := OpenStore(factory)
|
||||||
|
require.NoError(t, err)
|
||||||
|
stored, err = reopened.Get(ctx, b.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, receipt, *stored.Receipt)
|
||||||
|
claimed, err := reopened.Claim(ctx, b.ID, b.Namespace)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, claimed)
|
||||||
|
_, err = reopened.Get(ctx, bson.NewObjectID())
|
||||||
|
require.ErrorIs(t, err, merrors.ErrNoData)
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Profile holds unverified customer assertions. Email and customer reference are
|
||||||
|
// always supplied by the authenticated backend, never accepted from the browser.
|
||||||
|
type Profile struct {
|
||||||
|
FullName string
|
||||||
|
DateOfBirth string
|
||||||
|
ResidenceCountry string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Profile) Validate() error {
|
||||||
|
birth, err := time.Parse("2006-01-02", p.DateOfBirth)
|
||||||
|
if strings.TrimSpace(p.FullName) == "" || len(p.FullName) > 800 || err != nil || !birth.Before(time.Now()) || len(p.ResidenceCountry) != 2 || p.ResidenceCountry[0] < 'A' || p.ResidenceCountry[0] > 'Z' || p.ResidenceCountry[1] < 'A' || p.ResidenceCountry[1] > 'Z' {
|
||||||
|
return merrors.InvalidArgument("complete identity profile required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Verification struct {
|
||||||
|
Status string
|
||||||
|
UpgradePending bool
|
||||||
|
Reason string
|
||||||
|
SubmissionID string
|
||||||
|
Profile Profile
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerificationProvider consumes reviewed state through the platform public API.
|
||||||
|
// It cannot accept a browser-selected subject, policy profile or approval.
|
||||||
|
type VerificationProvider interface {
|
||||||
|
Verification(context.Context, string) (Verification, error)
|
||||||
|
SubmitVerification(context.Context, string, string, Profile, Facts, []byte, []byte) (Verification, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decimal amounts are transported losslessly in their currency's major unit.
|
||||||
|
// No fiat scale or illustrative client caps are applied to provider policy.
|
||||||
|
type Limit struct {
|
||||||
|
Operation string
|
||||||
|
Period string
|
||||||
|
Currency string
|
||||||
|
Cap string
|
||||||
|
Remaining string
|
||||||
|
Used string
|
||||||
|
Reserved string
|
||||||
|
}
|
||||||
|
type Limits struct {
|
||||||
|
Tier string
|
||||||
|
ProfileRef string
|
||||||
|
Entries []Limit
|
||||||
|
}
|
||||||
|
type LimitsProvider interface {
|
||||||
|
Limits(context.Context, string) (Limits, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Limits(ctx context.Context, a *customer.Account) (Limits, error) {
|
||||||
|
b, err := s.binding(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return Limits{}, err
|
||||||
|
}
|
||||||
|
p, ok := s.Provider.(LimitsProvider)
|
||||||
|
if !ok {
|
||||||
|
return Limits{}, unavailable()
|
||||||
|
}
|
||||||
|
return p.Limits(ctx, b.SubjectRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) binding(ctx context.Context, a *customer.Account) (*Binding, error) {
|
||||||
|
if s == nil || s.Provider == nil || s.Store == nil {
|
||||||
|
return nil, merrors.ServiceUnavailable("identity verification unavailable")
|
||||||
|
}
|
||||||
|
if a == nil || a.ID.IsZero() {
|
||||||
|
return nil, merrors.InvalidArgument("customer required")
|
||||||
|
}
|
||||||
|
b, err := s.Store.Get(ctx, a.ID)
|
||||||
|
if errors.Is(err, merrors.ErrNoData) || err == nil && b.Receipt == nil {
|
||||||
|
if err = s.Ensure(ctx, a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b, err = s.Store.Get(ctx, a.ID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if b.Namespace != s.Provider.Namespace() || b.SubjectRef == "" || b.Receipt == nil || b.Receipt.SubjectRef != b.SubjectRef {
|
||||||
|
return nil, merrors.FailedPrecondition("identity binding requires reconciliation")
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Verification(ctx context.Context, a *customer.Account) (Verification, error) {
|
||||||
|
b, err := s.binding(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return Verification{}, err
|
||||||
|
}
|
||||||
|
provider, ok := s.Provider.(VerificationProvider)
|
||||||
|
if !ok {
|
||||||
|
return Verification{}, unavailable()
|
||||||
|
}
|
||||||
|
v, err := provider.Verification(ctx, b.SubjectRef)
|
||||||
|
if err == nil && v.Profile.FullName == "" {
|
||||||
|
v.Profile.FullName = a.Name
|
||||||
|
}
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SubmitVerification(ctx context.Context, a *customer.Account, key string, p Profile, document, selfie []byte) (Verification, error) {
|
||||||
|
if len(key) < 16 || len(key) > 128 {
|
||||||
|
return Verification{}, merrors.InvalidArgument("valid idempotency key required")
|
||||||
|
}
|
||||||
|
if err := p.Validate(); err != nil {
|
||||||
|
return Verification{}, err
|
||||||
|
}
|
||||||
|
for _, photo := range [][]byte{document, selfie} {
|
||||||
|
if err := ValidatePhoto(photo); err != nil {
|
||||||
|
return Verification{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, err := s.binding(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return Verification{}, err
|
||||||
|
}
|
||||||
|
provider, ok := s.Provider.(VerificationProvider)
|
||||||
|
if !ok {
|
||||||
|
return Verification{}, unavailable()
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256([]byte(a.ID.Hex() + "\x00" + key))
|
||||||
|
facts := b.Facts
|
||||||
|
facts.Email = a.Login
|
||||||
|
facts.CustomerRef = a.ID.Hex()
|
||||||
|
v, err := provider.SubmitVerification(ctx, b.SubjectRef, "sxchnge:identity:"+hex.EncodeToString(digest[:]), p, facts, document, selfie)
|
||||||
|
if err != nil {
|
||||||
|
s.Logger.Warn("Identity proof submission incomplete", zap.String("account_ref", a.ID.Hex()))
|
||||||
|
return Verification{}, err
|
||||||
|
}
|
||||||
|
s.Logger.Info("Identity proofs accepted for review", zap.String("account_ref", a.ID.Hex()), zap.String("submission_ref", v.SubmissionID))
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subject resolves the authenticated customer's confirmed provider binding.
|
||||||
|
// Only server-side services may use this resource; clients cannot select it.
|
||||||
|
func (s *Service) Subject(ctx context.Context, a *customer.Account) (string, error) {
|
||||||
|
b, err := s.binding(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return b.SubjectRef, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type proofProvider struct {
|
||||||
|
*providerStub
|
||||||
|
subject, key string
|
||||||
|
profile Profile
|
||||||
|
facts Facts
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *proofProvider) Verification(_ context.Context, subject string) (Verification, error) {
|
||||||
|
p.subject = subject
|
||||||
|
return Verification{Status: "unverified"}, nil
|
||||||
|
}
|
||||||
|
func (p *proofProvider) SubmitVerification(_ context.Context, subject, key string, profile Profile, facts Facts, _, _ []byte) (Verification, error) {
|
||||||
|
p.subject = subject
|
||||||
|
p.key = key
|
||||||
|
p.profile = profile
|
||||||
|
p.facts = facts
|
||||||
|
p.calls++
|
||||||
|
return Verification{Status: "pending", SubmissionID: "receipt"}, nil
|
||||||
|
}
|
||||||
|
func proofImage(t *testing.T, width, height int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var body bytes.Buffer
|
||||||
|
require.NoError(t, png.Encode(&body, image.NewNRGBA(image.Rect(0, 0, width, height))))
|
||||||
|
return body.Bytes()
|
||||||
|
}
|
||||||
|
func TestVerificationUsesBoundSubjectAndStableCustomerKey(t *testing.T) {
|
||||||
|
s, _, base, a := fixture()
|
||||||
|
p := &proofProvider{providerStub: base}
|
||||||
|
s.Provider = p
|
||||||
|
ctx := t.Context()
|
||||||
|
result, err := s.Verification(ctx, a)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, a.Name, result.Profile.FullName)
|
||||||
|
require.Equal(t, "subject-1", p.subject)
|
||||||
|
photo := proofImage(t, 600, 600)
|
||||||
|
profile := Profile{FullName: "Legal Customer Name", DateOfBirth: "1990-04-23", ResidenceCountry: "PL"}
|
||||||
|
_, err = s.SubmitVerification(ctx, a, "0123456789012345", profile, photo, photo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
key := p.key
|
||||||
|
require.Equal(t, a.ID.Hex(), p.facts.CustomerRef)
|
||||||
|
require.Equal(t, a.Login, p.facts.Email)
|
||||||
|
require.Equal(t, profile, p.profile)
|
||||||
|
_, err = s.SubmitVerification(ctx, a, "0123456789012345", profile, photo, photo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, key, p.key)
|
||||||
|
require.Equal(t, 1, base.registrations)
|
||||||
|
other := a.Copy()
|
||||||
|
other.ID[0]++
|
||||||
|
_, err = s.SubmitVerification(ctx, other, "0123456789012345", profile, photo, photo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, key, p.key)
|
||||||
|
}
|
||||||
|
func TestVerificationValidationBeforeExternalHandoff(t *testing.T) {
|
||||||
|
s, _, base, a := fixture()
|
||||||
|
p := &proofProvider{providerStub: base}
|
||||||
|
s.Provider = p
|
||||||
|
photo := proofImage(t, 600, 600)
|
||||||
|
for _, profile := range []Profile{{}, {FullName: "Name", DateOfBirth: "1990-02-30", ResidenceCountry: "PL"}, {FullName: "Name", DateOfBirth: "2990-04-23", ResidenceCountry: "PL"}, {FullName: "Name", DateOfBirth: "1990-04-23", ResidenceCountry: "USA"}} {
|
||||||
|
_, err := s.SubmitVerification(t.Context(), a, "0123456789012345", profile, photo, photo)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
require.Zero(t, p.calls)
|
||||||
|
require.Zero(t, base.registrations)
|
||||||
|
require.Error(t, ValidatePhoto([]byte("not an image")))
|
||||||
|
require.Error(t, ValidatePhoto(proofImage(t, 599, 600)))
|
||||||
|
require.Error(t, ValidatePhoto(photo[:len(photo)-20]))
|
||||||
|
require.Error(t, ValidatePhoto(make([]byte, MaxPhotoBytes+1)))
|
||||||
|
var unavailableService *Service
|
||||||
|
_, err := unavailableService.Verification(t.Context(), a)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package paymenthttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sendico.io/tech/sxchnge/product/payments"
|
||||||
|
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type requestDTO struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Destination string `json:"destination"`
|
||||||
|
Recipient string `json:"recipient"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d requestDTO) model() payments.Request {
|
||||||
|
return payments.Request{Kind: d.Kind, Source: d.Source, Destination: d.Destination, Recipient: d.Recipient, Amount: d.Amount}
|
||||||
|
}
|
||||||
|
func fromRequest(r payments.Request) requestDTO {
|
||||||
|
return requestDTO{r.Kind, r.Source, r.Destination, r.Recipient, r.Amount}
|
||||||
|
}
|
||||||
|
|
||||||
|
type quoteDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Request requestDTO `json:"request"`
|
||||||
|
Fee int64 `json:"fee"`
|
||||||
|
Debit int64 `json:"debit"`
|
||||||
|
Credit int64 `json:"credit"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
Numerator int64 `json:"rateNumerator"`
|
||||||
|
Denominator int64 `json:"rateDenominator"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromQuote(q payments.Quote) quoteDTO {
|
||||||
|
return quoteDTO{q.ID, fromRequest(q.Request), q.Fee, q.Debit, q.Credit, q.ExpiresAt, q.RateNumerator, q.RateDenominator}
|
||||||
|
}
|
||||||
|
|
||||||
|
type operationDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Quote quoteDTO `json:"quote"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromOperation(o payments.Operation) operationDTO {
|
||||||
|
return operationDTO{o.ID, fromQuote(o.Quote), o.Status, o.CreatedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Precision int `json:"precision"`
|
||||||
|
Available int64 `json:"available"`
|
||||||
|
PayoutMethod string `json:"payoutMethod"`
|
||||||
|
PayoutLabel string `json:"payoutLabel"`
|
||||||
|
CanFund bool `json:"canFund"`
|
||||||
|
}
|
||||||
|
type snapshotDTO struct {
|
||||||
|
Accounts []accountDTO `json:"accounts"`
|
||||||
|
Operations []operationDTO `json:"operations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromSnapshot(s payments.Snapshot) snapshotDTO {
|
||||||
|
v := snapshotDTO{Accounts: []accountDTO{}, Operations: []operationDTO{}}
|
||||||
|
for _, a := range s.Accounts {
|
||||||
|
v.Accounts = append(v.Accounts, accountDTO{a.ID, a.Currency, a.Precision, a.Available, a.PayoutMethod, a.PayoutLabel, a.FundingMethod != ""})
|
||||||
|
}
|
||||||
|
for i := len(s.Operations) - 1; i >= 0; i-- {
|
||||||
|
v.Operations = append(v.Operations, fromOperation(s.Operations[i]))
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
type challengeDTO struct {
|
||||||
|
ID string `json:"challengeId"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
ResendAt time.Time `json:"resendAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromChallenge(c factor.Challenge) challengeDTO {
|
||||||
|
return challengeDTO{c.ID, c.Target, c.ExpiresAt, c.ResendAt}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package paymenthttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/payments"
|
||||||
|
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||||
|
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
|
mservice "git.sendico.io/tech/sxchnge/shared/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Registrar interface {
|
||||||
|
AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc)
|
||||||
|
}
|
||||||
|
type router struct {
|
||||||
|
service *payments.Service
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func Install(reg Registrar, s *payments.Service, logger mlogger.Logger) {
|
||||||
|
h := &router{s, logger.Named("payment_http")}
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/wallet/profile", api.Get, h.profile)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/wallet", api.Get, h.fetch)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/wallet/quote", api.Post, h.quote)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/wallet/submit", api.Post, h.submit)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/wallet/submission", api.Post, h.reconcile)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/wallet/challenge", api.Post, h.challenge)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/wallet/resend", api.Post, h.resend)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/wallet/verify", api.Post, h.verify)
|
||||||
|
}
|
||||||
|
func decode(r *http.Request, dst any) error {
|
||||||
|
d := json.NewDecoder(io.LimitReader(r.Body, 8193))
|
||||||
|
d.DisallowUnknownFields()
|
||||||
|
if d.Decode(dst) != nil {
|
||||||
|
return payments.InvalidAmount
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if d.Decode(&extra) != io.EOF {
|
||||||
|
return payments.InvalidAmount
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (h *router) result(data any, err error) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
if err == nil {
|
||||||
|
response.Ok(h.logger, data)(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := "unavailable"
|
||||||
|
status := http.StatusServiceUnavailable
|
||||||
|
var f payments.Failure
|
||||||
|
if errors.As(err, &f) {
|
||||||
|
code = string(f)
|
||||||
|
if f != payments.Unavailable && f != payments.Uncertain {
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, verification.ErrTokenExpired):
|
||||||
|
code = "factorExpired"
|
||||||
|
status = 400
|
||||||
|
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
|
||||||
|
code = "factorLocked"
|
||||||
|
status = 400
|
||||||
|
case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed), errors.Is(err, merrors.ErrUnauthorized):
|
||||||
|
code = "factorInvalid"
|
||||||
|
status = 400
|
||||||
|
}
|
||||||
|
if throttle, ok := verification.Throttle(err); ok {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(throttle.RetryAfter.Seconds()))))
|
||||||
|
code = "factorCooldown"
|
||||||
|
status = 429
|
||||||
|
}
|
||||||
|
response.Error(h.logger, mservice.Accounts, status, code, "Unable to complete wallet operation")(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (h *router) fetch(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
v, e := h.service.Fetch(r.Context(), a)
|
||||||
|
return h.result(fromSnapshot(v), e)
|
||||||
|
}
|
||||||
|
func (h *router) quote(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
var d requestDTO
|
||||||
|
if e := decode(r, &d); e != nil {
|
||||||
|
return h.result(nil, e)
|
||||||
|
}
|
||||||
|
v, e := h.service.Quote(r.Context(), a, d.model())
|
||||||
|
return h.result(fromQuote(v), e)
|
||||||
|
}
|
||||||
|
|
||||||
|
type actionDTO struct {
|
||||||
|
Quote string `json:"quoteId"`
|
||||||
|
Key string `json:"idempotencyKey"`
|
||||||
|
Challenge string `json:"challengeId"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *router) submit(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
var d actionDTO
|
||||||
|
if e := decode(r, &d); e != nil {
|
||||||
|
return h.result(nil, e)
|
||||||
|
}
|
||||||
|
v, e := h.service.Submit(r.Context(), a, d.Quote, d.Key)
|
||||||
|
return h.result(fromOperation(v), e)
|
||||||
|
}
|
||||||
|
func (h *router) reconcile(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
var d actionDTO
|
||||||
|
if e := decode(r, &d); e != nil {
|
||||||
|
return h.result(nil, e)
|
||||||
|
}
|
||||||
|
v, e := h.service.Reconcile(r.Context(), a, d.Key)
|
||||||
|
if v == nil {
|
||||||
|
return h.result(nil, e)
|
||||||
|
}
|
||||||
|
return h.result(fromOperation(*v), e)
|
||||||
|
}
|
||||||
|
func requestIP(r *http.Request) string {
|
||||||
|
if ip := ipguard.ClientIP(r); ip != nil {
|
||||||
|
return ip.String()
|
||||||
|
}
|
||||||
|
return "unresolved"
|
||||||
|
}
|
||||||
|
func (h *router) challenge(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
var d actionDTO
|
||||||
|
if e := decode(r, &d); e != nil {
|
||||||
|
return h.result(nil, e)
|
||||||
|
}
|
||||||
|
v, e := h.service.Challenge(r.Context(), a, d.Quote, requestIP(r))
|
||||||
|
return h.result(fromChallenge(v), e)
|
||||||
|
}
|
||||||
|
func (h *router) resend(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
var d actionDTO
|
||||||
|
if e := decode(r, &d); e != nil {
|
||||||
|
return h.result(nil, e)
|
||||||
|
}
|
||||||
|
v, e := h.service.Resend(r.Context(), a, d.Quote, d.Challenge, d.Key, requestIP(r))
|
||||||
|
return h.result(fromChallenge(v), e)
|
||||||
|
}
|
||||||
|
func (h *router) verify(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
var d actionDTO
|
||||||
|
if e := decode(r, &d); e != nil {
|
||||||
|
return h.result(nil, e)
|
||||||
|
}
|
||||||
|
e := h.service.Authorize(r.Context(), a, d.Quote, d.Challenge, d.Code, d.Key)
|
||||||
|
return h.result(struct {
|
||||||
|
Key string `json:"idempotencyKey"`
|
||||||
|
}{d.Key}, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *router) profile(_ *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
return h.result(struct {
|
||||||
|
Locale string `json:"locale"`
|
||||||
|
}{a.Locale}, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package paymenthttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUnconfiguredWalletCannotReturnDemoFunds(t *testing.T) {
|
||||||
|
h := &router{logger: zap.NewNop()}
|
||||||
|
a := &customer.Account{}
|
||||||
|
a.ID = bson.NewObjectID()
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/accounts/wallet", nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
h.fetch(request, a, nil)(recorder, request)
|
||||||
|
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
|
||||||
|
require.Equal(t, "no-store", recorder.Header().Get("Cache-Control"))
|
||||||
|
require.NotContains(t, recorder.Body.String(), "7500")
|
||||||
|
}
|
||||||
|
func TestQuoteRejectsUnknownFieldsAndTrailingRequests(t *testing.T) {
|
||||||
|
h := &router{logger: zap.NewNop()}
|
||||||
|
a := &customer.Account{}
|
||||||
|
a.ID = bson.NewObjectID()
|
||||||
|
for _, body := range []string{`{"kind":"payout","subject":"foreign"}`, `{"amount":10} {"amount":20}`, `{"amount":1.2}`} {
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/accounts/wallet/quote", strings.NewReader(body))
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
h.quote(request, a, nil)(recorder, request)
|
||||||
|
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Package payments owns customer quotation ownership and durable execution intent.
|
||||||
|
// Money and settlement remain authoritative in the provider.
|
||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/database/record"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Account struct {
|
||||||
|
ID, Currency string
|
||||||
|
Precision int
|
||||||
|
Available int64
|
||||||
|
PayoutMethod, PayoutLabel, FundingMethod string
|
||||||
|
}
|
||||||
|
type Request struct {
|
||||||
|
Kind, Source, Destination, Recipient string
|
||||||
|
Amount int64
|
||||||
|
}
|
||||||
|
type Quote struct {
|
||||||
|
ID string
|
||||||
|
Request Request
|
||||||
|
Fee, Debit, Credit int64
|
||||||
|
ExpiresAt time.Time
|
||||||
|
RateNumerator, RateDenominator int64
|
||||||
|
}
|
||||||
|
type Operation struct {
|
||||||
|
ID string
|
||||||
|
Quote Quote
|
||||||
|
Status string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
type Snapshot struct {
|
||||||
|
Accounts []Account
|
||||||
|
Operations []Operation
|
||||||
|
}
|
||||||
|
type Record struct {
|
||||||
|
storable.Base `bson:",inline"`
|
||||||
|
Customer bson.ObjectID `bson:"customer"`
|
||||||
|
Namespace string `bson:"namespace"`
|
||||||
|
Subject string `bson:"subject"`
|
||||||
|
Quote Quote `bson:"quote"`
|
||||||
|
ProviderQuote string `bson:"providerQuote"`
|
||||||
|
Key string `bson:"key"`
|
||||||
|
Payment string `bson:"payment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Record) Collection() string { return "payment_quotes" }
|
||||||
|
|
||||||
|
type Provider interface {
|
||||||
|
Namespace() string
|
||||||
|
Accounts(context.Context, string) ([]Account, error)
|
||||||
|
Quote(context.Context, string, Request, []Account) (Quote, error)
|
||||||
|
Execute(context.Context, string, string) (RemoteOperation, error)
|
||||||
|
Payment(context.Context, string) (RemoteOperation, error)
|
||||||
|
}
|
||||||
|
type RemoteOperation struct {
|
||||||
|
ID, QuoteRef, Status string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
type Identity interface {
|
||||||
|
Subject(context.Context, *customer.Account) (string, error)
|
||||||
|
}
|
||||||
|
type Failure string
|
||||||
|
|
||||||
|
func (e Failure) Error() string { return string(e) }
|
||||||
|
|
||||||
|
const (
|
||||||
|
Unavailable Failure = "unavailable"
|
||||||
|
InvalidAmount Failure = "invalidAmount"
|
||||||
|
InvalidRecipient Failure = "invalidRecipient"
|
||||||
|
InsufficientFunds Failure = "insufficientFunds"
|
||||||
|
LimitExceeded Failure = "limitExceeded"
|
||||||
|
ExpiredQuote Failure = "expiredQuote"
|
||||||
|
Unsupported Failure = "unsupported"
|
||||||
|
NotFound Failure = "notFound"
|
||||||
|
Uncertain Failure = "uncertain"
|
||||||
|
FactorRequired Failure = "factorRequired"
|
||||||
|
)
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||||
|
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||||
|
verification "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
Store Store
|
||||||
|
Provider Provider
|
||||||
|
Identity Identity
|
||||||
|
Factors twofactor.API
|
||||||
|
Logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ready() error {
|
||||||
|
if s == nil || s.Store == nil || s.Provider == nil || s.Identity == nil || s.Factors == nil {
|
||||||
|
return Unavailable
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *Service) record(ctx context.Context, a *customer.Account, id string) (*Record, error) {
|
||||||
|
if err := s.ready(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if a == nil || a.ID.IsZero() {
|
||||||
|
return nil, NotFound
|
||||||
|
}
|
||||||
|
ref, err := bson.ObjectIDFromHex(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, NotFound
|
||||||
|
}
|
||||||
|
r, err := s.Store.Get(ctx, ref, a.ID, s.Provider.Namespace())
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return nil, NotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
subject, err := s.Identity.Subject(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if subject != r.Subject {
|
||||||
|
return nil, Unavailable
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
func (s *Service) Quote(ctx context.Context, a *customer.Account, request Request) (Quote, error) {
|
||||||
|
if err := s.ready(); err != nil {
|
||||||
|
return Quote{}, err
|
||||||
|
}
|
||||||
|
subject, err := s.Identity.Subject(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return Quote{}, err
|
||||||
|
}
|
||||||
|
accounts, err := s.Provider.Accounts(ctx, subject)
|
||||||
|
if err != nil {
|
||||||
|
return Quote{}, err
|
||||||
|
}
|
||||||
|
q, err := s.Provider.Quote(ctx, subject, request, accounts)
|
||||||
|
if err != nil {
|
||||||
|
return Quote{}, err
|
||||||
|
}
|
||||||
|
providerRef := q.ID
|
||||||
|
q.ID = bson.NewObjectID().Hex()
|
||||||
|
r := &Record{Customer: a.ID, Namespace: s.Provider.Namespace(), Subject: subject, Quote: q, ProviderQuote: providerRef}
|
||||||
|
r.SetID(mustID(q.ID))
|
||||||
|
if err = s.Store.Create(ctx, r); err != nil {
|
||||||
|
return Quote{}, err
|
||||||
|
}
|
||||||
|
s.Logger.Info("Customer quotation stored", zap.String("quote_ref", q.ID), zap.String("kind", request.Kind))
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
func mustID(id string) bson.ObjectID { value, _ := bson.ObjectIDFromHex(id); return value }
|
||||||
|
func operationScope(r *Record) factor.Scope {
|
||||||
|
return factor.Scope{Purpose: verification.PurposePayment, ContextRef: r.Quote.ID}
|
||||||
|
}
|
||||||
|
func (s *Service) Challenge(ctx context.Context, a *customer.Account, id, ip string) (factor.Challenge, error) {
|
||||||
|
r, err := s.record(ctx, a, id)
|
||||||
|
if err != nil {
|
||||||
|
return factor.Challenge{}, err
|
||||||
|
}
|
||||||
|
if !time.Now().Before(r.Quote.ExpiresAt) || r.Key != "" {
|
||||||
|
return factor.Challenge{}, ExpiredQuote
|
||||||
|
}
|
||||||
|
return s.Factors.Begin(ctx, factor.Request{Subject: a.ID, Scope: operationScope(r), Deadline: r.Quote.ExpiresAt, IdempotencyKey: "payment:" + r.Quote.ID, IP: ip})
|
||||||
|
}
|
||||||
|
func (s *Service) Resend(ctx context.Context, a *customer.Account, id, challenge, key, ip string) (factor.Challenge, error) {
|
||||||
|
r, err := s.record(ctx, a, id)
|
||||||
|
if err != nil {
|
||||||
|
return factor.Challenge{}, err
|
||||||
|
}
|
||||||
|
if !time.Now().Before(r.Quote.ExpiresAt) || r.Key != "" {
|
||||||
|
return factor.Challenge{}, ExpiredQuote
|
||||||
|
}
|
||||||
|
if !validKey(key) {
|
||||||
|
return factor.Challenge{}, InvalidAmount
|
||||||
|
}
|
||||||
|
return s.Factors.Resend(ctx, factor.Request{Subject: a.ID, Scope: operationScope(r), Deadline: r.Quote.ExpiresAt, IdempotencyKey: key, IP: ip}, challenge)
|
||||||
|
}
|
||||||
|
func validKey(key string) bool {
|
||||||
|
if len(key) < 16 || len(key) > 128 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range key {
|
||||||
|
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize consumes the challenge atomically with a durable execution intent.
|
||||||
|
// No upstream I/O occurs inside the transaction. Replaying the same authorization
|
||||||
|
// recovers a lost response without reusing the one-use verification code.
|
||||||
|
func (s *Service) Authorize(ctx context.Context, a *customer.Account, id, challenge, code, key string) error {
|
||||||
|
r, err := s.record(ctx, a, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !validKey(key) {
|
||||||
|
return InvalidAmount
|
||||||
|
}
|
||||||
|
if r.Key != "" {
|
||||||
|
if r.Key == key {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ExpiredQuote
|
||||||
|
}
|
||||||
|
return s.Factors.Complete(ctx, a.ID, operationScope(r), challenge, code, func(tx context.Context) error {
|
||||||
|
if !time.Now().Before(r.Quote.ExpiresAt) {
|
||||||
|
return ExpiredQuote
|
||||||
|
}
|
||||||
|
return s.Store.Authorize(tx, r, key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
func (s *Service) execute(ctx context.Context, r *Record) (Operation, error) {
|
||||||
|
var remote RemoteOperation
|
||||||
|
var err error
|
||||||
|
if r.Payment != "" {
|
||||||
|
remote, err = s.Provider.Payment(ctx, r.Payment)
|
||||||
|
} else {
|
||||||
|
remote, err = s.Provider.Execute(ctx, r.ProviderQuote, "sxchnge:payment:"+r.ID.Hex())
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.Logger.Warn("Payment execution needs reconciliation", zap.String("quote_ref", r.Quote.ID))
|
||||||
|
return Operation{}, merrors.OpaqueWrap(Uncertain, err, "provider payment outcome unknown")
|
||||||
|
}
|
||||||
|
if remote.QuoteRef != r.ProviderQuote || remote.ID == "" || (r.Payment != "" && r.Payment != remote.ID) {
|
||||||
|
return Operation{}, Uncertain
|
||||||
|
}
|
||||||
|
if r.Payment == "" {
|
||||||
|
if err = s.Store.Attach(ctx, r, remote.ID); err != nil {
|
||||||
|
return Operation{}, merrors.OpaqueWrap(Uncertain, err, "persist provider payment reference")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Logger.Info("Payment execution reconciled", zap.String("quote_ref", r.Quote.ID), zap.String("payment_ref", remote.ID), zap.String("state", remote.Status))
|
||||||
|
return Operation{ID: r.Quote.ID, Quote: r.Quote, Status: remote.Status, CreatedAt: remote.CreatedAt}, nil
|
||||||
|
}
|
||||||
|
func (s *Service) Submit(ctx context.Context, a *customer.Account, id, key string) (Operation, error) {
|
||||||
|
r, err := s.record(ctx, a, id)
|
||||||
|
if err != nil {
|
||||||
|
return Operation{}, err
|
||||||
|
}
|
||||||
|
if !validKey(key) || r.Key == "" || r.Key != key {
|
||||||
|
return Operation{}, FactorRequired
|
||||||
|
}
|
||||||
|
return s.execute(ctx, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile replays the already authorized durable intent with the same provider
|
||||||
|
// key. A timeout or an empty upstream list is never treated as proof of absence.
|
||||||
|
func (s *Service) Reconcile(ctx context.Context, a *customer.Account, key string) (*Operation, error) {
|
||||||
|
if err := s.ready(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !validKey(key) {
|
||||||
|
return nil, NotFound
|
||||||
|
}
|
||||||
|
r, err := s.Store.ByKey(ctx, a.ID, s.Provider.Namespace(), key)
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r, err = s.record(ctx, a, r.ID.Hex())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
op, err := s.execute(ctx, r)
|
||||||
|
return &op, err
|
||||||
|
}
|
||||||
|
func (s *Service) Fetch(ctx context.Context, a *customer.Account) (Snapshot, error) {
|
||||||
|
if err := s.ready(); err != nil {
|
||||||
|
return Snapshot{}, err
|
||||||
|
}
|
||||||
|
subject, err := s.Identity.Subject(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return Snapshot{}, err
|
||||||
|
}
|
||||||
|
rows, err := s.Store.List(ctx, a.ID, s.Provider.Namespace())
|
||||||
|
if err != nil {
|
||||||
|
return Snapshot{}, err
|
||||||
|
}
|
||||||
|
result := Snapshot{Operations: []Operation{}}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Subject != subject {
|
||||||
|
return Snapshot{}, Unavailable
|
||||||
|
}
|
||||||
|
op, err := s.execute(ctx, &r)
|
||||||
|
if errors.Is(err, Uncertain) {
|
||||||
|
// A durable authorization with an unknown provider outcome remains
|
||||||
|
// pending. It must not hide unrelated balances or report failure.
|
||||||
|
op = Operation{ID: r.Quote.ID, Quote: r.Quote, Status: "pending", CreatedAt: r.CreatedAt}
|
||||||
|
} else if err != nil {
|
||||||
|
return Snapshot{}, err
|
||||||
|
}
|
||||||
|
result.Operations = append(result.Operations, op)
|
||||||
|
}
|
||||||
|
result.Accounts, err = s.Provider.Accounts(ctx, subject)
|
||||||
|
if err != nil {
|
||||||
|
return Snapshot{}, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type memoryStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
records map[bson.ObjectID]Record
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memoryStore) Create(_ context.Context, r *Record) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.records[r.ID] = *r
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *memoryStore) Get(_ context.Context, id, customer bson.ObjectID, namespace string) (*Record, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
r, ok := s.records[id]
|
||||||
|
if !ok || r.Customer != customer || r.Namespace != namespace {
|
||||||
|
return nil, merrors.ErrNoData
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
func (s *memoryStore) ByKey(_ context.Context, customer bson.ObjectID, namespace, key string) (*Record, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for _, r := range s.records {
|
||||||
|
if r.Customer == customer && r.Namespace == namespace && r.Key == key {
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, merrors.ErrNoData
|
||||||
|
}
|
||||||
|
func (s *memoryStore) List(_ context.Context, customer bson.ObjectID, namespace string) ([]Record, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
rows := []Record{}
|
||||||
|
for _, r := range s.records {
|
||||||
|
if r.Customer == customer && r.Namespace == namespace && r.Key != "" {
|
||||||
|
rows = append(rows, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
func (s *memoryStore) Authorize(_ context.Context, r *Record, key string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
current := s.records[r.ID]
|
||||||
|
if current.Key != "" {
|
||||||
|
return Uncertain
|
||||||
|
}
|
||||||
|
current.Key = key
|
||||||
|
s.records[r.ID] = current
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *memoryStore) Attach(_ context.Context, r *Record, id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
current := s.records[r.ID]
|
||||||
|
current.Payment = id
|
||||||
|
s.records[r.ID] = current
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type provider struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
accepted int
|
||||||
|
lost bool
|
||||||
|
status string
|
||||||
|
key string
|
||||||
|
namespace string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *provider) Namespace() string { return p.namespace }
|
||||||
|
func (p *provider) Accounts(context.Context, string) ([]Account, error) {
|
||||||
|
return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil
|
||||||
|
}
|
||||||
|
func (p *provider) Quote(_ context.Context, _ string, r Request, _ []Account) (Quote, error) {
|
||||||
|
return Quote{ID: "provider-quote", Request: r, ExpiresAt: time.Now().Add(time.Minute)}, nil
|
||||||
|
}
|
||||||
|
func (p *provider) Execute(_ context.Context, quote, key string) (RemoteOperation, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.key == "" {
|
||||||
|
p.accepted++
|
||||||
|
p.key = key
|
||||||
|
}
|
||||||
|
if p.key != key {
|
||||||
|
return RemoteOperation{}, Unavailable
|
||||||
|
}
|
||||||
|
if p.lost {
|
||||||
|
p.lost = false
|
||||||
|
return RemoteOperation{}, Unavailable
|
||||||
|
}
|
||||||
|
return RemoteOperation{ID: "payment", QuoteRef: quote, Status: p.status, CreatedAt: time.Now()}, nil
|
||||||
|
}
|
||||||
|
func (p *provider) Payment(context.Context, string) (RemoteOperation, error) {
|
||||||
|
return RemoteOperation{ID: "payment", QuoteRef: "provider-quote", Status: p.status, CreatedAt: time.Now()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type identity struct{}
|
||||||
|
|
||||||
|
func (identity) Subject(context.Context, *customer.Account) (string, error) { return "subject", nil }
|
||||||
|
|
||||||
|
type factors struct {
|
||||||
|
calls int
|
||||||
|
subject bson.ObjectID
|
||||||
|
scope factor.Scope
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *factors) Begin(_ context.Context, r factor.Request) (factor.Challenge, error) {
|
||||||
|
f.subject = r.Subject
|
||||||
|
f.scope = r.Scope
|
||||||
|
return factor.Challenge{ID: "challenge"}, nil
|
||||||
|
}
|
||||||
|
func (f *factors) Resend(ctx context.Context, r factor.Request, _ string) (factor.Challenge, error) {
|
||||||
|
return f.Begin(ctx, r)
|
||||||
|
}
|
||||||
|
func (f *factors) Complete(ctx context.Context, subject bson.ObjectID, scope factor.Scope, id, code string, action func(context.Context) error) error {
|
||||||
|
if subject != f.subject || scope != f.scope || id != "challenge" || code != "123456" {
|
||||||
|
return FactorRequired
|
||||||
|
}
|
||||||
|
f.calls++
|
||||||
|
return action(ctx)
|
||||||
|
}
|
||||||
|
func TestCustomerExecutionOwnershipAndLostResponse(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
a := &customer.Account{}
|
||||||
|
a.ID = bson.NewObjectID()
|
||||||
|
other := &customer.Account{}
|
||||||
|
other.ID = bson.NewObjectID()
|
||||||
|
store := &memoryStore{records: map[bson.ObjectID]Record{}}
|
||||||
|
p := &provider{lost: true, status: "pending", namespace: "provider"}
|
||||||
|
f := &factors{}
|
||||||
|
s := &Service{Store: store, Provider: p, Identity: identity{}, Factors: f, Logger: zap.NewNop()}
|
||||||
|
q, err := s.Quote(ctx, a, Request{Kind: "payout", Source: "ledger", Amount: 1000})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = s.Submit(ctx, a, q.ID, "unapproved-key-123")
|
||||||
|
require.ErrorIs(t, err, FactorRequired)
|
||||||
|
require.Zero(t, p.accepted)
|
||||||
|
_, err = s.Challenge(ctx, other, q.ID, "")
|
||||||
|
require.ErrorIs(t, err, NotFound)
|
||||||
|
_, err = s.Challenge(ctx, a, q.ID, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.ErrorIs(t, s.Authorize(ctx, a, q.ID, "challenge", "bad-code", "authorized-key-123"), FactorRequired)
|
||||||
|
require.NoError(t, s.Authorize(ctx, a, q.ID, "challenge", "123456", "authorized-key-123"))
|
||||||
|
require.NoError(t, s.Authorize(ctx, a, q.ID, "challenge", "123456", "authorized-key-123"))
|
||||||
|
require.Equal(t, 1, f.calls)
|
||||||
|
_, err = s.Submit(ctx, a, q.ID, "authorized-key-123")
|
||||||
|
require.ErrorIs(t, err, Uncertain)
|
||||||
|
op, err := s.Reconcile(ctx, other, "authorized-key-123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, op)
|
||||||
|
op, err = s.Reconcile(ctx, a, "authorized-key-123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "pending", op.Status)
|
||||||
|
require.Equal(t, 1, p.accepted)
|
||||||
|
p.status = "completed"
|
||||||
|
snapshot, err := s.Fetch(ctx, a)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "completed", snapshot.Operations[0].Status)
|
||||||
|
require.ErrorIs(t, s.Authorize(ctx, a, q.ID, "challenge", "123456", "different-key-1234"), ExpiredQuote)
|
||||||
|
// A changed provider namespace cannot execute the old quote.
|
||||||
|
p.namespace = "rotated-provider"
|
||||||
|
_, err = s.Submit(ctx, a, q.ID, "authorized-key-123")
|
||||||
|
require.ErrorIs(t, err, NotFound)
|
||||||
|
}
|
||||||
|
func TestExpiredQuoteCannotAuthorize(t *testing.T) {
|
||||||
|
a := &customer.Account{}
|
||||||
|
a.ID = bson.NewObjectID()
|
||||||
|
r := Record{Customer: a.ID, Namespace: "p", Subject: "subject", Quote: Quote{ID: bson.NewObjectID().Hex(), ExpiresAt: time.Now().Add(-time.Second)}}
|
||||||
|
r.SetID(mustID(r.Quote.ID))
|
||||||
|
s := &Service{Store: &memoryStore{records: map[bson.ObjectID]Record{r.ID: r}}, Provider: &provider{namespace: "p"}, Identity: identity{}, Factors: &factors{}, Logger: zap.NewNop()}
|
||||||
|
_, err := s.Challenge(context.Background(), a, r.Quote.ID, "")
|
||||||
|
require.ErrorIs(t, err, ExpiredQuote)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||||
|
deps "git.sendico.io/tech/sxchnge/shared/database/dependencies"
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||||
|
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
Create(context.Context, *Record) error
|
||||||
|
Get(context.Context, bson.ObjectID, bson.ObjectID, string) (*Record, error)
|
||||||
|
ByKey(context.Context, bson.ObjectID, string, string) (*Record, error)
|
||||||
|
List(context.Context, bson.ObjectID, string) ([]Record, error)
|
||||||
|
Authorize(context.Context, *Record, string) error
|
||||||
|
Attach(context.Context, *Record, string) error
|
||||||
|
}
|
||||||
|
type mongoStore struct{ records repository.Repository }
|
||||||
|
|
||||||
|
func OpenStore(factory db.Factory) (Store, error) {
|
||||||
|
d, err := deps.ResolveDependencies(factory)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r := repository.CreateRepository(source.Majority(d.Database), "payment_quotes")
|
||||||
|
err = r.CreateIndex(&ri.Definition{Name: "customer_execution_key", Unique: true, Keys: []ri.Key{{Field: "customer", Sort: ri.Asc}, {Field: "namespace", Sort: ri.Asc}, {Field: "key", Sort: ri.Asc}}, PartialFilter: repository.Query().Comparison(repository.Field("key"), builder.Gt, "")})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = r.CreateIndex(&ri.Definition{Name: "provider_quote_unique", Unique: true, Keys: []ri.Key{{Field: "namespace", Sort: ri.Asc}, {Field: "providerQuote", Sort: ri.Asc}}, PartialFilter: repository.Query().Comparison(repository.Field("providerQuote"), builder.Gt, "")}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &mongoStore{r}, nil
|
||||||
|
}
|
||||||
|
func scope(customer bson.ObjectID, namespace string) repository.FilterQuery {
|
||||||
|
return repository.Query().Filter(repository.Field("customer"), customer).Filter(repository.Field("namespace"), namespace)
|
||||||
|
}
|
||||||
|
func (s *mongoStore) Create(ctx context.Context, r *Record) error {
|
||||||
|
return s.records.Insert(ctx, r, nil)
|
||||||
|
}
|
||||||
|
func (s *mongoStore) Get(ctx context.Context, id, customer bson.ObjectID, namespace string) (*Record, error) {
|
||||||
|
r := &Record{}
|
||||||
|
err := s.records.FindOneByFilter(ctx, scope(customer, namespace).Filter(repository.IDField(), id), r)
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
func (s *mongoStore) ByKey(ctx context.Context, customer bson.ObjectID, namespace, key string) (*Record, error) {
|
||||||
|
r := &Record{}
|
||||||
|
err := s.records.FindOneByFilter(ctx, scope(customer, namespace).Filter(repository.Field("key"), key), r)
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
func (s *mongoStore) List(ctx context.Context, customer bson.ObjectID, namespace string) ([]Record, error) {
|
||||||
|
var rows []Record
|
||||||
|
err := s.records.FindAllByFilter(ctx, scope(customer, namespace).Comparison(repository.Field("key"), builder.Gt, "").Sort(repository.Field("createdAt"), true), &rows)
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
func (s *mongoStore) Authorize(ctx context.Context, r *Record, key string) error {
|
||||||
|
matched, err := s.records.PatchOneByFilter(ctx, scope(r.Customer, r.Namespace).Filter(repository.IDField(), r.ID).Filter(repository.Field("key"), ""), repository.Patch().Set(repository.Field("key"), key), &Record{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
return Uncertain
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mongoStore) Attach(ctx context.Context, r *Record, id string) error {
|
||||||
|
matched, err := s.records.PatchOneByFilter(ctx, scope(r.Customer, r.Namespace).Filter(repository.IDField(), r.ID).Filter(repository.Field("payment"), ""), repository.Patch().Set(repository.Field("payment"), id), &Record{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
current, err := s.Get(ctx, r.ID, r.Customer, r.Namespace)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if current.Payment != id {
|
||||||
|
return Uncertain
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDurableAuthorizationTransactionAndCustomerIsolation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
|
||||||
|
uri, err := container.ConnectionString(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
u, err := url.Parse(uri)
|
||||||
|
require.NoError(t, err)
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("directConnection", "true")
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "payments"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(factory.CloseConnection)
|
||||||
|
store, err := OpenStore(factory)
|
||||||
|
require.NoError(t, err)
|
||||||
|
customer := bson.NewObjectID()
|
||||||
|
r := &Record{Customer: customer, Namespace: "provider", Subject: "subject"}
|
||||||
|
r.SetID(bson.NewObjectID())
|
||||||
|
require.NoError(t, store.Create(ctx, r))
|
||||||
|
_, err = store.Get(ctx, r.ID, bson.NewObjectID(), r.Namespace)
|
||||||
|
require.ErrorIs(t, err, merrors.ErrNoData)
|
||||||
|
_, err = factory.TransactionFactory().CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
||||||
|
require.NoError(t, store.Authorize(tx, r, "authorized-key-123"))
|
||||||
|
return nil, Uncertain
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, Uncertain)
|
||||||
|
stored, err := store.Get(ctx, r.ID, customer, r.Namespace)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, stored.Key)
|
||||||
|
require.NoError(t, store.Authorize(ctx, r, "authorized-key-123"))
|
||||||
|
require.ErrorIs(t, store.Authorize(ctx, r, "other-key-123456"), Uncertain)
|
||||||
|
another := &Record{Customer: customer, Namespace: r.Namespace}
|
||||||
|
another.SetID(bson.NewObjectID())
|
||||||
|
require.NoError(t, store.Create(ctx, another))
|
||||||
|
require.ErrorIs(t, store.Authorize(ctx, another, "authorized-key-123"), merrors.ErrDataConflict)
|
||||||
|
require.NoError(t, store.Attach(ctx, r, "payment-1"))
|
||||||
|
require.NoError(t, store.Attach(ctx, r, "payment-1"))
|
||||||
|
require.ErrorIs(t, store.Attach(ctx, r, "different-payment"), Uncertain)
|
||||||
|
reopened, err := OpenStore(factory)
|
||||||
|
require.NoError(t, err)
|
||||||
|
stored, err = reopened.ByKey(ctx, customer, r.Namespace, "authorized-key-123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "payment-1", stored.Payment)
|
||||||
|
_, err = reopened.ByKey(ctx, customer, "other-provider", "authorized-key-123")
|
||||||
|
require.ErrorIs(t, err, merrors.ErrNoData)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) post(ctx context.Context, resource, requestRef string, payload any) (receiptDTO, error) {
|
||||||
|
var receipt receiptDTO
|
||||||
|
token, err := c.token()
|
||||||
|
if err != nil {
|
||||||
|
return receipt, err
|
||||||
|
}
|
||||||
|
var body []byte
|
||||||
|
if payload != nil {
|
||||||
|
body, err = json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "encode Sendico request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/"+resource+"/"+c.config.OrganizationID, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "create Sendico request")
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Request-ID", requestRef)
|
||||||
|
response, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "Sendico request failed")
|
||||||
|
}
|
||||||
|
defer func() { _ = response.Body.Close() }()
|
||||||
|
// Never decode or expose upstream error bodies, which can contain assertions.
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return receipt, merrors.ServiceUnavailable("Sendico request was not accepted")
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(io.LimitReader(response.Body, 16385))
|
||||||
|
var envelope envelopeDTO
|
||||||
|
if err := decoder.Decode(&envelope); err != nil {
|
||||||
|
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode Sendico receipt")
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if decoder.Decode(&trailing) != io.EOF || envelope.Status != "success" {
|
||||||
|
return receipt, merrors.ServiceUnavailable("invalid Sendico response")
|
||||||
|
}
|
||||||
|
return envelope.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Register(ctx context.Context, requestRef string) (string, error) {
|
||||||
|
dto, err := c.post(ctx, "individuals", requestRef, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return mapSubject(dto)
|
||||||
|
}
|
||||||
|
func (c *Client) Submit(ctx context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) {
|
||||||
|
dto, err := c.post(ctx, "identity_facts", bson.NewObjectID().Hex(), mapFacts(subject, source, facts))
|
||||||
|
if err != nil {
|
||||||
|
return identity.Receipt{}, err
|
||||||
|
}
|
||||||
|
return mapReceipt(dto, subject)
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const org = "507f1f77bcf86cd799439011"
|
||||||
|
const source = "507f1f77bcf86cd799439012"
|
||||||
|
const subject = "507f1f77bcf86cd799439013"
|
||||||
|
const submission = "507f1f77bcf86cd799439014"
|
||||||
|
|
||||||
|
func credential(t *testing.T, id string, expiry int64) string {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.Marshal(map[string]any{"id": id, "exp": expiry})
|
||||||
|
require.NoError(t, err)
|
||||||
|
return "header." + base64.RawURLEncoding.EncodeToString(raw) + ".signature"
|
||||||
|
}
|
||||||
|
func config(t *testing.T, endpoint string) Config {
|
||||||
|
t.Helper()
|
||||||
|
file := filepath.Join(t.TempDir(), "token")
|
||||||
|
require.NoError(t, os.WriteFile(file, []byte(credential(t, source, time.Now().Add(time.Hour).Unix())), 0600))
|
||||||
|
return Config{APIURL: endpoint + "/api/v1", OrganizationID: org, SourceID: source, TokenFile: file}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignupWireContractAndCredentialRotation(t *testing.T) {
|
||||||
|
paths := []string{}
|
||||||
|
var token string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, http.MethodPost, r.Method)
|
||||||
|
require.Equal(t, "Bearer "+token, r.Header.Get("Authorization"))
|
||||||
|
paths = append(paths, r.URL.Path)
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if strings.Contains(r.URL.Path, "/individuals/") {
|
||||||
|
require.Equal(t, "registration-request", r.Header.Get("X-Request-ID"))
|
||||||
|
require.Empty(t, body)
|
||||||
|
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"registered_at":"2026-10-07T10:30:00.123Z"}}`, subject)
|
||||||
|
} else {
|
||||||
|
require.JSONEq(t, `{"subject_ref":"`+subject+`","source_submission_ref":"signup-1","facts":{"full_name":"Ada Lovelace","email":"ada@example.com","external_customer_ref":"local-user"}}`, string(body))
|
||||||
|
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"submission_ref":%q,"accepted_at":"2026-10-07T10:30:00.123Z"}}`, subject, submission)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
cfg := config(t, server.URL)
|
||||||
|
token = credential(t, source, time.Now().Add(time.Hour).Unix())
|
||||||
|
client, err := Open(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ref, err := client.Register(context.Background(), "registration-request")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, subject, ref)
|
||||||
|
// File rotation takes effect without restarting; account identity stays fixed.
|
||||||
|
token = credential(t, source, time.Now().Add(2*time.Hour).Unix())
|
||||||
|
require.NoError(t, os.WriteFile(cfg.TokenFile, []byte(token), 0600))
|
||||||
|
receipt, err := client.Submit(context.Background(), subject, "signup-1", identity.Facts{FullName: "Ada Lovelace", Email: "ada@example.com", CustomerRef: "local-user"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, submission, receipt.SubmissionRef)
|
||||||
|
require.Equal(t, subject, receipt.SubjectRef)
|
||||||
|
require.Equal(t, []string{"/api/v1/individuals/" + org, "/api/v1/identity_facts/" + org}, paths)
|
||||||
|
require.NoError(t, os.WriteFile(cfg.TokenFile, []byte(credential(t, org, time.Now().Add(time.Hour).Unix())), 0600))
|
||||||
|
_, err = client.Register(context.Background(), "registration-request")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Len(t, paths, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectInvalidConfiguration(t *testing.T) {
|
||||||
|
for _, endpoint := range []string{"http://remote.example", "https://user:pass@api.example", "https://api.example?secret=x", "https://api.example/#fragment", "//api.example", "ftp://localhost"} {
|
||||||
|
_, err := Open(config(t, endpoint))
|
||||||
|
require.Error(t, err, endpoint)
|
||||||
|
}
|
||||||
|
cfg := config(t, "https://api.example")
|
||||||
|
cfg.OrganizationID = "../other"
|
||||||
|
_, err := Open(cfg)
|
||||||
|
require.Error(t, err)
|
||||||
|
cfg = config(t, "https://api.example")
|
||||||
|
cfg.SourceID = ""
|
||||||
|
_, err = Open(cfg)
|
||||||
|
require.Error(t, err)
|
||||||
|
cfg = config(t, "https://api.example")
|
||||||
|
require.NoError(t, os.WriteFile(cfg.TokenFile, []byte(credential(t, source, 1)), 0600))
|
||||||
|
_, err = Open(cfg)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpstreamFailuresNeverProduceReceiptsOrLeakBodies(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{"denied", 403, `{"secret":"private-assertion"}`},
|
||||||
|
{"conflict", 409, `{"secret":"private-assertion"}`},
|
||||||
|
{"unavailable", 503, `{"secret":"private-assertion"}`},
|
||||||
|
{"malformed", 200, `{"private-assertion":`},
|
||||||
|
{"empty", 200, `{"status":"success","data":{}}`},
|
||||||
|
{"wrong-status", 200, `{"status":"error","data":{}}`},
|
||||||
|
{"oversized", 200, `{"padding":"` + strings.Repeat("x", 17000) + `"}`},
|
||||||
|
{"wrong-subject", 200, `{"status":"success","data":{"subject_ref":"` + org + `","submission_ref":"` + submission + `","accepted_at":"2026-10-07T10:30:00Z"}}`},
|
||||||
|
{"trailing", 200, `{"status":"success","data":{}} {}`},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(tc.status)
|
||||||
|
_, _ = io.WriteString(w, tc.body)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = client.Submit(context.Background(), subject, "key", identity.Facts{})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.NotContains(t, err.Error(), "private-assertion")
|
||||||
|
_, err = client.Register(context.Background(), "registration-request")
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedirectAndTimeoutDoNotRepeatRegistration(t *testing.T) {
|
||||||
|
var calls int
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls++
|
||||||
|
http.Redirect(w, r, "/redirected", http.StatusTemporaryRedirect)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = client.Register(context.Background(), "registration-request")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, 1, calls)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
_, err = client.Register(ctx, "registration-request")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, 1, calls)
|
||||||
|
timeoutServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }))
|
||||||
|
defer timeoutServer.Close()
|
||||||
|
client, err = Open(config(t, timeoutServer.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
client.http.Timeout = 20 * time.Millisecond
|
||||||
|
_, err = client.Register(context.Background(), "timeout-request")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Package sendico consumes only Sendico's public HTTP contracts.
|
||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
APIURL string `yaml:"api_url"`
|
||||||
|
OrganizationID string `yaml:"organization_ref"`
|
||||||
|
SourceID string `yaml:"source_account_ref"`
|
||||||
|
TokenFile string `yaml:"token_file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
config Config
|
||||||
|
base string
|
||||||
|
namespace string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(c Config) (*Client, error) {
|
||||||
|
u, err := url.Parse(c.APIURL)
|
||||||
|
if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) {
|
||||||
|
return nil, merrors.InvalidArgument("Sendico API URL must be absolute HTTPS (HTTP allowed on localhost)")
|
||||||
|
}
|
||||||
|
for _, ref := range []string{c.OrganizationID, c.SourceID} {
|
||||||
|
id, err := bson.ObjectIDFromHex(ref)
|
||||||
|
if err != nil || id.IsZero() || ref != id.Hex() {
|
||||||
|
return nil, merrors.InvalidArgument("Sendico organization and source references must be lowercase object IDs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.TokenFile) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("Sendico token file required")
|
||||||
|
}
|
||||||
|
base := strings.TrimRight(c.APIURL, "/")
|
||||||
|
hash := sha256.Sum256([]byte(base + "\x00" + c.OrganizationID + "\x00" + c.SourceID))
|
||||||
|
client := &Client{config: c, base: base, namespace: hex.EncodeToString(hash[:]), http: &http.Client{Timeout: 4 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||||
|
if _, err := client.token(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Namespace() string { return c.namespace }
|
||||||
|
|
||||||
|
// Rotation may replace this file, but must retain the authenticated account.
|
||||||
|
// Claim inspection only protects the replay namespace from operator mistakes;
|
||||||
|
// Sendico remains responsible for signature validation and authorization.
|
||||||
|
func (c *Client) token() (string, error) {
|
||||||
|
raw, err := os.ReadFile(c.config.TokenFile)
|
||||||
|
if err != nil {
|
||||||
|
return "", merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "read Sendico credential")
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(string(raw))
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(token) > 16384 || len(parts) != 3 {
|
||||||
|
return "", merrors.FailedPrecondition("invalid Sendico credential")
|
||||||
|
}
|
||||||
|
claims, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return "", merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "invalid Sendico credential claims")
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Pending bool `json:"pending"`
|
||||||
|
Exp int64 `json:"exp"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(claims, &payload); err != nil {
|
||||||
|
return "", merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "invalid Sendico credential claims")
|
||||||
|
}
|
||||||
|
if payload.ID != c.config.SourceID || payload.Pending || payload.Exp <= time.Now().Unix() {
|
||||||
|
return "", merrors.FailedPrecondition("Sendico credential must be unexpired and match configured source account")
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only consumed wire fields are owned here; never import platform modules.
|
||||||
|
type factsDTO struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
CustomerRef string `json:"external_customer_ref"`
|
||||||
|
}
|
||||||
|
type submissionDTO struct {
|
||||||
|
SubjectRef string `json:"subject_ref"`
|
||||||
|
SourceRef string `json:"source_submission_ref"`
|
||||||
|
Facts factsDTO `json:"facts"`
|
||||||
|
}
|
||||||
|
type receiptDTO struct {
|
||||||
|
SubjectRef string `json:"subject_ref"`
|
||||||
|
RegisteredAt time.Time `json:"registered_at"`
|
||||||
|
SubmissionRef string `json:"submission_ref"`
|
||||||
|
AcceptedAt time.Time `json:"accepted_at"`
|
||||||
|
}
|
||||||
|
type envelopeDTO struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Data receiptDTO `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validRef(ref string) bool {
|
||||||
|
id, err := bson.ObjectIDFromHex(ref)
|
||||||
|
return err == nil && !id.IsZero() && id.Hex() == ref
|
||||||
|
}
|
||||||
|
func mapFacts(subject, source string, facts identity.Facts) submissionDTO {
|
||||||
|
return submissionDTO{SubjectRef: subject, SourceRef: source, Facts: factsDTO{FullName: facts.FullName, Email: facts.Email, CustomerRef: facts.CustomerRef}}
|
||||||
|
}
|
||||||
|
func mapSubject(dto receiptDTO) (string, error) {
|
||||||
|
if !validRef(dto.SubjectRef) || dto.RegisteredAt.IsZero() {
|
||||||
|
return "", merrors.ServiceUnavailable("invalid Sendico registration receipt")
|
||||||
|
}
|
||||||
|
return dto.SubjectRef, nil
|
||||||
|
}
|
||||||
|
func mapReceipt(dto receiptDTO, subject string) (identity.Receipt, error) {
|
||||||
|
if !validRef(dto.SubmissionRef) || dto.SubjectRef != subject || dto.AcceptedAt.IsZero() {
|
||||||
|
return identity.Receipt{}, merrors.ServiceUnavailable("invalid Sendico intake receipt")
|
||||||
|
}
|
||||||
|
return identity.Receipt{SubmissionRef: dto.SubmissionRef, SubjectRef: dto.SubjectRef, AcceptedAt: dto.AcceptedAt}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/payments"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Accounts exposes only dedicated, organization-owned customer ledgers tagged by
|
||||||
|
// trusted platform provisioning. The integration login's personal/pooled ledgers
|
||||||
|
// never become customer balances. Method bindings are provisioned on that ledger.
|
||||||
|
func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Account, error) {
|
||||||
|
if !validRef(subject) {
|
||||||
|
return nil, payments.Unavailable
|
||||||
|
}
|
||||||
|
var currencies struct {
|
||||||
|
Rows []currencyDTO `json:"currencies"`
|
||||||
|
}
|
||||||
|
if err := c.paymentRequest(ctx, http.MethodGet, "/payments/fx/currencies/"+c.config.OrganizationID, nil, ¤cies); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
scales := map[string]int{}
|
||||||
|
for _, row := range currencies.Rows {
|
||||||
|
if row.Scale != nil && *row.Scale >= 0 && *row.Scale <= 8 {
|
||||||
|
scales[row.Code] = *row.Scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var data struct {
|
||||||
|
Accounts []accountDTO `json:"accounts"`
|
||||||
|
}
|
||||||
|
root := "/ledger_accounts/" + c.config.OrganizationID
|
||||||
|
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
accounts := []payments.Account{}
|
||||||
|
for _, row := range data.Accounts {
|
||||||
|
if row.Metadata["sxchnge_subject_ref"] != subject {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !validRef(row.ID) || row.ScopeRef != c.config.OrganizationID || row.Owner != "" || row.Status != "ACCOUNT_STATUS_ACTIVE" || row.Type != "ACCOUNT_TYPE_ASSET" || row.LedgerRole != "ACCOUNT_ROLE_OPERATING" {
|
||||||
|
return nil, payments.Unavailable
|
||||||
|
}
|
||||||
|
scale, ok := scales[row.Currency]
|
||||||
|
if !ok {
|
||||||
|
return nil, payments.Unsupported
|
||||||
|
}
|
||||||
|
var balance struct {
|
||||||
|
Value balanceDTO `json:"balance"`
|
||||||
|
}
|
||||||
|
if err := c.paymentRequest(ctx, http.MethodGet, root+"/"+row.ID+"/balance", nil, &balance); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
account, err := mapAccount(row, balance.Value, scale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
accounts = append(accounts, account)
|
||||||
|
}
|
||||||
|
return accounts, nil
|
||||||
|
}
|
||||||
|
func (c *Client) Quote(ctx context.Context, subject string, r payments.Request, accounts []payments.Account) (payments.Quote, error) {
|
||||||
|
mapped, err := mapIntent(subject, r, accounts)
|
||||||
|
if err != nil {
|
||||||
|
return payments.Quote{}, err
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Quote paymentQuoteDTO `json:"quote"`
|
||||||
|
Subject string `json:"externalSubjectRef"`
|
||||||
|
}
|
||||||
|
err = c.paymentRequest(ctx, http.MethodPost, "/payments/quote/"+c.config.OrganizationID, quoteRequestDTO{Key: "sxchnge:quote:" + bson.NewObjectID().Hex(), Intent: mapped.wire}, &response)
|
||||||
|
if err != nil {
|
||||||
|
return payments.Quote{}, err
|
||||||
|
}
|
||||||
|
if response.Subject != subject {
|
||||||
|
return payments.Quote{}, payments.Unavailable
|
||||||
|
}
|
||||||
|
return mapPaymentQuote(response.Quote, mapped.request, mapped.source, mapped.destination)
|
||||||
|
}
|
||||||
|
func (c *Client) Execute(ctx context.Context, quote, key string) (payments.RemoteOperation, error) {
|
||||||
|
if !safePaymentRef(quote) {
|
||||||
|
return payments.RemoteOperation{}, payments.Unavailable
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Payment paymentDTO `json:"payment"`
|
||||||
|
}
|
||||||
|
payload := struct {
|
||||||
|
Quote string `json:"quoteRef"`
|
||||||
|
Key string `json:"idempotencyKey"`
|
||||||
|
}{quote, key}
|
||||||
|
err := c.paymentRequest(ctx, http.MethodPost, "/payments/by-quote/"+c.config.OrganizationID, payload, &result)
|
||||||
|
if err != nil {
|
||||||
|
return payments.RemoteOperation{}, err
|
||||||
|
}
|
||||||
|
return mapPayment(result.Payment)
|
||||||
|
}
|
||||||
|
func (c *Client) Payment(ctx context.Context, id string) (payments.RemoteOperation, error) {
|
||||||
|
if !safePaymentRef(id) {
|
||||||
|
return payments.RemoteOperation{}, payments.Unavailable
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Payment paymentDTO `json:"payment"`
|
||||||
|
}
|
||||||
|
err := c.paymentRequest(ctx, http.MethodGet, "/payments/by-ref/"+id, nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return payments.RemoteOperation{}, err
|
||||||
|
}
|
||||||
|
return mapPayment(result.Payment)
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/big"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/payments"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxMinor int64 = 9007199254740991
|
||||||
|
|
||||||
|
func safePaymentRef(value string) bool {
|
||||||
|
if len(value) == 0 || len(value) > 200 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range value {
|
||||||
|
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == ':') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decimal parsing never rounds provider values or converts money through floats.
|
||||||
|
func minorMoney(m moneyDTO, currency string, scale int) (int64, error) {
|
||||||
|
if m.Currency != currency || scale < 0 || scale > 8 {
|
||||||
|
return 0, payments.Unavailable
|
||||||
|
}
|
||||||
|
parts := strings.Split(m.Amount, ".")
|
||||||
|
if len(parts) > 2 || parts[0] == "" {
|
||||||
|
return 0, payments.Unavailable
|
||||||
|
}
|
||||||
|
digits := parts[0]
|
||||||
|
if len(parts) == 2 {
|
||||||
|
if parts[1] == "" || len(parts[1]) > scale {
|
||||||
|
return 0, payments.Unavailable
|
||||||
|
}
|
||||||
|
digits += parts[1] + strings.Repeat("0", scale-len(parts[1]))
|
||||||
|
} else {
|
||||||
|
digits += strings.Repeat("0", scale)
|
||||||
|
}
|
||||||
|
for _, r := range digits {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return 0, payments.Unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseInt(digits, 10, 64)
|
||||||
|
if err != nil || value > maxMinor {
|
||||||
|
return 0, payments.Unavailable
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
func majorMoney(minor int64, scale int) string {
|
||||||
|
digits := strconv.FormatInt(minor, 10)
|
||||||
|
if scale == 0 {
|
||||||
|
return digits
|
||||||
|
}
|
||||||
|
if len(digits) <= scale {
|
||||||
|
digits = strings.Repeat("0", scale+1-len(digits)) + digits
|
||||||
|
}
|
||||||
|
return digits[:len(digits)-scale] + "." + digits[len(digits)-scale:]
|
||||||
|
}
|
||||||
|
func mapPaymentQuote(dto paymentQuoteDTO, r payments.Request, source, destination payments.Account) (payments.Quote, error) {
|
||||||
|
if !dto.Executable || dto.State != "executable" {
|
||||||
|
switch dto.BlockReason {
|
||||||
|
case "limit_blocked":
|
||||||
|
return payments.Quote{}, payments.LimitExceeded
|
||||||
|
case "amount_too_small", "amount_too_large":
|
||||||
|
return payments.Quote{}, payments.InvalidAmount
|
||||||
|
default:
|
||||||
|
return payments.Quote{}, payments.Unsupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !safePaymentRef(dto.ID) || !dto.Expires.After(time.Now()) {
|
||||||
|
return payments.Quote{}, payments.ExpiredQuote
|
||||||
|
}
|
||||||
|
debit, err := minorMoney(dto.Amounts.Debit, source.Currency, source.Precision)
|
||||||
|
if err != nil {
|
||||||
|
return payments.Quote{}, err
|
||||||
|
}
|
||||||
|
principal, err := minorMoney(dto.Amounts.Principal, source.Currency, source.Precision)
|
||||||
|
if err != nil {
|
||||||
|
return payments.Quote{}, err
|
||||||
|
}
|
||||||
|
credit, err := minorMoney(dto.Amounts.Credit, destination.Currency, destination.Precision)
|
||||||
|
if err != nil {
|
||||||
|
return payments.Quote{}, err
|
||||||
|
}
|
||||||
|
if principal != r.Amount || debit < principal || credit <= 0 {
|
||||||
|
return payments.Quote{}, payments.Unavailable
|
||||||
|
}
|
||||||
|
if r.Kind != "topUp" && debit > source.Available {
|
||||||
|
return payments.Quote{}, payments.InsufficientFunds
|
||||||
|
}
|
||||||
|
num, den := big.NewInt(credit), big.NewInt(principal)
|
||||||
|
num.Mul(num, new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(source.Precision)), nil))
|
||||||
|
den.Mul(den, new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(destination.Precision)), nil))
|
||||||
|
gcd := new(big.Int).GCD(nil, nil, num, den)
|
||||||
|
num.Quo(num, gcd)
|
||||||
|
den.Quo(den, gcd)
|
||||||
|
if !num.IsInt64() || !den.IsInt64() || num.Int64() > maxMinor || den.Int64() > maxMinor {
|
||||||
|
return payments.Quote{}, payments.Unsupported
|
||||||
|
}
|
||||||
|
return payments.Quote{ID: dto.ID, Request: r, Fee: debit - principal, Debit: debit, Credit: credit, ExpiresAt: dto.Expires, RateNumerator: num.Int64(), RateDenominator: den.Int64()}, nil
|
||||||
|
}
|
||||||
|
func mapPayment(dto paymentDTO) (payments.RemoteOperation, error) {
|
||||||
|
if !safePaymentRef(dto.ID) || !safePaymentRef(dto.Quote.ID) || dto.Created.IsZero() {
|
||||||
|
return payments.RemoteOperation{}, payments.Unavailable
|
||||||
|
}
|
||||||
|
status := "pending"
|
||||||
|
switch dto.State {
|
||||||
|
case "orchestration_state_settled":
|
||||||
|
status = "completed"
|
||||||
|
case "orchestration_state_failed":
|
||||||
|
status = "failed"
|
||||||
|
case "orchestration_state_created", "orchestration_state_executing", "orchestration_state_needs_attention":
|
||||||
|
default:
|
||||||
|
return payments.RemoteOperation{}, payments.Unavailable
|
||||||
|
}
|
||||||
|
return payments.RemoteOperation{ID: dto.ID, QuoteRef: dto.Quote.ID, Status: status, CreatedAt: dto.Created}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountByID(accounts []payments.Account, id string) (payments.Account, error) {
|
||||||
|
for _, a := range accounts {
|
||||||
|
if a.ID == id {
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payments.Account{}, payments.NotFound
|
||||||
|
}
|
||||||
|
func ledgerEndpoint(id string) endpointDTO {
|
||||||
|
return endpointDTO{Type: "ledger", Data: map[string]string{"account_ref": id}}
|
||||||
|
}
|
||||||
|
func methodEndpoint(id string) endpointDTO {
|
||||||
|
return endpointDTO{Type: "paymentMethod", Data: map[string]string{"ref": id}}
|
||||||
|
}
|
||||||
|
|
||||||
|
type mappedIntent struct {
|
||||||
|
wire paymentIntentDTO
|
||||||
|
request payments.Request
|
||||||
|
source, destination payments.Account
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapIntent(subject string, r payments.Request, accounts []payments.Account) (mappedIntent, error) {
|
||||||
|
source, err := accountByID(accounts, r.Source)
|
||||||
|
if err != nil {
|
||||||
|
return mappedIntent{}, err
|
||||||
|
}
|
||||||
|
if r.Amount <= 0 || r.Amount > maxMinor {
|
||||||
|
return mappedIntent{}, payments.InvalidAmount
|
||||||
|
}
|
||||||
|
intent := paymentIntentDTO{Subject: subject, Source: ledgerEndpoint(source.ID), Amount: moneyDTO{Amount: majorMoney(r.Amount, source.Precision), Currency: source.Currency}, FeeTreatment: "add_to_source"}
|
||||||
|
destination := source
|
||||||
|
switch r.Kind {
|
||||||
|
case "payout":
|
||||||
|
r.Destination = ""
|
||||||
|
if source.PayoutMethod == "" || r.Recipient != source.PayoutMethod {
|
||||||
|
return mappedIntent{}, payments.InvalidRecipient
|
||||||
|
}
|
||||||
|
intent.Kind = "payout"
|
||||||
|
intent.Destination = methodEndpoint(source.PayoutMethod)
|
||||||
|
// Store the safe provisioned display label, never present an opaque method ID.
|
||||||
|
r.Recipient = source.PayoutLabel
|
||||||
|
case "exchange":
|
||||||
|
r.Recipient = ""
|
||||||
|
destination, err = accountByID(accounts, r.Destination)
|
||||||
|
if err != nil {
|
||||||
|
return mappedIntent{}, err
|
||||||
|
}
|
||||||
|
if source.ID == destination.ID || source.Currency == destination.Currency {
|
||||||
|
return mappedIntent{}, payments.Unsupported
|
||||||
|
}
|
||||||
|
intent.Kind = "fx_conversion"
|
||||||
|
intent.Destination = ledgerEndpoint(destination.ID)
|
||||||
|
case "topUp":
|
||||||
|
r.Destination = ""
|
||||||
|
r.Recipient = ""
|
||||||
|
if source.FundingMethod == "" {
|
||||||
|
return mappedIntent{}, payments.Unsupported
|
||||||
|
}
|
||||||
|
intent.Kind = "internal_transfer"
|
||||||
|
intent.Source = methodEndpoint(source.FundingMethod)
|
||||||
|
intent.Destination = ledgerEndpoint(source.ID)
|
||||||
|
default:
|
||||||
|
return mappedIntent{}, payments.Unsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
return mappedIntent{intent, r, source, destination}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapAccount(row accountDTO, balance balanceDTO, scale int) (payments.Account, error) {
|
||||||
|
if balance.ID != row.ID {
|
||||||
|
return payments.Account{}, payments.Unavailable
|
||||||
|
}
|
||||||
|
minor, err := minorMoney(balance.Money, row.Currency, scale)
|
||||||
|
if err != nil {
|
||||||
|
return payments.Account{}, err
|
||||||
|
}
|
||||||
|
account := payments.Account{ID: row.ID, Currency: row.Currency, Precision: scale, Available: minor, PayoutMethod: row.Metadata["sxchnge_payout_method_ref"], PayoutLabel: row.Metadata["sxchnge_payout_label"], FundingMethod: row.Metadata["sxchnge_funding_method_ref"]}
|
||||||
|
for _, ref := range []string{account.PayoutMethod, account.FundingMethod} {
|
||||||
|
if ref != "" && !validRef(ref) {
|
||||||
|
return payments.Account{}, payments.Unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if account.PayoutMethod != "" && (strings.TrimSpace(account.PayoutLabel) == "" || len(account.PayoutLabel) > 140) {
|
||||||
|
return payments.Account{}, payments.Unavailable
|
||||||
|
}
|
||||||
|
return account, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/payments"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPaymentPublicWireAndExactMoney(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
ledger := "507f1f77bcf86cd799439015"
|
||||||
|
method := "507f1f77bcf86cd799439016"
|
||||||
|
calls := 0
|
||||||
|
acknowledge := true
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.NotEmpty(t, r.Header.Get("Authorization"))
|
||||||
|
var result any
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/payments/fx/currencies/" + org:
|
||||||
|
result = map[string]any{"currencies": []any{map[string]any{"code": "EUR", "accountingScale": 2}}}
|
||||||
|
case "/api/v1/ledger_accounts/" + org:
|
||||||
|
result = map[string]any{"accounts": []any{
|
||||||
|
map[string]any{"ledgerAccountRef": source, "organizationRef": org, "ownerRef": source, "currency": "EUR", "accountType": "ACCOUNT_TYPE_ASSET", "role": "ACCOUNT_ROLE_OPERATING", "status": "ACCOUNT_STATUS_ACTIVE"},
|
||||||
|
map[string]any{"ledgerAccountRef": ledger, "organizationRef": org, "currency": "EUR", "accountType": "ACCOUNT_TYPE_ASSET", "role": "ACCOUNT_ROLE_OPERATING", "status": "ACCOUNT_STATUS_ACTIVE", "metadata": map[string]string{"sxchnge_subject_ref": subject, "sxchnge_payout_method_ref": method, "sxchnge_payout_label": "Saved bank ending 1234"}},
|
||||||
|
}}
|
||||||
|
case "/api/v1/ledger_accounts/" + org + "/" + ledger + "/balance":
|
||||||
|
result = map[string]any{"balance": map[string]any{"ledgerAccountRef": ledger, "balance": moneyDTO{"75.00", "EUR"}}}
|
||||||
|
case "/api/v1/payments/quote/" + org:
|
||||||
|
var request quoteRequestDTO
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
|
||||||
|
require.Equal(t, subject, request.Intent.Subject)
|
||||||
|
require.Equal(t, "20.01", request.Intent.Amount.Amount)
|
||||||
|
require.Equal(t, ledger, request.Intent.Source.Data["account_ref"])
|
||||||
|
require.Equal(t, method, request.Intent.Destination.Data["ref"])
|
||||||
|
confirmedSubject := subject
|
||||||
|
if !acknowledge {
|
||||||
|
confirmedSubject = ""
|
||||||
|
}
|
||||||
|
result = map[string]any{"externalSubjectRef": confirmedSubject, "quote": paymentQuoteDTO{ID: "quote:1", State: "executable", Executable: true, Expires: time.Now().Add(time.Minute), Amounts: quoteAmountsDTO{Principal: moneyDTO{"20.01", "EUR"}, Debit: moneyDTO{"21.01", "EUR"}, Credit: moneyDTO{"20.01", "EUR"}}}}
|
||||||
|
case "/api/v1/payments/by-quote/" + org:
|
||||||
|
calls++
|
||||||
|
var payload map[string]string
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
|
||||||
|
require.Equal(t, "stable-execution", payload["idempotencyKey"])
|
||||||
|
require.Equal(t, "quote:1", payload["quoteRef"])
|
||||||
|
result = map[string]any{"payment": paymentDTO{ID: "payment:1", State: "orchestration_state_needs_attention", Quote: paymentQuoteDTO{ID: "quote:1"}, Created: time.Now()}}
|
||||||
|
case "/api/v1/payments/by-ref/payment:1":
|
||||||
|
result = map[string]any{"payment": paymentDTO{ID: "payment:1", State: "orchestration_state_settled", Quote: paymentQuoteDTO{ID: "quote:1"}, Created: time.Now()}}
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
w.WriteHeader(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"status": "success", "data": result}))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
accounts, err := client.Accounts(ctx, subject)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, accounts, 1)
|
||||||
|
require.EqualValues(t, 7500, accounts[0].Available)
|
||||||
|
q, err := client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: ledger, Amount: 2001, Recipient: method}, accounts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 100, q.Fee)
|
||||||
|
require.EqualValues(t, 2101, q.Debit)
|
||||||
|
require.Equal(t, "Saved bank ending 1234", q.Request.Recipient)
|
||||||
|
_, err = client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: source, Amount: 2001, Recipient: method}, accounts)
|
||||||
|
require.ErrorIs(t, err, payments.NotFound)
|
||||||
|
_, err = client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: ledger, Amount: 2001, Recipient: "foreign-method"}, accounts)
|
||||||
|
require.ErrorIs(t, err, payments.InvalidRecipient)
|
||||||
|
acknowledge = false
|
||||||
|
_, err = client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: ledger, Amount: 2001, Recipient: method}, accounts)
|
||||||
|
require.ErrorIs(t, err, payments.Unavailable)
|
||||||
|
acknowledge = true
|
||||||
|
op, err := client.Execute(ctx, q.ID, "stable-execution")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "pending", op.Status)
|
||||||
|
require.Equal(t, 1, calls)
|
||||||
|
op, err = client.Payment(ctx, op.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "completed", op.Status)
|
||||||
|
}
|
||||||
|
func TestPaymentMappingRejectsUnsafeOrUnexecutableValues(t *testing.T) {
|
||||||
|
for _, amount := range []string{"1e3", "0.001", "-2", "NaN", "90071992547409.92", ""} {
|
||||||
|
_, err := minorMoney(moneyDTO{amount, "EUR"}, "EUR", 2)
|
||||||
|
require.Error(t, err, amount)
|
||||||
|
}
|
||||||
|
_, err := minorMoney(moneyDTO{"10.00", "USD"}, "EUR", 2)
|
||||||
|
require.Error(t, err)
|
||||||
|
_, err = mapPayment(paymentDTO{ID: "p", State: "new_unknown_state", Quote: paymentQuoteDTO{ID: "q"}, Created: time.Now()})
|
||||||
|
require.Error(t, err)
|
||||||
|
_, err = mapPaymentQuote(paymentQuoteDTO{State: "blocked", BlockReason: "limit_blocked"}, payments.Request{}, payments.Account{}, payments.Account{})
|
||||||
|
require.ErrorIs(t, err, payments.LimitExceeded)
|
||||||
|
_, err = mapPaymentQuote(paymentQuoteDTO{State: "executable", Executable: false}, payments.Request{}, payments.Account{}, payments.Account{})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/payments"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type moneyDTO struct {
|
||||||
|
Amount string `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
}
|
||||||
|
type endpointDTO struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Data map[string]string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit wire fields avoid coupling local domain models to provider JSON.
|
||||||
|
type quoteRequestDTO struct {
|
||||||
|
Key string `json:"idempotencyKey"`
|
||||||
|
Intent paymentIntentDTO `json:"intent"`
|
||||||
|
}
|
||||||
|
type paymentIntentDTO struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Source endpointDTO `json:"source"`
|
||||||
|
Destination endpointDTO `json:"destination"`
|
||||||
|
Amount moneyDTO `json:"amount"`
|
||||||
|
Subject string `json:"external_subject_ref"`
|
||||||
|
FeeTreatment string `json:"fee_treatment"`
|
||||||
|
}
|
||||||
|
type quoteAmountsDTO struct {
|
||||||
|
Principal moneyDTO `json:"sourcePrincipal"`
|
||||||
|
Debit moneyDTO `json:"sourceDebitTotal"`
|
||||||
|
Credit moneyDTO `json:"destinationSettlement"`
|
||||||
|
}
|
||||||
|
type paymentQuoteDTO struct {
|
||||||
|
ID string `json:"quoteRef"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Executable bool `json:"executable"`
|
||||||
|
BlockReason string `json:"blockReason"`
|
||||||
|
Expires time.Time `json:"expiresAt"`
|
||||||
|
Amounts quoteAmountsDTO `json:"amounts"`
|
||||||
|
}
|
||||||
|
type paymentDTO struct {
|
||||||
|
ID string `json:"paymentRef"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Quote paymentQuoteDTO `json:"lastQuote"`
|
||||||
|
Created time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
type accountDTO struct {
|
||||||
|
ID string `json:"ledgerAccountRef"`
|
||||||
|
ScopeRef string `json:"organizationRef"`
|
||||||
|
Owner string `json:"ownerRef"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Type string `json:"accountType"`
|
||||||
|
LedgerRole string `json:"role"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
}
|
||||||
|
type currencyDTO struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Scale *int `json:"accountingScale"`
|
||||||
|
}
|
||||||
|
type balanceDTO struct {
|
||||||
|
ID string `json:"ledgerAccountRef"`
|
||||||
|
Money moneyDTO `json:"balance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any) error {
|
||||||
|
token, err := c.token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var body []byte
|
||||||
|
if payload != nil {
|
||||||
|
body, err = json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(payments.Unavailable, err, "encode Sendico payment request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.base+path, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(payments.Unavailable, err, "create Sendico payment request")
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
client := *c.http
|
||||||
|
client.Timeout = 30 * time.Second
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(payments.Unavailable, err, "Sendico payment request failed")
|
||||||
|
}
|
||||||
|
defer func() { _ = res.Body.Close() }()
|
||||||
|
// Never forward upstream errors, credentials, or arbitrary error bodies.
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
var envelope struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
d := json.NewDecoder(io.LimitReader(res.Body, 4*1024*1024+1))
|
||||||
|
if err = d.Decode(&envelope); err != nil {
|
||||||
|
return merrors.OpaqueWrap(payments.Unavailable, err, "decode Sendico payment envelope")
|
||||||
|
}
|
||||||
|
if envelope.Status != "success" {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if d.Decode(&extra) != io.EOF {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(envelope.Data, dst); err != nil {
|
||||||
|
return merrors.OpaqueWrap(payments.Unavailable, err, "decode Sendico payment payload")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type profileDTO struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
DateOfBirth string `json:"date_of_birth"`
|
||||||
|
ResidenceCountry string `json:"residence_country"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
CustomerRef string `json:"external_customer_ref"`
|
||||||
|
}
|
||||||
|
type verificationDTO struct {
|
||||||
|
SubjectRef string `json:"subject_ref"`
|
||||||
|
SubmissionRef string `json:"submission_ref"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Profile profileDTO `json:"profile"`
|
||||||
|
UpgradePending bool `json:"upgrade_pending"`
|
||||||
|
}
|
||||||
|
type policyDTO struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
ProfileRef string `json:"profile_ref"`
|
||||||
|
Entries []limitDTO `json:"entries"`
|
||||||
|
}
|
||||||
|
type limitDTO struct {
|
||||||
|
Operation string `json:"operation"`
|
||||||
|
Period string `json:"period"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Cap string `json:"cap"`
|
||||||
|
Remaining string `json:"remaining"`
|
||||||
|
Used string `json:"used"`
|
||||||
|
Reserved string `json:"reserved"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) identityRequest(ctx context.Context, subject, suffix, method, contentType string, body []byte, dst any) error {
|
||||||
|
if !validRef(subject) {
|
||||||
|
return merrors.InvalidArgument("invalid bound subject")
|
||||||
|
}
|
||||||
|
token, err := c.token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.base+"/identity_facts/"+c.config.OrganizationID+"/proofs/"+subject+suffix, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "create identity request")
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
if contentType != "" {
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
// Uploads need a longer bound than small sign-up receipts. The base client is
|
||||||
|
// copied so concurrent sign-up operations keep their own four-second timeout.
|
||||||
|
client := *c.http
|
||||||
|
client.Timeout = 60 * time.Second
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "identity provider request failed")
|
||||||
|
}
|
||||||
|
defer func() { _ = res.Body.Close() }()
|
||||||
|
switch res.StatusCode {
|
||||||
|
case http.StatusOK:
|
||||||
|
case http.StatusBadRequest, http.StatusRequestEntityTooLarge:
|
||||||
|
return merrors.InvalidArgument("identity profile or photos were not accepted")
|
||||||
|
case http.StatusConflict:
|
||||||
|
return merrors.DataConflict("identity submission changed; refresh")
|
||||||
|
case http.StatusForbidden:
|
||||||
|
return merrors.PermissionDenied("identity resource unavailable")
|
||||||
|
case http.StatusTooManyRequests:
|
||||||
|
retry := 60
|
||||||
|
if seconds, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil && seconds > 0 && seconds <= 86400 {
|
||||||
|
retry = seconds
|
||||||
|
}
|
||||||
|
return merrors.WithThrottle(merrors.ErrServiceUnavailable, "identity", time.Duration(retry)*time.Second, "identity submission temporarily limited")
|
||||||
|
default:
|
||||||
|
return merrors.ServiceUnavailable("identity provider unavailable")
|
||||||
|
}
|
||||||
|
var envelope struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(io.LimitReader(res.Body, 256*1024+1))
|
||||||
|
if err := decoder.Decode(&envelope); err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode identity response")
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if decoder.Decode(&extra) != io.EOF || envelope.Status != "success" {
|
||||||
|
return merrors.ServiceUnavailable("invalid identity response")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(envelope.Data, dst); err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode identity payload")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func mapVerification(dto verificationDTO, subject string) (identity.Verification, error) {
|
||||||
|
if dto.SubjectRef != subject {
|
||||||
|
return identity.Verification{}, merrors.ServiceUnavailable("identity response scope mismatch")
|
||||||
|
}
|
||||||
|
switch dto.Status {
|
||||||
|
case "unverified", "pending", "approved", "changesRequested", "denied":
|
||||||
|
default:
|
||||||
|
return identity.Verification{}, merrors.ServiceUnavailable("invalid identity status")
|
||||||
|
}
|
||||||
|
if dto.Status == "approved" && dto.UpgradePending {
|
||||||
|
dto.Status = "pending"
|
||||||
|
}
|
||||||
|
if dto.Status != "unverified" && !validRef(dto.SubmissionRef) {
|
||||||
|
return identity.Verification{}, merrors.ServiceUnavailable("invalid identity receipt")
|
||||||
|
}
|
||||||
|
profile := identity.Profile{FullName: dto.Profile.FullName, DateOfBirth: dto.Profile.DateOfBirth, ResidenceCountry: dto.Profile.ResidenceCountry}
|
||||||
|
if dto.Status != "unverified" {
|
||||||
|
if err := profile.Validate(); err != nil {
|
||||||
|
return identity.Verification{}, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "invalid identity profile response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return identity.Verification{Status: dto.Status, UpgradePending: dto.UpgradePending, Reason: dto.Reason, SubmissionID: dto.SubmissionRef, Profile: profile}, nil
|
||||||
|
}
|
||||||
|
func (c *Client) Verification(ctx context.Context, subject string) (identity.Verification, error) {
|
||||||
|
var dto verificationDTO
|
||||||
|
if err := c.identityRequest(ctx, subject, "", http.MethodGet, "", nil, &dto); err != nil {
|
||||||
|
return identity.Verification{}, err
|
||||||
|
}
|
||||||
|
return mapVerification(dto, subject)
|
||||||
|
}
|
||||||
|
func (c *Client) SubmitVerification(ctx context.Context, subject, key string, p identity.Profile, f identity.Facts, document, selfie []byte) (identity.Verification, error) {
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
profile, err := json.Marshal(profileDTO{FullName: p.FullName, DateOfBirth: p.DateOfBirth, ResidenceCountry: p.ResidenceCountry, Email: f.Email, CustomerRef: f.CustomerRef})
|
||||||
|
if err != nil {
|
||||||
|
return identity.Verification{}, merrors.OpaqueWrap(merrors.ErrInternal, err, "encode identity profile")
|
||||||
|
}
|
||||||
|
if err = writer.WriteField("profile", string(profile)); err != nil {
|
||||||
|
return identity.Verification{}, err
|
||||||
|
}
|
||||||
|
if err = writer.WriteField("source_submission_ref", key); err != nil {
|
||||||
|
return identity.Verification{}, err
|
||||||
|
}
|
||||||
|
for i, data := range [][]byte{document, selfie} {
|
||||||
|
name := "document"
|
||||||
|
if i == 1 {
|
||||||
|
name = "selfie"
|
||||||
|
}
|
||||||
|
part, writeErr := writer.CreateFormFile(name, name)
|
||||||
|
if writeErr != nil {
|
||||||
|
return identity.Verification{}, writeErr
|
||||||
|
}
|
||||||
|
if _, writeErr = part.Write(data); writeErr != nil {
|
||||||
|
return identity.Verification{}, writeErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = writer.Close(); err != nil {
|
||||||
|
return identity.Verification{}, err
|
||||||
|
}
|
||||||
|
var dto verificationDTO
|
||||||
|
if err = c.identityRequest(ctx, subject, "", http.MethodPost, writer.FormDataContentType(), body.Bytes(), &dto); err != nil {
|
||||||
|
return identity.Verification{}, err
|
||||||
|
}
|
||||||
|
return mapVerification(dto, subject)
|
||||||
|
}
|
||||||
|
func (c *Client) Limits(ctx context.Context, subject string) (identity.Limits, error) {
|
||||||
|
var dto policyDTO
|
||||||
|
if err := c.identityRequest(ctx, subject, "/limits", http.MethodGet, "", nil, &dto); err != nil {
|
||||||
|
return identity.Limits{}, err
|
||||||
|
}
|
||||||
|
if (dto.Tier != "basic" && dto.Tier != "verified") || dto.ProfileRef == "" || dto.Entries == nil {
|
||||||
|
return identity.Limits{}, merrors.ServiceUnavailable("invalid limits response")
|
||||||
|
}
|
||||||
|
entries := make([]identity.Limit, 0, len(dto.Entries))
|
||||||
|
for _, v := range dto.Entries {
|
||||||
|
entries = append(entries, identity.Limit{Operation: v.Operation, Period: v.Period, Currency: v.Currency, Cap: v.Cap, Remaining: v.Remaining, Used: v.Used, Reserved: v.Reserved})
|
||||||
|
}
|
||||||
|
return identity.Limits{Tier: dto.Tier, ProfileRef: dto.ProfileRef, Entries: entries}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProofWireContractAndLimitsProjection(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Contains(t, r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
expected := "/api/v1/identity_facts/" + org + "/proofs/" + subject
|
||||||
|
if r.URL.Path == expected+"/limits" {
|
||||||
|
require.Equal(t, http.MethodGet, r.Method)
|
||||||
|
_, _ = io.WriteString(w, `{"status":"success","data":{"tier":"verified","profile_ref":"profile_personal_verified","entries":[{"operation":"Transfer","period":"day","currency":"BTC","cap":"0.12345678","remaining":"0.10000001","used":"0.02","reserved":"0.00345677"}]}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.Equal(t, expected, r.URL.Path)
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
reader, err := r.MultipartReader()
|
||||||
|
require.NoError(t, err)
|
||||||
|
fields := map[string]string{}
|
||||||
|
for {
|
||||||
|
part, err := reader.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
data, err := io.ReadAll(part)
|
||||||
|
require.NoError(t, err)
|
||||||
|
fields[part.FormName()] = string(data)
|
||||||
|
}
|
||||||
|
require.Equal(t, "document bytes", fields["document"])
|
||||||
|
require.Equal(t, "selfie bytes", fields["selfie"])
|
||||||
|
require.Equal(t, "stable-source", fields["source_submission_ref"])
|
||||||
|
var profile map[string]string
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(fields["profile"]), &profile))
|
||||||
|
require.Equal(t, "Legal Name", profile["full_name"])
|
||||||
|
require.Equal(t, "local-customer", profile["external_customer_ref"])
|
||||||
|
require.Equal(t, "verified@example.test", profile["email"])
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `{"status":"success","data":{"subject_ref":"`+subject+`","submission_ref":"`+submission+`","status":"approved","upgrade_pending":true,"profile":{"full_name":"Legal Name","date_of_birth":"1990-04-23","residence_country":"PL"}}}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
result, err := client.SubmitVerification(t.Context(), subject, "stable-source", identity.Profile{FullName: "Legal Name", DateOfBirth: "1990-04-23", ResidenceCountry: "PL"}, identity.Facts{Email: "verified@example.test", CustomerRef: "local-customer"}, []byte("document bytes"), []byte("selfie bytes"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "pending", result.Status)
|
||||||
|
require.True(t, result.UpgradePending)
|
||||||
|
policy, err := client.Limits(t.Context(), subject)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "0.12345678", policy.Entries[0].Cap)
|
||||||
|
require.Equal(t, "verified", policy.Tier)
|
||||||
|
require.Equal(t, 4*time.Second, client.http.Timeout)
|
||||||
|
}
|
||||||
|
func TestProofRateLimitPreservesTypedRecoveryWithoutUpstreamBody(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Retry-After", "120")
|
||||||
|
w.WriteHeader(429)
|
||||||
|
_, _ = io.WriteString(w, "private identity assertions")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = client.Verification(t.Context(), subject)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.NotContains(t, err.Error(), "private identity assertions")
|
||||||
|
throttle, ok := merrors.ThrottleDetailsFrom(err)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, 120*time.Second, throttle.RetryAfter)
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ func (r *MongoRepository) PatchOneByFilter(ctx context.Context, query builder.Qu
|
|||||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
if mongo.IsDuplicateKeyError(err) {
|
||||||
|
return false, merrors.OpaqueWrap(merrors.ErrDataConflict, err, "duplicate_key")
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/readconcern"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/writeconcern"
|
||||||
|
)
|
||||||
|
|
||||||
|
type majority struct{ Source }
|
||||||
|
|
||||||
|
// Majority retains the source's lease and transaction lifecycle while requiring
|
||||||
|
// durable writes and primary majority reads for externally visible handoffs.
|
||||||
|
func Majority(database Source) Source { return &majority{Source: database} }
|
||||||
|
|
||||||
|
func (s *majority) Acquire(ctx context.Context) (*mongo.Database, context.Context, func(), error) {
|
||||||
|
database, scoped, release, err := s.Source.Acquire(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, scoped, release, err
|
||||||
|
}
|
||||||
|
return database.Client().Database(database.Name(), options.Database().SetWriteConcern(writeconcern.Majority()).SetReadConcern(readconcern.Majority()).SetReadPreference(readpref.Primary())), scoped, release, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/writeconcern"
|
||||||
|
)
|
||||||
|
|
||||||
|
type trackedSource struct {
|
||||||
|
database *mongo.Database
|
||||||
|
released bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *trackedSource) Acquire(ctx context.Context) (*mongo.Database, context.Context, func(), error) {
|
||||||
|
return s.database, ctx, func() { s.released = true }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMajorityPreservesSourceLifetime(t *testing.T) {
|
||||||
|
client, err := mongo.Connect(options.Client().ApplyURI("mongodb://localhost:27017").SetWriteConcern(&writeconcern.WriteConcern{W: 1}).SetReadPreference(readpref.Secondary()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Disconnect(context.Background())) })
|
||||||
|
underlying := &trackedSource{database: client.Database("handoff")}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
database, scoped, release, err := Majority(underlying).Acquire(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Same(t, client, database.Client())
|
||||||
|
require.Equal(t, "handoff", database.Name())
|
||||||
|
require.Equal(t, ctx, scoped)
|
||||||
|
require.False(t, underlying.released)
|
||||||
|
release()
|
||||||
|
require.True(t, underlying.released)
|
||||||
|
_, _, _, err = Majority(Fixed(nil)).Acquire(ctx)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ envelope. Credentials, codes and activation tokens are sent in POST bodies.
|
|||||||
|
|
||||||
| Route | Request | Successful result |
|
| Route | Request | Successful result |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| POST `/signup` | `name`, `email`, `password`, `locale`, `idempotencyKey` | 202 with `userId`, after activation email queue acceptance |
|
| POST `/signup` | `name`, `email`, `password`, `locale`, `idempotencyKey` | 202 with `userId`, after configured Facts intake and activation email queue acceptance |
|
||||||
| POST `/verification/send` | `email`, `idempotencyKey` | 202; generic acceptance also applies to absent/already-active accounts |
|
| POST `/verification/send` | `email`, `idempotencyKey` | 202; generic acceptance also applies to absent/already-active accounts |
|
||||||
| POST `/verification/confirm` | `token` | 202 with `verified: true` after activation commits |
|
| POST `/verification/confirm` | `token` | 202 with `verified: true` after activation commits |
|
||||||
| POST `/login` | `login: {login, password}`, `clientId`, `deviceId` | 202 with `userId`, `pendingToken: {token, expiration}`, `challengeId`, `channel`, masked `target`, `expiresAt`, `resendAt` |
|
| POST `/login` | `login: {login, password}`, `clientId`, `deviceId` | 202 with `userId`, `pendingToken: {token, expiration}`, `challengeId`, `channel`, masked `target`, `expiresAt`, `resendAt` |
|
||||||
@@ -101,6 +101,14 @@ retries can create replacement messages; they are not distributed exactly-once
|
|||||||
operations. Tokens are stored before the RPC, so a process crash between those
|
operations. Tokens are stored before the RPC, so a process crash between those
|
||||||
steps requires user recovery; there is no product-side delivery outbox.
|
steps requires user recovery; there is no product-side delivery outbox.
|
||||||
|
|
||||||
|
When `api.sendico` is enabled, sign-up and activation resend first require a
|
||||||
|
durable Facts intake receipt. Activation checks that receipt in its transaction.
|
||||||
|
Initial account/token creation uses the same server-secret-derived activation
|
||||||
|
key as a same-key retry, so an intake outage does not force an extra cooldown for
|
||||||
|
an unsent initial token. Existing pending customers created before this change
|
||||||
|
retain the prior cooldown behavior. See [Facts integration](signup-facts.md) for
|
||||||
|
uncertain registration recovery and the `signup_attention_required` error.
|
||||||
|
|
||||||
Code verification, one-use proof consumption and session creation now commit in
|
Code verification, one-use proof consumption and session creation now commit in
|
||||||
one transaction. A session-write failure leaves the code retryable; a lost HTTP
|
one transaction. A session-write failure leaves the code retryable; a lost HTTP
|
||||||
response after commit requires restarting password login. The adapter
|
response after commit requires restarting password login. The adapter
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Customer identity verification
|
||||||
|
|
||||||
|
Task [SXCHNGE #48](https://git.sendico.io/tech/sxchnge/issues/48), following
|
||||||
|
[sign-up Facts intake](signup-facts.md). Platform implementation:
|
||||||
|
[Sendico #2885](https://git.sendico.io/tech/sendico/issues/2885).
|
||||||
|
|
||||||
|
## Customer flow
|
||||||
|
|
||||||
|
Sign-up still creates a basic account and submits its initial name/email/customer
|
||||||
|
reference as immutable facts. Email verification and login remain independent of
|
||||||
|
identity proof collection. After the first authenticated login in an app session,
|
||||||
|
an unverified customer is offered verification and can choose **Keep basic
|
||||||
|
limits**. An unavailable optional identity dependency does not prevent login.
|
||||||
|
**Accounts → Identity verification**, **Limits**, and the existing identity
|
||||||
|
navigation let the customer return later.
|
||||||
|
|
||||||
|
The customer supplies their full legal name, date of birth (YYYY-MM-DD), country
|
||||||
|
of residence, an ID document photo and a selfie holding that same document. The
|
||||||
|
backend supplies the authenticated customer's email and reference. The client
|
||||||
|
cannot choose a platform subject, organization, source principal, review status
|
||||||
|
or limits profile. JPEG/PNG photos must decode, be at least 600×600, at most
|
||||||
|
40 megapixels and no larger than 10 MiB each. Sendico also enforces its existing
|
||||||
|
configured evidence cap, MIME/extension rules and submission rate limits.
|
||||||
|
|
||||||
|
Unsent fields and photos stay in memory, survive retryable errors and are cleared
|
||||||
|
on successful submission, discard or page disposal. Previews leave Flutter's
|
||||||
|
image cache on exit/replacement. Profile edits or photo replacements create a
|
||||||
|
new request key; unchanged retries retain their key. Draft loss requires an
|
||||||
|
explicit discard choice. No sensitive extras enter browser history or analytics.
|
||||||
|
|
||||||
|
A successful submission means **pending staff review**. Corrections permit a new
|
||||||
|
submission; rejection retains basic access and the existing support route. A
|
||||||
|
recorded approval with an unfinished limits assignment is shown as **Limits
|
||||||
|
upgrade pending**, rather than verified. Refresh fetches the real reviewed state
|
||||||
|
and the authoritative limits. Production uses no simulated KYC review controls
|
||||||
|
or illustrative limit amounts. Demo mode remains separately simulated.
|
||||||
|
|
||||||
|
## Boundaries and contracts
|
||||||
|
|
||||||
|
The authenticated local routes are `GET/POST /accounts/identity` and
|
||||||
|
`GET /accounts/limits`, under the configured API mount. Multipart POST fields are
|
||||||
|
`profile` (JSON), `document`, `selfie`, and `idempotencyKey`. A customer ID is
|
||||||
|
always resolved from the full authenticated session. Pending-login tokens do not
|
||||||
|
provide account access. Browser and native requests both carry the access token;
|
||||||
|
browser refresh cookies remain an auth concern.
|
||||||
|
|
||||||
|
The existing `signup_facts` binding resolves each local customer to one independent
|
||||||
|
Sendico subject. A conditional unique `(namespace, subjectRef)` index prevents
|
||||||
|
binding the same provider subject to two customers. Empty registration claims
|
||||||
|
are excluded. Existing duplicates cause bootstrap to fail and require controlled
|
||||||
|
reconciliation; the application never guesses a replacement subject. Credential
|
||||||
|
rotation must retain the configured source account.
|
||||||
|
|
||||||
|
The Sendico adapter exclusively uses public HTTP:
|
||||||
|
|
||||||
|
- `GET/POST /identity_facts/{organization}/proofs/{subject}`;
|
||||||
|
- `GET /identity_facts/{organization}/proofs/{subject}/limits`.
|
||||||
|
|
||||||
|
Adapter DTOs and mappers remain separate from local models and local HTTP DTOs.
|
||||||
|
There are no Sendico database, gRPC, bus or sibling-module imports in SXCHNGE.
|
||||||
|
Provider organization/source settings remain in the adapter configuration.
|
||||||
|
Uploads have a bounded 60-second upstream timeout and redirects are rejected.
|
||||||
|
Upstream error bodies and photo bytes are never logged. Rate-limit responses
|
||||||
|
retain typed recovery. Registration's existing non-idempotent reconciliation
|
||||||
|
rules are unchanged.
|
||||||
|
|
||||||
|
Visible policy entries retain exact decimal strings, currency, group labels and
|
||||||
|
per-operation minimum/maximum, day and month slots. Usage is not invented for
|
||||||
|
per-operation slots. The backend owns the effective policy and remaining
|
||||||
|
capacity. The existing wallet remains a simulated service; this contribution
|
||||||
|
connects identity and visible limits, not real money movement. Future money
|
||||||
|
adapters must use the platform-owned customer account resource and authoritative
|
||||||
|
Limits reservation path, not a browser-selected policy or displayed balance.
|
||||||
|
|
||||||
|
## Review and rollout
|
||||||
|
|
||||||
|
Staff work in the existing **Sendico backoffice → KYC → Identity proof review**.
|
||||||
|
The platform stores private proofs, immutable profile assertions, review history
|
||||||
|
and the reviewed limits assignment. See the platform's
|
||||||
|
`docs/customer-identity-proofs.md` for authorization, retry and retention details.
|
||||||
|
|
||||||
|
Deploy Sendico Product/provider, Backoffice, Limits and backoffice frontend
|
||||||
|
changes together before enabling the new SXCHNGE frontend/backend adapters.
|
||||||
|
Existing `api.sendico` settings are reused. The integration account needs
|
||||||
|
organization read/update access. The platform's existing evidence/storage,
|
||||||
|
cleanup, discovery and limits configuration must be provisioned. Its ACCOUNT
|
||||||
|
default for SXCHNGE must be the existing unverified profile, and the existing
|
||||||
|
verified profile and personal-account visible view must be active. No new runtime
|
||||||
|
configuration parameters or live submissions are introduced by this work.
|
||||||
|
|
||||||
|
Merge [SXCHNGE sign-up Facts PR #47](https://git.sendico.io/tech/sxchnge/pulls/47)
|
||||||
|
before [this flow's PR #49](https://git.sendico.io/tech/sxchnge/pulls/49).
|
||||||
|
For the combined existing backoffice, apply
|
||||||
|
[Sendico identity-proofs PR #2886](https://git.sendico.io/tech/sendico/pulls/2886)
|
||||||
|
before [Facts review PR #2887](https://git.sendico.io/tech/sendico/pulls/2887).
|
||||||
|
They share one Limits assignment contract and retain both review destinations.
|
||||||
|
Proof cases go to Identity proof review; generic Facts review cannot link them
|
||||||
|
to a login account or assign limits through that path. Deploy the reconciled
|
||||||
|
Sendico services and frontend before using this flow. Deployment is separate.
|
||||||
|
|
||||||
|
## UX, measurement and validation
|
||||||
|
|
||||||
|
Existing KYC journey events measure preparation, selection, accepted submission,
|
||||||
|
status reads, retries, abandonment and latency. Profile contents, filenames,
|
||||||
|
photo bytes and credentials are excluded. Submission success is an intake
|
||||||
|
outcome, not proof of approval. Pending asynchronous staff review is not counted
|
||||||
|
as customer abandonment. A resumed route starts a new client attempt; immutable
|
||||||
|
submission IDs deduplicate backend business outcomes.
|
||||||
|
|
||||||
|
Authoritative completion is the staff decision plus the Limits assignment
|
||||||
|
receipt for that submission. Backend IDs correlate these records without becoming
|
||||||
|
metric labels. Existing identity request counters/histograms distinguish accepted,
|
||||||
|
invalid, denied, conflict, rate-limited and unavailable operations and include
|
||||||
|
actual authorization/I/O duration. Client analytics still defaults to the
|
||||||
|
existing in-memory sink; retained cross-device analytics depends on deployment.
|
||||||
|
|
||||||
|
Review covers optional onboarding, later account navigation, editable profile,
|
||||||
|
readable ID/selfie guidance, disabled actions, error/session recovery, input
|
||||||
|
preservation, correction/rejection/pending/approval, exact monetary presentation,
|
||||||
|
EN/RU at narrow widths and large text, keyboard access and private-image cache
|
||||||
|
eviction. Tests use synthetic data, local HTTP servers and disposable MongoDB.
|
||||||
|
No live ID documents, live email, production review or deployment were exercised.
|
||||||
|
Full validation commands and final PR links are recorded in `implementation.md`
|
||||||
|
and the task.
|
||||||
@@ -828,3 +828,143 @@ existing mount requires reauthentication at its new cookie path. Notification
|
|||||||
activation URLs remain configured frontend URLs. No live deployment, optional
|
activation URLs remain configured frontend URLs. No live deployment, optional
|
||||||
security scans or merge were performed. The unrelated local notifications sender
|
security scans or merge were performed. The unrelated local notifications sender
|
||||||
configuration remains uncommitted. See [API version installation](api-versions.md).
|
configuration remains uncommitted. See [API version installation](api-versions.md).
|
||||||
|
|
||||||
|
## Sign-up Facts intake — task #46
|
||||||
|
|
||||||
|
[Task #46](https://git.sendico.io/tech/sxchnge/issues/46), branch
|
||||||
|
`identity-facts-46`, connects the product sign-up service to Sendico's public
|
||||||
|
individual registry and immutable Facts intake through optional `api.sendico`
|
||||||
|
configuration. Owner: `tech`; project: SXCHNGE; labels: backend and frontend.
|
||||||
|
|
||||||
|
Delivered in [PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), targeting
|
||||||
|
`main`. Implementation commit: `a584c81`.
|
||||||
|
|
||||||
|
The adapter sends full name, normalized email and the local customer reference.
|
||||||
|
The BFF persists a customer-owned subject binding, immutable facts snapshot and
|
||||||
|
confirmed receipt before activation email or sign-up acceptance. Activation and
|
||||||
|
resend enforce the handoff. The shared database source can require majority
|
||||||
|
writes and primary majority reads without bypassing its connection/transaction
|
||||||
|
lifecycle. Initial account/token creation remains atomic and uses a secret-derived
|
||||||
|
activation key, allowing same-key recovery after an intake outage without an
|
||||||
|
extra cooldown for an unsent token. Archived customers cannot resume sign-up.
|
||||||
|
|
||||||
|
Backend and frontend completion reviews covered the full task contribution,
|
||||||
|
including the public contracts, customer ownership, stable replay namespace,
|
||||||
|
concurrent claims, error classification, credential handling, logging, metrics and
|
||||||
|
DTO/mapper/service/model/provider separation. UX review preserved input, loading
|
||||||
|
suppression and confirmation-based success. Typed support guidance reaches both
|
||||||
|
sign-up and verification resend/confirmation through the shared auth package;
|
||||||
|
EN/RU feedback preserves live-region semantics and existing responsive controls.
|
||||||
|
The new localized widget fixture initially double-disposed the app-owned locale
|
||||||
|
provider; the fixture ownership was corrected. Focused integration tests also
|
||||||
|
exposed the initial-token cooldown issue, which was fixed in the creation flow.
|
||||||
|
|
||||||
|
Individual registration is not idempotent upstream. A durable claim prevents
|
||||||
|
speculative repeat registration after an ambiguous result; reconciliation uses
|
||||||
|
the saved request reference and authoritative Sendico records. Intake replay is
|
||||||
|
safe after receipt loss and retains original assertions. Actual provider IDs,
|
||||||
|
the endpoint and a same-account Vault-rendered access token must be provisioned
|
||||||
|
before enabling this integration. Tests use simulated HTTP and disposable MongoDB;
|
||||||
|
no live provider resources, live email, deployment, merge or optional security
|
||||||
|
scan is part of this delivery. The unrelated notifications runtime configuration
|
||||||
|
is excluded. Configuration, rollout, telemetry and recovery details are in
|
||||||
|
[Sign-up Facts intake](signup-facts.md).
|
||||||
|
|
||||||
|
Final validation passed: `GOFLAGS=-p=2 make test` (all backend race tests and
|
||||||
|
`make test-frontend` suites), `make lint-backend`,
|
||||||
|
`dart format --output=none --set-exit-if-changed` for all 11 changed Dart files,
|
||||||
|
and `flutter analyze` in `frontend/app` and `frontend/packages/sxchnge_auth`.
|
||||||
|
Focused Go race suites and Flutter backend/provider/widget tests also passed;
|
||||||
|
the diagnostic public API run
|
||||||
|
`go -C backend/product test -race -v ./auth/http/v1/public -count=1 -timeout=90s`
|
||||||
|
passed. The initial unrestricted `make test` failed when Testcontainers could
|
||||||
|
not start its reaper before the Mongo fixture deadline. Lower Go package
|
||||||
|
parallelism resolved that infrastructure contention. The next full run exposed
|
||||||
|
the new locale fixture disposal error noted above; the corrected final run
|
||||||
|
passed, including EN/RU support states and verification recovery. No checks were
|
||||||
|
weakened or unrelated work changed.
|
||||||
|
|
||||||
|
## Customer identity verification — task #48
|
||||||
|
|
||||||
|
[Implementation and rollout](identity-verification.md) adds optional verification
|
||||||
|
on authenticated onboarding and later from the account: legal profile, ID photo
|
||||||
|
and selfie holding the same ID. Real adapters replace demo KYC/limits in backend
|
||||||
|
mode. Sendico's existing backoffice owns decisions and the reviewed Limits profile;
|
||||||
|
SXCHNGE has no local tenancy, roles or policy engine. Provider-subject uniqueness,
|
||||||
|
private bounded uploads, stable retries, exact decimal policy projections and
|
||||||
|
explicit upgrade-pending feedback are included. The unrelated notifications
|
||||||
|
runtime configuration is excluded.
|
||||||
|
|
||||||
|
Delivered in [PR #49](https://git.sendico.io/tech/sxchnge/pulls/49), branch
|
||||||
|
`identity-verification-48`, implementation commit `1354551`. It is stacked on
|
||||||
|
[sign-up Facts PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), which must
|
||||||
|
merge first, and requires
|
||||||
|
[Sendico PR #2886](https://git.sendico.io/tech/sendico/pulls/2886) to be deployed
|
||||||
|
before enabling the new flow. Tracking tasks:
|
||||||
|
[SXCHNGE #48](https://git.sendico.io/tech/sxchnge/issues/48) and
|
||||||
|
[Sendico #2885](https://git.sendico.io/tech/sendico/issues/2885).
|
||||||
|
|
||||||
|
Completion review covered the full backend and frontend contribution, including
|
||||||
|
subject ownership, private uploads, idempotent retries, session changes, exact
|
||||||
|
limits, DTO/mapper/service/model/provider boundaries, and the UX/measurement
|
||||||
|
checklists. Review fixes included duplicate-binding error classification,
|
||||||
|
upgrade-pending feedback, bounded response timeouts and stale-session protection.
|
||||||
|
|
||||||
|
Final validation passed: `GOFLAGS=-p=1 make test` (all backend race tests and all
|
||||||
|
frontend suites; 79 app tests), `make lint-backend`, changed-Dart formatting,
|
||||||
|
and `flutter analyze` in the app and shared KYC package. Focused KYC adapter and
|
||||||
|
onboarding tests passed (20 tests), as did focused Go identity/provider/HTTP
|
||||||
|
checks. An initial parallel run hit the existing Testcontainers reaper deadline;
|
||||||
|
serial Go package execution resolved local Docker contention without weakening
|
||||||
|
checks. No optional security scan, live customer proof, email, production
|
||||||
|
approval, deployment or merge was performed. Generated outputs remain ignored.
|
||||||
|
|
||||||
|
Reconciliation follow-up: the shared Limits assignment implementation and its
|
||||||
|
persisted-state validation are owned by Sendico PR #2886. The subsequent
|
||||||
|
[Sendico Facts review PR #2887](https://git.sendico.io/tech/sendico/pulls/2887)
|
||||||
|
retains both Product providers, Backoffice services and KYC destinations, and
|
||||||
|
routes proof cases to Identity proof review while blocking generic login linking
|
||||||
|
and assignment. Apply #2886 before #2887 and deploy the combined Sendico result
|
||||||
|
before this SXCHNGE flow. The public proof/limits contract is unchanged; this
|
||||||
|
SXCHNGE follow-up changes documentation only and requires consistency/diff checks.
|
||||||
|
|
||||||
|
## Sendico quotations and payment execution — task #50
|
||||||
|
|
||||||
|
[Task #50](https://git.sendico.io/tech/sxchnge/issues/50), branch `payments-50`,
|
||||||
|
connects backend wallet mode through the authenticated customer BFF to Sendico's
|
||||||
|
public account, quotation, by-quote execution and payment-status APIs. It is
|
||||||
|
stacked on `identity-verification-48` / PR #49; merge #47 → #49 first.
|
||||||
|
|
||||||
|
The separate public-provider prerequisite is
|
||||||
|
[Sendico PR #2897](https://git.sendico.io/tech/sendico/pulls/2897), task #2896.
|
||||||
|
It validates independent customer context and selects the reviewed ACCOUNT limits
|
||||||
|
resource rather than the integration login or an unassigned CUSTOMER policy.
|
||||||
|
Deploy its Product Edge and Quotation/Orchestrator consumers together before
|
||||||
|
SXCHNGE. The BFF requires an exact customer-context acknowledgement from quotes.
|
||||||
|
|
||||||
|
The contribution adds durable customer/namespace/subject-bound quotation ownership,
|
||||||
|
transactional payment-factor authorization, unique quote/execution keys, stable
|
||||||
|
provider replay, payment-reference reconciliation and authoritative status polling.
|
||||||
|
Live UI uses provisioned destinations, accurate quote expiry, explicit missing
|
||||||
|
method/loading/error/pending states and long labels at narrow widths. It clears
|
||||||
|
account state on customer changes. DTO/mapper/service/model/provider boundaries,
|
||||||
|
shared database wrappers, existing credentials and explicit demo mode are retained.
|
||||||
|
|
||||||
|
Validation passed: `make test`, `make lint-backend`, `make test-frontend` after the
|
||||||
|
final UI review, changed-Dart format verification, and app/wallet `flutter analyze`.
|
||||||
|
The final frontend run passed 21 wallet and 81 app tests, plus the unchanged shared
|
||||||
|
package suites. Tests include synthetic public HTTP, Mongo transaction rollback,
|
||||||
|
unique-key/customer isolation, lost-response reconciliation, factor retry and
|
||||||
|
session replacement. Sendico's full backend test/lint targets also passed.
|
||||||
|
|
||||||
|
[Payment contracts, provisioning, UX measurement and limitations](payments.md)
|
||||||
|
document rollout. Dedicated customer ledger/method bindings and platform access
|
||||||
|
must be provisioned through existing operator APIs; this work does not run live
|
||||||
|
payments or deploy either repository. Generic ambiguous rejections remain pending
|
||||||
|
for operational reconciliation. Cross-session analytics retention, additional
|
||||||
|
funding/recipient onboarding, backend locale saving and external history ingestion
|
||||||
|
remain separate capabilities. Unrelated notifications runtime changes are excluded.
|
||||||
|
|
||||||
|
Delivered in [PR #51](https://git.sendico.io/tech/sxchnge/pulls/51), targeting
|
||||||
|
`identity-verification-48`. Merge order is #47 → #49 → #51; deploy Sendico #2897
|
||||||
|
before SXCHNGE #51. Both implementation branches are pushed and remain unmerged.
|
||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Sign-up Facts intake
|
||||||
|
|
||||||
|
Task [#46](https://git.sendico.io/tech/sxchnge/issues/46), branch
|
||||||
|
`identity-facts-46`. Primary module: identity-facts; secondary modules: auth,
|
||||||
|
customer creation, runtime, shared database source and Flutter error feedback.
|
||||||
|
Delivery: [PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), targeting `main`.
|
||||||
|
|
||||||
|
## Behavior and boundaries
|
||||||
|
|
||||||
|
With `api.sendico` configured, the product BFF creates the local pending customer
|
||||||
|
and activation token atomically, then registers an independent individual through
|
||||||
|
Sendico's public HTTP API and submits the sign-up assertions. It persists the
|
||||||
|
intake receipt before sending activation email or returning sign-up success.
|
||||||
|
Activation resend runs the same handoff; activation checks the receipt in its
|
||||||
|
local transaction, without external I/O. A failed receipt check rolls back token
|
||||||
|
consumption and leaves the account pending. Existing active customers are not
|
||||||
|
backfilled or blocked at login.
|
||||||
|
|
||||||
|
The adapter consumes these existing Sendico contracts:
|
||||||
|
|
||||||
|
- `POST /individuals/{organization_ref}`, empty body, HTTP 200 registration
|
||||||
|
receipt containing `subject_ref` and `registered_at`.
|
||||||
|
- `POST /identity_facts/{organization_ref}`, HTTP 200 immutable intake receipt
|
||||||
|
containing `submission_ref`, `subject_ref` and `accepted_at`.
|
||||||
|
|
||||||
|
The configured API prefix precedes both paths. See Sendico
|
||||||
|
[individual subjects](https://git.sendico.io/tech/sendico/src/branch/main/docs/api/individual-subjects.md)
|
||||||
|
and [Facts intake](https://git.sendico.io/tech/sendico/src/branch/main/docs/api/identity-facts-intake.md).
|
||||||
|
The only assertions sent are `full_name`, normalized `email` and
|
||||||
|
`external_customer_ref` (the local customer ID). Passwords, password hashes,
|
||||||
|
activation tokens, session credentials and device data never enter the adapter.
|
||||||
|
The input name is retained as a full name, without guessing name parts. No facts
|
||||||
|
are marked verified; acceptance does not establish identity, KYC completeness,
|
||||||
|
verification, limits or payout eligibility. The existing KYC preview is unchanged.
|
||||||
|
|
||||||
|
Only the BFF owns Sendico credentials and organization scope. Local customer
|
||||||
|
models have no organization, membership or platform permissions. HTTP DTOs and
|
||||||
|
mappers stay in `providers/sendico`; the handoff operates on domain facts and
|
||||||
|
receipts in `identity`. Flutter continues through its DTO, mapper, service,
|
||||||
|
model and provider layers. It never calls Sendico or supplies subject references.
|
||||||
|
|
||||||
|
## Configuration and rollout
|
||||||
|
|
||||||
|
Add this block under `api` in the product service configuration, retaining
|
||||||
|
`api.notifications`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sendico:
|
||||||
|
api_url: https://your-sendico-host/api/v1
|
||||||
|
organization_ref: <provisioned-SXCHNGE-organization-object-id>
|
||||||
|
source_account_ref: <provisioned-integration-account-object-id>
|
||||||
|
token_file: /run/sxchnge/sendico-token
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolve the real IDs from the target Sendico deployment. The integration account
|
||||||
|
must be a member of the SXCHNGE organization with the existing organizations
|
||||||
|
`update` permission; reconciliation GET requests also require `read`. The token
|
||||||
|
must be a full, unexpired Sendico access JWT whose `id` claim is that account.
|
||||||
|
The adapter inspects claims only to protect the replay namespace; Sendico checks
|
||||||
|
the signature and authorizes every call. No login, refresh or privileged account
|
||||||
|
provisioning is performed by this adapter.
|
||||||
|
|
||||||
|
Keep the token in Vault at `services/bff/sendico`, field `access_token`. Render it
|
||||||
|
with the deployment's Vault Agent into the configured private runtime file; give
|
||||||
|
only the BFF policy read access to that KV path. Endpoint, organization and source
|
||||||
|
IDs remain in service YAML. For the local stack, configure
|
||||||
|
`infra/local/bff/runtime.yml`, render to the BFF's existing `/run/sxchnge` tmpfs,
|
||||||
|
and extend its Vault Agent policy/template for this provisioned secret. These
|
||||||
|
deployment values and secret provisioning are deliberately not guessed or
|
||||||
|
enabled by the example. Do not place tokens in Git, browser builds or shell
|
||||||
|
history. Refresh the file atomically before expiry using the same Sendico account;
|
||||||
|
the adapter rereads it on every request. Startup rejects missing, expired or
|
||||||
|
mismatched credentials and rejects Sendico configuration without notifications.
|
||||||
|
|
||||||
|
HTTPS is required except for loopback HTTP test/development endpoints. Redirects
|
||||||
|
are rejected and each HTTP call has a four-second timeout. Non-200 responses,
|
||||||
|
malformed/truncated receipts, invalid identifiers, wrong subjects and unsuccessful
|
||||||
|
envelopes never count as acceptance. Upstream error bodies are not surfaced.
|
||||||
|
|
||||||
|
Omitting `api.sendico` keeps existing local-only behavior. Enabling it also gates
|
||||||
|
activation for previously pending customers; use activation resend to submit
|
||||||
|
their saved name/email before confirming their old link. There is no automatic
|
||||||
|
bulk migration. Changing endpoint, organization or source account changes the
|
||||||
|
adapter fingerprint and blocks existing bindings for explicit reconciliation.
|
||||||
|
Token rotation for the same account retains that fingerprint. Plan such changes
|
||||||
|
as migrations; never reset a binding just to bypass the check.
|
||||||
|
|
||||||
|
## Persistence, retries and recovery
|
||||||
|
|
||||||
|
The local `signup_facts` collection uses the customer ID as `_id`, so its unique
|
||||||
|
primary key coordinates concurrent requests and separate BFF instances. It stores
|
||||||
|
an immutable sign-up facts snapshot, adapter fingerprint, random registration
|
||||||
|
request correlation, registration claim, subject and confirmed receipt. It uses
|
||||||
|
the shared database repository and connection lifecycle with majority writes and
|
||||||
|
primary majority reads, overriding weaker connection defaults. No external call
|
||||||
|
runs in a database transaction. Snapshots and bindings contain personal data and
|
||||||
|
must follow the customer data retention policy; no TTL silently removes replay
|
||||||
|
state, and customer deletion does not delete the immutable Sendico submission.
|
||||||
|
|
||||||
|
Once a subject is stored, intake retries use
|
||||||
|
`sxchnge:signup:<local-customer-id>` as `source_submission_ref`. Sendico's
|
||||||
|
organization/authenticated-account/source-reference uniqueness returns the
|
||||||
|
original receipt for that subject. Retries send the saved snapshot even if a
|
||||||
|
later request changes name or email. Losing the HTTP receipt or a local receipt
|
||||||
|
write is recoverable by replay. A saved receipt skips subsequent external calls.
|
||||||
|
Retrying sign-up still requires the original password; active, archived or
|
||||||
|
otherwise conflicting accounts cannot be resumed as a new customer.
|
||||||
|
|
||||||
|
**Individual registration is not idempotent.** The BFF commits a claim before
|
||||||
|
dispatch and never expires or automatically retries that registration. It cannot
|
||||||
|
prove that a timed-out request, malformed response or failed local binding write
|
||||||
|
did not create a subject. Another request sees an in-progress claim as temporarily
|
||||||
|
unavailable for 30 seconds; an older unresolved claim requires support. The
|
||||||
|
registration caller immediately reports assistance on an uncertain failure. An
|
||||||
|
accepted registration is saved with a short independent context even if the
|
||||||
|
browser disconnects. A crash before dispatch can also require reconciliation.
|
||||||
|
This trades automatic recovery for avoiding duplicate subjects under the current
|
||||||
|
upstream contract; there is no distributed exactly-once guarantee.
|
||||||
|
|
||||||
|
For an unresolved registration, operators must stop requests for the affected
|
||||||
|
customer and inspect the saved `registrationRequest` plus Sendico identity logs
|
||||||
|
(`X-Request-ID`, operation `subject_register`) under the configured account and
|
||||||
|
organization. A successful registration's server-issued `record_ref` identifies
|
||||||
|
the candidate subject. Confirm it using the organization-scoped individual GET
|
||||||
|
API before atomically setting the matching local binding's `subjectRef` and
|
||||||
|
clearing `registering`; retain its snapshot, fingerprint and request reference.
|
||||||
|
If a local binding write failed after receipt, BFF diagnostics also contain the
|
||||||
|
subject reference. If authoritative records prove no subject was created, an
|
||||||
|
operator may clear the claim after fixing the dependency. If neither result can
|
||||||
|
be established, leave it blocked; never register a speculative replacement.
|
||||||
|
Resume sign-up with the original credentials or activation resend after this
|
||||||
|
repair. These are controlled operational repairs, not customer API endpoints.
|
||||||
|
|
||||||
|
HTTP 503 `signup_attention_required` maps to typed shared auth and verification
|
||||||
|
state and localized EN/RU support guidance, including activation resend recovery.
|
||||||
|
Other intake/receipt failures remain temporary service
|
||||||
|
failures. Form input is retained, duplicate submissions stay disabled during
|
||||||
|
loading, and neither failure navigates to an email-sent screen or creates a
|
||||||
|
session. The existing support channel must be available to handle reconciliation.
|
||||||
|
|
||||||
|
## Measurement and UX review
|
||||||
|
|
||||||
|
The existing [auth journey](ux/auth-journeys.md) keeps its attempt, retry,
|
||||||
|
abandonment and 30-minute settled inactivity rules. `auth_signup_succeeded` now
|
||||||
|
requires server confirmation of configured intake plus activation queue acceptance;
|
||||||
|
it remains an intermediate step and email confirmation remains the terminal
|
||||||
|
onboarding outcome. Sign-up dependency failures and assistance outcomes use the
|
||||||
|
existing allowlisted `unavailable` analytics category; verification assistance
|
||||||
|
uses it too. Retries retain the journey
|
||||||
|
and operation semantics. No names, emails or provider response bodies are added.
|
||||||
|
|
||||||
|
`sxchnge_signup_facts_total{outcome}` records `accepted`, `replayed`,
|
||||||
|
`unavailable` and `attention_required` handoff attempts;
|
||||||
|
`sxchnge_signup_facts_duration_seconds` measures their latency. Counts are
|
||||||
|
attempts, not distinct users. Use unavailable/attention counts divided by total
|
||||||
|
handoff attempts for operational failure rates; use the existing correlated auth
|
||||||
|
journey for conversion, duration and abandonment. Structured handoff logs include
|
||||||
|
local customer and random registration request references for support correlation,
|
||||||
|
without asserted values, credentials or raw errors. IDs are never metric labels.
|
||||||
|
The product team uses the distinction to separate retryable intake outages from
|
||||||
|
registration reconciliation. The default client analytics sink remains in memory;
|
||||||
|
production retention and cross-device conversion correlation remain deployment
|
||||||
|
gaps, as documented by the existing journey contract.
|
||||||
|
|
||||||
|
Review covers loading suppression, input preservation, success only on confirmed
|
||||||
|
handoff, temporary retry and assistance states, existing navigation, mobile layout,
|
||||||
|
text scaling and live-region error feedback. Focused tests cover these states,
|
||||||
|
the mapped backend error, non-inflated client success, immutable retry payloads,
|
||||||
|
credential rotation, timeout/cancellation/redirect behavior, receipt validation,
|
||||||
|
privacy-safe errors, concurrent durable claims and activation rollback. The tests
|
||||||
|
use disposable local MongoDB and a simulated Sendico HTTP server; they do not
|
||||||
|
create live subjects or send email. Live integration and support operations must
|
||||||
|
be verified against provisioned deployment credentials before enabling the block.
|
||||||
|
The EN/RU error state is exercised at a 390px viewport with 1.5x text scaling;
|
||||||
|
existing keyboard regression tests and a code review of the live-region semantics
|
||||||
|
cover the unchanged controls. No manual browser or screen-reader walkthrough
|
||||||
|
against a live Sendico deployment was performed.
|
||||||
|
|
||||||
|
The initial sign-up assertion remains a separate immutable submission. Optional
|
||||||
|
profile/ID/selfie collection, later account uploads and reviewed limits are now
|
||||||
|
covered by [Customer identity verification](identity-verification.md), task #48.
|
||||||
|
Proofs are not required to activate or log into a basic account.
|
||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
Task [#20](https://git.sendico.io/tech/sxchnge/issues/20). Plan recorded before implementation.
|
Task [#20](https://git.sendico.io/tech/sxchnge/issues/20). Plan recorded before implementation.
|
||||||
|
|
||||||
|
> This original demo plan is retained as design history. Production identity
|
||||||
|
> uploads, staff review and authoritative limits are implemented by task #48;
|
||||||
|
> see [Customer identity verification](../identity-verification.md). Demo behavior
|
||||||
|
> below applies only when the backend adapters are not selected.
|
||||||
|
|
||||||
## User outcome and scope
|
## User outcome and scope
|
||||||
|
|
||||||
Understand current access, decide whether higher limits are useful, and submit two
|
Understand current access, decide whether higher limits are useful, and submit two
|
||||||
|
|||||||
@@ -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/`.
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class AuthFeedback extends StatelessWidget {
|
|||||||
AuthError.accountNotVerified => l.authVerifyEmail,
|
AuthError.accountNotVerified => l.authVerifyEmail,
|
||||||
AuthError.invalidCredentials => l.authInvalidCredentials,
|
AuthError.invalidCredentials => l.authInvalidCredentials,
|
||||||
AuthError.emailInUse => l.authEmailInUse,
|
AuthError.emailInUse => l.authEmailInUse,
|
||||||
|
AuthError.signupNeedsAssistance => l.authSignupNeedsAssistance,
|
||||||
AuthError.unavailable => l.authUnavailable,
|
AuthError.unavailable => l.authUnavailable,
|
||||||
AuthError.unexpected => l.authUnexpected,
|
AuthError.unexpected => l.authUnexpected,
|
||||||
AuthError.factorLocked => l.factorLocked,
|
AuthError.factorLocked => l.factorLocked,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
|||||||
|
|
||||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
import '../routing/routes.dart';
|
import '../routing/routes.dart';
|
||||||
import '../security/challenge.dart';
|
import '../security/challenge.dart';
|
||||||
import 'feedback.dart';
|
import 'feedback.dart';
|
||||||
@@ -127,7 +129,16 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
}
|
}
|
||||||
await login.complete(challenge, grant);
|
await login.complete(challenge, grant);
|
||||||
if (mounted && login.state.status == AuthStatus.success) {
|
if (mounted && login.state.status == AuthStatus.success) {
|
||||||
context.go('/accounts');
|
final accepted = login.state.result!;
|
||||||
|
final offer = await context.read<IdentityOnboardingProvider>().offer(
|
||||||
|
accepted.userId,
|
||||||
|
);
|
||||||
|
if (mounted &&
|
||||||
|
!_signUp &&
|
||||||
|
identical(login.state.result, accepted) &&
|
||||||
|
login.state.status == AuthStatus.success) {
|
||||||
|
context.go(offer ? '/identity?onboarding=1' : '/accounts');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,7 +310,9 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
key: const ValueKey('submit'),
|
key: const ValueKey('submit'),
|
||||||
onPressed: state.isLoading ? null : _submit,
|
onPressed: state.isLoading || state.status == AuthStatus.success
|
||||||
|
? null
|
||||||
|
: _submit,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ String reasonText(AppLocalizations l, ReviewReason r) => switch (r) {
|
|||||||
ReviewReason.passportExpired => l.kycExpiredReason,
|
ReviewReason.passportExpired => l.kycExpiredReason,
|
||||||
ReviewReason.unableToVerify => l.kycDeniedReason,
|
ReviewReason.unableToVerify => l.kycDeniedReason,
|
||||||
};
|
};
|
||||||
String errorText(AppLocalizations l, KycError e) => switch (e) {
|
String errorText(AppLocalizations l, KycError e, {bool photo = false}) =>
|
||||||
|
switch (e) {
|
||||||
KycError.unavailable => l.kycErrorUnavailable,
|
KycError.unavailable => l.kycErrorUnavailable,
|
||||||
KycError.internal => l.kycErrorInternal,
|
KycError.internal => l.kycErrorInternal,
|
||||||
KycError.permission => l.kycErrorPermission,
|
KycError.permission => photo ? l.kycErrorPermission : l.kycErrorAccess,
|
||||||
KycError.format => l.kycErrorFormat,
|
KycError.format => l.kycErrorFormat,
|
||||||
KycError.tooLarge => l.kycErrorLarge,
|
KycError.tooLarge => l.kycErrorLarge,
|
||||||
KycError.unreadable => l.kycErrorUnreadable,
|
KycError.unreadable => l.kycErrorUnreadable,
|
||||||
@@ -34,4 +35,4 @@ String errorText(AppLocalizations l, KycError e) => switch (e) {
|
|||||||
KycError.sessionExpired => l.kycErrorSession,
|
KycError.sessionExpired => l.kycErrorSession,
|
||||||
KycError.rateLimited => l.kycErrorRate,
|
KycError.rateLimited => l.kycErrorRate,
|
||||||
KycError.conflict => l.kycErrorConflict,
|
KycError.conflict => l.kycErrorConflict,
|
||||||
};
|
};
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
import 'photo.dart';
|
import 'photo.dart';
|
||||||
|
import 'profile.dart';
|
||||||
|
|
||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -26,6 +27,10 @@ class IdentityForm extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
if (p.step == 0) ...[
|
||||||
|
ProfileForm(provider: p),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
value: (p.step + 1) / 3,
|
value: (p.step + 1) / 3,
|
||||||
semanticsLabel: titles[p.step],
|
semanticsLabel: titles[p.step],
|
||||||
@@ -47,6 +52,10 @@ class IdentityForm extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (p.step == 2) ...[
|
if (p.step == 2) ...[
|
||||||
|
Text(
|
||||||
|
'${p.profile.fullName}\n${p.profile.dateOfBirth} · ${p.profile.residenceCountry}',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
Text(l.kycReviewHelp),
|
Text(l.kycReviewHelp),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
PhotoPanel(kind: PhotoKind.passport, photo: p.passport, review: true),
|
PhotoPanel(kind: PhotoKind.passport, photo: p.passport, review: true),
|
||||||
@@ -68,7 +77,10 @@ class IdentityForm extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
key: const ValueKey('kyc-next'),
|
key: const ValueKey('kyc-next'),
|
||||||
onPressed: p.busy || (p.step == 0 ? p.passport : p.selfie) == null
|
onPressed:
|
||||||
|
p.busy ||
|
||||||
|
(p.step == 0 && !p.profile.isComplete) ||
|
||||||
|
(p.step == 0 ? p.passport : p.selfie) == null
|
||||||
? null
|
? null
|
||||||
: p.next,
|
: p.next,
|
||||||
child: Text(l.kycNext),
|
child: Text(l.kycNext),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
import 'policy.dart';
|
||||||
|
|
||||||
class LimitsPanel extends StatelessWidget {
|
class LimitsPanel extends StatelessWidget {
|
||||||
const LimitsPanel({super.key});
|
const LimitsPanel({super.key});
|
||||||
@@ -48,7 +49,9 @@ class LimitsPanel extends StatelessWidget {
|
|||||||
style: t.textTheme.titleLarge,
|
style: t.textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (data.rows.isEmpty) Text(l.kycLimitsEmpty),
|
if (data.rows.isEmpty && data.entries.isEmpty)
|
||||||
|
Text(l.kycLimitsEmpty),
|
||||||
|
for (final row in data.entries) PolicyRow(row: row),
|
||||||
for (final row in data.rows) ...[
|
for (final row in data.rows) ...[
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import 'status.dart';
|
|||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
class IdentityPage extends StatefulWidget {
|
class IdentityPage extends StatefulWidget {
|
||||||
const IdentityPage({super.key});
|
const IdentityPage({super.key, this.onboarding = false});
|
||||||
|
final bool onboarding;
|
||||||
@override
|
@override
|
||||||
State<IdentityPage> createState() => IdentityPageState();
|
State<IdentityPage> createState() => IdentityPageState();
|
||||||
}
|
}
|
||||||
@@ -98,6 +99,10 @@ class IdentityPageState extends State<IdentityPage> {
|
|||||||
if (!await allowExit() || !mounted) return;
|
if (!await allowExit() || !mounted) return;
|
||||||
_identity.exit();
|
_identity.exit();
|
||||||
if (account) {
|
if (account) {
|
||||||
|
if (widget.onboarding) {
|
||||||
|
context.go('/accounts');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (context.canPop()) {
|
if (context.canPop()) {
|
||||||
context.pop();
|
context.pop();
|
||||||
} else {
|
} else {
|
||||||
@@ -166,7 +171,11 @@ class IdentityPageState extends State<IdentityPage> {
|
|||||||
key: _feedbackKey,
|
key: _feedbackKey,
|
||||||
liveRegion: true,
|
liveRegion: true,
|
||||||
child: Text(
|
child: Text(
|
||||||
errorText(l, p.error!),
|
errorText(
|
||||||
|
l,
|
||||||
|
p.error!,
|
||||||
|
photo: p.photoError != null,
|
||||||
|
),
|
||||||
key: const ValueKey('kyc-error'),
|
key: const ValueKey('kyc-error'),
|
||||||
style: TextStyle(color: t.colorScheme.error),
|
style: TextStyle(color: t.colorScheme.error),
|
||||||
),
|
),
|
||||||
@@ -178,6 +187,16 @@ class IdentityPageState extends State<IdentityPage> {
|
|||||||
l.kycStatusStale,
|
l.kycStatusStale,
|
||||||
style: TextStyle(color: t.colorScheme.error),
|
style: TextStyle(color: t.colorScheme.error),
|
||||||
),
|
),
|
||||||
|
if (p.error == KycError.sessionExpired)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (await allowExit() && mounted) {
|
||||||
|
_identity.exit();
|
||||||
|
if (context.mounted) context.go('/');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(l.login),
|
||||||
|
),
|
||||||
Focus(
|
Focus(
|
||||||
includeSemantics: false,
|
includeSemantics: false,
|
||||||
key: _contentKey,
|
key: _contentKey,
|
||||||
@@ -198,7 +217,7 @@ class IdentityPageState extends State<IdentityPage> {
|
|||||||
const LimitsPanel(),
|
const LimitsPanel(),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (p.editing)
|
if (p.editing && !widget.onboarding)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: p.busy ? null : _leave,
|
onPressed: p.busy ? null : _leave,
|
||||||
child: Text(l.kycSkip),
|
child: Text(l.kycSkip),
|
||||||
@@ -207,7 +226,15 @@ class IdentityPageState extends State<IdentityPage> {
|
|||||||
onPressed: p.busy
|
onPressed: p.busy
|
||||||
? null
|
? null
|
||||||
: () => _leave(account: true),
|
: () => _leave(account: true),
|
||||||
child: Text(l.wLimits),
|
child: Text(
|
||||||
|
widget.onboarding
|
||||||
|
? (p.record?.status ==
|
||||||
|
IdentityStatus.unverified ||
|
||||||
|
p.editing
|
||||||
|
? l.kycSkip
|
||||||
|
: l.kycContinue)
|
||||||
|
: l.wLimits,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Display exact provider decimals; never infer currency scale or active caps.
|
||||||
|
class PolicyRow extends StatelessWidget {
|
||||||
|
const PolicyRow({super.key, required this.row});
|
||||||
|
final PolicyLimit row;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Divider(height: 32),
|
||||||
|
Text(
|
||||||
|
'${row.operation} · ${switch (row.period) {
|
||||||
|
'day' => l.kycDay,
|
||||||
|
'month' => l.kycMonth,
|
||||||
|
'operationMin' => l.kycOperationMinimum,
|
||||||
|
'operationMax' => l.kycOperationMaximum,
|
||||||
|
_ => row.period,
|
||||||
|
}}',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 28,
|
||||||
|
runSpacing: 16,
|
||||||
|
children: [
|
||||||
|
_amount(context, l.kycCurrent, row.cap),
|
||||||
|
if (row.remaining.isNotEmpty)
|
||||||
|
_amount(context, l.kycRemaining, row.remaining),
|
||||||
|
if (row.used.isNotEmpty) _amount(context, l.wUsage, row.used),
|
||||||
|
if (row.reserved.isNotEmpty)
|
||||||
|
_amount(context, l.kycReserved, row.reserved),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _amount(BuildContext context, String label, String amount) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label),
|
||||||
|
Text(
|
||||||
|
'$amount ${row.currency}',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class ProfileForm extends StatelessWidget {
|
||||||
|
const ProfileForm({super.key, required this.provider});
|
||||||
|
final IdentityProvider provider;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = provider;
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
void update({String? name, String? birth, String? country}) =>
|
||||||
|
p.updateProfile(
|
||||||
|
IdentityProfile(
|
||||||
|
fullName: name ?? p.profile.fullName,
|
||||||
|
dateOfBirth: birth ?? p.profile.dateOfBirth,
|
||||||
|
residenceCountry: country ?? p.profile.residenceCountry,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(l.kycProfileHelp),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
key: const ValueKey('kyc-name'),
|
||||||
|
initialValue: p.profile.fullName,
|
||||||
|
enabled: !p.busy,
|
||||||
|
maxLength: 200,
|
||||||
|
autofillHints: const [AutofillHints.name],
|
||||||
|
decoration: InputDecoration(labelText: l.kycFullName),
|
||||||
|
onChanged: (value) => update(name: value),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
key: const ValueKey('kyc-birth'),
|
||||||
|
initialValue: p.profile.dateOfBirth,
|
||||||
|
enabled: !p.busy,
|
||||||
|
keyboardType: TextInputType.datetime,
|
||||||
|
autofillHints: const [AutofillHints.birthday],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.kycBirth,
|
||||||
|
helperText: l.kycBirthHelp,
|
||||||
|
),
|
||||||
|
onChanged: (value) => update(birth: value.trim()),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
key: const ValueKey('kyc-country'),
|
||||||
|
initialValue: p.profile.residenceCountry,
|
||||||
|
enabled: !p.busy,
|
||||||
|
maxLength: 2,
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.kycCountry,
|
||||||
|
helperText: l.kycCountryHelp,
|
||||||
|
),
|
||||||
|
onChanged: (value) => update(country: value.trim().toUpperCase()),
|
||||||
|
),
|
||||||
|
if (!p.profile.isComplete) Text(l.kycProfileRequired),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,9 @@ class StatusPanel extends StatelessWidget {
|
|||||||
header: true,
|
header: true,
|
||||||
liveRegion: true,
|
liveRegion: true,
|
||||||
child: Text(
|
child: Text(
|
||||||
statusTitle(l, status),
|
record.upgradePending
|
||||||
|
? l.kycUpgradePendingTitle
|
||||||
|
: statusTitle(l, status),
|
||||||
key: ValueKey('kyc-${status.name}'),
|
key: ValueKey('kyc-${status.name}'),
|
||||||
style: t.textTheme.headlineMedium?.copyWith(
|
style: t.textTheme.headlineMedium?.copyWith(
|
||||||
color: t.colorScheme.onPrimaryContainer,
|
color: t.colorScheme.onPrimaryContainer,
|
||||||
@@ -53,7 +55,9 @@ class StatusPanel extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
statusBody(l, status),
|
record.upgradePending
|
||||||
|
? l.kycUpgradePendingBody
|
||||||
|
: statusBody(l, status),
|
||||||
style: TextStyle(color: t.colorScheme.onPrimaryContainer),
|
style: TextStyle(color: t.colorScheme.onPrimaryContainer),
|
||||||
),
|
),
|
||||||
if (record.reason != null) ...[
|
if (record.reason != null) ...[
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"@@locale": "en",
|
"@@locale": "en",
|
||||||
|
"authSignupNeedsAssistance": "We could not finish setting up your account. Please contact support.",
|
||||||
"appTitle": "SXCHNGE · Your next move",
|
"appTitle": "SXCHNGE · Your next move",
|
||||||
"nextChapter": "YOUR NEXT CHAPTER",
|
"nextChapter": "YOUR NEXT CHAPTER",
|
||||||
"loginHeading": "Hey, welcome\nback.",
|
"loginHeading": "Hey, welcome\nback.",
|
||||||
@@ -72,19 +73,19 @@
|
|||||||
"kycUnverified": "Not verified",
|
"kycUnverified": "Not verified",
|
||||||
"kycPending": "In review",
|
"kycPending": "In review",
|
||||||
"kycApproved": "Identity verified",
|
"kycApproved": "Identity verified",
|
||||||
"kycChanges": "New photos needed",
|
"kycChanges": "Changes requested",
|
||||||
"kycDenied": "Verification declined",
|
"kycDenied": "Verification declined",
|
||||||
"kycUnverifiedBody": "You can keep using your account with basic limits. Submit a passport photo and a selfie holding it to request an upgrade.",
|
"kycUnverifiedBody": "You can keep using your account with basic limits. Submit an ID document photo and a selfie holding it to request an upgrade.",
|
||||||
"kycPendingBody": "Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.",
|
"kycPendingBody": "Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.",
|
||||||
"kycApprovedBody": "Your review is complete. Check your current limits below.",
|
"kycApprovedBody": "Your review is complete. Check your current limits below.",
|
||||||
"kycChangesBody": "Your account remains available with basic limits. Replace the photos and submit a new review request.",
|
"kycChangesBody": "Basic limits remain available. Check your details, replace the photos and submit a new review request.",
|
||||||
"kycDeniedBody": "Basic limits remain available. If you believe this decision is incorrect, contact support for a review.",
|
"kycDeniedBody": "Basic limits remain available. If you believe this decision is incorrect, contact support for a review.",
|
||||||
"kycPassportReason": "The passport details could not be read. Retake the photo in good light, with every edge visible.",
|
"kycPassportReason": "The ID document details could not be read. Retake the photo in good light, with every edge visible.",
|
||||||
"kycSelfieReason": "Your face or passport was not clear in the selfie. Keep both visible and in focus.",
|
"kycSelfieReason": "Your face or ID document was not clear in the selfie. Keep both visible and in focus.",
|
||||||
"kycExpiredReason": "The passport has expired. Use a valid passport and take both photos again.",
|
"kycExpiredReason": "The ID document has expired. Use a valid ID document and take both photos again.",
|
||||||
"kycDeniedReason": "We could not verify your identity from this submission.",
|
"kycDeniedReason": "We could not verify your identity from this submission.",
|
||||||
"kycStart": "Verify identity",
|
"kycStart": "Verify identity",
|
||||||
"kycResubmit": "Replace photos",
|
"kycResubmit": "Update details and photos",
|
||||||
"kycRefresh": "Refresh status & limits",
|
"kycRefresh": "Refresh status & limits",
|
||||||
"kycSupport": "Contact support",
|
"kycSupport": "Contact support",
|
||||||
"kycSupportBody": "Support is currently unavailable. Please try again later.",
|
"kycSupportBody": "Support is currently unavailable. Please try again later.",
|
||||||
@@ -99,11 +100,11 @@
|
|||||||
"kycLimitsError": "Limits could not be refreshed. Any values below are out of date. Retry to see your current access.",
|
"kycLimitsError": "Limits could not be refreshed. Any values below are out of date. Retry to see your current access.",
|
||||||
"kycLimitsEmpty": "No limits were returned. Refresh or check with support before making a transaction.",
|
"kycLimitsEmpty": "No limits were returned. Refresh or check with support before making a transaction.",
|
||||||
"kycLoading": "Loading your status…",
|
"kycLoading": "Loading your status…",
|
||||||
"kycPassport": "Passport photo",
|
"kycPassport": "ID document photo",
|
||||||
"kycSelfie": "Selfie with passport",
|
"kycSelfie": "Selfie with ID document",
|
||||||
"kycReview": "Review your photos",
|
"kycReview": "Review details and photos",
|
||||||
"kycPassportHelp": "Open your valid passport to the photo page. Show all four edges and keep text sharp. Avoid glare, blur and covered details.",
|
"kycPassportHelp": "Photograph the photo page or front of your valid ID document. Show all four edges and keep every detail readable. Avoid glare, blur and covered details.",
|
||||||
"kycSelfieHelp": "Hold the open passport beside your face. Show your whole face and the photo page in the same image. Keep both sharp and uncovered.",
|
"kycSelfieHelp": "Hold the same ID document beside your face. Show your whole face and the document details in the same image, sharp and uncovered.",
|
||||||
"kycFileRules": "JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.",
|
"kycFileRules": "JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.",
|
||||||
"kycChoose": "Choose photo",
|
"kycChoose": "Choose photo",
|
||||||
"kycReplace": "Choose a different photo",
|
"kycReplace": "Choose a different photo",
|
||||||
@@ -112,12 +113,12 @@
|
|||||||
"kycBack": "Back",
|
"kycBack": "Back",
|
||||||
"kycSkip": "Keep basic limits",
|
"kycSkip": "Keep basic limits",
|
||||||
"kycClose": "Back to account",
|
"kycClose": "Back to account",
|
||||||
"kycReviewHelp": "Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.",
|
"kycReviewHelp": "Check your details and make sure both photos are readable and show the same ID document. When you submit, they are stored privately for staff review. Submission does not immediately raise your limits.",
|
||||||
"kycConfirm": "These photos show me and my own valid passport.",
|
"kycConfirm": "These photos show me and my own valid ID document.",
|
||||||
"kycSubmit": "Submit for review",
|
"kycSubmit": "Submit for review",
|
||||||
"kycWorking": "Processing… Please keep this page open.",
|
"kycWorking": "Processing… Please keep this page open.",
|
||||||
"kycDiscardTitle": "Discard these photos?",
|
"kycDiscardTitle": "Discard this verification draft?",
|
||||||
"kycDiscardBody": "Your draft is only in memory. Leaving will remove the selected photos; you can start again later.",
|
"kycDiscardBody": "Leaving removes unsent profile changes and selected photos from this session. You can start again later.",
|
||||||
"kycStay": "Keep editing",
|
"kycStay": "Keep editing",
|
||||||
"kycDiscard": "Discard & leave",
|
"kycDiscard": "Discard & leave",
|
||||||
"kycErrorUnavailable": "The service is unavailable. Your selected photos are still here. Try again.",
|
"kycErrorUnavailable": "The service is unavailable. Your selected photos are still here. Try again.",
|
||||||
@@ -263,5 +264,30 @@
|
|||||||
},
|
},
|
||||||
"factorEmailMethod": "Email code",
|
"factorEmailMethod": "Email code",
|
||||||
"factorAppMethod": "Authenticator app",
|
"factorAppMethod": "Authenticator app",
|
||||||
"factorAppExpired": "This verification has expired. Return and start again."
|
"factorAppExpired": "This verification has expired. Return and start again.",
|
||||||
|
"kycProfileHelp": "Enter your details exactly as shown on your identity document.",
|
||||||
|
"kycFullName": "Full legal name",
|
||||||
|
"kycBirth": "Date of birth",
|
||||||
|
"kycBirthHelp": "YYYY-MM-DD, for example 1990-04-23",
|
||||||
|
"kycCountry": "Country of residence",
|
||||||
|
"kycCountryHelp": "Two-letter country code, for example PL or DE",
|
||||||
|
"kycProfileRequired": "Enter your full name, a valid date of birth and a two-letter country code to continue.",
|
||||||
|
"kycContinue": "Continue to my account",
|
||||||
|
"kycOperationMinimum": "Per operation minimum",
|
||||||
|
"kycOperationMaximum": "Per operation maximum",
|
||||||
|
"kycErrorAccess": "Verification is unavailable for this account. Contact support if this continues.",
|
||||||
|
"kycUpgradePendingTitle": "Limits upgrade pending",
|
||||||
|
"kycUpgradePendingBody": "Your identity review is approved. We are waiting for confirmation of the limits upgrade. You do not need to submit your photos again.",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"@@locale": "ru",
|
"@@locale": "ru",
|
||||||
|
"authSignupNeedsAssistance": "Не удалось завершить создание аккаунта. Обратитесь в службу поддержки.",
|
||||||
"appTitle": "SXCHNGE · Ваш следующий шаг",
|
"appTitle": "SXCHNGE · Ваш следующий шаг",
|
||||||
"nextChapter": "НОВАЯ ГЛАВА",
|
"nextChapter": "НОВАЯ ГЛАВА",
|
||||||
"loginHeading": "Рады видеть\nвас снова.",
|
"loginHeading": "Рады видеть\nвас снова.",
|
||||||
@@ -72,19 +73,19 @@
|
|||||||
"kycUnverified": "Личность не подтверждена",
|
"kycUnverified": "Личность не подтверждена",
|
||||||
"kycPending": "На проверке",
|
"kycPending": "На проверке",
|
||||||
"kycApproved": "Личность подтверждена",
|
"kycApproved": "Личность подтверждена",
|
||||||
"kycChanges": "Нужны новые фото",
|
"kycChanges": "Нужны исправления",
|
||||||
"kycDenied": "Верификация отклонена",
|
"kycDenied": "Верификация отклонена",
|
||||||
"kycUnverifiedBody": "Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото паспорта и селфи с ним.",
|
"kycUnverifiedBody": "Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото документа и селфи с ним.",
|
||||||
"kycPendingBody": "Заявка ожидает проверки сотрудником. До одобрения действуют базовые лимиты. Повторная отправка не нужна.",
|
"kycPendingBody": "Заявка ожидает проверки сотрудником. До одобрения действуют базовые лимиты. Повторная отправка не нужна.",
|
||||||
"kycApprovedBody": "Проверка завершена. Актуальные лимиты указаны ниже.",
|
"kycApprovedBody": "Проверка завершена. Актуальные лимиты указаны ниже.",
|
||||||
"kycChangesBody": "Аккаунт доступен с базовыми лимитами. Замените фото и отправьте новую заявку.",
|
"kycChangesBody": "Базовые лимиты доступны. Проверьте данные, замените фото и отправьте новый запрос на проверку.",
|
||||||
"kycDeniedBody": "Базовые лимиты остаются доступными. Если вы не согласны с решением, обратитесь в поддержку для пересмотра.",
|
"kycDeniedBody": "Базовые лимиты остаются доступными. Если вы не согласны с решением, обратитесь в поддержку для пересмотра.",
|
||||||
"kycPassportReason": "Данные паспорта не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.",
|
"kycPassportReason": "Данные документа не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.",
|
||||||
"kycSelfieReason": "На селфи плохо видно лицо или паспорт. Оба должны быть в кадре и в фокусе.",
|
"kycSelfieReason": "На селфи плохо видно лицо или документ. Оба должны быть в кадре и в фокусе.",
|
||||||
"kycExpiredReason": "Срок действия паспорта истёк. Используйте действующий паспорт и сделайте оба фото заново.",
|
"kycExpiredReason": "Срок действия документа истёк. Используйте действующий документ и сделайте оба фото заново.",
|
||||||
"kycDeniedReason": "По этой заявке не удалось подтвердить вашу личность.",
|
"kycDeniedReason": "По этой заявке не удалось подтвердить вашу личность.",
|
||||||
"kycStart": "Подтвердить личность",
|
"kycStart": "Подтвердить личность",
|
||||||
"kycResubmit": "Заменить фото",
|
"kycResubmit": "Исправить данные и фото",
|
||||||
"kycRefresh": "Обновить статус и лимиты",
|
"kycRefresh": "Обновить статус и лимиты",
|
||||||
"kycSupport": "Обратиться в поддержку",
|
"kycSupport": "Обратиться в поддержку",
|
||||||
"kycSupportBody": "Поддержка сейчас недоступна. Попробуйте позже.",
|
"kycSupportBody": "Поддержка сейчас недоступна. Попробуйте позже.",
|
||||||
@@ -99,11 +100,11 @@
|
|||||||
"kycLimitsError": "Не удалось обновить лимиты. Значения ниже могут быть устаревшими. Повторите запрос.",
|
"kycLimitsError": "Не удалось обновить лимиты. Значения ниже могут быть устаревшими. Повторите запрос.",
|
||||||
"kycLimitsEmpty": "Сервис не вернул лимиты. Обновите данные или уточните их в поддержке перед операцией.",
|
"kycLimitsEmpty": "Сервис не вернул лимиты. Обновите данные или уточните их в поддержке перед операцией.",
|
||||||
"kycLoading": "Загружаем ваш статус…",
|
"kycLoading": "Загружаем ваш статус…",
|
||||||
"kycPassport": "Фото паспорта",
|
"kycPassport": "Фото документа",
|
||||||
"kycSelfie": "Селфи с паспортом",
|
"kycSelfie": "Селфи с документом",
|
||||||
"kycReview": "Проверьте фото",
|
"kycReview": "Проверьте данные и фото",
|
||||||
"kycPassportHelp": "Откройте действующий паспорт на странице с фото. Все четыре края и текст должны быть чётко видны. Избегайте бликов и размытия, не закрывайте данные.",
|
"kycPassportHelp": "Снимите страницу с фото или лицевую сторону действующего документа. Все четыре края и данные должны быть чётко видны. Избегайте бликов и размытия.",
|
||||||
"kycSelfieHelp": "Держите открытый паспорт рядом с лицом. Лицо целиком и страница с фото должны быть в одном кадре, чёткими и ничем не закрытыми.",
|
"kycSelfieHelp": "Держите тот же документ рядом с лицом. Лицо целиком и данные документа должны быть в одном кадре, чёткими и ничем не закрытыми.",
|
||||||
"kycFileRules": "JPEG или PNG · до 10 МБ · не менее 600 × 600 пикселей. Сделайте фото камерой, затем выберите его здесь.",
|
"kycFileRules": "JPEG или PNG · до 10 МБ · не менее 600 × 600 пикселей. Сделайте фото камерой, затем выберите его здесь.",
|
||||||
"kycChoose": "Выбрать фото",
|
"kycChoose": "Выбрать фото",
|
||||||
"kycReplace": "Выбрать другое фото",
|
"kycReplace": "Выбрать другое фото",
|
||||||
@@ -112,12 +113,12 @@
|
|||||||
"kycBack": "Назад",
|
"kycBack": "Назад",
|
||||||
"kycSkip": "Оставить базовые лимиты",
|
"kycSkip": "Оставить базовые лимиты",
|
||||||
"kycClose": "К аккаунту",
|
"kycClose": "К аккаунту",
|
||||||
"kycReviewHelp": "Убедитесь, что оба фото читаемы и на них один паспорт. Их проверит сотрудник. Отправка не повышает лимиты сразу.",
|
"kycReviewHelp": "Проверьте данные: оба фото должны быть читаемы и показывать один документ. После отправки они хранятся конфиденциально для проверки сотрудником. Отправка не повышает лимиты сразу.",
|
||||||
"kycConfirm": "На фото я и мой действующий паспорт.",
|
"kycConfirm": "На фото я и мой действующий документ.",
|
||||||
"kycSubmit": "Отправить на проверку",
|
"kycSubmit": "Отправить на проверку",
|
||||||
"kycWorking": "Обработка… Не закрывайте страницу.",
|
"kycWorking": "Обработка… Не закрывайте страницу.",
|
||||||
"kycDiscardTitle": "Удалить выбранные фото?",
|
"kycDiscardTitle": "Удалить черновик проверки?",
|
||||||
"kycDiscardBody": "Черновик хранится только в памяти. При выходе выбранные фото будут удалены. Позже можно начать заново.",
|
"kycDiscardBody": "Несохранённые данные и выбранные фото будут удалены из этой сессии. Вы сможете начать заново позже.",
|
||||||
"kycStay": "Продолжить",
|
"kycStay": "Продолжить",
|
||||||
"kycDiscard": "Удалить и выйти",
|
"kycDiscard": "Удалить и выйти",
|
||||||
"kycErrorUnavailable": "Сервис недоступен. Выбранные фото сохранены в памяти. Повторите попытку.",
|
"kycErrorUnavailable": "Сервис недоступен. Выбранные фото сохранены в памяти. Повторите попытку.",
|
||||||
@@ -127,7 +128,7 @@
|
|||||||
"kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.",
|
"kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.",
|
||||||
"kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.",
|
"kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.",
|
||||||
"kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.",
|
"kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.",
|
||||||
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш паспорт.",
|
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш документ.",
|
||||||
"kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.",
|
"kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.",
|
||||||
"kycErrorRate": "Слишком много запросов. Немного подождите и повторите попытку.",
|
"kycErrorRate": "Слишком много запросов. Немного подождите и повторите попытку.",
|
||||||
"kycErrorConflict": "Статус проверки изменился. Обновите его, прежде чем продолжить.",
|
"kycErrorConflict": "Статус проверки изменился. Обновите его, прежде чем продолжить.",
|
||||||
@@ -263,5 +264,30 @@
|
|||||||
},
|
},
|
||||||
"factorEmailMethod": "Код из письма",
|
"factorEmailMethod": "Код из письма",
|
||||||
"factorAppMethod": "Приложение-аутентификатор",
|
"factorAppMethod": "Приложение-аутентификатор",
|
||||||
"factorAppExpired": "Срок проверки истёк. Вернитесь и начните снова."
|
"factorAppExpired": "Срок проверки истёк. Вернитесь и начните снова.",
|
||||||
|
"kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.",
|
||||||
|
"kycFullName": "Полное имя по документу",
|
||||||
|
"kycBirth": "Дата рождения",
|
||||||
|
"kycBirthHelp": "ГГГГ-ММ-ДД, например 1990-04-23",
|
||||||
|
"kycCountry": "Страна проживания",
|
||||||
|
"kycCountryHelp": "Код страны из двух букв, например PL или DE",
|
||||||
|
"kycProfileRequired": "Укажите полное имя, корректную дату рождения и код страны из двух букв.",
|
||||||
|
"kycContinue": "Перейти в личный кабинет",
|
||||||
|
"kycOperationMinimum": "Минимум на операцию",
|
||||||
|
"kycOperationMaximum": "Максимум на операцию",
|
||||||
|
"kycErrorAccess": "Проверка недоступна для этого аккаунта. Если это повторяется, обратитесь в поддержку.",
|
||||||
|
"kycUpgradePendingTitle": "Ожидается повышение лимитов",
|
||||||
|
"kycUpgradePendingBody": "Проверка личности одобрена. Ожидается подтверждение повышения лимитов. Отправлять фото повторно не нужно.",
|
||||||
|
"wNoPayoutMethod": "Для этого счёта не настроен способ вывода. Обратитесь в поддержку.",
|
||||||
|
"wNoFundingMethod": "Для этого счёта не настроен способ пополнения. Обратитесь в поддержку за инструкциями.",
|
||||||
|
"wLivePending": "Ожидается окончательный результат платежа. Обновите страницу, чтобы проверить статус и баланс.",
|
||||||
|
"wLiveFailed": "Платёж не завершён. Обновите счета, чтобы проверить доступный баланс.",
|
||||||
|
"wLiveQuoteNotice": "Предложение истекает через {seconds} сек.",
|
||||||
|
"@wLiveQuoteNotice": {
|
||||||
|
"placeholders": {
|
||||||
|
"seconds": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -63,17 +63,32 @@ 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 = widget.identityService ?? DummyIdentityService();
|
late final _identity =
|
||||||
|
widget.identityService ??
|
||||||
|
(widget.backend == null
|
||||||
|
? DummyIdentityService()
|
||||||
|
: BackendIdentityService(
|
||||||
|
baseUri: widget.backend!.baseUri,
|
||||||
|
accessToken: () => widget.backend!.session?.accessToken,
|
||||||
|
));
|
||||||
late final _walletService =
|
late final _walletService =
|
||||||
widget.walletService ??
|
widget.walletService ??
|
||||||
DummyWalletService(
|
(widget.backend != null
|
||||||
|
? BackendWalletService(
|
||||||
|
baseUri: widget.backend!.baseUri,
|
||||||
|
accessToken: () => widget.backend!.session?.accessToken,
|
||||||
|
)
|
||||||
|
: DummyWalletService(
|
||||||
_identity,
|
_identity,
|
||||||
initialLocale: _locale.locale.languageCode,
|
initialLocale: _locale.locale.languageCode,
|
||||||
factors: _factors,
|
factors: _authFactors,
|
||||||
);
|
));
|
||||||
late final _wallet = WalletProvider(
|
late final _wallet = WalletProvider(
|
||||||
_walletService,
|
_walletService,
|
||||||
_analytics,
|
_analytics,
|
||||||
@@ -81,7 +96,9 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
);
|
);
|
||||||
late final _limits =
|
late final _limits =
|
||||||
widget.limitsService ??
|
widget.limitsService ??
|
||||||
(_walletService is DummyWalletService
|
(_identity is BackendIdentityService
|
||||||
|
? BackendLimitsService(_identity)
|
||||||
|
: _walletService is DummyWalletService
|
||||||
? WalletLimitsService(_walletService)
|
? WalletLimitsService(_walletService)
|
||||||
: DummyLimitsService(_identity));
|
: DummyLimitsService(_identity));
|
||||||
late final _journeys = JourneyRecorder(_analytics);
|
late final _journeys = JourneyRecorder(_analytics);
|
||||||
@@ -100,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);
|
||||||
@@ -121,7 +145,13 @@ 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) {
|
||||||
|
_identity.close();
|
||||||
|
}
|
||||||
_locale.dispose();
|
_locale.dispose();
|
||||||
widget.backend?.close();
|
widget.backend?.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -136,6 +166,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
Provider<JourneyRecorder>.value(value: _journeys),
|
Provider<JourneyRecorder>.value(value: _journeys),
|
||||||
Provider<AnalyticsService>.value(value: _analytics),
|
Provider<AnalyticsService>.value(value: _analytics),
|
||||||
Provider<IdentityService>.value(value: _identity),
|
Provider<IdentityService>.value(value: _identity),
|
||||||
|
Provider(create: (_) => IdentityOnboardingProvider(_identity)),
|
||||||
Provider<LimitsService>.value(value: _limits),
|
Provider<LimitsService>.value(value: _limits),
|
||||||
Provider<PhotoService>.value(value: widget.photoService),
|
Provider<PhotoService>.value(value: widget.photoService),
|
||||||
ChangeNotifierProvider.value(value: _locale),
|
ChangeNotifierProvider.value(value: _locale),
|
||||||
|
|||||||
@@ -116,7 +116,10 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/identity',
|
path: '/identity',
|
||||||
name: AppRoutes.identity,
|
name: AppRoutes.identity,
|
||||||
builder: (context, state) => IdentityPage(key: identityKey),
|
builder: (context, state) => IdentityPage(
|
||||||
|
key: identityKey,
|
||||||
|
onboarding: state.uri.queryParameters['onboarding'] == '1',
|
||||||
|
),
|
||||||
onExit: (context, state) async =>
|
onExit: (context, state) async =>
|
||||||
await identityKey.currentState?.allowExit() ?? true,
|
await identityKey.currentState?.allowExit() ?? true,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -122,8 +122,10 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
(_provider.service as SimulationInfo).isSimulated) ||
|
(_provider.service as SimulationInfo).isSimulated) ||
|
||||||
state.isSimulated;
|
state.isSimulated;
|
||||||
final sent = status == VerificationStatus.sent;
|
final sent = status == VerificationStatus.sent;
|
||||||
|
final needsAssistance = state.error == VerificationError.needsAssistance;
|
||||||
final retryConfirmation =
|
final retryConfirmation =
|
||||||
status == VerificationStatus.error &&
|
status == VerificationStatus.error &&
|
||||||
|
!needsAssistance &&
|
||||||
!_lastWasSend &&
|
!_lastWasSend &&
|
||||||
widget.token != null;
|
widget.token != null;
|
||||||
final title = switch (status) {
|
final title = switch (status) {
|
||||||
@@ -139,7 +141,9 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
? l.verificationChecking
|
? l.verificationChecking
|
||||||
: l.verificationRequest,
|
: l.verificationRequest,
|
||||||
};
|
};
|
||||||
final body = switch (status) {
|
final body = needsAssistance
|
||||||
|
? l.authSignupNeedsAssistance
|
||||||
|
: switch (status) {
|
||||||
VerificationStatus.sent => l.verificationSent,
|
VerificationStatus.sent => l.verificationSent,
|
||||||
VerificationStatus.verified => l.verificationVerifiedBody,
|
VerificationStatus.verified => l.verificationVerifiedBody,
|
||||||
VerificationStatus.expired => l.verificationExpiredBody,
|
VerificationStatus.expired => l.verificationExpiredBody,
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ class AccountsPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(l.wSubheading, style: t.textTheme.bodyLarge),
|
Text(l.wSubheading, style: t.textTheme.bodyLarge),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(l.kycIntro),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.push('/identity'),
|
||||||
|
child: Text(l.wIdentity),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
if (p.loading) const LinearProgressIndicator(),
|
if (p.loading) const LinearProgressIndicator(),
|
||||||
if (p.failed)
|
if (p.failed)
|
||||||
|
|||||||
@@ -28,13 +28,21 @@ 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,
|
||||||
|
WalletOperation o, {
|
||||||
|
bool simulated = true,
|
||||||
|
}) => !simulated && o.status == OperationStatus.pending
|
||||||
|
? l.wLivePending
|
||||||
|
: !simulated && o.status == OperationStatus.failed
|
||||||
|
? l.wLiveFailed
|
||||||
|
: switch ((o.status, o.quote.request.kind)) {
|
||||||
(OperationStatus.pending, TransferKind.topUp) => l.wPendingTopUp,
|
(OperationStatus.pending, TransferKind.topUp) => l.wPendingTopUp,
|
||||||
(OperationStatus.pending, TransferKind.exchange) => l.wPendingExchange,
|
(OperationStatus.pending, TransferKind.exchange) => l.wPendingExchange,
|
||||||
(OperationStatus.pending, TransferKind.payout) => l.wPendingPayout,
|
(OperationStatus.pending, TransferKind.payout) => l.wPendingPayout,
|
||||||
(OperationStatus.completed, TransferKind.topUp) => l.wCompleteTopUp,
|
(OperationStatus.completed, TransferKind.topUp) => l.wCompleteTopUp,
|
||||||
(OperationStatus.completed, TransferKind.exchange) => l.wCompleteExchange,
|
(OperationStatus.completed, TransferKind.exchange) =>
|
||||||
|
l.wCompleteExchange,
|
||||||
(OperationStatus.completed, TransferKind.payout) => l.wCompletePayout,
|
(OperationStatus.completed, TransferKind.payout) => l.wCompletePayout,
|
||||||
(OperationStatus.failed, TransferKind.topUp) => l.wFailedTopUp,
|
(OperationStatus.failed, TransferKind.topUp) => l.wFailedTopUp,
|
||||||
_ => l.wFailedOutgoing,
|
_ => l.wFailedOutgoing,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,6 +316,14 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
),
|
),
|
||||||
if (widget.kind == TransferKind.payout) ...[
|
if (widget.kind == TransferKind.payout) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
if (live && source?.payoutMethod.isNotEmpty == true)
|
||||||
|
InputDecorator(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.wRecipient,
|
||||||
|
),
|
||||||
|
child: Text(source!.payoutLabel),
|
||||||
|
),
|
||||||
|
if (!live)
|
||||||
TextField(
|
TextField(
|
||||||
key: const ValueKey('transfer-recipient'),
|
key: const ValueKey('transfer-recipient'),
|
||||||
controller: _recipient,
|
controller: _recipient,
|
||||||
@@ -310,10 +335,20 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
onChanged: (v) => p.change(recipient: v),
|
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)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_app/main.dart';
|
import 'package:sxchnge_app/main.dart';
|
||||||
@@ -41,6 +43,84 @@ class UnverifiedBackend extends BackendAuthServices {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
for (final language in ['en', 'ru']) {
|
||||||
|
testWidgets(
|
||||||
|
'signup requiring assistance preserves input and records failure ($language)',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.platformDispatcher.textScaleFactorTestValue = 1.5;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
final analytics = DummyAnalyticsService();
|
||||||
|
final locale = LocaleProvider(initialLocale: Locale(language));
|
||||||
|
await tester.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
localeProvider: locale,
|
||||||
|
analyticsService: analytics,
|
||||||
|
signupService: const DummySignupService(
|
||||||
|
delay: Duration.zero,
|
||||||
|
failure: AuthError.signupNeedsAssistance,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final signupLabel = language == 'ru' ? 'Регистрация' : 'Sign up';
|
||||||
|
await tester.ensureVisible(find.text(signupLabel));
|
||||||
|
await tester.tap(find.text(signupLabel));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
for (final entry in {
|
||||||
|
'name': 'Ada Lovelace',
|
||||||
|
'email': 'ada@example.com',
|
||||||
|
'password': 'password preserved',
|
||||||
|
'confirmation': 'password preserved',
|
||||||
|
}.entries) {
|
||||||
|
await tester.enterText(find.byKey(ValueKey(entry.key)), entry.value);
|
||||||
|
}
|
||||||
|
await tester.ensureVisible(find.byKey(const ValueKey('submit')));
|
||||||
|
await tester.tap(find.byKey(const ValueKey('submit')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final feedback = find.byKey(const ValueKey('auth-error'));
|
||||||
|
await tester.ensureVisible(feedback);
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
language == 'ru'
|
||||||
|
? 'Не удалось завершить создание аккаунта. Обратитесь в службу поддержки.'
|
||||||
|
: 'We could not finish setting up your account. Please contact support.',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<TextFormField>(find.byKey(const ValueKey('email')))
|
||||||
|
.controller!
|
||||||
|
.text,
|
||||||
|
'ada@example.com',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<TextFormField>(find.byKey(const ValueKey('password')))
|
||||||
|
.controller!
|
||||||
|
.text,
|
||||||
|
'password preserved',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
analytics.events.where((event) => event.name == 'auth_signup_failed'),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
analytics.events.where(
|
||||||
|
(event) => event.name == 'auth_signup_succeeded',
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
testWidgets('backend mode gates workspace and recovers unverified login', (
|
testWidgets('backend mode gates workspace and recovers unverified login', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ void main() {
|
|||||||
|
|
||||||
Future<void> submit(WidgetTester t) async {
|
Future<void> submit(WidgetTester t) async {
|
||||||
await tap(t, find.byKey(const ValueKey('kyc-start')));
|
await tap(t, find.byKey(const ValueKey('kyc-start')));
|
||||||
|
await t.enterText(find.byKey(const ValueKey('kyc-name')), 'Example Person');
|
||||||
|
await t.enterText(find.byKey(const ValueKey('kyc-birth')), '1990-04-23');
|
||||||
|
await t.enterText(find.byKey(const ValueKey('kyc-country')), 'PL');
|
||||||
|
await t.pumpAndSettle();
|
||||||
await tap(t, find.byKey(const ValueKey('pick-passport')));
|
await tap(t, find.byKey(const ValueKey('pick-passport')));
|
||||||
await tap(t, find.byKey(const ValueKey('kyc-next')));
|
await tap(t, find.byKey(const ValueKey('kyc-next')));
|
||||||
await tap(t, find.byKey(const ValueKey('pick-selfie')));
|
await tap(t, find.byKey(const ValueKey('pick-selfie')));
|
||||||
@@ -100,6 +104,25 @@ void main() {
|
|||||||
await tap(t, find.text('Contact support'));
|
await tap(t, find.text('Contact support'));
|
||||||
expect(find.byType(AlertDialog), findsOneWidget);
|
expect(find.byType(AlertDialog), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
testWidgets('optional onboarding can be skipped before collecting proofs', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
t.platformDispatcher.defaultRouteNameTestValue = '/identity?onboarding=1';
|
||||||
|
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||||
|
await t.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
identityService: DummyIdentityService(delay: Duration.zero),
|
||||||
|
photoService: Photos(bytes),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
final router = GoRouter.of(
|
||||||
|
t.element(find.byKey(const ValueKey('kyc-start'))),
|
||||||
|
);
|
||||||
|
await tap(t, find.text('Keep basic limits'));
|
||||||
|
expect(router.routeInformationProvider.value.uri.path, '/accounts');
|
||||||
|
expect(find.byType(AlertDialog), findsNothing);
|
||||||
|
});
|
||||||
testWidgets('correctable rejection can be resubmitted', (t) async {
|
testWidgets('correctable rejection can be resubmitted', (t) async {
|
||||||
await open(t);
|
await open(t);
|
||||||
await submit(t);
|
await submit(t);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,37 @@ Future<void> openVerification(
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
testWidgets('resend assistance retains email and does not claim delivery', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await openVerification(
|
||||||
|
tester,
|
||||||
|
'/verification-sent',
|
||||||
|
service: const DummyVerificationService(
|
||||||
|
delay: Duration.zero,
|
||||||
|
sendFailure: VerificationError.needsAssistance,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final email = find.byKey(const ValueKey('verification-email'));
|
||||||
|
await tester.enterText(email, 'ada@example.com');
|
||||||
|
await tester.ensureVisible(
|
||||||
|
find.byKey(const ValueKey('verification-resend')),
|
||||||
|
);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('verification-resend')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
'We could not finish setting up your account. Please contact support.',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.widget<TextFormField>(email).controller!.text,
|
||||||
|
'ada@example.com',
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
for (final scenario in {
|
for (final scenario in {
|
||||||
'demo-valid': 'verified',
|
'demo-valid': 'verified',
|
||||||
'demo-expired': 'expired',
|
'demo-expired': 'expired',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import '../dto/backend.dart';
|
|||||||
import '../models/auth.dart';
|
import '../models/auth.dart';
|
||||||
import '../models/session.dart';
|
import '../models/session.dart';
|
||||||
import '../models/two_factor.dart';
|
import '../models/two_factor.dart';
|
||||||
|
import '../models/verification.dart';
|
||||||
|
|
||||||
abstract final class BackendMapper {
|
abstract final class BackendMapper {
|
||||||
static AuthResult signup(BackendDTO dto) {
|
static AuthResult signup(BackendDTO dto) {
|
||||||
@@ -33,11 +34,19 @@ abstract final class BackendMapper {
|
|||||||
static AuthException authFailure(int status, BackendDTO dto) =>
|
static AuthException authFailure(int status, BackendDTO dto) =>
|
||||||
AuthException(switch (dto.error) {
|
AuthException(switch (dto.error) {
|
||||||
'email_in_use' => AuthError.emailInUse,
|
'email_in_use' => AuthError.emailInUse,
|
||||||
|
'signup_attention_required' => AuthError.signupNeedsAssistance,
|
||||||
'account_not_verified' => AuthError.accountNotVerified,
|
'account_not_verified' => AuthError.accountNotVerified,
|
||||||
_ when status == 401 => AuthError.invalidCredentials,
|
_ when status == 401 => AuthError.invalidCredentials,
|
||||||
_ when status == 429 => AuthError.factorCooldown,
|
_ when status == 429 => AuthError.factorCooldown,
|
||||||
_ => AuthError.unavailable,
|
_ => AuthError.unavailable,
|
||||||
});
|
});
|
||||||
|
static VerificationException verificationFailure(BackendDTO dto) =>
|
||||||
|
VerificationException(switch (dto.error) {
|
||||||
|
'expired' => VerificationError.expired,
|
||||||
|
'invalid' => VerificationError.invalid,
|
||||||
|
'signup_attention_required' => VerificationError.needsAssistance,
|
||||||
|
_ => VerificationError.internal,
|
||||||
|
});
|
||||||
static FactorException factorFailure(int status, BackendDTO dto) =>
|
static FactorException factorFailure(int status, BackendDTO dto) =>
|
||||||
FactorException(switch (dto.error) {
|
FactorException(switch (dto.error) {
|
||||||
'expired' => FactorError.expired,
|
'expired' => FactorError.expired,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ enum AuthError {
|
|||||||
invalidCredentials,
|
invalidCredentials,
|
||||||
accountNotVerified,
|
accountNotVerified,
|
||||||
emailInUse,
|
emailInUse,
|
||||||
|
signupNeedsAssistance,
|
||||||
unavailable,
|
unavailable,
|
||||||
unexpected,
|
unexpected,
|
||||||
factorLocked,
|
factorLocked,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ enum VerificationStatus {
|
|||||||
error,
|
error,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum VerificationError { expired, invalid, internal }
|
enum VerificationError { expired, invalid, internal, needsAssistance }
|
||||||
|
|
||||||
class VerificationException implements Exception {
|
class VerificationException implements Exception {
|
||||||
const VerificationException(this.code);
|
const VerificationException(this.code);
|
||||||
@@ -22,8 +22,9 @@ class VerificationResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class VerificationState {
|
class VerificationState {
|
||||||
const VerificationState(this.status, {this.isSimulated = false});
|
const VerificationState(this.status, {this.isSimulated = false, this.error});
|
||||||
final VerificationStatus status;
|
final VerificationStatus status;
|
||||||
|
final VerificationError? error;
|
||||||
final bool isSimulated;
|
final bool isSimulated;
|
||||||
bool get isLoading =>
|
bool get isLoading =>
|
||||||
status == VerificationStatus.sending ||
|
status == VerificationStatus.sending ||
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ class VerificationProvider extends ChangeNotifier {
|
|||||||
VerificationError.expired => VerificationStatus.expired,
|
VerificationError.expired => VerificationStatus.expired,
|
||||||
VerificationError.invalid => VerificationStatus.invalid,
|
VerificationError.invalid => VerificationStatus.invalid,
|
||||||
VerificationError.internal => VerificationStatus.error,
|
VerificationError.internal => VerificationStatus.error,
|
||||||
|
VerificationError.needsAssistance => VerificationStatus.error,
|
||||||
},
|
},
|
||||||
|
error: code,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class BackendAuthServices
|
|||||||
String path,
|
String path,
|
||||||
Map<String, Object?> body, {
|
Map<String, Object?> body, {
|
||||||
bool factor = false,
|
bool factor = false,
|
||||||
|
bool verification = false,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final uri = baseUri.replace(
|
final uri = baseUri.replace(
|
||||||
@@ -81,6 +82,7 @@ class BackendAuthServices
|
|||||||
.timeout(const Duration(seconds: 15));
|
.timeout(const Duration(seconds: 15));
|
||||||
final dto = BackendDTO.decode(res.body);
|
final dto = BackendDTO.decode(res.body);
|
||||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
if (verification) throw BackendMapper.verificationFailure(dto);
|
||||||
throw factor
|
throw factor
|
||||||
? BackendMapper.factorFailure(res.statusCode, dto)
|
? BackendMapper.factorFailure(res.statusCode, dto)
|
||||||
: BackendMapper.authFailure(res.statusCode, dto);
|
: BackendMapper.authFailure(res.statusCode, dto);
|
||||||
@@ -90,7 +92,11 @@ class BackendAuthServices
|
|||||||
rethrow;
|
rethrow;
|
||||||
} on FactorException {
|
} on FactorException {
|
||||||
rethrow;
|
rethrow;
|
||||||
|
} on VerificationException {
|
||||||
|
rethrow;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
if (verification)
|
||||||
|
throw const VerificationException(VerificationError.internal);
|
||||||
if (factor) throw const FactorException(FactorError.unavailable);
|
if (factor) throw const FactorException(FactorError.unavailable);
|
||||||
throw const AuthException(AuthError.unavailable);
|
throw const AuthException(AuthError.unavailable);
|
||||||
}
|
}
|
||||||
@@ -148,16 +154,17 @@ class BackendAuthServices
|
|||||||
await _post('verification/send', {
|
await _post('verification/send', {
|
||||||
'email': normalized,
|
'email': normalized,
|
||||||
'idempotencyKey': key,
|
'idempotencyKey': key,
|
||||||
}, factor: true);
|
}, verification: true);
|
||||||
_retryKeys.remove('activation:$normalized');
|
_retryKeys.remove('activation:$normalized');
|
||||||
return VerificationMapper.fromDTO(
|
return VerificationMapper.fromDTO(
|
||||||
const VerificationDTO(simulated: false),
|
const VerificationDTO(simulated: false),
|
||||||
);
|
);
|
||||||
} on FactorException catch (e) {
|
} on VerificationException catch (e) {
|
||||||
if (e.code == FactorError.expired || e.code == FactorError.invalid) {
|
if (e.code == VerificationError.expired ||
|
||||||
|
e.code == VerificationError.invalid) {
|
||||||
_retryKeys.remove('activation:$normalized');
|
_retryKeys.remove('activation:$normalized');
|
||||||
}
|
}
|
||||||
throw const VerificationException(VerificationError.internal);
|
rethrow;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
throw const VerificationException(VerificationError.internal);
|
throw const VerificationException(VerificationError.internal);
|
||||||
}
|
}
|
||||||
@@ -165,18 +172,8 @@ class BackendAuthServices
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<VerificationResult> confirm({required String token}) async {
|
Future<VerificationResult> confirm({required String token}) async {
|
||||||
try {
|
await _post('verification/confirm', {'token': token}, verification: true);
|
||||||
await _post('verification/confirm', {'token': token}, factor: true);
|
return VerificationMapper.fromDTO(const VerificationDTO(simulated: false));
|
||||||
return VerificationMapper.fromDTO(
|
|
||||||
const VerificationDTO(simulated: false),
|
|
||||||
);
|
|
||||||
} on FactorException catch (e) {
|
|
||||||
throw VerificationException(switch (e.code) {
|
|
||||||
FactorError.expired => VerificationError.expired,
|
|
||||||
FactorError.invalid => VerificationError.invalid,
|
|
||||||
_ => VerificationError.internal,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class InstrumentedAuthServices
|
|||||||
AuthError.invalidCredentials => JourneyFailure.invalidCredentials,
|
AuthError.invalidCredentials => JourneyFailure.invalidCredentials,
|
||||||
AuthError.emailInUse => JourneyFailure.emailInUse,
|
AuthError.emailInUse => JourneyFailure.emailInUse,
|
||||||
AuthError.unavailable => JourneyFailure.unavailable,
|
AuthError.unavailable => JourneyFailure.unavailable,
|
||||||
|
AuthError.signupNeedsAssistance => JourneyFailure.unavailable,
|
||||||
AuthError.unexpected => JourneyFailure.unexpected,
|
AuthError.unexpected => JourneyFailure.unexpected,
|
||||||
AuthError.factorLocked ||
|
AuthError.factorLocked ||
|
||||||
AuthError.factorCooldown => JourneyFailure.unavailable,
|
AuthError.factorCooldown => JourneyFailure.unavailable,
|
||||||
@@ -69,6 +70,7 @@ class InstrumentedAuthServices
|
|||||||
VerificationError.expired => JourneyFailure.expired,
|
VerificationError.expired => JourneyFailure.expired,
|
||||||
VerificationError.invalid => JourneyFailure.invalid,
|
VerificationError.invalid => JourneyFailure.invalid,
|
||||||
VerificationError.internal => JourneyFailure.internal,
|
VerificationError.internal => JourneyFailure.internal,
|
||||||
|
VerificationError.needsAssistance => JourneyFailure.unavailable,
|
||||||
},
|
},
|
||||||
FactorException(code: FactorError.expired) => JourneyFailure.expired,
|
FactorException(code: FactorError.expired) => JourneyFailure.expired,
|
||||||
FactorException() => JourneyFailure.unavailable,
|
FactorException() => JourneyFailure.unavailable,
|
||||||
|
|||||||
@@ -10,6 +10,43 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
http.Response response(Map<String, Object?> data, [int status = 202]) =>
|
http.Response response(Map<String, Object?> data, [int status = 202]) =>
|
||||||
http.Response(jsonEncode({'data': data}), status);
|
http.Response(jsonEncode({'data': data}), status);
|
||||||
|
test(
|
||||||
|
'signup distinguishes support recovery and keeps the retry key',
|
||||||
|
() async {
|
||||||
|
final keys = <String>[];
|
||||||
|
var failed = true;
|
||||||
|
final service = BackendAuthServices(
|
||||||
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||||
|
clientId: 'web',
|
||||||
|
client: MockClient((request) async {
|
||||||
|
keys.add(jsonDecode(request.body)['idempotencyKey'] as String);
|
||||||
|
return failed
|
||||||
|
? response({'error': 'signup_attention_required'}, 503)
|
||||||
|
: response({'userId': 'user'});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
addTearDown(service.close);
|
||||||
|
const input = SignupRequest(
|
||||||
|
name: 'Ada',
|
||||||
|
email: 'ada@example.com',
|
||||||
|
password: 'password preserved',
|
||||||
|
locale: 'en',
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
service.signup(input),
|
||||||
|
throwsA(
|
||||||
|
isA<AuthException>().having(
|
||||||
|
(error) => error.code,
|
||||||
|
'code',
|
||||||
|
AuthError.signupNeedsAssistance,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
failed = false;
|
||||||
|
expect((await service.signup(input)).isSimulated, isFalse);
|
||||||
|
expect(keys[0], keys[1]);
|
||||||
|
},
|
||||||
|
);
|
||||||
test('signup receipt avoids duplicate delivery and subsequent resend reaches API', () async {
|
test('signup receipt avoids duplicate delivery and subsequent resend reaches API', () async {
|
||||||
final paths = <String>[];
|
final paths = <String>[];
|
||||||
final service = BackendAuthServices(
|
final service = BackendAuthServices(
|
||||||
@@ -148,6 +185,34 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
test(
|
||||||
|
'verification recovery preserves assistance instead of requesting retry',
|
||||||
|
() async {
|
||||||
|
final service = BackendAuthServices(
|
||||||
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||||
|
clientId: 'web',
|
||||||
|
client: MockClient(
|
||||||
|
(_) async => response({'error': 'signup_attention_required'}, 503),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(service.close);
|
||||||
|
final provider = VerificationProvider(service);
|
||||||
|
addTearDown(provider.dispose);
|
||||||
|
await provider.send(email: 'ada@example.com', locale: 'en');
|
||||||
|
expect(provider.state.status, VerificationStatus.error);
|
||||||
|
expect(provider.state.error, VerificationError.needsAssistance);
|
||||||
|
await expectLater(
|
||||||
|
service.confirm(token: 'old-token'),
|
||||||
|
throwsA(
|
||||||
|
isA<VerificationException>().having(
|
||||||
|
(e) => e.code,
|
||||||
|
'code',
|
||||||
|
VerificationError.needsAssistance,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
test(
|
test(
|
||||||
'resend preserves server challenge and retries the same delivery key',
|
'resend preserves server challenge and retries the same delivery key',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import '../models/identity.dart';
|
||||||
|
import '../models/limits.dart';
|
||||||
|
import '../transport/backend.dart';
|
||||||
|
|
||||||
|
abstract final class VerificationMapper {
|
||||||
|
static Limits limits(PolicyDTO dto) => Limits(
|
||||||
|
tier: AccountTier.values.byName(dto.tier),
|
||||||
|
rows: const [],
|
||||||
|
simulated: false,
|
||||||
|
profileRef: dto.profileRef,
|
||||||
|
entries: dto.entries
|
||||||
|
.map(
|
||||||
|
(v) => PolicyLimit(
|
||||||
|
operation: v.operation,
|
||||||
|
period: v.period,
|
||||||
|
currency: v.currency,
|
||||||
|
cap: v.cap,
|
||||||
|
remaining: v.remaining,
|
||||||
|
used: v.used,
|
||||||
|
reserved: v.reserved,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
static Identity fromDTO(VerificationDTO dto) => Identity(
|
||||||
|
upgradePending: dto.upgradePending,
|
||||||
|
status: IdentityStatus.values.byName(dto.status),
|
||||||
|
reason: dto.reason == null || dto.reason!.isEmpty
|
||||||
|
? null
|
||||||
|
: ReviewReason.values.byName(dto.reason!),
|
||||||
|
submissionId: dto.id,
|
||||||
|
profile: IdentityProfile(
|
||||||
|
fullName: dto.profile.fullName,
|
||||||
|
dateOfBirth: dto.profile.dateOfBirth,
|
||||||
|
residenceCountry: dto.profile.residenceCountry,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
static ProfileDTO profile(IdentityProfile value) => ProfileDTO(
|
||||||
|
value.fullName.trim(),
|
||||||
|
value.dateOfBirth,
|
||||||
|
value.residenceCountry,
|
||||||
|
);
|
||||||
|
static KycException failure(int status) => KycException(switch (status) {
|
||||||
|
400 || 413 || 415 || 422 => KycError.format,
|
||||||
|
401 => KycError.sessionExpired,
|
||||||
|
403 => KycError.permission,
|
||||||
|
409 => KycError.conflict,
|
||||||
|
429 => KycError.rateLimited,
|
||||||
|
_ => KycError.unavailable,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -36,16 +36,40 @@ class Identity {
|
|||||||
this.reason,
|
this.reason,
|
||||||
this.submissionId,
|
this.submissionId,
|
||||||
this.simulated = false,
|
this.simulated = false,
|
||||||
|
this.profile = const IdentityProfile(),
|
||||||
|
this.upgradePending = false,
|
||||||
});
|
});
|
||||||
final IdentityStatus status;
|
final IdentityStatus status;
|
||||||
final ReviewReason? reason;
|
final ReviewReason? reason;
|
||||||
final String? submissionId;
|
final String? submissionId;
|
||||||
final bool simulated;
|
final bool simulated;
|
||||||
|
final IdentityProfile profile;
|
||||||
|
final bool upgradePending;
|
||||||
bool get canSubmit =>
|
bool get canSubmit =>
|
||||||
status == IdentityStatus.unverified ||
|
status == IdentityStatus.unverified ||
|
||||||
status == IdentityStatus.changesRequested;
|
status == IdentityStatus.changesRequested;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Customer assertions awaiting human review, distinct from a verified identity.
|
||||||
|
class IdentityProfile {
|
||||||
|
const IdentityProfile({
|
||||||
|
this.fullName = '',
|
||||||
|
this.dateOfBirth = '',
|
||||||
|
this.residenceCountry = '',
|
||||||
|
});
|
||||||
|
final String fullName, dateOfBirth, residenceCountry;
|
||||||
|
bool get isComplete {
|
||||||
|
final date = DateTime.tryParse(dateOfBirth);
|
||||||
|
return fullName.trim().isNotEmpty &&
|
||||||
|
fullName.length <= 200 &&
|
||||||
|
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(dateOfBirth) &&
|
||||||
|
date != null &&
|
||||||
|
date.toIso8601String().startsWith(dateOfBirth) &&
|
||||||
|
date.isBefore(DateTime.now()) &&
|
||||||
|
RegExp(r'^[A-Z]{2}$').hasMatch(residenceCountry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ephemeral image data only. Never serialize this model into storage or telemetry.
|
/// Ephemeral image data only. Never serialize this model into storage or telemetry.
|
||||||
class Photo {
|
class Photo {
|
||||||
Photo(Uint8List bytes)
|
Photo(Uint8List bytes)
|
||||||
|
|||||||
@@ -33,8 +33,28 @@ class Limits {
|
|||||||
required this.tier,
|
required this.tier,
|
||||||
required List<VisibleLimit> rows,
|
required List<VisibleLimit> rows,
|
||||||
required this.simulated,
|
required this.simulated,
|
||||||
}) : rows = List.unmodifiable(rows);
|
this.profileRef,
|
||||||
|
List<PolicyLimit> entries = const [],
|
||||||
|
}) : rows = List.unmodifiable(rows),
|
||||||
|
entries = List.unmodifiable(entries);
|
||||||
final AccountTier tier;
|
final AccountTier tier;
|
||||||
final List<VisibleLimit> rows;
|
final List<VisibleLimit> rows;
|
||||||
final bool simulated;
|
final bool simulated;
|
||||||
|
final String? profileRef;
|
||||||
|
final List<PolicyLimit> entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authoritative policy amounts in major units. Keep decimal strings intact,
|
||||||
|
/// including currencies whose precision differs from fiat's common two digits.
|
||||||
|
class PolicyLimit {
|
||||||
|
const PolicyLimit({
|
||||||
|
required this.operation,
|
||||||
|
required this.period,
|
||||||
|
required this.currency,
|
||||||
|
required this.cap,
|
||||||
|
required this.remaining,
|
||||||
|
required this.used,
|
||||||
|
required this.reserved,
|
||||||
|
});
|
||||||
|
final String operation, period, currency, cap, remaining, used, reserved;
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@ class IdentityProvider extends ChangeNotifier {
|
|||||||
Identity? record;
|
Identity? record;
|
||||||
Photo? passport;
|
Photo? passport;
|
||||||
Photo? selfie;
|
Photo? selfie;
|
||||||
|
IdentityProfile profile = const IdentityProfile();
|
||||||
KycError? error;
|
KycError? error;
|
||||||
PhotoKind? photoError;
|
PhotoKind? photoError;
|
||||||
bool busy = false;
|
bool busy = false;
|
||||||
@@ -31,11 +32,13 @@ class IdentityProvider extends ChangeNotifier {
|
|||||||
int step = 0;
|
int step = 0;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
String? _key;
|
String? _key;
|
||||||
bool get hasDraft => passport != null || selfie != null;
|
bool _profileTouched = false;
|
||||||
|
bool get hasDraft => passport != null || selfie != null || _profileTouched;
|
||||||
bool get canSubmit =>
|
bool get canSubmit =>
|
||||||
!busy &&
|
!busy &&
|
||||||
!stale &&
|
!stale &&
|
||||||
confirmed &&
|
confirmed &&
|
||||||
|
profile.isComplete &&
|
||||||
passport != null &&
|
passport != null &&
|
||||||
selfie != null &&
|
selfie != null &&
|
||||||
(record?.canSubmit ?? false);
|
(record?.canSubmit ?? false);
|
||||||
@@ -57,6 +60,7 @@ class IdentityProvider extends ChangeNotifier {
|
|||||||
final result = await service.fetch();
|
final result = await service.fetch();
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
record = result;
|
record = result;
|
||||||
|
if (!editing) profile = result.profile;
|
||||||
stale = false;
|
stale = false;
|
||||||
await onStatusChanged?.call();
|
await onStatusChanged?.call();
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
@@ -144,6 +148,7 @@ class IdentityProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
void next() {
|
void next() {
|
||||||
if (busy ||
|
if (busy ||
|
||||||
|
(step == 0 && !profile.isComplete) ||
|
||||||
(step == 0 && passport == null) ||
|
(step == 0 && passport == null) ||
|
||||||
(step == 1 && selfie == null))
|
(step == 1 && selfie == null))
|
||||||
return;
|
return;
|
||||||
@@ -152,6 +157,15 @@ class IdentityProvider extends ChangeNotifier {
|
|||||||
_notify();
|
_notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void updateProfile(IdentityProfile value) {
|
||||||
|
if (busy || !editing) return;
|
||||||
|
profile = value;
|
||||||
|
_profileTouched = true;
|
||||||
|
confirmed = false;
|
||||||
|
_key = null;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
void confirm(bool value) {
|
void confirm(bool value) {
|
||||||
if (!busy) {
|
if (!busy) {
|
||||||
confirmed = value;
|
confirmed = value;
|
||||||
@@ -172,6 +186,7 @@ class IdentityProvider extends ChangeNotifier {
|
|||||||
final op = _begin(JourneyStep.kycSubmission);
|
final op = _begin(JourneyStep.kycSubmission);
|
||||||
try {
|
try {
|
||||||
final result = await service.submit(
|
final result = await service.submit(
|
||||||
|
profile: profile,
|
||||||
passport: passport!,
|
passport: passport!,
|
||||||
selfie: selfie!,
|
selfie: selfie!,
|
||||||
idempotencyKey: _key!,
|
idempotencyKey: _key!,
|
||||||
@@ -217,6 +232,8 @@ class IdentityProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _clear() {
|
void _clear() {
|
||||||
|
_profileTouched = false;
|
||||||
|
profile = const IdentityProfile();
|
||||||
passport = null;
|
passport = null;
|
||||||
selfie = null;
|
selfie = null;
|
||||||
confirmed = false;
|
confirmed = false;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import '../models/identity.dart';
|
||||||
|
import '../services/contracts.dart';
|
||||||
|
|
||||||
|
/// Offers optional verification once per customer in this app session. Identity
|
||||||
|
/// dependency failures must never prevent an already authenticated basic login.
|
||||||
|
class IdentityOnboardingProvider {
|
||||||
|
IdentityOnboardingProvider(this.service);
|
||||||
|
final IdentityService service;
|
||||||
|
final _offered = <String>{};
|
||||||
|
Future<bool> offer(String customer) async {
|
||||||
|
if (customer.isEmpty || service.isSimulated || !_offered.add(customer))
|
||||||
|
return false;
|
||||||
|
try {
|
||||||
|
final identity = await service.fetch().timeout(
|
||||||
|
const Duration(seconds: 3),
|
||||||
|
);
|
||||||
|
return identity.status == IdentityStatus.unverified ||
|
||||||
|
identity.status == IdentityStatus.changesRequested;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../mappers/backend.dart';
|
||||||
|
import '../models/identity.dart';
|
||||||
|
import '../models/limits.dart';
|
||||||
|
import '../transport/backend.dart';
|
||||||
|
import 'contracts.dart';
|
||||||
|
import 'http/client.dart';
|
||||||
|
|
||||||
|
/// Credentials are read for each operation, so a replaced session cannot leave
|
||||||
|
/// documents attached to a previously authenticated customer.
|
||||||
|
class BackendIdentityService implements IdentityService {
|
||||||
|
BackendIdentityService({
|
||||||
|
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 identity API URL required');
|
||||||
|
}
|
||||||
|
final Uri baseUri;
|
||||||
|
final String? Function() accessToken;
|
||||||
|
final http.Client _client;
|
||||||
|
@override
|
||||||
|
bool get isSimulated => false;
|
||||||
|
Uri get _uri => baseUri.replace(
|
||||||
|
path: '${baseUri.path.replaceFirst(RegExp(r"/$"), "")}/accounts/identity',
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<String> _send(http.BaseRequest request) async {
|
||||||
|
final token = accessToken();
|
||||||
|
if (token == null) throw const KycException(KycError.sessionExpired);
|
||||||
|
request.followRedirects = false;
|
||||||
|
request.headers.addAll({
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
if (kIsWeb) 'X-CSRF-Protection': '1',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final response = await _client
|
||||||
|
.send(request)
|
||||||
|
.then(http.Response.fromStream)
|
||||||
|
.timeout(const Duration(seconds: 60));
|
||||||
|
if (token != accessToken())
|
||||||
|
throw const KycException(KycError.sessionExpired);
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300)
|
||||||
|
throw VerificationMapper.failure(response.statusCode);
|
||||||
|
return response.body;
|
||||||
|
} on KycException {
|
||||||
|
rethrow;
|
||||||
|
} catch (_) {
|
||||||
|
throw const KycException(KycError.unavailable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T> _mapped<T>(Future<String> response, T Function(String) map) async {
|
||||||
|
try {
|
||||||
|
return map(await response);
|
||||||
|
} on KycException {
|
||||||
|
rethrow;
|
||||||
|
} catch (_) {
|
||||||
|
throw const KycException(KycError.unavailable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Identity> fetch() => _mapped(
|
||||||
|
_send(http.Request('GET', _uri)),
|
||||||
|
(body) => VerificationMapper.fromDTO(VerificationDTO.decode(body)),
|
||||||
|
);
|
||||||
|
Future<Limits> fetchLimits() => _mapped(
|
||||||
|
_send(
|
||||||
|
http.Request(
|
||||||
|
'GET',
|
||||||
|
_uri.replace(
|
||||||
|
path: _uri.path.replaceFirst(RegExp(r'/identity$'), '/limits'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(body) => VerificationMapper.limits(PolicyDTO.decode(body)),
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
Future<Identity> submit({
|
||||||
|
required IdentityProfile profile,
|
||||||
|
required Photo passport,
|
||||||
|
required Photo selfie,
|
||||||
|
required String idempotencyKey,
|
||||||
|
}) async {
|
||||||
|
if (!profile.isComplete) throw const KycException(KycError.incomplete);
|
||||||
|
final request = http.MultipartRequest('POST', _uri)
|
||||||
|
..fields.addAll({
|
||||||
|
'profile': jsonEncode(VerificationMapper.profile(profile).toJson()),
|
||||||
|
'idempotencyKey': idempotencyKey,
|
||||||
|
})
|
||||||
|
..files.addAll([
|
||||||
|
http.MultipartFile.fromBytes(
|
||||||
|
'document',
|
||||||
|
passport.bytes,
|
||||||
|
filename: 'document',
|
||||||
|
),
|
||||||
|
http.MultipartFile.fromBytes(
|
||||||
|
'selfie',
|
||||||
|
selfie.bytes,
|
||||||
|
filename: 'selfie',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return _mapped(
|
||||||
|
_send(request),
|
||||||
|
(body) => VerificationMapper.fromDTO(VerificationDTO.decode(body)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() => _client.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackendLimitsService implements LimitsService {
|
||||||
|
const BackendLimitsService(this.identity);
|
||||||
|
final BackendIdentityService identity;
|
||||||
|
@override
|
||||||
|
Future<Limits> fetch() => identity.fetchLimits();
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ abstract interface class IdentityService {
|
|||||||
/// Backend adapters must scope the key to the authenticated account and reject
|
/// Backend adapters must scope the key to the authenticated account and reject
|
||||||
/// stale/duplicate review transitions. Retrying the same key is idempotent.
|
/// stale/duplicate review transitions. Retrying the same key is idempotent.
|
||||||
Future<Identity> submit({
|
Future<Identity> submit({
|
||||||
|
required IdentityProfile profile,
|
||||||
required Photo passport,
|
required Photo passport,
|
||||||
required Photo selfie,
|
required Photo selfie,
|
||||||
required String idempotencyKey,
|
required String idempotencyKey,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class DummyIdentityService implements IdentityService {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Identity> submit({
|
Future<Identity> submit({
|
||||||
|
required IdentityProfile profile,
|
||||||
required Photo passport,
|
required Photo passport,
|
||||||
required Photo selfie,
|
required Photo selfie,
|
||||||
required String idempotencyKey,
|
required String idempotencyKey,
|
||||||
@@ -35,7 +36,7 @@ class DummyIdentityService implements IdentityService {
|
|||||||
if (_lastKey == idempotencyKey) return IdentityMapper.fromDTO(_record);
|
if (_lastKey == idempotencyKey) return IdentityMapper.fromDTO(_record);
|
||||||
if (!IdentityMapper.fromDTO(_record).canSubmit)
|
if (!IdentityMapper.fromDTO(_record).canSubmit)
|
||||||
throw const KycException(KycError.conflict);
|
throw const KycException(KycError.conflict);
|
||||||
if (passport.bytes.isEmpty || selfie.bytes.isEmpty)
|
if (!profile.isComplete || passport.bytes.isEmpty || selfie.bytes.isEmpty)
|
||||||
throw const KycException(KycError.incomplete);
|
throw const KycException(KycError.incomplete);
|
||||||
_lastKey = idempotencyKey;
|
_lastKey = idempotencyKey;
|
||||||
_record = IdentityDTO('pending', id: idempotencyKey);
|
_record = IdentityDTO('pending', id: idempotencyKey);
|
||||||
|
|||||||
@@ -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();
|
||||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user