50 changed files with 1801 additions and 67 deletions

No files matched your search

+2
View File
@@ -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
+2
View File
@@ -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
}
+2 -1
View File
@@ -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)
+48
View File
@@ -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}
}
+107
View File
@@ -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"))
}
+31
View File
@@ -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 -1
View File
@@ -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) {
+7
View File
@@ -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))
+149
View File
@@ -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
}
+124
View File
@@ -0,0 +1,124 @@
# Customer identity verification
Task [SXCHNGE #48](https://git.sendico.io/tech/sxchnge/issues/48), following
[sign-up Facts intake](signup-facts.md). Platform implementation:
[Sendico #2885](https://git.sendico.io/tech/sendico/issues/2885).
## Customer flow
Sign-up still creates a basic account and submits its initial name/email/customer
reference as immutable facts. Email verification and login remain independent of
identity proof collection. After the first authenticated login in an app session,
an unverified customer is offered verification and can choose **Keep basic
limits**. An unavailable optional identity dependency does not prevent login.
**Accounts → Identity verification**, **Limits**, and the existing identity
navigation let the customer return later.
The customer supplies their full legal name, date of birth (YYYY-MM-DD), country
of residence, an ID document photo and a selfie holding that same document. The
backend supplies the authenticated customer's email and reference. The client
cannot choose a platform subject, organization, source principal, review status
or limits profile. JPEG/PNG photos must decode, be at least 600×600, at most
40 megapixels and no larger than 10 MiB each. Sendico also enforces its existing
configured evidence cap, MIME/extension rules and submission rate limits.
Unsent fields and photos stay in memory, survive retryable errors and are cleared
on successful submission, discard or page disposal. Previews leave Flutter's
image cache on exit/replacement. Profile edits or photo replacements create a
new request key; unchanged retries retain their key. Draft loss requires an
explicit discard choice. No sensitive extras enter browser history or analytics.
A successful submission means **pending staff review**. Corrections permit a new
submission; rejection retains basic access and the existing support route. A
recorded approval with an unfinished limits assignment is shown as **Limits
upgrade pending**, rather than verified. Refresh fetches the real reviewed state
and the authoritative limits. Production uses no simulated KYC review controls
or illustrative limit amounts. Demo mode remains separately simulated.
## Boundaries and contracts
The authenticated local routes are `GET/POST /accounts/identity` and
`GET /accounts/limits`, under the configured API mount. Multipart POST fields are
`profile` (JSON), `document`, `selfie`, and `idempotencyKey`. A customer ID is
always resolved from the full authenticated session. Pending-login tokens do not
provide account access. Browser and native requests both carry the access token;
browser refresh cookies remain an auth concern.
The existing `signup_facts` binding resolves each local customer to one independent
Sendico subject. A conditional unique `(namespace, subjectRef)` index prevents
binding the same provider subject to two customers. Empty registration claims
are excluded. Existing duplicates cause bootstrap to fail and require controlled
reconciliation; the application never guesses a replacement subject. Credential
rotation must retain the configured source account.
The Sendico adapter exclusively uses public HTTP:
- `GET/POST /identity_facts/{organization}/proofs/{subject}`;
- `GET /identity_facts/{organization}/proofs/{subject}/limits`.
Adapter DTOs and mappers remain separate from local models and local HTTP DTOs.
There are no Sendico database, gRPC, bus or sibling-module imports in SXCHNGE.
Provider organization/source settings remain in the adapter configuration.
Uploads have a bounded 60-second upstream timeout and redirects are rejected.
Upstream error bodies and photo bytes are never logged. Rate-limit responses
retain typed recovery. Registration's existing non-idempotent reconciliation
rules are unchanged.
Visible policy entries retain exact decimal strings, currency, group labels and
per-operation minimum/maximum, day and month slots. Usage is not invented for
per-operation slots. The backend owns the effective policy and remaining
capacity. The existing wallet remains a simulated service; this contribution
connects identity and visible limits, not real money movement. Future money
adapters must use the platform-owned customer account resource and authoritative
Limits reservation path, not a browser-selected policy or displayed balance.
## Review and rollout
Staff work in the existing **Sendico backoffice → KYC → Identity proof review**.
The platform stores private proofs, immutable profile assertions, review history
and the reviewed limits assignment. See the platform's
`docs/customer-identity-proofs.md` for authorization, retry and retention details.
Deploy Sendico Product/provider, Backoffice, Limits and backoffice frontend
changes together before enabling the new SXCHNGE frontend/backend adapters.
Existing `api.sendico` settings are reused. The integration account needs
organization read/update access. The platform's existing evidence/storage,
cleanup, discovery and limits configuration must be provisioned. Its ACCOUNT
default for SXCHNGE must be the existing unverified profile, and the existing
verified profile and personal-account visible view must be active. No new runtime
configuration parameters or live submissions are introduced by this work.
Merge [SXCHNGE sign-up Facts PR #47](https://git.sendico.io/tech/sxchnge/pulls/47)
before [this flow's PR #49](https://git.sendico.io/tech/sxchnge/pulls/49).
For the combined existing backoffice, apply
[Sendico identity-proofs PR #2886](https://git.sendico.io/tech/sendico/pulls/2886)
before [Facts review PR #2887](https://git.sendico.io/tech/sendico/pulls/2887).
They share one Limits assignment contract and retain both review destinations.
Proof cases go to Identity proof review; generic Facts review cannot link them
to a login account or assign limits through that path. Deploy the reconciled
Sendico services and frontend before using this flow. Deployment is separate.
## UX, measurement and validation
Existing KYC journey events measure preparation, selection, accepted submission,
status reads, retries, abandonment and latency. Profile contents, filenames,
photo bytes and credentials are excluded. Submission success is an intake
outcome, not proof of approval. Pending asynchronous staff review is not counted
as customer abandonment. A resumed route starts a new client attempt; immutable
submission IDs deduplicate backend business outcomes.
Authoritative completion is the staff decision plus the Limits assignment
receipt for that submission. Backend IDs correlate these records without becoming
metric labels. Existing identity request counters/histograms distinguish accepted,
invalid, denied, conflict, rate-limited and unavailable operations and include
actual authorization/I/O duration. Client analytics still defaults to the
existing in-memory sink; retained cross-device analytics depends on deployment.
Review covers optional onboarding, later account navigation, editable profile,
readable ID/selfie guidance, disabled actions, error/session recovery, input
preservation, correction/rejection/pending/approval, exact monetary presentation,
EN/RU at narrow widths and large text, keyboard access and private-image cache
eviction. Tests use synthetic data, local HTTP servers and disposable MongoDB.
No live ID documents, live email, production review or deployment were exercised.
Full validation commands and final PR links are recorded in `implementation.md`
and the task.
+47
View File
@@ -836,6 +836,9 @@ configuration remains uncommitted. See [API version installation](api-versions.m
individual registry and immutable Facts intake through optional `api.sendico`
configuration. Owner: `tech`; project: SXCHNGE; labels: backend and frontend.
Delivered in [PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), targeting
`main`. Implementation commit: `a584c81`.
The adapter sends full name, normalized email and the local customer reference.
The BFF persists a customer-owned subject binding, immutable facts snapshot and
confirmed receipt before activation email or sign-up acceptance. Activation and
@@ -880,3 +883,47 @@ parallelism resolved that infrastructure contention. The next full run exposed
the new locale fixture disposal error noted above; the corrected final run
passed, including EN/RU support states and verification recovery. No checks were
weakened or unrelated work changed.
## Customer identity verification — task #48
[Implementation and rollout](identity-verification.md) adds optional verification
on authenticated onboarding and later from the account: legal profile, ID photo
and selfie holding the same ID. Real adapters replace demo KYC/limits in backend
mode. Sendico's existing backoffice owns decisions and the reviewed Limits profile;
SXCHNGE has no local tenancy, roles or policy engine. Provider-subject uniqueness,
private bounded uploads, stable retries, exact decimal policy projections and
explicit upgrade-pending feedback are included. The unrelated notifications
runtime configuration is excluded.
Delivered in [PR #49](https://git.sendico.io/tech/sxchnge/pulls/49), branch
`identity-verification-48`, implementation commit `1354551`. It is stacked on
[sign-up Facts PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), which must
merge first, and requires
[Sendico PR #2886](https://git.sendico.io/tech/sendico/pulls/2886) to be deployed
before enabling the new flow. Tracking tasks:
[SXCHNGE #48](https://git.sendico.io/tech/sxchnge/issues/48) and
[Sendico #2885](https://git.sendico.io/tech/sendico/issues/2885).
Completion review covered the full backend and frontend contribution, including
subject ownership, private uploads, idempotent retries, session changes, exact
limits, DTO/mapper/service/model/provider boundaries, and the UX/measurement
checklists. Review fixes included duplicate-binding error classification,
upgrade-pending feedback, bounded response timeouts and stale-session protection.
Final validation passed: `GOFLAGS=-p=1 make test` (all backend race tests and all
frontend suites; 79 app tests), `make lint-backend`, changed-Dart formatting,
and `flutter analyze` in the app and shared KYC package. Focused KYC adapter and
onboarding tests passed (20 tests), as did focused Go identity/provider/HTTP
checks. An initial parallel run hit the existing Testcontainers reaper deadline;
serial Go package execution resolved local Docker contention without weakening
checks. No optional security scan, live customer proof, email, production
approval, deployment or merge was performed. Generated outputs remain ignored.
Reconciliation follow-up: the shared Limits assignment implementation and its
persisted-state validation are owned by Sendico PR #2886. The subsequent
[Sendico Facts review PR #2887](https://git.sendico.io/tech/sendico/pulls/2887)
retains both Product providers, Backoffice services and KYC destinations, and
routes proof cases to Identity proof review while blocking generic login linking
and assignment. Apply #2886 before #2887 and deploy the combined Sendico result
before this SXCHNGE flow. The public proof/limits contract is unchanged; this
SXCHNGE follow-up changes documentation only and requires consistency/diff checks.
+6
View File
@@ -3,6 +3,7 @@
Task [#46](https://git.sendico.io/tech/sxchnge/issues/46), branch
`identity-facts-46`. Primary module: identity-facts; secondary modules: auth,
customer creation, runtime, shared database source and Flutter error feedback.
Delivery: [PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), targeting `main`.
## Behavior and boundaries
@@ -174,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.
+5
View File
@@ -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
+15 -2
View File
@@ -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(
+14 -13
View File
@@ -22,16 +22,17 @@ String reasonText(AppLocalizations l, ReviewReason r) => switch (r) {
ReviewReason.passportExpired => l.kycExpiredReason,
ReviewReason.unableToVerify => l.kycDeniedReason,
};
String errorText(AppLocalizations l, KycError e) => switch (e) {
KycError.unavailable => l.kycErrorUnavailable,
KycError.internal => l.kycErrorInternal,
KycError.permission => l.kycErrorPermission,
KycError.format => l.kycErrorFormat,
KycError.tooLarge => l.kycErrorLarge,
KycError.unreadable => l.kycErrorUnreadable,
KycError.tooSmall => l.kycErrorSmall,
KycError.incomplete => l.kycErrorIncomplete,
KycError.sessionExpired => l.kycErrorSession,
KycError.rateLimited => l.kycErrorRate,
KycError.conflict => l.kycErrorConflict,
};
String errorText(AppLocalizations l, KycError e, {bool photo = false}) =>
switch (e) {
KycError.unavailable => l.kycErrorUnavailable,
KycError.internal => l.kycErrorInternal,
KycError.permission => photo ? l.kycErrorPermission : l.kycErrorAccess,
KycError.format => l.kycErrorFormat,
KycError.tooLarge => l.kycErrorLarge,
KycError.unreadable => l.kycErrorUnreadable,
KycError.tooSmall => l.kycErrorSmall,
KycError.incomplete => l.kycErrorIncomplete,
KycError.sessionExpired => l.kycErrorSession,
KycError.rateLimited => l.kycErrorRate,
KycError.conflict => l.kycErrorConflict,
};
+13 -1
View File
@@ -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),
+4 -1
View File
@@ -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(
+31 -4
View File
@@ -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,
),
),
],
),
+55
View File
@@ -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,
),
],
);
}
+67
View File
@@ -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),
],
);
}
}
+6 -2
View File
@@ -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) ...[
+30 -17
View File
@@ -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."
}
+31 -18
View File
@@ -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": "Проверка личности одобрена. Ожидается подтверждение повышения лимитов. Отправлять фото повторно не нужно."
}
+15 -2
View File
@@ -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),
+4 -1
View File
@@ -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,
),
+16
View File
@@ -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)
+23
View File
@@ -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,
);
},
);
}