Submit identity proofs now or later and show reviewed limits #49
No files matched your search
@@ -6,6 +6,7 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
"git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/identity"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -17,6 +18,7 @@ type Dependencies struct {
|
||||
Customers accountservice.AccountService
|
||||
Auth *flow.Service
|
||||
Factors *twofactor.Service
|
||||
Identity *identity.Service
|
||||
Token middleware.TokenConfig
|
||||
Signature middleware.Signature
|
||||
LoginLimits middleware.LoginRateLimits
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
auth "git.sendico.io/tech/sxchnge/product/auth/http/v1"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
|
||||
identity "git.sendico.io/tech/sxchnge/product/identity/http/v1"
|
||||
factors "git.sendico.io/tech/sxchnge/product/twofactor/http/v1"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
@@ -22,5 +23,6 @@ func Install(scope api.Scope, deps *api.Dependencies) error {
|
||||
if deps.Factors != nil {
|
||||
factors.Install(routes, deps.Factors, scope.Logger)
|
||||
}
|
||||
identity.Install(routes, deps.Identity, scope.Logger)
|
||||
return nil
|
||||
}
|
||||
@@ -67,7 +67,8 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
deps.Auth.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("signup_facts")}
|
||||
deps.Identity = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("identity")}
|
||||
deps.Auth.Facts = deps.Identity
|
||||
logger.Info("Sendico sign-up facts integration enabled")
|
||||
}
|
||||
records, err := factorstore.Open(logger, db)
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
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"
|
||||
)
|
||||
@@ -26,7 +28,11 @@ func OpenStore(factory db.Factory) (Store, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mongoStore{records: repository.CreateRepository(source.Majority(d.Database), "signup_facts")}, nil
|
||||
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) {
|
||||
|
||||
@@ -62,6 +62,13 @@ func TestDurableBindingClaimsAndIsolation(t *testing.T) {
|
||||
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))
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
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
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
@@ -70,6 +70,9 @@ func (r *MongoRepository) PatchOneByFilter(ctx context.Context, query builder.Qu
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return false, nil
|
||||
}
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return false, merrors.OpaqueWrap(merrors.ErrDataConflict, err, "duplicate_key")
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# 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 before this task's branch. Deploy the Sendico
|
||||
identity-proofs PR before using this flow. The platform change reuses the Limits
|
||||
assignment contract being developed in Sendico #2884; its PR documents the shared
|
||||
scope and merge order. No merge or deployment is automatic.
|
||||
|
||||
## 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.
|
||||
@@ -883,3 +883,32 @@ 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.
|
||||
|
||||
The implementation requires Sendico #2885 and follows sign-up Facts PR #47.
|
||||
Validation and delivery links are recorded below after the final checks.
|
||||
|
||||
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.
|
||||
@@ -175,3 +175,8 @@ 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.
|
||||
|
||||
> 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
|
||||
|
||||
Understand current access, decide whether higher limits are useful, and submit two
|
||||
|
||||
@@ -8,6 +8,8 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import '../routing/routes.dart';
|
||||
import '../security/challenge.dart';
|
||||
import 'feedback.dart';
|
||||
@@ -127,7 +129,16 @@ class _AuthFormState extends State<AuthForm> {
|
||||
}
|
||||
await login.complete(challenge, grant);
|
||||
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),
|
||||
FilledButton(
|
||||
key: const ValueKey('submit'),
|
||||
onPressed: state.isLoading ? null : _submit,
|
||||
onPressed: state.isLoading || state.status == AuthStatus.success
|
||||
? null
|
||||
: _submit,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
|
||||
@@ -22,10 +22,11 @@ String reasonText(AppLocalizations l, ReviewReason r) => switch (r) {
|
||||
ReviewReason.passportExpired => l.kycExpiredReason,
|
||||
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.internal => l.kycErrorInternal,
|
||||
KycError.permission => l.kycErrorPermission,
|
||||
KycError.permission => photo ? l.kycErrorPermission : l.kycErrorAccess,
|
||||
KycError.format => l.kycErrorFormat,
|
||||
KycError.tooLarge => l.kycErrorLarge,
|
||||
KycError.unreadable => l.kycErrorUnreadable,
|
||||
@@ -34,4 +35,4 @@ String errorText(AppLocalizations l, KycError e) => switch (e) {
|
||||
KycError.sessionExpired => l.kycErrorSession,
|
||||
KycError.rateLimited => l.kycErrorRate,
|
||||
KycError.conflict => l.kycErrorConflict,
|
||||
};
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import 'photo.dart';
|
||||
import 'profile.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
@@ -26,6 +27,10 @@ class IdentityForm extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (p.step == 0) ...[
|
||||
ProfileForm(provider: p),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
LinearProgressIndicator(
|
||||
value: (p.step + 1) / 3,
|
||||
semanticsLabel: titles[p.step],
|
||||
@@ -47,6 +52,10 @@ class IdentityForm extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
if (p.step == 2) ...[
|
||||
Text(
|
||||
'${p.profile.fullName}\n${p.profile.dateOfBirth} · ${p.profile.residenceCountry}',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(l.kycReviewHelp),
|
||||
const SizedBox(height: 16),
|
||||
PhotoPanel(kind: PhotoKind.passport, photo: p.passport, review: true),
|
||||
@@ -68,7 +77,10 @@ class IdentityForm extends StatelessWidget {
|
||||
),
|
||||
FilledButton(
|
||||
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
|
||||
: p.next,
|
||||
child: Text(l.kycNext),
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
import 'policy.dart';
|
||||
|
||||
class LimitsPanel extends StatelessWidget {
|
||||
const LimitsPanel({super.key});
|
||||
@@ -48,7 +49,9 @@ class LimitsPanel extends StatelessWidget {
|
||||
style: t.textTheme.titleLarge,
|
||||
),
|
||||
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) ...[
|
||||
const Divider(height: 32),
|
||||
Text(
|
||||
|
||||
@@ -16,7 +16,8 @@ import 'status.dart';
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class IdentityPage extends StatefulWidget {
|
||||
const IdentityPage({super.key});
|
||||
const IdentityPage({super.key, this.onboarding = false});
|
||||
final bool onboarding;
|
||||
@override
|
||||
State<IdentityPage> createState() => IdentityPageState();
|
||||
}
|
||||
@@ -98,6 +99,10 @@ class IdentityPageState extends State<IdentityPage> {
|
||||
if (!await allowExit() || !mounted) return;
|
||||
_identity.exit();
|
||||
if (account) {
|
||||
if (widget.onboarding) {
|
||||
context.go('/accounts');
|
||||
return;
|
||||
}
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
@@ -166,7 +171,11 @@ class IdentityPageState extends State<IdentityPage> {
|
||||
key: _feedbackKey,
|
||||
liveRegion: true,
|
||||
child: Text(
|
||||
errorText(l, p.error!),
|
||||
errorText(
|
||||
l,
|
||||
p.error!,
|
||||
photo: p.photoError != null,
|
||||
),
|
||||
key: const ValueKey('kyc-error'),
|
||||
style: TextStyle(color: t.colorScheme.error),
|
||||
),
|
||||
@@ -178,6 +187,16 @@ class IdentityPageState extends State<IdentityPage> {
|
||||
l.kycStatusStale,
|
||||
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(
|
||||
includeSemantics: false,
|
||||
key: _contentKey,
|
||||
@@ -198,7 +217,7 @@ class IdentityPageState extends State<IdentityPage> {
|
||||
const LimitsPanel(),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (p.editing)
|
||||
if (p.editing && !widget.onboarding)
|
||||
TextButton(
|
||||
onPressed: p.busy ? null : _leave,
|
||||
child: Text(l.kycSkip),
|
||||
@@ -207,7 +226,15 @@ class IdentityPageState extends State<IdentityPage> {
|
||||
onPressed: p.busy
|
||||
? null
|
||||
: () => _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,
|
||||
liveRegion: true,
|
||||
child: Text(
|
||||
statusTitle(l, status),
|
||||
record.upgradePending
|
||||
? l.kycUpgradePendingTitle
|
||||
: statusTitle(l, status),
|
||||
key: ValueKey('kyc-${status.name}'),
|
||||
style: t.textTheme.headlineMedium?.copyWith(
|
||||
color: t.colorScheme.onPrimaryContainer,
|
||||
@@ -53,7 +55,9 @@ class StatusPanel extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
statusBody(l, status),
|
||||
record.upgradePending
|
||||
? l.kycUpgradePendingBody
|
||||
: statusBody(l, status),
|
||||
style: TextStyle(color: t.colorScheme.onPrimaryContainer),
|
||||
),
|
||||
if (record.reason != null) ...[
|
||||
|
||||
@@ -73,19 +73,19 @@
|
||||
"kycUnverified": "Not verified",
|
||||
"kycPending": "In review",
|
||||
"kycApproved": "Identity verified",
|
||||
"kycChanges": "New photos needed",
|
||||
"kycChanges": "Changes requested",
|
||||
"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.",
|
||||
"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.",
|
||||
"kycPassportReason": "The passport 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.",
|
||||
"kycExpiredReason": "The passport has expired. Use a valid passport and take both photos again.",
|
||||
"kycPassportReason": "The ID document details could not be read. Retake the photo in good light, with every edge visible.",
|
||||
"kycSelfieReason": "Your face or ID document was not clear in the selfie. Keep both visible and in focus.",
|
||||
"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.",
|
||||
"kycStart": "Verify identity",
|
||||
"kycResubmit": "Replace photos",
|
||||
"kycResubmit": "Update details and photos",
|
||||
"kycRefresh": "Refresh status & limits",
|
||||
"kycSupport": "Contact support",
|
||||
"kycSupportBody": "Support is currently unavailable. Please try again later.",
|
||||
@@ -100,11 +100,11 @@
|
||||
"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.",
|
||||
"kycLoading": "Loading your status…",
|
||||
"kycPassport": "Passport photo",
|
||||
"kycSelfie": "Selfie with passport",
|
||||
"kycReview": "Review your photos",
|
||||
"kycPassportHelp": "Open your valid passport to the photo page. Show all four edges and keep text sharp. 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.",
|
||||
"kycPassport": "ID document photo",
|
||||
"kycSelfie": "Selfie with ID document",
|
||||
"kycReview": "Review details and photos",
|
||||
"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 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.",
|
||||
"kycChoose": "Choose photo",
|
||||
"kycReplace": "Choose a different photo",
|
||||
@@ -113,12 +113,12 @@
|
||||
"kycBack": "Back",
|
||||
"kycSkip": "Keep basic limits",
|
||||
"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.",
|
||||
"kycConfirm": "These photos show me and my own valid passport.",
|
||||
"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 ID document.",
|
||||
"kycSubmit": "Submit for review",
|
||||
"kycWorking": "Processing… Please keep this page open.",
|
||||
"kycDiscardTitle": "Discard these photos?",
|
||||
"kycDiscardBody": "Your draft is only in memory. Leaving will remove the selected photos; you can start again later.",
|
||||
"kycDiscardTitle": "Discard this verification draft?",
|
||||
"kycDiscardBody": "Leaving removes unsent profile changes and selected photos from this session. You can start again later.",
|
||||
"kycStay": "Keep editing",
|
||||
"kycDiscard": "Discard & leave",
|
||||
"kycErrorUnavailable": "The service is unavailable. Your selected photos are still here. Try again.",
|
||||
@@ -264,5 +264,18 @@
|
||||
},
|
||||
"factorEmailMethod": "Email code",
|
||||
"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."
|
||||
}
|
||||
@@ -73,19 +73,19 @@
|
||||
"kycUnverified": "Личность не подтверждена",
|
||||
"kycPending": "На проверке",
|
||||
"kycApproved": "Личность подтверждена",
|
||||
"kycChanges": "Нужны новые фото",
|
||||
"kycChanges": "Нужны исправления",
|
||||
"kycDenied": "Верификация отклонена",
|
||||
"kycUnverifiedBody": "Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото паспорта и селфи с ним.",
|
||||
"kycUnverifiedBody": "Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото документа и селфи с ним.",
|
||||
"kycPendingBody": "Заявка ожидает проверки сотрудником. До одобрения действуют базовые лимиты. Повторная отправка не нужна.",
|
||||
"kycApprovedBody": "Проверка завершена. Актуальные лимиты указаны ниже.",
|
||||
"kycChangesBody": "Аккаунт доступен с базовыми лимитами. Замените фото и отправьте новую заявку.",
|
||||
"kycChangesBody": "Базовые лимиты доступны. Проверьте данные, замените фото и отправьте новый запрос на проверку.",
|
||||
"kycDeniedBody": "Базовые лимиты остаются доступными. Если вы не согласны с решением, обратитесь в поддержку для пересмотра.",
|
||||
"kycPassportReason": "Данные паспорта не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.",
|
||||
"kycSelfieReason": "На селфи плохо видно лицо или паспорт. Оба должны быть в кадре и в фокусе.",
|
||||
"kycExpiredReason": "Срок действия паспорта истёк. Используйте действующий паспорт и сделайте оба фото заново.",
|
||||
"kycPassportReason": "Данные документа не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.",
|
||||
"kycSelfieReason": "На селфи плохо видно лицо или документ. Оба должны быть в кадре и в фокусе.",
|
||||
"kycExpiredReason": "Срок действия документа истёк. Используйте действующий документ и сделайте оба фото заново.",
|
||||
"kycDeniedReason": "По этой заявке не удалось подтвердить вашу личность.",
|
||||
"kycStart": "Подтвердить личность",
|
||||
"kycResubmit": "Заменить фото",
|
||||
"kycResubmit": "Исправить данные и фото",
|
||||
"kycRefresh": "Обновить статус и лимиты",
|
||||
"kycSupport": "Обратиться в поддержку",
|
||||
"kycSupportBody": "Поддержка сейчас недоступна. Попробуйте позже.",
|
||||
@@ -100,11 +100,11 @@
|
||||
"kycLimitsError": "Не удалось обновить лимиты. Значения ниже могут быть устаревшими. Повторите запрос.",
|
||||
"kycLimitsEmpty": "Сервис не вернул лимиты. Обновите данные или уточните их в поддержке перед операцией.",
|
||||
"kycLoading": "Загружаем ваш статус…",
|
||||
"kycPassport": "Фото паспорта",
|
||||
"kycSelfie": "Селфи с паспортом",
|
||||
"kycReview": "Проверьте фото",
|
||||
"kycPassportHelp": "Откройте действующий паспорт на странице с фото. Все четыре края и текст должны быть чётко видны. Избегайте бликов и размытия, не закрывайте данные.",
|
||||
"kycSelfieHelp": "Держите открытый паспорт рядом с лицом. Лицо целиком и страница с фото должны быть в одном кадре, чёткими и ничем не закрытыми.",
|
||||
"kycPassport": "Фото документа",
|
||||
"kycSelfie": "Селфи с документом",
|
||||
"kycReview": "Проверьте данные и фото",
|
||||
"kycPassportHelp": "Снимите страницу с фото или лицевую сторону действующего документа. Все четыре края и данные должны быть чётко видны. Избегайте бликов и размытия.",
|
||||
"kycSelfieHelp": "Держите тот же документ рядом с лицом. Лицо целиком и данные документа должны быть в одном кадре, чёткими и ничем не закрытыми.",
|
||||
"kycFileRules": "JPEG или PNG · до 10 МБ · не менее 600 × 600 пикселей. Сделайте фото камерой, затем выберите его здесь.",
|
||||
"kycChoose": "Выбрать фото",
|
||||
"kycReplace": "Выбрать другое фото",
|
||||
@@ -113,12 +113,12 @@
|
||||
"kycBack": "Назад",
|
||||
"kycSkip": "Оставить базовые лимиты",
|
||||
"kycClose": "К аккаунту",
|
||||
"kycReviewHelp": "Убедитесь, что оба фото читаемы и на них один паспорт. Их проверит сотрудник. Отправка не повышает лимиты сразу.",
|
||||
"kycConfirm": "На фото я и мой действующий паспорт.",
|
||||
"kycReviewHelp": "Проверьте данные: оба фото должны быть читаемы и показывать один документ. После отправки они хранятся конфиденциально для проверки сотрудником. Отправка не повышает лимиты сразу.",
|
||||
"kycConfirm": "На фото я и мой действующий документ.",
|
||||
"kycSubmit": "Отправить на проверку",
|
||||
"kycWorking": "Обработка… Не закрывайте страницу.",
|
||||
"kycDiscardTitle": "Удалить выбранные фото?",
|
||||
"kycDiscardBody": "Черновик хранится только в памяти. При выходе выбранные фото будут удалены. Позже можно начать заново.",
|
||||
"kycDiscardTitle": "Удалить черновик проверки?",
|
||||
"kycDiscardBody": "Несохранённые данные и выбранные фото будут удалены из этой сессии. Вы сможете начать заново позже.",
|
||||
"kycStay": "Продолжить",
|
||||
"kycDiscard": "Удалить и выйти",
|
||||
"kycErrorUnavailable": "Сервис недоступен. Выбранные фото сохранены в памяти. Повторите попытку.",
|
||||
@@ -128,7 +128,7 @@
|
||||
"kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.",
|
||||
"kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.",
|
||||
"kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.",
|
||||
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш паспорт.",
|
||||
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш документ.",
|
||||
"kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.",
|
||||
"kycErrorRate": "Слишком много запросов. Немного подождите и повторите попытку.",
|
||||
"kycErrorConflict": "Статус проверки изменился. Обновите его, прежде чем продолжить.",
|
||||
@@ -264,5 +264,18 @@
|
||||
},
|
||||
"factorEmailMethod": "Код из письма",
|
||||
"factorAppMethod": "Приложение-аутентификатор",
|
||||
"factorAppExpired": "Срок проверки истёк. Вернитесь и начните снова."
|
||||
"factorAppExpired": "Срок проверки истёк. Вернитесь и начните снова.",
|
||||
"kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.",
|
||||
"kycFullName": "Полное имя по документу",
|
||||
"kycBirth": "Дата рождения",
|
||||
"kycBirthHelp": "ГГГГ-ММ-ДД, например 1990-04-23",
|
||||
"kycCountry": "Страна проживания",
|
||||
"kycCountryHelp": "Код страны из двух букв, например PL или DE",
|
||||
"kycProfileRequired": "Укажите полное имя, корректную дату рождения и код страны из двух букв.",
|
||||
"kycContinue": "Перейти в личный кабинет",
|
||||
"kycOperationMinimum": "Минимум на операцию",
|
||||
"kycOperationMaximum": "Максимум на операцию",
|
||||
"kycErrorAccess": "Проверка недоступна для этого аккаунта. Если это повторяется, обратитесь в поддержку.",
|
||||
"kycUpgradePendingTitle": "Ожидается повышение лимитов",
|
||||
"kycUpgradePendingBody": "Проверка личности одобрена. Ожидается подтверждение повышения лимитов. Отправлять фото повторно не нужно."
|
||||
}
|
||||
@@ -66,7 +66,14 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
late final _factors =
|
||||
widget.backend ?? widget.twoFactorService ?? DummyTwoFactorService();
|
||||
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 =
|
||||
widget.walletService ??
|
||||
DummyWalletService(
|
||||
@@ -81,7 +88,9 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
);
|
||||
late final _limits =
|
||||
widget.limitsService ??
|
||||
(_walletService is DummyWalletService
|
||||
(_identity is BackendIdentityService
|
||||
? BackendLimitsService(_identity)
|
||||
: _walletService is DummyWalletService
|
||||
? WalletLimitsService(_walletService)
|
||||
: DummyLimitsService(_identity));
|
||||
late final _journeys = JourneyRecorder(_analytics);
|
||||
@@ -122,6 +131,9 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
_journeys.dispose();
|
||||
_wallet.dispose();
|
||||
_security.dispose();
|
||||
if (_identity is BackendIdentityService) {
|
||||
_identity.close();
|
||||
}
|
||||
_locale.dispose();
|
||||
widget.backend?.close();
|
||||
super.dispose();
|
||||
@@ -136,6 +148,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
Provider<JourneyRecorder>.value(value: _journeys),
|
||||
Provider<AnalyticsService>.value(value: _analytics),
|
||||
Provider<IdentityService>.value(value: _identity),
|
||||
Provider(create: (_) => IdentityOnboardingProvider(_identity)),
|
||||
Provider<LimitsService>.value(value: _limits),
|
||||
Provider<PhotoService>.value(value: widget.photoService),
|
||||
ChangeNotifierProvider.value(value: _locale),
|
||||
|
||||
@@ -116,7 +116,10 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
|
||||
GoRoute(
|
||||
path: '/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 =>
|
||||
await identityKey.currentState?.allowExit() ?? true,
|
||||
),
|
||||
|
||||
@@ -47,6 +47,22 @@ class AccountsPage extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
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),
|
||||
if (p.loading) const LinearProgressIndicator(),
|
||||
if (p.failed)
|
||||
|
||||
@@ -67,6 +67,10 @@ void main() {
|
||||
|
||||
Future<void> submit(WidgetTester t) async {
|
||||
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('kyc-next')));
|
||||
await tap(t, find.byKey(const ValueKey('pick-selfie')));
|
||||
@@ -100,6 +104,25 @@ void main() {
|
||||
await tap(t, find.text('Contact support'));
|
||||
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 {
|
||||
await open(t);
|
||||
await submit(t);
|
||||
|
||||
@@ -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.submissionId,
|
||||
this.simulated = false,
|
||||
this.profile = const IdentityProfile(),
|
||||
this.upgradePending = false,
|
||||
});
|
||||
final IdentityStatus status;
|
||||
final ReviewReason? reason;
|
||||
final String? submissionId;
|
||||
final bool simulated;
|
||||
final IdentityProfile profile;
|
||||
final bool upgradePending;
|
||||
bool get canSubmit =>
|
||||
status == IdentityStatus.unverified ||
|
||||
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.
|
||||
class Photo {
|
||||
Photo(Uint8List bytes)
|
||||
|
||||
@@ -33,8 +33,28 @@ class Limits {
|
||||
required this.tier,
|
||||
required List<VisibleLimit> rows,
|
||||
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 List<VisibleLimit> rows;
|
||||
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;
|
||||
Photo? passport;
|
||||
Photo? selfie;
|
||||
IdentityProfile profile = const IdentityProfile();
|
||||
KycError? error;
|
||||
PhotoKind? photoError;
|
||||
bool busy = false;
|
||||
@@ -31,11 +32,13 @@ class IdentityProvider extends ChangeNotifier {
|
||||
int step = 0;
|
||||
bool _disposed = false;
|
||||
String? _key;
|
||||
bool get hasDraft => passport != null || selfie != null;
|
||||
bool _profileTouched = false;
|
||||
bool get hasDraft => passport != null || selfie != null || _profileTouched;
|
||||
bool get canSubmit =>
|
||||
!busy &&
|
||||
!stale &&
|
||||
confirmed &&
|
||||
profile.isComplete &&
|
||||
passport != null &&
|
||||
selfie != null &&
|
||||
(record?.canSubmit ?? false);
|
||||
@@ -57,6 +60,7 @@ class IdentityProvider extends ChangeNotifier {
|
||||
final result = await service.fetch();
|
||||
if (_disposed) return;
|
||||
record = result;
|
||||
if (!editing) profile = result.profile;
|
||||
stale = false;
|
||||
await onStatusChanged?.call();
|
||||
if (_disposed) return;
|
||||
@@ -144,6 +148,7 @@ class IdentityProvider extends ChangeNotifier {
|
||||
|
||||
void next() {
|
||||
if (busy ||
|
||||
(step == 0 && !profile.isComplete) ||
|
||||
(step == 0 && passport == null) ||
|
||||
(step == 1 && selfie == null))
|
||||
return;
|
||||
@@ -152,6 +157,15 @@ class IdentityProvider extends ChangeNotifier {
|
||||
_notify();
|
||||
}
|
||||
|
||||
void updateProfile(IdentityProfile value) {
|
||||
if (busy || !editing) return;
|
||||
profile = value;
|
||||
_profileTouched = true;
|
||||
confirmed = false;
|
||||
_key = null;
|
||||
_notify();
|
||||
}
|
||||
|
||||
void confirm(bool value) {
|
||||
if (!busy) {
|
||||
confirmed = value;
|
||||
@@ -172,6 +186,7 @@ class IdentityProvider extends ChangeNotifier {
|
||||
final op = _begin(JourneyStep.kycSubmission);
|
||||
try {
|
||||
final result = await service.submit(
|
||||
profile: profile,
|
||||
passport: passport!,
|
||||
selfie: selfie!,
|
||||
idempotencyKey: _key!,
|
||||
@@ -217,6 +232,8 @@ class IdentityProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _clear() {
|
||||
_profileTouched = false;
|
||||
profile = const IdentityProfile();
|
||||
passport = null;
|
||||
selfie = null;
|
||||
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
|
||||
/// stale/duplicate review transitions. Retrying the same key is idempotent.
|
||||
Future<Identity> submit({
|
||||
required IdentityProfile profile,
|
||||
required Photo passport,
|
||||
required Photo selfie,
|
||||
required String idempotencyKey,
|
||||
|
||||
@@ -27,6 +27,7 @@ class DummyIdentityService implements IdentityService {
|
||||
|
||||
@override
|
||||
Future<Identity> submit({
|
||||
required IdentityProfile profile,
|
||||
required Photo passport,
|
||||
required Photo selfie,
|
||||
required String idempotencyKey,
|
||||
@@ -35,7 +36,7 @@ class DummyIdentityService implements IdentityService {
|
||||
if (_lastKey == idempotencyKey) return IdentityMapper.fromDTO(_record);
|
||||
if (!IdentityMapper.fromDTO(_record).canSubmit)
|
||||
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);
|
||||
_lastKey = 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();
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class ProfileDTO {
|
||||
const ProfileDTO(this.fullName, this.dateOfBirth, this.residenceCountry);
|
||||
factory ProfileDTO.fromJson(Map<String, dynamic> data) => ProfileDTO(
|
||||
data['fullName'] as String? ?? '',
|
||||
data['dateOfBirth'] as String? ?? '',
|
||||
data['residenceCountry'] as String? ?? '',
|
||||
);
|
||||
final String fullName, dateOfBirth, residenceCountry;
|
||||
Map<String, String> toJson() => {
|
||||
'fullName': fullName,
|
||||
'dateOfBirth': dateOfBirth,
|
||||
'residenceCountry': residenceCountry,
|
||||
};
|
||||
}
|
||||
|
||||
class VerificationDTO {
|
||||
const VerificationDTO(
|
||||
this.status,
|
||||
this.reason,
|
||||
this.id,
|
||||
this.profile, {
|
||||
this.upgradePending = false,
|
||||
});
|
||||
factory VerificationDTO.decode(String body) {
|
||||
final envelope = jsonDecode(body) as Map<String, dynamic>;
|
||||
if (envelope['status'] != 'success')
|
||||
throw const FormatException('Unaccepted verification');
|
||||
final data = envelope['data'] as Map<String, dynamic>;
|
||||
return VerificationDTO(
|
||||
data['status'] as String,
|
||||
data['reason'] as String?,
|
||||
data['submissionId'] as String?,
|
||||
ProfileDTO.fromJson(data['profile'] as Map<String, dynamic>),
|
||||
upgradePending: data['upgradePending'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
final String status;
|
||||
final String? reason, id;
|
||||
final ProfileDTO profile;
|
||||
final bool upgradePending;
|
||||
}
|
||||
|
||||
class PolicyDTO {
|
||||
const PolicyDTO(this.tier, this.profileRef, this.entries);
|
||||
factory PolicyDTO.decode(String body) {
|
||||
final envelope = jsonDecode(body) as Map<String, dynamic>;
|
||||
if (envelope['status'] != 'success')
|
||||
throw const FormatException('Unaccepted limits');
|
||||
final data = envelope['data'] as Map<String, dynamic>;
|
||||
return PolicyDTO(
|
||||
data['tier'] as String,
|
||||
data['profileRef'] as String,
|
||||
(data['entries'] as List)
|
||||
.map((v) => LimitDTO.fromJson(v as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
final String tier, profileRef;
|
||||
final List<LimitDTO> entries;
|
||||
}
|
||||
|
||||
class LimitDTO {
|
||||
LimitDTO.fromJson(Map<String, dynamic> d)
|
||||
: operation = d['operation'] as String,
|
||||
period = d['period'] as String,
|
||||
currency = d['currency'] as String,
|
||||
cap = d['cap'] as String,
|
||||
remaining = d['remaining'] as String,
|
||||
used = d['used'] as String,
|
||||
reserved = d['reserved'] as String;
|
||||
final String operation, period, currency, cap, remaining, used, reserved;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import '../models/identity.dart';
|
||||
import '../models/limits.dart';
|
||||
|
||||
// Transport contracts for the stub. Replace with generated backend DTOs later.
|
||||
// Transport fixtures for simulated services. Production owns its backend DTOs.
|
||||
class IdentityDTO {
|
||||
const IdentityDTO(this.status, {this.reason, this.id, this.simulated = true});
|
||||
final String status;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export 'src/models/identity.dart';
|
||||
export 'src/models/limits.dart';
|
||||
export 'src/providers/identity.dart';
|
||||
export 'src/providers/onboarding.dart';
|
||||
export 'src/providers/limits.dart';
|
||||
export 'src/services/contracts.dart';
|
||||
export 'src/services/backend.dart';
|
||||
export 'src/services/dummy.dart';
|
||||
export 'src/services/photos.dart';
|
||||
@@ -6,6 +6,7 @@ environment:
|
||||
sdk: '>=3.13.0 <4.0.0'
|
||||
flutter: '>=3.47.0'
|
||||
dependencies:
|
||||
http: ^1.6.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
image: ^4.9.0
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
String response({String status = 'pending'}) => jsonEncode({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'status': status,
|
||||
'submissionId': 'submission',
|
||||
'profile': {
|
||||
'fullName': 'Legal Name',
|
||||
'dateOfBirth': '1990-04-23',
|
||||
'residenceCountry': 'PL',
|
||||
},
|
||||
},
|
||||
});
|
||||
void main() {
|
||||
test('an approved review with an unfinished upgrade stays pending and cannot resubmit', () async {
|
||||
final payload = jsonDecode(response()) as Map<String, dynamic>;
|
||||
(payload['data'] as Map<String, dynamic>)['upgradePending'] = true;
|
||||
final service = BackendIdentityService(
|
||||
baseUri: Uri.parse('https://api.example.test'),
|
||||
accessToken: () => 'token',
|
||||
client: MockClient((_) async => http.Response(jsonEncode(payload), 200)),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = await service.fetch();
|
||||
expect(result.status, IdentityStatus.pending);
|
||||
expect(result.upgradePending, true);
|
||||
expect(result.canSubmit, false);
|
||||
});
|
||||
test(
|
||||
'uploads profile plus both files, credentials scoped to current session',
|
||||
() async {
|
||||
final service = BackendIdentityService(
|
||||
baseUri: Uri.parse('https://api.example.test/api/v1'),
|
||||
accessToken: () => 'session-token',
|
||||
client: MockClient((request) async {
|
||||
expect(request.url.path, '/api/v1/accounts/identity');
|
||||
expect(request.headers['Authorization'], 'Bearer session-token');
|
||||
expect(request.followRedirects, false);
|
||||
expect(
|
||||
request.headers['content-type'],
|
||||
contains('multipart/form-data'),
|
||||
);
|
||||
expect(request.body, contains('name="document"'));
|
||||
expect(request.body, contains('name="selfie"'));
|
||||
expect(request.body, contains('"fullName":"Legal Name"'));
|
||||
expect(request.body, contains('stable-key'));
|
||||
expect(request.body, isNot(contains('customer_ref')));
|
||||
return http.Response(response(), 200);
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = await service.submit(
|
||||
profile: const IdentityProfile(
|
||||
fullName: 'Legal Name',
|
||||
dateOfBirth: '1990-04-23',
|
||||
residenceCountry: 'PL',
|
||||
),
|
||||
passport: Photo(Uint8List.fromList([1])),
|
||||
selfie: Photo(Uint8List.fromList([2])),
|
||||
idempotencyKey: 'stable-key',
|
||||
);
|
||||
expect(result.status, IdentityStatus.pending);
|
||||
expect(result.simulated, false);
|
||||
},
|
||||
);
|
||||
test('session switch rejects a late response', () async {
|
||||
var token = 'first';
|
||||
final pending = Completer<http.Response>();
|
||||
final service = BackendIdentityService(
|
||||
baseUri: Uri.parse('https://api.example.test'),
|
||||
accessToken: () => token,
|
||||
client: MockClient((_) => pending.future),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = service.fetch();
|
||||
token = 'second';
|
||||
pending.complete(http.Response(response(), 200));
|
||||
await expectLater(
|
||||
result,
|
||||
throwsA(
|
||||
isA<KycException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
KycError.sessionExpired,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
test(
|
||||
'production limits preserve exact decimals and never use demo rows',
|
||||
() async {
|
||||
final service = BackendIdentityService(
|
||||
baseUri: Uri.parse('https://api.example.test'),
|
||||
accessToken: () => 'token',
|
||||
client: MockClient((request) async {
|
||||
expect(request.url.path, '/accounts/limits');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'tier': 'verified',
|
||||
'profileRef': 'verified-policy',
|
||||
'entries': [
|
||||
{
|
||||
'operation': 'Transfer',
|
||||
'period': 'day',
|
||||
'currency': 'BTC',
|
||||
'cap': '0.12345678',
|
||||
'remaining': '0.10000001',
|
||||
'used': '0.02',
|
||||
'reserved': '0.00345677',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = await BackendLimitsService(service).fetch();
|
||||
expect(result.rows, isEmpty);
|
||||
expect(result.simulated, false);
|
||||
expect(result.entries.single.cap, '0.12345678');
|
||||
expect(result.tier, AccountTier.verified);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'unavailable, conflict and expired sessions retain typed recovery',
|
||||
() async {
|
||||
for (final pair in [
|
||||
(503, KycError.unavailable),
|
||||
(409, KycError.conflict),
|
||||
(401, KycError.sessionExpired),
|
||||
]) {
|
||||
final service = BackendIdentityService(
|
||||
baseUri: Uri.parse('https://api.example.test'),
|
||||
accessToken: () => 'token',
|
||||
client: MockClient(
|
||||
(_) async => http.Response('private upstream details', pair.$1),
|
||||
),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
await expectLater(
|
||||
service.fetch(),
|
||||
throwsA(isA<KycException>().having((e) => e.code, 'code', pair.$2)),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,13 @@ class BrokenAnalytics implements AnalyticsService {
|
||||
Future<void> draft(IdentityProvider p) async {
|
||||
await p.refresh();
|
||||
p.start();
|
||||
p.updateProfile(
|
||||
const IdentityProfile(
|
||||
fullName: "Example Person",
|
||||
dateOfBirth: "1990-04-23",
|
||||
residenceCountry: "PL",
|
||||
),
|
||||
);
|
||||
await p.pick(PhotoKind.passport);
|
||||
p.next();
|
||||
await p.pick(PhotoKind.selfie);
|
||||
@@ -170,6 +177,11 @@ void main() {
|
||||
1,
|
||||
);
|
||||
final replay = await service.submit(
|
||||
profile: const IdentityProfile(
|
||||
fullName: "Example Person",
|
||||
dateOfBirth: "1990-04-23",
|
||||
residenceCountry: "PL",
|
||||
),
|
||||
passport: photos.result!,
|
||||
selfie: photos.result!,
|
||||
idempotencyKey: p.record!.submissionId!,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
class RealIdentity extends DummyIdentityService {
|
||||
IdentityStatus status = IdentityStatus.unverified;
|
||||
bool unavailable = false;
|
||||
@override
|
||||
bool get isSimulated => false;
|
||||
@override
|
||||
Future<Identity> fetch() async {
|
||||
if (unavailable) throw const KycException(KycError.unavailable);
|
||||
return Identity(status: status);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('offers optional verification once to a basic customer and allows later corrections', () async {
|
||||
final service = RealIdentity(),
|
||||
provider = IdentityOnboardingProvider(RealIdentity());
|
||||
expect(await provider.offer('customer'), true);
|
||||
expect(await provider.offer('customer'), false);
|
||||
final subject = IdentityOnboardingProvider(service);
|
||||
service.status = IdentityStatus.pending;
|
||||
expect(await subject.offer('pending'), false);
|
||||
service.status = IdentityStatus.approved;
|
||||
expect(await subject.offer('approved'), false);
|
||||
service.status = IdentityStatus.changesRequested;
|
||||
expect(await subject.offer('correction'), true);
|
||||
});
|
||||
test(
|
||||
'a failed optional identity dependency never blocks basic login',
|
||||
() async {
|
||||
final service = RealIdentity()..unavailable = true;
|
||||
expect(
|
||||
await IdentityOnboardingProvider(service).offer('customer'),
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user