Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a0b211d5f | ||
|
|
cd0981cea1 | ||
|
|
efeac88a49 | ||
|
|
3b551f1565 | ||
|
|
645181c65c | ||
|
|
de449d53bf | ||
|
|
fe60b7ac1a | ||
|
|
b644a10663 | ||
|
|
9370eacd9c | ||
|
|
5c255296d3 | ||
|
|
c106706615 | ||
|
|
915b8e8123 | ||
|
|
ec4115d083 | ||
|
|
f3ff7fc016 | ||
|
|
658d715c8a | ||
|
|
288a2f0c02 | ||
|
|
4605a197ad | ||
|
|
62df9eac5f | ||
|
|
4b22f6ce12 | ||
|
|
b5c4772168 | ||
|
|
464bd2d565 | ||
|
|
f1ff40d072 | ||
|
|
417e6d96b6 | ||
|
|
c8ef9c3d14 | ||
|
|
3af60f38b1 | ||
|
|
e84d2d1ad3 | ||
|
|
fa7b75d1b8 | ||
|
|
a587cbbc24 | ||
|
|
5f3b443e7b | ||
|
|
30f06c2f8f | ||
|
|
4c4b639321 | ||
|
|
13cc9c302e | ||
|
|
021bd04324 | ||
|
|
4844fb5bc9 | ||
|
|
3aeea9aa77 | ||
|
|
69fba8df70 | ||
|
|
b36eb72c23 | ||
|
|
3e4e496037 | ||
|
|
c1d28e705e | ||
|
|
abca15f047 | ||
|
|
c6ab4680fd | ||
|
|
86b6aafe7d | ||
|
|
bd675357bb | ||
|
|
24af794112 |
No files matched your search
@@ -13,14 +13,17 @@
|
|||||||
- Never commit autogenerated code to the repository. This applies to backend,
|
- Never commit autogenerated code to the repository. This applies to backend,
|
||||||
frontend, shared packages, and every other module, including generated
|
frontend, shared packages, and every other module, including generated
|
||||||
protobuf bindings, API clients, serializers, and framework-generated code.
|
protobuf bindings, API clients, serializers, and framework-generated code.
|
||||||
- Commit the source definitions, generator configuration, and reproducible
|
- Use externally published schemas as generation inputs. Do not embed or commit
|
||||||
generation commands instead. Generate code locally or during build and CI
|
schema snapshots in the repository. Commit external schema references,
|
||||||
workflows before any dependent validation or compilation steps.
|
generator configuration, and reproducible generation commands instead.
|
||||||
|
- Fetch the externally published schemas and generate code at build time, before
|
||||||
|
any dependent validation or compilation steps, including in CI.
|
||||||
- Keep generated outputs ignored by Git. Do not force-add ignored generated
|
- Keep generated outputs ignored by Git. Do not force-add ignored generated
|
||||||
files or copy generated code into tracked source files.
|
files or copy generated code into tracked source files.
|
||||||
- Change the source definitions or generator inputs rather than editing
|
- Change schema sources in their owning project or update external schema
|
||||||
generated outputs. Document the generation workflow so a clean checkout can
|
references and generator inputs rather than editing generated outputs.
|
||||||
build without committed generated code.
|
Document the generation workflow so a clean checkout can build without
|
||||||
|
committed generated code or embedded schema snapshots.
|
||||||
|
|
||||||
## Local development tooling
|
## Local development tooling
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ classifies delivery outcomes and never logs provider response bodies. Sendico's
|
|||||||
builders, dynamic-template dependency, notification events and unused producer
|
builders, dynamic-template dependency, notification events and unused producer
|
||||||
were removed. Resend is a new driver using the same contract.
|
were removed. Resend is a new driver using the same contract.
|
||||||
|
|
||||||
|
SMTP is an owned adapter using Go's standard `net/smtp`, verified `crypto/tls`
|
||||||
|
connections and standard MIME encoders. Its implicit-TLS connection configuration
|
||||||
|
matches Sendico's existing mail client, without importing its modules or copying
|
||||||
|
its keepalive/reconnect behavior. Local mailbox credentials are provisioned into
|
||||||
|
SXCHNGE Vault explicitly; the application does not read Sendico files or secrets.
|
||||||
|
|
||||||
Shared messaging was selected from that same Sendico revision's
|
Shared messaging was selected from that same Sendico revision's
|
||||||
`api/pkg/messaging`: `config.go`, `capability/errors.go`, `reliable/transport.go`,
|
`api/pkg/messaging`: `config.go`, `capability/errors.go`, `reliable/transport.go`,
|
||||||
`reliable/durable_settings.go`, `reliable/durable_settings_behavior.go`,
|
`reliable/durable_settings.go`, `reliable/durable_settings_behavior.go`,
|
||||||
|
|||||||
@@ -12,12 +12,35 @@ pending-work state atomically in one document through the shared DB wrapper.
|
|||||||
The outbox dispatcher publishes opaque IDs using shared messaging's durable
|
The outbox dispatcher publishes opaque IDs using shared messaging's durable
|
||||||
transport, adapted from Sendico. A durable NATS worker claims the notification
|
transport, adapted from Sendico. A durable NATS worker claims the notification
|
||||||
with an atomic conditional patch and saves the outcome before acknowledging.
|
with an atomic conditional patch and saves the outcome before acknowledging.
|
||||||
`DriverConfig` selects the email driver: `sendgrid` or `resend`.
|
`DriverConfig` selects the email driver: `sendgrid`, `resend` or `smtp`.
|
||||||
|
|
||||||
Email adapters only map rendered email models to provider wire payloads. SendGrid
|
Email adapters only map rendered email models to provider wire payloads. SendGrid
|
||||||
uses adapted Sendico SDK construction; Resend uses its email HTTP API. Both support
|
uses adapted Sendico SDK construction; Resend uses its email HTTP API. Both support
|
||||||
subject, text and HTML, bounded timeouts and safe error classification.
|
subject, text and HTML, bounded timeouts and safe error classification.
|
||||||
|
|
||||||
|
The owned SMTP adapter uses authenticated implicit TLS (normally port 465),
|
||||||
|
verified server certificates and context-bounded connections. It sends MIME
|
||||||
|
multipart/alternative with quoted-printable UTF-8 text/HTML and encoded subjects.
|
||||||
|
It uses Go's standard SMTP client and does not import Sendico modules or share a
|
||||||
|
mutable connection between workers. Configure it with:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
email:
|
||||||
|
driver: smtp
|
||||||
|
settings:
|
||||||
|
host: mail.example.com
|
||||||
|
port: 465
|
||||||
|
username: notifications@example.com
|
||||||
|
password_env: SMTP_PASSWORD
|
||||||
|
sender_address: notifications@example.com
|
||||||
|
sender_name: SXCHNGE
|
||||||
|
network_timeout: 10
|
||||||
|
```
|
||||||
|
|
||||||
|
`network_timeout` must be 1–20 seconds; the worker's expiry/shutdown context also
|
||||||
|
bounds every operation. Only the password belongs in Vault. There is no plaintext
|
||||||
|
fallback or certificate-verification bypass.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with
|
Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with
|
||||||
@@ -75,13 +98,41 @@ remaining status and request fingerprints expire through a seven-day TTL.
|
|||||||
- Resend receives the notification ID as its provider idempotency key. Its window
|
- Resend receives the notification ID as its provider idempotency key. Its window
|
||||||
is 24 hours. SendGrid has no equivalent guarantee; exactly-once external email
|
is 24 hours. SendGrid has no equivalent guarantee; exactly-once external email
|
||||||
delivery is not promised.
|
delivery is not promised.
|
||||||
|
- SMTP acceptance requires the final DATA 250 reply. A later QUIT failure cannot
|
||||||
|
undo acceptance. Explicit SMTP 4xx replies become retryable
|
||||||
|
`provider_temporarily_unavailable`; 5xx replies are terminal (authentication
|
||||||
|
failures retain `provider_authentication_failed`). Transport/TLS errors and a
|
||||||
|
lost final DATA reply become UNKNOWN. No immediate reconnect/resend occurs.
|
||||||
|
A stable Message-ID is included for correlation, not an exactly-once promise.
|
||||||
- Changing the configured driver fails queued jobs pinned to the old driver with
|
- Changing the configured driver fails queued jobs pinned to the old driver with
|
||||||
`driver_changed`. Drain queued jobs before changing sender credentials/config.
|
`driver_changed`. Drain queued jobs before changing sender credentials/config.
|
||||||
- Callers authorize recipients and preferences. No direct customer-database access,
|
- Callers authorize recipients and preferences. No direct customer-database access,
|
||||||
bulk campaigns, provider failover, SMS, push or Telegram delivery is included.
|
bulk campaigns, provider failover, SMS, push or Telegram delivery is included.
|
||||||
|
|
||||||
Structured logs report notification IDs, driver, state and attempt count; message
|
Structured logs report notification IDs, driver, state and attempt count. Provider
|
||||||
content, template values, addresses and provider response bodies are excluded.
|
HTTP outcomes include `provider_status`; failures also include `provider_error`
|
||||||
|
and `provider_message` when available. The adapter reads at most 16 KiB plus an
|
||||||
|
overflow byte and extracts only the Resend `name`/`message` or first SendGrid
|
||||||
|
`errors[].message`. Diagnostic messages are bounded to 512 Unicode characters
|
||||||
|
plus an ellipsis. Credentials, idempotency keys, outgoing request string values,
|
||||||
|
email addresses, URLs, long token-like strings and numeric verification/account
|
||||||
|
values embedded in the request are redacted; controls and formatting characters
|
||||||
|
are normalized. Malformed, incomplete and oversized error
|
||||||
|
responses produce a diagnostic fallback instead of raw content. Successful
|
||||||
|
response bodies are not logged. Diagnostic fields are transient: they are neither
|
||||||
|
persisted in notifications nor exposed in gRPC responses. The existing bounded
|
||||||
|
failure codes and delivery/retry decisions remain unchanged. Transport failures
|
||||||
|
include a sanitized cause. Provider outcome logging happens before the result
|
||||||
|
write so a storage failure cannot discard the only provider diagnostic.
|
||||||
|
|
||||||
|
Message content, template values, addresses and raw provider bodies must remain
|
||||||
|
excluded from logs. Provider text is sanitized external diagnostic data, never
|
||||||
|
an instruction. Use `provider_status`, `provider_error` and `provider_message`
|
||||||
|
together: the generic `provider_authentication_failed` category combines HTTP
|
||||||
|
401 and 403, including sender/domain authorization failures. A sending-only
|
||||||
|
Resend key is valid for sending, but cannot read the provider's log API.
|
||||||
|
SMTP outcomes use `provider_smtp_status` for protocol reply codes and the same
|
||||||
|
sanitized provider error/message fields, including password/auth-payload redaction.
|
||||||
Monitor pending age, UNKNOWN/FAILED states and repeated outbox/worker warnings.
|
Monitor pending age, UNKNOWN/FAILED states and repeated outbox/worker warnings.
|
||||||
|
|
||||||
## Validate and distribute
|
## Validate and distribute
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
_ = client.Disconnect(cleanup)
|
_ = client.Disconnect(cleanup)
|
||||||
}()
|
}()
|
||||||
startup, cancel := context.WithTimeout(ctx, 20*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
repo, err := store.New(logger, client.Database())
|
repo, err := store.New(logger, client.Database())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -57,7 +55,7 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
|
|||||||
}
|
}
|
||||||
defer rt.Close()
|
defer rt.Close()
|
||||||
svc := service.New(repo, catalog, sender, string(c.Email.Driver), logger)
|
svc := service.New(repo, catalog, sender, string(c.Email.Driver), logger)
|
||||||
broker, err := bus.New(startup, rt.Durable, rt.DurableAdmin, repo, svc, logger, c.Queue)
|
broker, err := bus.New(ctx, rt.Durable, rt.DurableAdmin, repo, svc, logger, c.Queue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,9 +35,13 @@ func New(ctx context.Context, transport reliable.Transport, admin reliable.Admin
|
|||||||
}
|
}
|
||||||
settings := reliable.DurableSettings{Enabled: true, Stream: config.Stream, Provisioning: config.Provisioning}
|
settings := reliable.DurableSettings{Enabled: true, Stream: config.Stream, Provisioning: config.Provisioning}
|
||||||
consumer := reliable.DurableConsumerSettings{Durable: "email", Subject: subject, AckWaitSeconds: 60, MaxDeliver: -1, DeliverPolicy: reliable.DurableDeliverAll}
|
consumer := reliable.DurableConsumerSettings{Durable: "email", Subject: subject, AckWaitSeconds: 60, MaxDeliver: -1, DeliverPolicy: reliable.DurableDeliverAll}
|
||||||
if err := admin.EnsureConsumerResources(ctx, settings, consumer); err != nil {
|
startup, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := admin.EnsureConsumerResources(startup, settings, consumer); err != nil {
|
||||||
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "configure notification messaging")
|
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "configure notification messaging")
|
||||||
}
|
}
|
||||||
|
// NATS retains this context and unsubscribes when it ends. The worker must
|
||||||
|
// live until service shutdown, independently of the provisioning deadline.
|
||||||
subscription, err := transport.OpenConsumer(ctx, settings, consumer)
|
subscription, err := transport.OpenConsumer(ctx, settings, consumer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "subscribe notification worker")
|
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "subscribe notification worker")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
|
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
type outbox struct{ jobs []model.Notification }
|
type outbox struct{ jobs []model.Notification }
|
||||||
@@ -23,6 +24,50 @@ type publisher struct {
|
|||||||
records []reliable.Record
|
records []reliable.Record
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type consumerTransport struct {
|
||||||
|
reliable.Transport
|
||||||
|
reliable.Subscription
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *consumerTransport) OpenConsumer(ctx context.Context, _ reliable.DurableSettings, _ reliable.DurableConsumerSettings) (reliable.Subscription, error) {
|
||||||
|
t.ctx = ctx
|
||||||
|
return t.Subscription, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type consumerAdmin struct {
|
||||||
|
reliable.Admin
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *consumerAdmin) EnsureConsumerResources(ctx context.Context, _ reliable.DurableSettings, _ reliable.DurableConsumerSettings) error {
|
||||||
|
a.ctx = ctx
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscriptionOutlivesProvisioning(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
transport := &consumerTransport{}
|
||||||
|
admin := &consumerAdmin{}
|
||||||
|
if _, err := New(ctx, transport, admin, &outbox{}, nil, zap.NewNop(), DefaultConfig()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if admin.ctx.Err() != context.Canceled {
|
||||||
|
t.Fatal("provisioning context was not released")
|
||||||
|
}
|
||||||
|
if _, ok := admin.ctx.Deadline(); !ok {
|
||||||
|
t.Fatal("provisioning has no deadline")
|
||||||
|
}
|
||||||
|
if err := transport.ctx.Err(); err != nil {
|
||||||
|
t.Fatalf("subscription ended with provisioning: %v", err)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
if transport.ctx.Err() != context.Canceled {
|
||||||
|
t.Fatal("subscription does not follow service shutdown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *publisher) Publish(_ context.Context, r reliable.Record) error {
|
func (p *publisher) Publish(_ context.Context, r reliable.Record) error {
|
||||||
if p.fail {
|
if p.fail {
|
||||||
return merrors.ServiceUnavailable("offline")
|
return merrors.ServiceUnavailable("offline")
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ messaging:
|
|||||||
reconnect_wait: 2
|
reconnect_wait: 2
|
||||||
templates: "templates.example.json"
|
templates: "templates.example.json"
|
||||||
email:
|
email:
|
||||||
driver: resend # sendgrid or resend
|
driver: resend # sendgrid or resend; see README for authenticated SMTP settings
|
||||||
settings:
|
settings:
|
||||||
api_key_env: "RESEND_API_KEY" # SENDGRID_API_KEY when driver is sendgrid
|
api_key_env: "RESEND_API_KEY" # SENDGRID_API_KEY when driver is sendgrid
|
||||||
sender_address: "notifications@example.com"
|
sender_address: "notifications@example.com"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type Driver string
|
|||||||
const (
|
const (
|
||||||
SendGrid Driver = "sendgrid"
|
SendGrid Driver = "sendgrid"
|
||||||
Resend Driver = "resend"
|
Resend Driver = "resend"
|
||||||
|
SMTP Driver = "smtp"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config = sharedmodel.DriverConfig[Driver]
|
type Config = sharedmodel.DriverConfig[Driver]
|
||||||
@@ -33,6 +34,11 @@ type Outcome struct {
|
|||||||
State model.State
|
State model.State
|
||||||
Code string
|
Code string
|
||||||
RetryAfter time.Duration
|
RetryAfter time.Duration
|
||||||
|
// Provider diagnostics are sanitized operational data, not public failures.
|
||||||
|
HTTPStatus int
|
||||||
|
SMTPStatus int
|
||||||
|
ProviderError string
|
||||||
|
ProviderMessage string
|
||||||
}
|
}
|
||||||
type Sender interface {
|
type Sender interface {
|
||||||
Send(context.Context, string, model.Email) Outcome
|
Send(context.Context, string, model.Email) Outcome
|
||||||
@@ -46,6 +52,9 @@ type client struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(config Config) (Sender, error) {
|
func New(config Config) (Sender, error) {
|
||||||
|
if config.Driver == SMTP {
|
||||||
|
return newSMTP(config)
|
||||||
|
}
|
||||||
var s Settings
|
var s Settings
|
||||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
|
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxErrorBody = 16 << 10
|
||||||
|
const maxErrorMessage = 512
|
||||||
|
|
||||||
|
var (
|
||||||
|
errorName = regexp.MustCompile(`^[a-zA-Z0-9_.-]{1,64}$`)
|
||||||
|
addresses = regexp.MustCompile(`[^\s<>"'(),;:]+@[^\s<>"'(),;:]+`)
|
||||||
|
urls = regexp.MustCompile(`(?i)https?://[^\s<>"']+`)
|
||||||
|
tokens = regexp.MustCompile(`[a-zA-Z0-9_+/-]{32,}={0,2}`)
|
||||||
|
numbers = regexp.MustCompile(`[0-9]{4,}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only the documented human-readable error fields cross the adapter boundary.
|
||||||
|
// Full bodies, headers and request data must never reach logs or public failures.
|
||||||
|
func providerFailure(body, request []byte, secrets ...string) (string, string) {
|
||||||
|
var dto struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Errors []struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"errors"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &dto); err != nil {
|
||||||
|
return "", "Provider error details unavailable: invalid JSON response"
|
||||||
|
}
|
||||||
|
if dto.Message == "" && len(dto.Errors) > 0 {
|
||||||
|
dto.Message = dto.Errors[0].Message
|
||||||
|
}
|
||||||
|
// Providers can echo any scalar string from their request DTOs. Strip those
|
||||||
|
// values before truncation, in addition to credentials and embedded tokens.
|
||||||
|
var payload any
|
||||||
|
if json.Unmarshal(request, &payload) == nil {
|
||||||
|
for _, value := range requestStrings(payload) {
|
||||||
|
secrets = append(secrets, value)
|
||||||
|
// Rendered login/verification codes and numeric account references
|
||||||
|
// can be echoed as fragments of an otherwise longer email body.
|
||||||
|
secrets = append(secrets, numbers.FindAllString(value, -1)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name := safeErrorText(dto.Name, secrets)
|
||||||
|
if !errorName.MatchString(name) {
|
||||||
|
name = ""
|
||||||
|
}
|
||||||
|
message := safeErrorText(dto.Message, secrets)
|
||||||
|
if message == "" {
|
||||||
|
message = "Provider did not include an error message"
|
||||||
|
}
|
||||||
|
return name, message
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestStrings(value any) []string {
|
||||||
|
var values []string
|
||||||
|
switch value := value.(type) {
|
||||||
|
case string:
|
||||||
|
values = append(values, value)
|
||||||
|
case []any:
|
||||||
|
for _, item := range value {
|
||||||
|
values = append(values, requestStrings(item)...)
|
||||||
|
}
|
||||||
|
case map[string]any:
|
||||||
|
for _, item := range value {
|
||||||
|
values = append(values, requestStrings(item)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeErrorText(text string, secrets []string) string {
|
||||||
|
sort.Slice(secrets, func(i, j int) bool { return len(secrets[i]) > len(secrets[j]) })
|
||||||
|
var replacements []string
|
||||||
|
for _, secret := range secrets {
|
||||||
|
if secret != "" {
|
||||||
|
replacements = append(replacements, secret, "[redacted]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text = strings.NewReplacer(replacements...).Replace(text)
|
||||||
|
text = addresses.ReplaceAllString(text, "[redacted-address]")
|
||||||
|
text = urls.ReplaceAllString(text, "[redacted-url]")
|
||||||
|
text = tokens.ReplaceAllString(text, "[redacted-token]")
|
||||||
|
text = strings.Join(strings.FieldsFunc(text, func(r rune) bool {
|
||||||
|
return unicode.IsSpace(r) || unicode.IsControl(r) || unicode.Is(unicode.Cf, r)
|
||||||
|
}), " ")
|
||||||
|
runes := []rune(text)
|
||||||
|
if len(runes) > maxErrorMessage {
|
||||||
|
text = string(runes[:maxErrorMessage]) + "…"
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProviderErrorDiagnostics(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, body, code, message string
|
||||||
|
}{
|
||||||
|
{"resend", `{"name":"validation_error","message":"The sxchnge.com domain is not verified"}`, "validation_error", "The sxchnge.com domain is not verified"},
|
||||||
|
{"sendgrid", `{"errors":[{"message":"The sender identity is not verified","field":"from","help":"private URL"}]}`, "", "The sender identity is not verified"},
|
||||||
|
{"no message", `{"unrecognized":"private response"}`, "", "Provider did not include an error message"},
|
||||||
|
{"malformed", `{"message":"private response"`, "", "Provider error details unavailable: invalid JSON response"},
|
||||||
|
{"wrong type", `{"message":{"secret":"private response"}}`, "", "Provider error details unavailable: invalid JSON response"},
|
||||||
|
{"trailing", `{"message":"private response"} {}`, "", "Provider error details unavailable: invalid JSON response"},
|
||||||
|
{"oversized", `{"message":"` + strings.Repeat("x", maxErrorBody) + `"}`, "", "Provider error details unavailable: incomplete or oversized response"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
_, _ = io.WriteString(w, tc.body)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
c := client{http: server.Client(), endpoint: server.URL}
|
||||||
|
out := c.post(context.Background(), "", struct{}{})
|
||||||
|
if out.State != model.Failed || out.Code != "provider_authentication_failed" || out.HTTPStatus != 403 || out.ProviderError != tc.code || out.ProviderMessage != tc.message {
|
||||||
|
t.Fatalf("unexpected provider outcome: %+v", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderDiagnosticsRedactRequestDataBeforeTruncation(t *testing.T) {
|
||||||
|
key := "re_credential-secret"
|
||||||
|
request := []byte(`{"from":"Sender <sender@example.com>","to":["recipient@example.com"],"personalizations":[{"dynamic_template_data":{"secret":"short-private-value"}}],"subject":"Private subject","text":"Private text","html":"<p>Private HTML</p>","content":[{"value":"Your verification code is 123456; account 9876543210"}]}`)
|
||||||
|
token := strings.Repeat("a", 43)
|
||||||
|
message := "Credential " + key + "; recipient@example.com; Private subject; Private text; <p>Private HTML</p>; short-private-value; https://example.com/verify?token=" + token + "; " + token + "; 123456; 9876543210; job-secret\n\r\x00\u202eforged-log"
|
||||||
|
body, err := json.Marshal(map[string]string{"name": "validation_error", "message": message})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
name, result := providerFailure(body, request, key, "job-secret")
|
||||||
|
if name != "validation_error" || !strings.Contains(result, "forged-log") || !strings.Contains(result, "[redacted]") {
|
||||||
|
t.Fatal("lost useful diagnostic", name, result)
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{key, "recipient@example.com", "Private subject", "Private text", "Private HTML", "short-private-value", "https://", token, "123456", "9876543210", "job-secret", "\n", "\r", "\x00", "\u202e"} {
|
||||||
|
if strings.Contains(result, forbidden) {
|
||||||
|
t.Fatalf("diagnostic contains sensitive value or control %q", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body, err = json.Marshal(map[string]string{"name": "validation_error", "message": strings.Repeat("ą", maxErrorMessage+20) + key})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, result = providerFailure(body, request, key)
|
||||||
|
if !utf8.ValidString(result) || utf8.RuneCountInString(result) != maxErrorMessage+1 || !strings.HasSuffix(result, "…") || strings.Contains(result, "re_") {
|
||||||
|
t.Fatal("invalid or unbounded diagnostic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuccessfulResponsesDoNotExposeProviderContent(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, `{"name":"private","message":"private provider response"}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
c := client{http: server.Client(), endpoint: server.URL}
|
||||||
|
if out := c.post(context.Background(), "", struct{}{}); out.State != model.Accepted || out.HTTPStatus != 200 || out.ProviderMessage != "" || out.ProviderError != "" {
|
||||||
|
t.Fatal("success leaked provider content", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransportFailureHasSanitizedDiagnostic(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
c := client{http: http.DefaultClient, key: "credential-secret", endpoint: "https://example.com/emails?credential=credential-secret"}
|
||||||
|
out := c.post(ctx, "job-secret", struct{}{})
|
||||||
|
if out.State != model.Unknown || !strings.Contains(out.ProviderMessage, "context canceled") || strings.Contains(out.ProviderMessage, "credential-secret") || strings.Contains(out.ProviderMessage, "https://") {
|
||||||
|
t.Fatal("transport cause missing or unsafe", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,14 +28,15 @@ func (c *client) post(ctx context.Context, key string, payload any) Outcome {
|
|||||||
}
|
}
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown"}
|
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown", ProviderMessage: safeErrorText(err.Error(), []string{c.key, key})}
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
// Discard a bounded response without logging provider-controlled content.
|
// Never pass raw provider bodies through the outcome or log them directly.
|
||||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
|
details, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody+1))
|
||||||
|
outcome := Outcome{HTTPStatus: resp.StatusCode}
|
||||||
switch {
|
switch {
|
||||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||||
return Outcome{State: model.Accepted}
|
outcome.State = model.Accepted
|
||||||
case resp.StatusCode == 429:
|
case resp.StatusCode == 429:
|
||||||
delay := time.Minute
|
delay := time.Minute
|
||||||
if n, e := strconv.Atoi(resp.Header.Get("Retry-After")); e == nil && n > 0 && n <= 86400 {
|
if n, e := strconv.Atoi(resp.Header.Get("Retry-After")); e == nil && n > 0 && n <= 86400 {
|
||||||
@@ -43,12 +44,20 @@ func (c *client) post(ctx context.Context, key string, payload any) Outcome {
|
|||||||
} else if date, e := http.ParseTime(resp.Header.Get("Retry-After")); e == nil && time.Until(date) > 0 {
|
} else if date, e := http.ParseTime(resp.Header.Get("Retry-After")); e == nil && time.Until(date) > 0 {
|
||||||
delay = time.Until(date)
|
delay = time.Until(date)
|
||||||
}
|
}
|
||||||
return Outcome{State: model.RetryWait, Code: "provider_rate_limited", RetryAfter: delay}
|
outcome.State, outcome.Code, outcome.RetryAfter = model.RetryWait, "provider_rate_limited", delay
|
||||||
case resp.StatusCode >= 500 || resp.StatusCode == 408:
|
case resp.StatusCode >= 500 || resp.StatusCode == 408:
|
||||||
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown"}
|
outcome.State, outcome.Code = model.Unknown, "provider_outcome_unknown"
|
||||||
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
||||||
return Outcome{State: model.Failed, Code: "provider_authentication_failed"}
|
outcome.State, outcome.Code = model.Failed, "provider_authentication_failed"
|
||||||
default:
|
default:
|
||||||
return Outcome{State: model.Failed, Code: "provider_rejected"}
|
outcome.State, outcome.Code = model.Failed, "provider_rejected"
|
||||||
}
|
}
|
||||||
|
if outcome.State != model.Accepted {
|
||||||
|
if readErr != nil || len(details) > maxErrorBody {
|
||||||
|
outcome.ProviderMessage = "Provider error details unavailable: incomplete or oversized response"
|
||||||
|
} else {
|
||||||
|
outcome.ProviderError, outcome.ProviderMessage = providerFailure(details, body, c.key, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outcome
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"mime/quotedprintable"
|
||||||
|
"net/mail"
|
||||||
|
"net/textproto"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func smtpMessage(s smtpSettings, key string, email model.Email) ([]byte, error) {
|
||||||
|
if !ValidAddress(email.Address) || strings.ContainsAny(email.Subject, "\r\n") || (email.Text == "" && email.HTML == "") {
|
||||||
|
return nil, merrors.InvalidArgument("invalid email content")
|
||||||
|
}
|
||||||
|
var body bytes.Buffer
|
||||||
|
parts := multipart.NewWriter(&body)
|
||||||
|
for _, part := range []struct{ kind, value string }{{"text/plain", email.Text}, {"text/html", email.HTML}} {
|
||||||
|
if part.value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
writer, err := parts.CreatePart(textproto.MIMEHeader{"Content-Type": {part.kind + "; charset=utf-8"}, "Content-Transfer-Encoding": {"quoted-printable"}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.InternalWrap(err, "create email MIME part")
|
||||||
|
}
|
||||||
|
encoded := quotedprintable.NewWriter(writer)
|
||||||
|
if _, err = encoded.Write([]byte(part.value)); err != nil {
|
||||||
|
return nil, merrors.InternalWrap(err, "encode email MIME part")
|
||||||
|
}
|
||||||
|
if err = encoded.Close(); err != nil {
|
||||||
|
return nil, merrors.InternalWrap(err, "finish email MIME part")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := parts.Close(); err != nil {
|
||||||
|
return nil, merrors.InternalWrap(err, "finish email MIME body")
|
||||||
|
}
|
||||||
|
from := (&mail.Address{Name: s.SenderName, Address: s.SenderAddress}).String()
|
||||||
|
to := (&mail.Address{Address: email.Address}).String()
|
||||||
|
domain := s.SenderAddress[strings.LastIndex(s.SenderAddress, "@")+1:]
|
||||||
|
headers := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: <%x@%s>\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=%q\r\n\r\n", from, to, mime.QEncoding.Encode("utf-8", email.Subject), time.Now().UTC().Format(time.RFC1123Z), sha256.Sum256([]byte(key)), domain, parts.Boundary())
|
||||||
|
return append([]byte(headers), body.Bytes()...), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/smtp"
|
||||||
|
"net/textproto"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"github.com/go-viper/mapstructure/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type smtpSettings struct {
|
||||||
|
Host string `mapstructure:"host"`
|
||||||
|
Port int `mapstructure:"port"`
|
||||||
|
Username string `mapstructure:"username"`
|
||||||
|
PasswordEnv string `mapstructure:"password_env"`
|
||||||
|
SenderAddress string `mapstructure:"sender_address"`
|
||||||
|
SenderName string `mapstructure:"sender_name"`
|
||||||
|
NetworkTimeout int `mapstructure:"network_timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type smtpSender struct {
|
||||||
|
settings smtpSettings
|
||||||
|
password string
|
||||||
|
tls *tls.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSMTP(config Config) (Sender, error) {
|
||||||
|
var s smtpSettings
|
||||||
|
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.InternalWrap(err, "create SMTP settings decoder")
|
||||||
|
}
|
||||||
|
if decoder.Decode(config.Settings) != nil || s.Host == "" || strings.ContainsAny(s.Host, " /@\r\n\t") || s.Port < 1 || s.Port > 65535 || !ValidAddress(s.Username) || !ValidAddress(s.SenderAddress) || strings.ContainsAny(s.SenderName, "\r\n") || s.NetworkTimeout < 1 || s.NetworkTimeout > 20 {
|
||||||
|
return nil, merrors.InvalidArgument("invalid SMTP settings")
|
||||||
|
}
|
||||||
|
password := os.Getenv(s.PasswordEnv)
|
||||||
|
if s.PasswordEnv == "" || password == "" || strings.ContainsAny(password, "\r\n\x00") {
|
||||||
|
return nil, merrors.FailedPrecondition("SMTP password environment variable is missing or invalid")
|
||||||
|
}
|
||||||
|
return &smtpSender{settings: s, password: password, tls: &tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTP uses a fresh authenticated TLS connection per attempt. No automatic
|
||||||
|
// resend follows an uncertain result, since SMTP has no idempotency guarantee.
|
||||||
|
func (s *smtpSender) Send(ctx context.Context, key string, email model.Email) Outcome {
|
||||||
|
body, err := smtpMessage(s.settings, key, email)
|
||||||
|
if err != nil {
|
||||||
|
return Outcome{State: model.Failed, Code: "invalid_payload"}
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, time.Duration(s.settings.NetworkTimeout)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
dialer := tls.Dialer{NetDialer: &net.Dialer{}, Config: s.tls}
|
||||||
|
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(s.settings.Host, strconv.Itoa(s.settings.Port)))
|
||||||
|
if err != nil {
|
||||||
|
return s.failure(err, key, email)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
|
||||||
|
defer stop()
|
||||||
|
deadline, _ := ctx.Deadline()
|
||||||
|
if err = conn.SetDeadline(deadline); err != nil {
|
||||||
|
return s.failure(err, key, email)
|
||||||
|
}
|
||||||
|
client, err := smtp.NewClient(conn, s.settings.Host)
|
||||||
|
if err == nil {
|
||||||
|
err = client.Auth(smtp.PlainAuth("", s.settings.Username, s.password, s.settings.Host))
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = client.Mail(s.settings.SenderAddress)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = client.Rcpt(email.Address)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return s.failure(err, key, email)
|
||||||
|
}
|
||||||
|
writer, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return s.failure(err, key, email)
|
||||||
|
}
|
||||||
|
if _, err = io.Copy(writer, bytes.NewReader(body)); err == nil {
|
||||||
|
err = writer.Close()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return s.failure(err, key, email)
|
||||||
|
}
|
||||||
|
// DATA's final 250 is acceptance. A later QUIT error cannot undo it.
|
||||||
|
_ = client.Quit()
|
||||||
|
return Outcome{State: model.Accepted, SMTPStatus: 250}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSender) failure(err error, key string, email model.Email) Outcome {
|
||||||
|
secrets := []string{s.password, s.settings.Username, key, email.Address, email.Subject, email.HTML, email.Text}
|
||||||
|
secrets = append(secrets, base64.StdEncoding.EncodeToString([]byte("\x00"+s.settings.Username+"\x00"+s.password)))
|
||||||
|
secrets = append(secrets, numbers.FindAllString(email.Text+" "+email.HTML, -1)...)
|
||||||
|
out := Outcome{State: model.Unknown, Code: "provider_outcome_unknown", ProviderError: "smtp", ProviderMessage: safeErrorText(err.Error(), secrets)}
|
||||||
|
var reply *textproto.Error
|
||||||
|
if errors.As(err, &reply) {
|
||||||
|
out.SMTPStatus = reply.Code
|
||||||
|
if reply.Code >= 400 && reply.Code < 500 {
|
||||||
|
out.State, out.Code, out.RetryAfter = model.RetryWait, "provider_temporarily_unavailable", time.Minute
|
||||||
|
} else if reply.Code >= 500 && reply.Code < 600 {
|
||||||
|
out.State, out.Code = model.Failed, "provider_rejected"
|
||||||
|
if reply.Code == 530 || reply.Code == 534 || reply.Code == 535 || reply.Code == 538 {
|
||||||
|
out.Code = "provider_authentication_failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"net"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/mail"
|
||||||
|
"net/textproto"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type smtpFixture struct {
|
||||||
|
settings smtpSettings
|
||||||
|
root *x509.CertPool
|
||||||
|
body chan []byte
|
||||||
|
auth atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func smtpServer(t *testing.T, reject string, code int) *smtpFixture {
|
||||||
|
t.Helper()
|
||||||
|
certServer := httptest.NewTLSServer(nil)
|
||||||
|
cert := certServer.TLS.Certificates[0]
|
||||||
|
root := x509.NewCertPool()
|
||||||
|
root.AddCert(certServer.Certificate())
|
||||||
|
certServer.Close()
|
||||||
|
listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = listener.Close() })
|
||||||
|
host, port, _ := net.SplitHostPort(listener.Addr().String())
|
||||||
|
n, _ := strconv.Atoi(port)
|
||||||
|
f := &smtpFixture{settings: smtpSettings{Host: host, Port: n, Username: "sender@example.com", SenderAddress: "sender@example.com", SenderName: "SXCHNGE", NetworkTimeout: 2}, root: root, body: make(chan []byte, 1)}
|
||||||
|
go func() {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
if reject == "greeting" {
|
||||||
|
_ = conn.(*tls.Conn).Handshake()
|
||||||
|
_, _ = io.Copy(io.Discard, conn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c := textproto.NewConn(conn)
|
||||||
|
if c.PrintfLine("220 localhost ESMTP") != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
line, err := c.ReadLine()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
command := strings.SplitN(line, " ", 2)[0]
|
||||||
|
if command == reject {
|
||||||
|
_ = c.PrintfLine("%d rejected recipient user@example.com credential test-password", code)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch command {
|
||||||
|
case "EHLO":
|
||||||
|
_ = c.PrintfLine("250-localhost\r\n250 AUTH PLAIN")
|
||||||
|
case "AUTH":
|
||||||
|
want := "AUTH PLAIN " + base64.StdEncoding.EncodeToString([]byte("\x00sender@example.com\x00test-password"))
|
||||||
|
f.auth.Store(line == want)
|
||||||
|
_ = c.PrintfLine("235 authenticated")
|
||||||
|
case "MAIL", "RCPT":
|
||||||
|
_ = c.PrintfLine("250 OK")
|
||||||
|
case "DATA":
|
||||||
|
_ = c.PrintfLine("354 send message")
|
||||||
|
body, err := c.ReadDotBytes()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.body <- body
|
||||||
|
if reject == "final" {
|
||||||
|
_ = c.PrintfLine("%d final rejection", code)
|
||||||
|
} else if reject == "disconnect" {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
_ = c.PrintfLine("250 queued")
|
||||||
|
}
|
||||||
|
case "QUIT":
|
||||||
|
// A lost QUIT reply must not turn accepted DATA into UNKNOWN.
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
_ = c.PrintfLine("500 unexpected command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *smtpFixture) sender() *smtpSender {
|
||||||
|
return &smtpSender{settings: f.settings, password: "test-password", tls: &tls.Config{ServerName: f.settings.Host, RootCAs: f.root, MinVersion: tls.VersionTLS12}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSMTPAcceptanceAndMIME(t *testing.T) {
|
||||||
|
f := smtpServer(t, "", 0)
|
||||||
|
sender := f.sender()
|
||||||
|
email := model.Email{Address: "user@example.com", Subject: "Подтвердите почту", Text: "Confirm code 123456\n.line", HTML: "<p>Confirm code 123456</p>"}
|
||||||
|
out := sender.Send(context.Background(), "notification-id", email)
|
||||||
|
if out.State != model.Accepted || out.SMTPStatus != 250 || !f.auth.Load() {
|
||||||
|
t.Fatal("SMTP acceptance/authentication failed", out)
|
||||||
|
}
|
||||||
|
msg, err := mail.ReadMessage(strings.NewReader(string(<-f.body)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
subject, err := new(mime.WordDecoder).DecodeHeader(msg.Header.Get("Subject"))
|
||||||
|
if err != nil || subject != email.Subject {
|
||||||
|
t.Fatal("wrong MIME subject", subject, err)
|
||||||
|
}
|
||||||
|
if msg.Header.Get("From") != "\"SXCHNGE\" <sender@example.com>" || !strings.HasSuffix(msg.Header.Get("Message-ID"), "@example.com>") {
|
||||||
|
t.Fatal("invalid sender/message identifier")
|
||||||
|
}
|
||||||
|
kind, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||||
|
if err != nil || kind != "multipart/alternative" {
|
||||||
|
t.Fatal("wrong MIME type", kind, err)
|
||||||
|
}
|
||||||
|
parts := multipart.NewReader(msg.Body, params["boundary"])
|
||||||
|
for _, want := range []string{email.Text, email.HTML} {
|
||||||
|
part, err := parts.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(part)
|
||||||
|
if err != nil || strings.ReplaceAll(string(body), "\r\n", "\n") != want {
|
||||||
|
t.Fatal("incorrect MIME content", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSMTPFailureSafety(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
stage string
|
||||||
|
code int
|
||||||
|
state model.State
|
||||||
|
failure string
|
||||||
|
}{
|
||||||
|
{"AUTH", 535, model.Failed, "provider_authentication_failed"},
|
||||||
|
{"RCPT", 550, model.Failed, "provider_rejected"},
|
||||||
|
{"final", 451, model.RetryWait, "provider_temporarily_unavailable"},
|
||||||
|
{"disconnect", 0, model.Unknown, "provider_outcome_unknown"},
|
||||||
|
} {
|
||||||
|
t.Run(fmt.Sprintf("%s-%d", tc.stage, tc.code), func(t *testing.T) {
|
||||||
|
f := smtpServer(t, tc.stage, tc.code)
|
||||||
|
out := f.sender().Send(context.Background(), "id", model.Email{Address: "user@example.com", Subject: "Subject", Text: "Body"})
|
||||||
|
if out.State != tc.state || out.Code != tc.failure || out.SMTPStatus != tc.code {
|
||||||
|
t.Fatal("incorrect SMTP classification", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out.ProviderMessage, "test-password") || strings.Contains(out.ProviderMessage, "user@example.com") {
|
||||||
|
t.Fatal("SMTP rejection leaked private data")
|
||||||
|
}
|
||||||
|
if tc.code == 451 && out.RetryAfter <= 0 {
|
||||||
|
t.Fatal("temporary rejection did not schedule retry")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSMTPCertificateAndCancellation(t *testing.T) {
|
||||||
|
t.Run("untrusted certificate", func(t *testing.T) {
|
||||||
|
f := smtpServer(t, "", 0)
|
||||||
|
sender := f.sender()
|
||||||
|
sender.tls.RootCAs = x509.NewCertPool()
|
||||||
|
if out := sender.Send(context.Background(), "id", model.Email{Address: "user@example.com", Text: "Body"}); out.State != model.Unknown || f.auth.Load() {
|
||||||
|
t.Fatal("untrusted TLS accepted", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("cancel blocked greeting", func(t *testing.T) {
|
||||||
|
f := smtpServer(t, "greeting", 0)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
started := time.Now()
|
||||||
|
if out := f.sender().Send(ctx, "id", model.Email{Address: "user@example.com", Text: "Body"}); out.State != model.Unknown || time.Since(started) > time.Second {
|
||||||
|
t.Fatal("SMTP ignored cancellation", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSMTPConfigAndHeaderValidation(t *testing.T) {
|
||||||
|
t.Setenv("TEST_SMTP_PASSWORD", "test-password")
|
||||||
|
valid := map[string]any{"host": "mail.example.com", "port": 465, "username": "sender@example.com", "password_env": "TEST_SMTP_PASSWORD", "sender_address": "sender@example.com", "network_timeout": 10}
|
||||||
|
if _, err := New(Config{Driver: SMTP, Settings: valid}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for field, value := range map[string]any{"port": 0, "host": "https://bad", "username": "bad", "network_timeout": 0, "password_env": "MISSING_SMTP_PASSWORD", "typo": true} {
|
||||||
|
settings := map[string]any{}
|
||||||
|
for key, item := range valid {
|
||||||
|
settings[key] = item
|
||||||
|
}
|
||||||
|
settings[field] = value
|
||||||
|
if _, err := New(Config{Driver: SMTP, Settings: settings}); err == nil {
|
||||||
|
t.Fatalf("invalid setting %s accepted", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := smtpMessage(smtpSettings{}, "id", model.Email{Address: "user@example.com", Subject: "Subject\r\nBcc: stolen@example.com", Text: "Body"}); err == nil {
|
||||||
|
t.Fatal("header injection accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSMTPAuthenticationDiagnosticRedactsEncodedCredential(t *testing.T) {
|
||||||
|
f := &smtpSender{settings: smtpSettings{Username: "sender@example.com"}, password: "a"}
|
||||||
|
auth := base64.StdEncoding.EncodeToString([]byte("\x00sender@example.com\x00a"))
|
||||||
|
out := f.failure(&textproto.Error{Code: 535, Msg: "Rejected AUTH " + auth}, "id", model.Email{})
|
||||||
|
if out.SMTPStatus != 535 || strings.Contains(out.ProviderMessage, auth) || !strings.Contains(out.ProviderMessage, "AUTH [redacted]") {
|
||||||
|
t.Fatalf("encoded SMTP credential was not fully redacted: %q", out.ProviderMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/notifications/email"
|
||||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -23,6 +24,7 @@ func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
var outcome email.Outcome
|
||||||
switch {
|
switch {
|
||||||
case !n.ExpiresAt.After(now):
|
case !n.ExpiresAt.After(now):
|
||||||
n.State = model.Expired
|
n.State = model.Expired
|
||||||
@@ -36,7 +38,7 @@ func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
|
|||||||
deadline = n.ExpiresAt
|
deadline = n.ExpiresAt
|
||||||
}
|
}
|
||||||
sendCtx, cancel := context.WithDeadline(ctx, deadline)
|
sendCtx, cancel := context.WithDeadline(ctx, deadline)
|
||||||
outcome := s.sender.Send(sendCtx, n.ID.Hex(), n.Email)
|
outcome = s.sender.Send(sendCtx, n.ID.Hex(), n.Email)
|
||||||
cancel()
|
cancel()
|
||||||
n.State = outcome.State
|
n.State = outcome.State
|
||||||
n.FailureCode = outcome.Code
|
n.FailureCode = outcome.Code
|
||||||
@@ -62,14 +64,27 @@ func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
|
|||||||
// Persist the result even when shutdown canceled the provider request.
|
// Persist the result even when shutdown canceled the provider request.
|
||||||
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err = s.repo.Finish(finishCtx, n); err != nil {
|
|
||||||
return true, err
|
|
||||||
}
|
|
||||||
fields := []zap.Field{zap.String("notification_id", n.ID.Hex()), zap.String("driver", n.Driver), zap.String("state", string(n.State)), zap.Uint32("attempts", n.Attempts), zap.String("failure_code", n.FailureCode)}
|
fields := []zap.Field{zap.String("notification_id", n.ID.Hex()), zap.String("driver", n.Driver), zap.String("state", string(n.State)), zap.Uint32("attempts", n.Attempts), zap.String("failure_code", n.FailureCode)}
|
||||||
|
if outcome.HTTPStatus != 0 {
|
||||||
|
fields = append(fields, zap.Int("provider_status", outcome.HTTPStatus))
|
||||||
|
}
|
||||||
|
if outcome.SMTPStatus != 0 {
|
||||||
|
fields = append(fields, zap.Int("provider_smtp_status", outcome.SMTPStatus))
|
||||||
|
}
|
||||||
|
if outcome.ProviderError != "" {
|
||||||
|
fields = append(fields, zap.String("provider_error", outcome.ProviderError))
|
||||||
|
}
|
||||||
|
if outcome.ProviderMessage != "" {
|
||||||
|
fields = append(fields, zap.String("provider_message", outcome.ProviderMessage))
|
||||||
|
}
|
||||||
|
// Keep the provider outcome observable even if its local result write fails.
|
||||||
if n.State == model.Accepted {
|
if n.State == model.Accepted {
|
||||||
s.logger.Info("Email accepted by provider", fields...)
|
s.logger.Info("Email accepted by provider", fields...)
|
||||||
} else {
|
} else {
|
||||||
s.logger.Warn("Email delivery attempt finished", fields...)
|
s.logger.Warn("Email delivery attempt finished", fields...)
|
||||||
}
|
}
|
||||||
|
if err = s.repo.Finish(finishCtx, n); err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -2,18 +2,23 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/notifications/email"
|
"git.sendico.io/tech/sxchnge/notifications/email"
|
||||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zaptest/observer"
|
||||||
)
|
)
|
||||||
|
|
||||||
type workerRepo struct {
|
type workerRepo struct {
|
||||||
Repository
|
Repository
|
||||||
job model.Notification
|
job model.Notification
|
||||||
saved *model.Notification
|
saved *model.Notification
|
||||||
|
finishErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *workerRepo) Recover(context.Context, time.Time) error { return nil }
|
func (r *workerRepo) Recover(context.Context, time.Time) error { return nil }
|
||||||
@@ -21,7 +26,10 @@ func (r *workerRepo) Claim(context.Context, string, time.Time) (*model.Notificat
|
|||||||
n := r.job
|
n := r.job
|
||||||
return &n, nil
|
return &n, nil
|
||||||
}
|
}
|
||||||
func (r *workerRepo) Finish(_ context.Context, n *model.Notification) error { r.saved = n; return nil }
|
func (r *workerRepo) Finish(_ context.Context, n *model.Notification) error {
|
||||||
|
r.saved = n
|
||||||
|
return r.finishErr
|
||||||
|
}
|
||||||
|
|
||||||
type workerSender struct {
|
type workerSender struct {
|
||||||
calls int
|
calls int
|
||||||
@@ -62,3 +70,36 @@ func TestWorkerTerminalAndRetryOutcomes(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkerLogsProviderFailureOnce(t *testing.T) {
|
||||||
|
for _, failPersistence := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprint(failPersistence), func(t *testing.T) {
|
||||||
|
core, logs := observer.New(zap.WarnLevel)
|
||||||
|
repo := &workerRepo{job: model.Notification{Driver: "resend", ExpiresAt: time.Now().Add(time.Hour), Attempts: 1, Email: model.Email{Address: "private@example.com", Subject: "Private subject", Text: "Private text"}}}
|
||||||
|
if failPersistence {
|
||||||
|
repo.finishErr = merrors.ServiceUnavailable("storage offline")
|
||||||
|
}
|
||||||
|
sender := &workerSender{outcome: email.Outcome{State: model.Failed, Code: "provider_authentication_failed", HTTPStatus: 403, ProviderError: "validation_error", ProviderMessage: "The sxchnge.com domain is not verified"}}
|
||||||
|
svc := New(repo, nil, sender, "resend", zap.New(core))
|
||||||
|
if _, err := svc.Dispatch(context.Background(), "id"); !errors.Is(err, repo.finishErr) {
|
||||||
|
t.Fatal("unexpected persistence error", err)
|
||||||
|
}
|
||||||
|
entries := logs.All()
|
||||||
|
if len(entries) != 1 || entries[0].Message != "Email delivery attempt finished" {
|
||||||
|
t.Fatal("expected one provider outcome log", entries)
|
||||||
|
}
|
||||||
|
fields := entries[0].ContextMap()
|
||||||
|
if fields["provider_status"] != int64(403) || fields["provider_error"] != "validation_error" || fields["provider_message"] != "The sxchnge.com domain is not verified" {
|
||||||
|
t.Fatal("provider diagnostic missing", fields)
|
||||||
|
}
|
||||||
|
if repo.saved.FailureCode != "provider_authentication_failed" {
|
||||||
|
t.Fatal("public failure code changed", repo.saved.FailureCode)
|
||||||
|
}
|
||||||
|
for _, field := range []string{"address", "email", "subject", "text", "html", "parameters"} {
|
||||||
|
if _, ok := fields[field]; ok {
|
||||||
|
t.Fatalf("private notification field %s logged", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,7 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
|
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
|
||||||
|
deps.Auth.Wallet = deps.Payments
|
||||||
logger.Info("Sendico sign-up facts integration enabled")
|
logger.Info("Sendico sign-up facts integration enabled")
|
||||||
}
|
}
|
||||||
records, err := factorstore.Open(logger, db)
|
records, err := factorstore.Open(logger, db)
|
||||||
|
|||||||
@@ -31,8 +31,13 @@ type SignupFacts interface {
|
|||||||
Ready(context.Context, bson.ObjectID) error
|
Ready(context.Context, bson.ObjectID) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CurrencyAccounts interface {
|
||||||
|
Ensure(context.Context, *customer.Account) error
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
Facts SignupFacts
|
Facts SignupFacts
|
||||||
|
Wallet CurrencyAccounts
|
||||||
Accounts account.DB
|
Accounts account.DB
|
||||||
Customers accountservice.AccountService
|
Customers accountservice.AccountService
|
||||||
Tokens verification.DB
|
Tokens verification.DB
|
||||||
@@ -110,6 +115,11 @@ func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key strin
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if s.Wallet != nil {
|
||||||
|
if err := s.Wallet.Ensure(ctx, a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
if raw != "" {
|
if raw != "" {
|
||||||
u, _ := url.Parse(s.ActivationURL)
|
u, _ := url.Parse(s.ActivationURL)
|
||||||
q := u.Query()
|
q := u.Query()
|
||||||
@@ -148,6 +158,11 @@ func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if s.Wallet != nil {
|
||||||
|
if err := s.Wallet.Ensure(ctx, a); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
_, err = s.sendActivation(ctx, a, key)
|
_, err = s.sendActivation(ctx, a, key)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -179,10 +194,11 @@ func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key s
|
|||||||
err = s.Sender.Send(ctx, notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"url": u.String()}})
|
err = s.Sender.Send(ctx, notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"url": u.String()}})
|
||||||
return result.ExpiresAt, err
|
return result.ExpiresAt, err
|
||||||
}
|
}
|
||||||
func (s *Service) Activate(ctx context.Context, raw string) error {
|
func (s *Service) Activate(ctx context.Context, raw string) (string, error) {
|
||||||
if len(raw) != 43 {
|
if len(raw) != 43 {
|
||||||
return verification.ErorrTokenNotFound()
|
return "", verification.ErorrTokenNotFound()
|
||||||
}
|
}
|
||||||
|
var email string
|
||||||
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
||||||
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
|
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -200,7 +216,14 @@ func (s *Service) Activate(ctx context.Context, raw string) error {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
|
if err := s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
email = a.Login
|
||||||
|
return nil, nil
|
||||||
})
|
})
|
||||||
return err
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return email, nil
|
||||||
}
|
}
|
||||||
@@ -139,11 +139,15 @@ func (pr *PublicRouter) activate(r *http.Request) http.HandlerFunc {
|
|||||||
if err := decodeFlow(r, &req); err != nil {
|
if err := decodeFlow(r, &req); err != nil {
|
||||||
return pr.flowError(err)
|
return pr.flowError(err)
|
||||||
}
|
}
|
||||||
if err := pr.flow.Activate(r.Context(), req.Token); err != nil {
|
email, err := pr.flow.Activate(r.Context(), req.Token)
|
||||||
|
if err != nil {
|
||||||
return pr.flowError(err)
|
return pr.flowError(err)
|
||||||
}
|
}
|
||||||
pr.logger.Info("Account activation completed")
|
pr.logger.Info("Account activation completed")
|
||||||
return response.Accepted(pr.logger, map[string]bool{"verified": true})
|
return response.Accepted(pr.logger, struct {
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}{Verified: true, Email: email})
|
||||||
}
|
}
|
||||||
func challengeScope(raw string) string {
|
func challengeScope(raw string) string {
|
||||||
h := sha256.Sum256([]byte(raw))
|
h := sha256.Sum256([]byte(raw))
|
||||||
|
|||||||
@@ -75,6 +75,17 @@ func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type signupWallet struct {
|
||||||
|
customers []bson.ObjectID
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *signupWallet) Ensure(_ context.Context, a *customer.Account) error {
|
||||||
|
s.customers = append(s.customers, a.ID)
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -102,6 +113,8 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
provider := &signupProvider{fail: true}
|
provider := &signupProvider{fail: true}
|
||||||
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()}
|
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()}
|
||||||
|
wallet := &signupWallet{err: merrors.ServiceUnavailable("account setup temporarily unavailable")}
|
||||||
|
service.Wallet = wallet
|
||||||
// Keep the real verification/customer stores and capture only session writes.
|
// Keep the real verification/customer stores and capture only session writes.
|
||||||
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
||||||
records, err := factorstore.Open(zap.NewNop(), factory)
|
records, err := factorstore.Open(zap.NewNop(), factory)
|
||||||
@@ -133,6 +146,14 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
signup["name"] = "Changed retry name"
|
signup["name"] = "Changed retry name"
|
||||||
provider.fail = false
|
provider.fail = false
|
||||||
w = post("signup", signup)
|
w = post("signup", signup)
|
||||||
|
require.Equal(t, 503, w.Code, w.Body.String())
|
||||||
|
require.Empty(t, mail.messages)
|
||||||
|
require.Len(t, wallet.customers, 1)
|
||||||
|
w = post("verification/send", map[string]string{"email": "user@example.com", "idempotencyKey": "wallet-retry"})
|
||||||
|
require.Equal(t, 503, w.Code, w.Body.String())
|
||||||
|
require.Empty(t, mail.messages)
|
||||||
|
wallet.err = nil
|
||||||
|
w = post("signup", signup)
|
||||||
require.Equal(t, 202, w.Code, w.Body.String())
|
require.Equal(t, 202, w.Code, w.Body.String())
|
||||||
require.Equal(t, 1, provider.registrations)
|
require.Equal(t, 1, provider.registrations)
|
||||||
require.Equal(t, "Customer", provider.last.FullName)
|
require.Equal(t, "Customer", provider.last.FullName)
|
||||||
@@ -149,20 +170,37 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.False(t, a.IsActive())
|
require.False(t, a.IsActive())
|
||||||
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
|
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
|
||||||
|
require.Equal(t, []bson.ObjectID{a.ID, a.ID, a.ID}, wallet.customers)
|
||||||
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
||||||
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
||||||
require.Equal(t, 403, post("login", login).Code)
|
require.Equal(t, 403, post("login", login).Code)
|
||||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).Code)
|
invalid := post("verification/confirm", map[string]string{"token": "invalid"})
|
||||||
|
require.Equal(t, 400, invalid.Code)
|
||||||
|
require.NotContains(t, invalid.Body.String(), a.Login)
|
||||||
guard := &activationGuard{SignupFacts: service.Facts, block: true}
|
guard := &activationGuard{SignupFacts: service.Facts, block: true}
|
||||||
service.Facts = guard
|
service.Facts = guard
|
||||||
require.Equal(t, 503, post("verification/confirm", map[string]string{"token": activation}).Code)
|
blocked := post("verification/confirm", map[string]string{"token": activation})
|
||||||
|
require.Equal(t, 503, blocked.Code)
|
||||||
|
require.NotContains(t, blocked.Body.String(), a.Login)
|
||||||
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.False(t, a.IsActive())
|
require.False(t, a.IsActive())
|
||||||
guard.block = false
|
guard.block = false
|
||||||
w = post("verification/confirm", map[string]string{"token": activation})
|
w = post("verification/confirm", map[string]string{"token": activation})
|
||||||
require.Equal(t, 202, w.Code, w.Body.String())
|
require.Equal(t, 202, w.Code, w.Body.String())
|
||||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code)
|
require.Equal(t, "no-store", w.Header().Get("Cache-Control"))
|
||||||
|
var confirmed struct {
|
||||||
|
Data struct {
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &confirmed))
|
||||||
|
require.True(t, confirmed.Data.Verified)
|
||||||
|
require.Equal(t, a.Login, confirmed.Data.Email)
|
||||||
|
used := post("verification/confirm", map[string]string{"token": activation})
|
||||||
|
require.Equal(t, 400, used.Code)
|
||||||
|
require.NotContains(t, used.Body.String(), a.Login)
|
||||||
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, customer.AccountActive, a.Status)
|
require.Equal(t, customer.AccountActive, a.Status)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var accountSetup = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_currency_accounts_setup_total", Help: "Default currency account setup attempts, including checks of existing accounts."}, []string{"outcome"})
|
||||||
|
var accountSetupDuration = promauto.NewHistogram(prometheus.HistogramOpts{Name: "sxchnge_currency_accounts_setup_duration_seconds", Help: "Time to confirm all default currency accounts exist."})
|
||||||
|
|
||||||
|
func (s *Service) Ensure(ctx context.Context, a *customer.Account) error {
|
||||||
|
_, err := s.ensureAccounts(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.ServiceUnavailableWrap(err, "currency account setup unavailable")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureAccounts(ctx context.Context, a *customer.Account) (subject string, result error) {
|
||||||
|
started := time.Now()
|
||||||
|
defer func() {
|
||||||
|
outcome := "ready"
|
||||||
|
if result != nil {
|
||||||
|
outcome = "failed"
|
||||||
|
}
|
||||||
|
accountSetup.WithLabelValues(outcome).Inc()
|
||||||
|
accountSetupDuration.Observe(time.Since(started).Seconds())
|
||||||
|
}()
|
||||||
|
if s == nil || s.Identity == nil || s.Provider == nil {
|
||||||
|
return "", Unavailable
|
||||||
|
}
|
||||||
|
if a == nil || a.ID.IsZero() {
|
||||||
|
return "", NotFound
|
||||||
|
}
|
||||||
|
subject, result = s.Identity.Subject(ctx, a)
|
||||||
|
if result != nil {
|
||||||
|
return "", result
|
||||||
|
}
|
||||||
|
if result = s.Provider.EnsureAccounts(ctx, subject); result != nil {
|
||||||
|
s.Logger.Warn("Currency account setup incomplete", zap.String("account_ref", a.ID.Hex()), zap.Error(result))
|
||||||
|
return "", result
|
||||||
|
}
|
||||||
|
return subject, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSignupAndWalletReadEnsureCustomerCurrencyAccounts(t *testing.T) {
|
||||||
|
a := &customer.Account{}
|
||||||
|
a.ID = bson.NewObjectID()
|
||||||
|
p := &provider{namespace: "provider"}
|
||||||
|
s := &Service{Provider: p, Identity: identity{}, Logger: zap.NewNop(), Factors: &factors{}, Store: &memoryStore{records: map[bson.ObjectID]Record{}}}
|
||||||
|
require.NoError(t, s.Ensure(t.Context(), a))
|
||||||
|
require.Equal(t, "subject", p.setupSubject)
|
||||||
|
snapshot, err := s.Fetch(t.Context(), a)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshot.Accounts, 1)
|
||||||
|
require.Equal(t, 2, p.setupCalls)
|
||||||
|
p.setupErr = Unavailable
|
||||||
|
_, err = s.Fetch(t.Context(), a)
|
||||||
|
require.ErrorIs(t, err, Unavailable)
|
||||||
|
require.Error(t, s.Ensure(t.Context(), a))
|
||||||
|
require.Error(t, s.Ensure(t.Context(), nil))
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ func (*Record) Collection() string { return "payment_quotes" }
|
|||||||
|
|
||||||
type Provider interface {
|
type Provider interface {
|
||||||
Namespace() string
|
Namespace() string
|
||||||
|
EnsureAccounts(context.Context, string) error
|
||||||
Accounts(context.Context, string) ([]Account, error)
|
Accounts(context.Context, string) ([]Account, error)
|
||||||
Quote(context.Context, string, Request, []Account) (Quote, error)
|
Quote(context.Context, string, Request, []Account) (Quote, error)
|
||||||
Execute(context.Context, string, string) (RemoteOperation, error)
|
Execute(context.Context, string, string) (RemoteOperation, error)
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ func (s *Service) Fetch(ctx context.Context, a *customer.Account) (Snapshot, err
|
|||||||
if err := s.ready(); err != nil {
|
if err := s.ready(); err != nil {
|
||||||
return Snapshot{}, err
|
return Snapshot{}, err
|
||||||
}
|
}
|
||||||
subject, err := s.Identity.Subject(ctx, a)
|
subject, err := s.ensureAccounts(ctx, a)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Snapshot{}, err
|
return Snapshot{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,9 +82,19 @@ type provider struct {
|
|||||||
status string
|
status string
|
||||||
key string
|
key string
|
||||||
namespace string
|
namespace string
|
||||||
|
setupCalls int
|
||||||
|
setupSubject string
|
||||||
|
setupErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *provider) Namespace() string { return p.namespace }
|
func (p *provider) Namespace() string { return p.namespace }
|
||||||
|
func (p *provider) EnsureAccounts(_ context.Context, subject string) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.setupCalls++
|
||||||
|
p.setupSubject = subject
|
||||||
|
return p.setupErr
|
||||||
|
}
|
||||||
func (p *provider) Accounts(context.Context, string) ([]Account, error) {
|
func (p *provider) Accounts(context.Context, string) ([]Account, error) {
|
||||||
return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil
|
return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/payments"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) validCustomerAccount(a accountDTO, subject string) bool {
|
||||||
|
return validRef(a.ID) && a.ScopeRef == c.config.OrganizationID && a.Owner == "" &&
|
||||||
|
a.Type == "ACCOUNT_TYPE_ASSET" && a.LedgerRole == "ACCOUNT_ROLE_OPERATING" &&
|
||||||
|
a.Status == "ACCOUNT_STATUS_ACTIVE" && !a.AllowNegative && a.Metadata["sxchnge_subject_ref"] == subject &&
|
||||||
|
(a.Metadata["external_subject_ref"] == "" || a.Metadata["external_subject_ref"] == subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureAccounts also repairs older customers, using provider-enforced
|
||||||
|
// organization/subject/currency uniqueness for safe retries after lost responses.
|
||||||
|
func (c *Client) EnsureAccounts(ctx context.Context, subject string) error {
|
||||||
|
if !validRef(subject) {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
root := "/ledger_accounts/" + c.config.OrganizationID
|
||||||
|
var data accountListDTO
|
||||||
|
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
existing := map[string]bool{}
|
||||||
|
reconcile := map[string]bool{}
|
||||||
|
for _, a := range data.Accounts {
|
||||||
|
if a.Metadata["sxchnge_subject_ref"] != subject && a.Metadata["external_subject_ref"] != subject {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !c.validCustomerAccount(a, subject) || existing[a.Currency] {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
existing[a.Currency] = true
|
||||||
|
reconcile[a.Currency] = a.Metadata["external_subject_ref"] == subject
|
||||||
|
}
|
||||||
|
missing := []string{}
|
||||||
|
for _, code := range []string{"RUB", "USDT", "USDC"} {
|
||||||
|
if !existing[code] || reconcile[code] {
|
||||||
|
missing = append(missing, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Older providers can accept the metadata but cannot prevent duplicates.
|
||||||
|
// Require the explicit capability before issuing the first create.
|
||||||
|
if !data.ExternalSubjectAccounts {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
var catalog struct {
|
||||||
|
Rows []currencyDTO `json:"currencies"`
|
||||||
|
}
|
||||||
|
if err := c.paymentRequest(ctx, http.MethodGet, "/payments/fx/currencies/"+c.config.OrganizationID, nil, &catalog); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, code := range missing {
|
||||||
|
found := false
|
||||||
|
for _, currency := range catalog.Rows {
|
||||||
|
if currency.Code == code && currency.Scale != nil && *currency.Scale >= 0 && *currency.Scale <= 8 {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return merrors.OpaqueWrap(payments.Unavailable, payments.Unsupported, "default currency missing from provider catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Replaying a bound create also resumes Sendico's durable wallet/route setup
|
||||||
|
// if a previous response failed after the ledger itself had been committed.
|
||||||
|
for _, code := range missing {
|
||||||
|
var response struct {
|
||||||
|
Account accountDTO `json:"account"`
|
||||||
|
}
|
||||||
|
request := createAccountDTO{Currency: code, Type: "asset", LedgerRole: "operating", Metadata: map[string]string{"external_subject_ref": subject, "sxchnge_subject_ref": subject}}
|
||||||
|
if err := c.paymentRequest(ctx, http.MethodPost, root, request, &response, http.StatusCreated); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !c.validCustomerAccount(response.Account, subject) || response.Account.Currency != code || response.Account.Metadata["external_subject_ref"] != subject {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
type accountServer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
rows []accountDTO
|
||||||
|
posts []string
|
||||||
|
capable bool
|
||||||
|
missingCurrency bool
|
||||||
|
failCurrency string
|
||||||
|
loseResponse bool
|
||||||
|
wrongResponse bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func customerLedger(code string) accountDTO {
|
||||||
|
return accountDTO{ID: bson.NewObjectID().Hex(), ScopeRef: org, Currency: code, Type: "ACCOUNT_TYPE_ASSET", LedgerRole: "ACCOUNT_ROLE_OPERATING", Status: "ACCOUNT_STATUS_ACTIVE", Metadata: map[string]string{"sxchnge_subject_ref": subject, "external_subject_ref": subject}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *accountServer) serve(t *testing.T) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
require.NotEmpty(t, r.Header.Get("Authorization"))
|
||||||
|
var data any
|
||||||
|
switch {
|
||||||
|
case strings.Contains(r.URL.Path, "/currencies/"):
|
||||||
|
rows := []map[string]any{{"code": "RUB", "accountingScale": 2}, {"code": "USDT", "accountingScale": 6}, {"code": "USDC", "accountingScale": 6}}
|
||||||
|
if s.missingCurrency {
|
||||||
|
rows = rows[:2]
|
||||||
|
}
|
||||||
|
data = map[string]any{"currencies": rows}
|
||||||
|
case r.Method == http.MethodPost:
|
||||||
|
var request createAccountDTO
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
|
||||||
|
require.Equal(t, "asset", request.Type)
|
||||||
|
require.Equal(t, "operating", request.LedgerRole)
|
||||||
|
require.False(t, request.AllowNegative)
|
||||||
|
require.Equal(t, subject, request.Metadata["external_subject_ref"])
|
||||||
|
require.Equal(t, subject, request.Metadata["sxchnge_subject_ref"])
|
||||||
|
s.posts = append(s.posts, request.Currency)
|
||||||
|
if s.failCurrency == request.Currency {
|
||||||
|
s.failCurrency = ""
|
||||||
|
w.WriteHeader(503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var row accountDTO
|
||||||
|
for _, a := range s.rows {
|
||||||
|
if a.Currency == request.Currency && a.Metadata["external_subject_ref"] == subject {
|
||||||
|
row = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row.ID == "" {
|
||||||
|
row = customerLedger(request.Currency)
|
||||||
|
s.rows = append(s.rows, row)
|
||||||
|
}
|
||||||
|
if s.loseResponse {
|
||||||
|
s.loseResponse = false
|
||||||
|
w.WriteHeader(503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.wrongResponse {
|
||||||
|
row.Currency = "EUR"
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
data = map[string]any{"account": row}
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/balance"):
|
||||||
|
parts := strings.Split(r.URL.Path, "/")
|
||||||
|
data = map[string]any{"balance": balanceDTO{ID: parts[len(parts)-2], Money: moneyDTO{Amount: "45.00", Currency: "RUB"}}}
|
||||||
|
default:
|
||||||
|
data = map[string]any{"accounts": s.rows, "externalSubjectAccounts": s.capable}
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"status": "success", "data": data}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureCurrencyAccountsPreservesExistingAndRecoversPartialFailure(t *testing.T) {
|
||||||
|
for _, lost := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "partial failure", true: "lost response"}[lost], func(t *testing.T) {
|
||||||
|
s := &accountServer{capable: true, failCurrency: "USDT", loseResponse: lost}
|
||||||
|
if lost {
|
||||||
|
s.failCurrency = ""
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Error(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Len(t, s.rows, 1)
|
||||||
|
firstID := s.rows[0].ID
|
||||||
|
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Len(t, s.rows, 3)
|
||||||
|
require.Equal(t, firstID, s.rows[0].ID)
|
||||||
|
// Bound creates are replayed to finish interrupted wallet/route setup;
|
||||||
|
// the provider must return the original ledger each time.
|
||||||
|
require.Equal(t, 3, countCurrency(s.posts, "RUB"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func countCurrency(rows []string, code string) int {
|
||||||
|
n := 0
|
||||||
|
for _, r := range rows {
|
||||||
|
if r == code {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAccountsConcurrentRequestsUseStableSubjectBinding(t *testing.T) {
|
||||||
|
s := &accountServer{capable: true}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
errors := make(chan error, 12)
|
||||||
|
for range 12 {
|
||||||
|
go func() { errors <- c.EnsureAccounts(t.Context(), subject) }()
|
||||||
|
}
|
||||||
|
for range 12 {
|
||||||
|
require.NoError(t, <-errors)
|
||||||
|
}
|
||||||
|
require.Len(t, s.rows, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAccountsRejectsUnsafeProviderStateBeforeCreating(t *testing.T) {
|
||||||
|
for _, kind := range []string{"old provider", "missing currency", "frozen", "owned", "negative", "duplicate", "wrong subject"} {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
s := &accountServer{capable: true}
|
||||||
|
a := customerLedger("RUB")
|
||||||
|
switch kind {
|
||||||
|
case "old provider":
|
||||||
|
s.capable = false
|
||||||
|
case "missing currency":
|
||||||
|
s.missingCurrency = true
|
||||||
|
case "frozen":
|
||||||
|
a.Status = "ACCOUNT_STATUS_FROZEN"
|
||||||
|
s.rows = []accountDTO{a}
|
||||||
|
case "owned":
|
||||||
|
a.Owner = source
|
||||||
|
s.rows = []accountDTO{a}
|
||||||
|
case "negative":
|
||||||
|
a.AllowNegative = true
|
||||||
|
s.rows = []accountDTO{a}
|
||||||
|
case "duplicate":
|
||||||
|
s.rows = []accountDTO{a, customerLedger("RUB")}
|
||||||
|
case "wrong subject":
|
||||||
|
a.Metadata["sxchnge_subject_ref"] = source
|
||||||
|
s.rows = []accountDTO{a}
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Error(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Empty(t, s.posts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAccountsValidatesCreateReceipt(t *testing.T) {
|
||||||
|
s := &accountServer{capable: true, wrongResponse: true}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Error(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Len(t, s.posts, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAccountsKeepsExistingBindingsAndBalances(t *testing.T) {
|
||||||
|
a := customerLedger("RUB")
|
||||||
|
delete(a.Metadata, "external_subject_ref")
|
||||||
|
s := &accountServer{capable: true, rows: []accountDTO{a}}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Equal(t, []string{"USDT", "USDC"}, s.posts)
|
||||||
|
// A subsequent read uses the original RUB ledger and its authoritative funds.
|
||||||
|
s.rows = s.rows[:1]
|
||||||
|
accounts, err := c.Accounts(t.Context(), subject)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, accounts, 1)
|
||||||
|
require.Equal(t, a.ID, accounts[0].ID)
|
||||||
|
require.EqualValues(t, 4500, accounts[0].Available)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
type accountListDTO struct {
|
||||||
|
Accounts []accountDTO `json:"accounts"`
|
||||||
|
ExternalSubjectAccounts bool `json:"externalSubjectAccounts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createAccountDTO struct {
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Type string `json:"accountType"`
|
||||||
|
LedgerRole string `json:"role"`
|
||||||
|
AllowNegative bool `json:"allowNegative"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (c *Client) post(ctx context.Context, resource, requestRef string, payload any) (receiptDTO, error) {
|
func (c *Client) post(ctx context.Context, resource, requestRef string, payload any, expectedStatus int) (receiptDTO, error) {
|
||||||
var receipt receiptDTO
|
var receipt receiptDTO
|
||||||
token, err := c.token()
|
token, err := c.token()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -38,7 +38,7 @@ func (c *Client) post(ctx context.Context, resource, requestRef string, payload
|
|||||||
}
|
}
|
||||||
defer func() { _ = response.Body.Close() }()
|
defer func() { _ = response.Body.Close() }()
|
||||||
// Never decode or expose upstream error bodies, which can contain assertions.
|
// Never decode or expose upstream error bodies, which can contain assertions.
|
||||||
if response.StatusCode != http.StatusOK {
|
if response.StatusCode != expectedStatus {
|
||||||
return receipt, merrors.ServiceUnavailable("Sendico request was not accepted")
|
return receipt, merrors.ServiceUnavailable("Sendico request was not accepted")
|
||||||
}
|
}
|
||||||
decoder := json.NewDecoder(io.LimitReader(response.Body, 16385))
|
decoder := json.NewDecoder(io.LimitReader(response.Body, 16385))
|
||||||
@@ -54,14 +54,14 @@ func (c *Client) post(ctx context.Context, resource, requestRef string, payload
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Register(ctx context.Context, requestRef string) (string, error) {
|
func (c *Client) Register(ctx context.Context, requestRef string) (string, error) {
|
||||||
dto, err := c.post(ctx, "individuals", requestRef, nil)
|
dto, err := c.post(ctx, "individuals", requestRef, nil, http.StatusCreated)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return mapSubject(dto)
|
return mapSubject(dto)
|
||||||
}
|
}
|
||||||
func (c *Client) Submit(ctx context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) {
|
func (c *Client) Submit(ctx context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) {
|
||||||
dto, err := c.post(ctx, "identity_facts", bson.NewObjectID().Hex(), mapFacts(subject, source, facts))
|
dto, err := c.post(ctx, "identity_facts", bson.NewObjectID().Hex(), mapFacts(subject, source, facts), http.StatusOK)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return identity.Receipt{}, err
|
return identity.Receipt{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ func TestSignupWireContractAndCredentialRotation(t *testing.T) {
|
|||||||
if strings.Contains(r.URL.Path, "/individuals/") {
|
if strings.Contains(r.URL.Path, "/individuals/") {
|
||||||
require.Equal(t, "registration-request", r.Header.Get("X-Request-ID"))
|
require.Equal(t, "registration-request", r.Header.Get("X-Request-ID"))
|
||||||
require.Empty(t, body)
|
require.Empty(t, body)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"registered_at":"2026-10-07T10:30:00.123Z"}}`, subject)
|
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"registered_at":"2026-10-07T10:30:00.123Z"}}`, subject)
|
||||||
} else {
|
} else {
|
||||||
require.JSONEq(t, `{"subject_ref":"`+subject+`","source_submission_ref":"signup-1","facts":{"full_name":"Ada Lovelace","email":"ada@example.com","external_customer_ref":"local-user"}}`, string(body))
|
require.JSONEq(t, `{"subject_ref":"`+subject+`","source_submission_ref":"signup-1","facts":{"full_name":"Ada Lovelace","email":"ada@example.com","external_customer_ref":"local-user"}}`, string(body))
|
||||||
@@ -95,6 +96,36 @@ func TestRejectInvalidConfiguration(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSignupReceiptsRequireOperationStatus(t *testing.T) {
|
||||||
|
for _, status := range []int{200, 201, 202, 204, 206, 300, 403, 409, 503} {
|
||||||
|
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"registered_at":"2026-10-07T10:30:00Z","submission_ref":%q,"accepted_at":"2026-10-07T10:30:00Z"}}`, subject, submission)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
ref, err := client.Register(context.Background(), "registration-request")
|
||||||
|
if status == http.StatusCreated {
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, subject, ref)
|
||||||
|
} else {
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, ref)
|
||||||
|
}
|
||||||
|
receipt, err := client.Submit(context.Background(), subject, "key", identity.Facts{})
|
||||||
|
if status == http.StatusOK {
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, submission, receipt.SubmissionRef)
|
||||||
|
} else {
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, receipt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpstreamFailuresNeverProduceReceiptsOrLeakBodies(t *testing.T) {
|
func TestUpstreamFailuresNeverProduceReceiptsOrLeakBodies(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -113,7 +144,11 @@ func TestUpstreamFailuresNeverProduceReceiptsOrLeakBodies(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(tc.status)
|
status := tc.status
|
||||||
|
if status == http.StatusOK && strings.Contains(r.URL.Path, "/individuals/") {
|
||||||
|
status = http.StatusCreated
|
||||||
|
}
|
||||||
|
w.WriteHeader(status)
|
||||||
_, _ = io.WriteString(w, tc.body)
|
_, _ = io.WriteString(w, tc.body)
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|||||||
@@ -27,9 +27,7 @@ func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Accou
|
|||||||
scales[row.Code] = *row.Scale
|
scales[row.Code] = *row.Scale
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var data struct {
|
var data accountListDTO
|
||||||
Accounts []accountDTO `json:"accounts"`
|
|
||||||
}
|
|
||||||
root := "/ledger_accounts/" + c.config.OrganizationID
|
root := "/ledger_accounts/" + c.config.OrganizationID
|
||||||
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
|
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -39,7 +37,7 @@ func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Accou
|
|||||||
if row.Metadata["sxchnge_subject_ref"] != subject {
|
if row.Metadata["sxchnge_subject_ref"] != subject {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !validRef(row.ID) || row.ScopeRef != c.config.OrganizationID || row.Owner != "" || row.Status != "ACCOUNT_STATUS_ACTIVE" || row.Type != "ACCOUNT_TYPE_ASSET" || row.LedgerRole != "ACCOUNT_ROLE_OPERATING" {
|
if !c.validCustomerAccount(row, subject) {
|
||||||
return nil, payments.Unavailable
|
return nil, payments.Unavailable
|
||||||
}
|
}
|
||||||
scale, ok := scales[row.Currency]
|
scale, ok := scales[row.Currency]
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ type accountDTO struct {
|
|||||||
Type string `json:"accountType"`
|
Type string `json:"accountType"`
|
||||||
LedgerRole string `json:"role"`
|
LedgerRole string `json:"role"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
AllowNegative bool `json:"allowNegative"`
|
||||||
Metadata map[string]string `json:"metadata"`
|
Metadata map[string]string `json:"metadata"`
|
||||||
}
|
}
|
||||||
type currencyDTO struct {
|
type currencyDTO struct {
|
||||||
@@ -72,7 +73,7 @@ type balanceDTO struct {
|
|||||||
Money moneyDTO `json:"balance"`
|
Money moneyDTO `json:"balance"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any) error {
|
func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any, accepted ...int) error {
|
||||||
token, err := c.token()
|
token, err := c.token()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -98,7 +99,11 @@ func (c *Client) paymentRequest(ctx context.Context, method, path string, payloa
|
|||||||
}
|
}
|
||||||
defer func() { _ = res.Body.Close() }()
|
defer func() { _ = res.Body.Close() }()
|
||||||
// Never forward upstream errors, credentials, or arbitrary error bodies.
|
// Never forward upstream errors, credentials, or arbitrary error bodies.
|
||||||
if res.StatusCode != http.StatusOK {
|
expected := http.StatusOK
|
||||||
|
if len(accepted) != 0 {
|
||||||
|
expected = accepted[0]
|
||||||
|
}
|
||||||
|
if res.StatusCode != expected {
|
||||||
return payments.Unavailable
|
return payments.Unavailable
|
||||||
}
|
}
|
||||||
var envelope struct {
|
var envelope struct {
|
||||||
|
|||||||
@@ -968,3 +968,35 @@ remain separate capabilities. Unrelated notifications runtime changes are exclud
|
|||||||
Delivered in [PR #51](https://git.sendico.io/tech/sxchnge/pulls/51), targeting
|
Delivered in [PR #51](https://git.sendico.io/tech/sxchnge/pulls/51), targeting
|
||||||
`identity-verification-48`. Merge order is #47 → #49 → #51; deploy Sendico #2897
|
`identity-verification-48`. Merge order is #47 → #49 → #51; deploy Sendico #2897
|
||||||
before SXCHNGE #51. Both implementation branches are pushed and remain unmerged.
|
before SXCHNGE #51. Both implementation branches are pushed and remain unmerged.
|
||||||
|
|
||||||
|
## Saved payout destinations UX — #54
|
||||||
|
|
||||||
|
Branch `payout-destinations-54`, based on `local-stand-52`. Implements Settings
|
||||||
|
creation, details, rename, replacement and removal; explicit payout selection;
|
||||||
|
optional saving and one-time preparation for crypto, cards and Russian personal
|
||||||
|
accounts. Shared typed service/model/provider layers separate destination identity,
|
||||||
|
metadata/execution revisions, ownership, provider bindings, contextual eligibility,
|
||||||
|
save outcomes and financial outcomes. The customer-scoped demo replays stable
|
||||||
|
created/duplicate results, preserves immutable quote/history snapshots, rechecks at
|
||||||
|
acceptance, and demonstrates card-binding restoration without creating a second
|
||||||
|
card. Bank directory/paste handling follows the reviewed Sendico interface.
|
||||||
|
|
||||||
|
EN/RU and 320 px/1.6× layouts are tested. Formatting (44 changed Dart files),
|
||||||
|
analysis of app/wallet/analytics, `make test-frontend` (including 89 app tests), and
|
||||||
|
`flutter build web` with Wasm dry run passed. Browser smoke inspection confirmed
|
||||||
|
bank form rendering, labeled controls and keyboard focus. Full assistive-technology
|
||||||
|
and real-provider audits were not performed. No security scans were requested/run.
|
||||||
|
|
||||||
|
Persistence and bindings in this delivery are simulated and reset on reload.
|
||||||
|
Authenticated live mode retains explicit use of its provisioned payout destination;
|
||||||
|
new types fail closed until the backend supplies the documented capabilities and
|
||||||
|
registry/atomic-acceptance contracts. No routing, ownership-verification system,
|
||||||
|
provider integration or financial engine was added. See
|
||||||
|
[the specification and integration boundary](ux/payout-destinations.md).
|
||||||
|
|
||||||
|
Stacked merge order: #47 → #49 → #51 → #53 → this contribution. The unrelated
|
||||||
|
notifications runtime edit is preserved outside task commits. Delivery links follow.
|
||||||
|
|
||||||
|
Delivery: [task #54](https://git.sendico.io/tech/sxchnge/issues/54) and
|
||||||
|
[PR #55](https://git.sendico.io/tech/sxchnge/pulls/55). Implementation commit
|
||||||
|
`24af794`; PR base `local-stand-52`. No merge performed.
|
||||||
+33
-17
@@ -27,10 +27,17 @@ project directory. Dockerfiles are in `infra/images/`. Runtime and startup files
|
|||||||
are grouped by service under `infra/local/`. The stack consists of Vault, Mongo,
|
are grouped by service under `infra/local/`. The stack consists of Vault, Mongo,
|
||||||
NATS, notifications, BFF and Caddy serving the frontend.
|
NATS, notifications, BFF and Caddy serving the frontend.
|
||||||
|
|
||||||
|
The frontend container uses Compose `init: true` to reap orphaned `ssl_client`
|
||||||
|
children created by BusyBox's HTTPS `wget` health probe. Keep this enabled:
|
||||||
|
Caddy alone does not reap those children when it runs as PID 1, and repeated
|
||||||
|
probes can accumulate thousands of zombie processes. Recreate the frontend
|
||||||
|
container when applying this setting; restarting an old container does not
|
||||||
|
change its init configuration.
|
||||||
|
|
||||||
On first startup, bootstrap initializes/unseals persistent Vault, provisions
|
On first startup, bootstrap initializes/unseals persistent Vault, provisions
|
||||||
AppRoles and secrets, initializes Mongo replica set `rs0`, configures Vault's Mongo
|
AppRoles and secrets, initializes Mongo replica set `rs0`, configures Vault's Mongo
|
||||||
plugin, and starts authenticated NATS JetStream. Product startup fails explicitly
|
plugin, and starts authenticated NATS JetStream. Product startup fails explicitly
|
||||||
until Resend credentials exist. Infrastructure remains available for provisioning.
|
until the SMTP mailbox password exists. Infrastructure remains available for provisioning.
|
||||||
|
|
||||||
After all services pass their startup health checks and the public Caddy CA is
|
After all services pass their startup health checks and the public Caddy CA is
|
||||||
exported, `make up` prints a Sendico-style completion message with the frontend,
|
exported, `make up` prints a Sendico-style completion message with the frontend,
|
||||||
@@ -56,7 +63,7 @@ kv/
|
|||||||
├── services/
|
├── services/
|
||||||
│ ├── bff/jwt # jwt
|
│ ├── bff/jwt # jwt
|
||||||
│ └── notifications/
|
│ └── notifications/
|
||||||
│ └── email/resend # api_key only
|
│ └── email/smtp # password only
|
||||||
└── infrastructure/
|
└── infrastructure/
|
||||||
├── nats/auth # token; read by NATS and notifications
|
├── nats/auth # token; read by NATS and notifications
|
||||||
└── mongo/bootstrap # local administrator and replica-set bootstrap material
|
└── mongo/bootstrap # local administrator and replica-set bootstrap material
|
||||||
@@ -74,29 +81,35 @@ There are no frontend certificates, notification certificates or generic `tls`
|
|||||||
entries in KV. Browser certificates belong to Caddy; internal gRPC is plaintext
|
entries in KV. Browser certificates belong to Caddy; internal gRPC is plaintext
|
||||||
on the trusted private Docker network without caller authentication.
|
on the trusted private Docker network without caller authentication.
|
||||||
|
|
||||||
## Resend
|
## Local SMTP mail
|
||||||
|
|
||||||
Open [Vault](http://127.0.0.1:18200) and edit
|
Open [Vault](http://127.0.0.1:18200) and edit
|
||||||
`kv/services/notifications/email/resend`:
|
`kv/services/notifications/email/smtp`:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `api_key` | Your Resend API key |
|
| `password` | Password for the configured SMTP mailbox |
|
||||||
|
|
||||||
The driver and sender settings belong in `infra/local/notifications/runtime.yml`:
|
The driver and sender settings belong in `infra/local/notifications/runtime.yml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
email:
|
email:
|
||||||
driver: resend
|
driver: smtp
|
||||||
settings:
|
settings:
|
||||||
api_key_env: EMAIL_API_KEY
|
host: mail.sendico.io
|
||||||
sender_address: "your-verified-sender@example.com"
|
port: 465
|
||||||
|
username: no-reply@sendico.io
|
||||||
|
password_env: SMTP_PASSWORD
|
||||||
|
network_timeout: 10
|
||||||
|
sender_address: no-reply@sendico.io
|
||||||
sender_name: "SXCHNGE"
|
sender_name: "SXCHNGE"
|
||||||
```
|
```
|
||||||
|
|
||||||
Set your verified address in that file and rebuild. It is intentionally empty by
|
The local stack uses Sendico's existing `no-reply@sendico.io` mailbox with
|
||||||
default, so notifications cannot start with an unconfigured sender. Vault Agent
|
authenticated implicit TLS on port 465 and certificate verification enabled.
|
||||||
injects only the API key; it does not generate email configuration.
|
Vault Agent injects only the password; host, username and sender are non-secret
|
||||||
|
runtime settings. Credentials are provisioned into SXCHNGE's own Vault; the
|
||||||
|
notification service does not read Sendico configuration or secrets at runtime.
|
||||||
|
|
||||||
Local administrator login material is in private `.local/dev/admin/init.json`.
|
Local administrator login material is in private `.local/dev/admin/init.json`.
|
||||||
Product containers cannot access it. Alternatively use the hidden terminal prompt:
|
Product containers cannot access it. Alternatively use the hidden terminal prompt:
|
||||||
@@ -106,12 +119,15 @@ make email
|
|||||||
make build && make up
|
make build && make up
|
||||||
```
|
```
|
||||||
|
|
||||||
No placeholder key or delivery fallback is installed. Startup validates presence;
|
No placeholder password or delivery fallback is installed. Startup validates
|
||||||
it does not send an email or validate domain ownership with Resend. Updating the
|
configuration and secret presence; the SMTP connection is opened on demand.
|
||||||
API key causes Agent to restart notifications. Driver/sender changes require an
|
Updating the password causes Agent to restart notifications. Driver/sender
|
||||||
image rebuild. Existing API keys survive bootstrap. Migration removes legacy
|
changes require an image rebuild. Old Resend secrets remain in Vault history
|
||||||
driver/sender fields from the current Vault secret with a version-checked write;
|
but are not used or readable by the notifications AppRole. HTTP providers remain
|
||||||
the preceding version is preserved in Vault history and the private archive.
|
available for other deployments; switching one requires matching Agent/policy
|
||||||
|
and runtime configuration. SMTP has no provider idempotency guarantee: explicit
|
||||||
|
4xx rejections retry, 5xx rejections fail, and ambiguous transport outcomes require
|
||||||
|
operator reconciliation before another send.
|
||||||
|
|
||||||
## Browser HTTPS and internal connections
|
## Browser HTTPS and internal connections
|
||||||
|
|
||||||
|
|||||||
+40
-2
@@ -105,12 +105,35 @@ docker compose --project-directory . -f infra/local/compose.yml \
|
|||||||
-f infra/local/sendico/compose.yml --env-file .local/dev/images.env restart sendico
|
-f infra/local/sendico/compose.yml --env-file .local/dev/images.env restart sendico
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A BFF container restart can leave its session worker in the old shared network
|
||||||
|
namespace. The worker checks Vault DNS every ten seconds, with a five-second
|
||||||
|
lookup limit. Three consecutive failures log a credential-free recovery reason
|
||||||
|
and terminate the session process; Docker's restart policy then attaches it to
|
||||||
|
the BFF's current namespace. A successful lookup resets the counter, so transient
|
||||||
|
DNS failures do not immediately restart the worker.
|
||||||
|
|
||||||
|
Container recreation changes the BFF container ID as well as its namespace.
|
||||||
|
Bootstrap and credential-rotation tooling recreate BFF and its configured session
|
||||||
|
worker together. Compose also marks the session dependency for restart when BFF
|
||||||
|
is explicitly updated. For a manual forced recreation, select both services:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose --project-directory . -f infra/local/compose.yml \
|
||||||
|
-f infra/local/sendico/compose.yml --env-file .local/dev/images.env \
|
||||||
|
up -d --no-deps --force-recreate --wait bff sendico
|
||||||
|
```
|
||||||
|
|
||||||
|
The parent process cleans up its bridge, DNS monitor and renewal delay on exit.
|
||||||
|
This recovery retains the configured provider URL, account and replay namespace.
|
||||||
|
|
||||||
`make test-dev` checks seven healthy services, verified browser TLS, the Vault
|
`make test-dev` checks seven healthy services, verified browser TLS, the Vault
|
||||||
layout and least-privilege identities, Mongo transactions/lease rotation, and
|
layout and least-privilege identities, Mongo transactions/lease rotation, and
|
||||||
authenticated Sendico currency and ledger reads through the same loopback
|
authenticated Sendico currency and ledger reads through the same loopback
|
||||||
connection and token used by BFF. It sends no email and executes no payment.
|
connection and token used by BFF. It sends no email and executes no payment.
|
||||||
An empty wallet is expected until dedicated subject-bound currency ledgers and
|
Signup and wallet access automatically ensure subject-bound RUB, USDT and USDC
|
||||||
saved methods are provisioned as described in [payment provisioning](payments.md).
|
ledgers as described in [account setup](payments.md#automatic-currency-accounts-72).
|
||||||
|
Deploy the provider prerequisite and grant ledger-create permission first.
|
||||||
|
Saved funding and payout methods remain separate from currency-account setup.
|
||||||
|
|
||||||
## Recovery
|
## Recovery
|
||||||
|
|
||||||
@@ -130,6 +153,21 @@ and the established CA. A subsequent verified TLS check must succeed.
|
|||||||
|
|
||||||
## Acceptance and operator UX review
|
## Acceptance and operator UX review
|
||||||
|
|
||||||
|
Recovery follow-up for [task #57](https://git.sendico.io/tech/sxchnge/issues/57) /
|
||||||
|
[PR #59](https://git.sendico.io/tech/sxchnge/pulls/59), validated on 2026-10-19:
|
||||||
|
`make test-dev-tooling`, `make test-dev-rotation`, Compose configuration validation,
|
||||||
|
`sh infra/local/tools.sh verify` and `git diff --check` pass. A BFF-only restart
|
||||||
|
triggered automatic session restart and restored matching network namespaces;
|
||||||
|
paired recreation also finished healthy. Rotation observed three real Vault
|
||||||
|
pool replacements, an unchanged BFF process and 534 transaction-backed requests
|
||||||
|
with zero failures. The final check verifies all seven SXCHNGE services, browser
|
||||||
|
HTTPS, Vault/database boundaries and authenticated Sendico currency/ledger reads.
|
||||||
|
An earlier check failed because Sendico's ledger announcement connection was
|
||||||
|
closed; restoring Discovery and its service announcers in dependency order
|
||||||
|
recovered that existing stand. No Sendico source or financial data was changed,
|
||||||
|
and no email or payment was sent. This follow-up changes shell/Compose tooling;
|
||||||
|
it does not rerun the earlier full Go/Flutter suite for unchanged product source.
|
||||||
|
|
||||||
Validated on 2026-10-16: `make build`, `make up`, `make test-dev-tooling`,
|
Validated on 2026-10-16: `make build`, `make up`, `make test-dev-tooling`,
|
||||||
`make test-dev-infra`, `make test-dev`, and `git diff --check` passed. Both product
|
`make test-dev-infra`, `make test-dev`, and `git diff --check` passed. Both product
|
||||||
sites, Sendico Backoffice and both API health endpoints returned HTTP 200;
|
sites, Sendico Backoffice and both API health endpoints returned HTTP 200;
|
||||||
|
|||||||
+36
-7
@@ -52,22 +52,50 @@ coordinated deployment of the platform's financial services.
|
|||||||
|
|
||||||
Reuse `api.sendico` and its existing Vault-rendered token file. Do not put provider
|
Reuse `api.sendico` and its existing Vault-rendered token file. Do not put provider
|
||||||
credentials or organization/source settings in Flutter. Provision the integration
|
credentials or organization/source settings in Flutter. Provision the integration
|
||||||
account's organization membership, payment-create/read, ledger-account/balance
|
account's organization membership, payment-create/read, ledger-account create/read,
|
||||||
read and the permissions needed to resolve its stored payment methods. Verify
|
balance read and the permissions needed to resolve its stored payment methods. Verify
|
||||||
those permissions in the intended environment before enabling real operations.
|
those permissions in the intended environment before enabling real operations.
|
||||||
|
|
||||||
Through Sendico's existing operator APIs, provision one dedicated unowned
|
### Automatic currency accounts (#72)
|
||||||
organization ledger per customer currency, with active `asset` / `operating`
|
|
||||||
semantics, no negative balance and metadata:
|
The backend ensures **RUB, USDT and USDC** accounts after confirmed identity intake
|
||||||
|
during signup, before sending the activation email. Resending activation retries
|
||||||
|
the same setup. Every authenticated wallet load also ensures the defaults, so
|
||||||
|
existing customers receive missing accounts automatically when they next open
|
||||||
|
their wallet. This is lazy backfill, not an eager scan of dormant customers.
|
||||||
|
|
||||||
|
Setup creates dedicated unowned organization ledgers through the public
|
||||||
|
`POST /ledger_accounts/{organization}` API, with active `asset` / `operating`
|
||||||
|
semantics, zero initial funds, no negative balance and metadata:
|
||||||
|
|
||||||
| Ledger metadata | Meaning |
|
| Ledger metadata | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `sxchnge_subject_ref` | Confirmed independent subject reference from sign-up |
|
| `sxchnge_subject_ref` | Confirmed independent subject reference from sign-up |
|
||||||
|
| `external_subject_ref` | Same confirmed subject; provider-enforced organization/subject/currency uniqueness |
|
||||||
| `sxchnge_payout_method_ref` | Optional prevalidated same-currency saved payout method |
|
| `sxchnge_payout_method_ref` | Optional prevalidated same-currency saved payout method |
|
||||||
| `sxchnge_payout_label` | Safe customer-visible destination label, e.g. bank plus masked suffix |
|
| `sxchnge_payout_label` | Safe customer-visible destination label, e.g. bank plus masked suffix |
|
||||||
| `sxchnge_funding_method_ref` | Optional same-currency saved funding source supported by the platform route |
|
| `sxchnge_funding_method_ref` | Optional same-currency saved funding source supported by the platform route |
|
||||||
|
|
||||||
These are financial-resource bindings maintained by trusted provisioning, not
|
Existing customer ledgers and their balances are preserved, including manually
|
||||||
|
provisioned ledgers with the original `sxchnge_subject_ref` tag. Only missing
|
||||||
|
defaults are created; existing additional currencies remain visible. Repeated
|
||||||
|
bound creates reuse the ledger and resume Sendico's durable wallet/route setup
|
||||||
|
after an interrupted response. Frozen, duplicate or incorrectly owned bindings
|
||||||
|
fail instead of creating a replacement. All required currency precision entries
|
||||||
|
are checked against the provider catalog before issuing creates. Saved funding
|
||||||
|
and payout bindings remain separate from creation of the currency accounts.
|
||||||
|
|
||||||
|
Deploy [Sendico #2968](https://git.sendico.io/tech/sendico/issues/2968) first:
|
||||||
|
Ledger (including its unique index), then Product Edge, then SXCHNGE backend and
|
||||||
|
frontend from [PR #73](https://git.sendico.io/tech/sxchnge/pulls/73). The public
|
||||||
|
ledger list must advertise `externalSubjectAccounts: true` before SXCHNGE sends
|
||||||
|
any create requests. Older providers fail safely without creating accounts.
|
||||||
|
The acknowledgement assumes Ledger and Edge are deployed in that order; it
|
||||||
|
does not discover the version of a separately running Ledger. No new runtime
|
||||||
|
configuration or credentials are introduced. Existing deployments must give the
|
||||||
|
integration principal the ledger-create permission in addition to its reads.
|
||||||
|
|
||||||
|
These are financial-resource bindings maintained by server-side setup, not
|
||||||
client choices or new runtime configuration. Do not tag a pooled/integration-login
|
client choices or new runtime configuration. Do not tag a pooled/integration-login
|
||||||
ledger as a customer ledger. Do not place full bank/card details or secret values
|
ledger as a customer ledger. Do not place full bank/card details or secret values
|
||||||
in labels or metadata. The BFF checks subject, organization, account type, role,
|
in labels or metadata. The BFF checks subject, organization, account type, role,
|
||||||
@@ -79,7 +107,8 @@ review action. Funding uses a quote for the provisioned stored source; it does
|
|||||||
not mint money or treat demo top-ups as real deposits. Additional destinations,
|
not mint money or treat demo top-ups as real deposits. Additional destinations,
|
||||||
recipient onboarding and other funding instructions remain separate features.
|
recipient onboarding and other funding instructions remain separate features.
|
||||||
|
|
||||||
An empty set of provisioned ledgers is an empty wallet. Existing organization
|
If setup reports a failure, the API returns an unavailable wallet rather than an
|
||||||
|
empty snapshot or demo balances. Retrying the app load retries setup. Existing organization
|
||||||
payments and pooled balances are never imported into customer history. This
|
payments and pooled balances are never imported into customer history. This
|
||||||
integration exposes the durable operations initiated through this BFF, with live
|
integration exposes the durable operations initiated through this BFF, with live
|
||||||
provider reconciliation; independent inbound deposits/history ingestion and
|
provider reconciliation; independent inbound deposits/history ingestion and
|
||||||
|
|||||||
+216
-4
@@ -5,8 +5,191 @@ Task [#46](https://git.sendico.io/tech/sxchnge/issues/46), branch
|
|||||||
customer creation, runtime, shared database source and Flutter error feedback.
|
customer creation, runtime, shared database source and Flutter error feedback.
|
||||||
Delivery: [PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), targeting `main`.
|
Delivery: [PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), targeting `main`.
|
||||||
|
|
||||||
|
## Registration response correction
|
||||||
|
|
||||||
|
Follow-up [#57](https://git.sendico.io/tech/sxchnge/issues/57), branch
|
||||||
|
`identity-facts-57`, delivered in [PR #59](https://git.sendico.io/tech/sxchnge/pulls/59)
|
||||||
|
against `main` (implementation `abca15f`), corrects the registration success status
|
||||||
|
to HTTP 201 Created. The PR remains unmerged.
|
||||||
|
Sendico's individual creation handler returns 201; the original adapter and test
|
||||||
|
fixture incorrectly required 200. A successful registration therefore left a
|
||||||
|
pending customer with an unresolved claim and `signup_attention_required`.
|
||||||
|
The adapter now checks the exact status per operation before validating receipts:
|
||||||
|
201 for registration and 200 for Facts intake. Other statuses, including other
|
||||||
|
2xx responses, remain failures. Malformed receipts and uncertain registrations
|
||||||
|
still require the existing recovery rules.
|
||||||
|
|
||||||
|
On 2026-10-17, the affected local signup was reconciled using its registration
|
||||||
|
correlation in Sendico logs and an authenticated organization-scoped individual
|
||||||
|
GET. A conditional majority write bound that confirmed individual to the existing
|
||||||
|
local claim, preserving the saved facts, namespace and registration request.
|
||||||
|
The rebuilt BFF resumed the public activation-resend flow: Facts intake returned
|
||||||
|
a persisted receipt and the endpoint returned 202 after activation-email queue
|
||||||
|
acceptance. No replacement individual was registered. The account remains
|
||||||
|
pending until the customer confirms the email; queue acceptance does not prove
|
||||||
|
inbox delivery.
|
||||||
|
|
||||||
|
Recovery also exposed a notification worker lifetime issue: its NATS subscription
|
||||||
|
retained the 20-second startup context and was automatically unsubscribed after
|
||||||
|
that deadline. Resource provisioning now has its own bounded context, while the
|
||||||
|
subscription follows the service lifetime and closes at shutdown. A regression
|
||||||
|
test checks that releasing provisioning leaves the subscription context active,
|
||||||
|
and service cancellation still ends it.
|
||||||
|
|
||||||
|
The rebuilt worker processed the queued activation message. The live Resend
|
||||||
|
provider rejected it with `provider_authentication_failed` (HTTP 401 or 403),
|
||||||
|
leaving delivery in `failed` after one attempt. Later diagnostics below identify
|
||||||
|
the unverified sender domain as the actual blocker. Correct domain authorization
|
||||||
|
before activation resend with a new idempotency key. Credential rotation is not
|
||||||
|
required by that error; if a separate rotation is needed, use `make email` and
|
||||||
|
allow Vault Agent to refresh/restart the notification process. Do not replay the
|
||||||
|
failed notification as a success or mark the customer active on their behalf.
|
||||||
|
|
||||||
|
Validation passed: `GOFLAGS=-p=2 make test` (all backend race tests and canonical
|
||||||
|
frontend suites), `make lint-backend`, focused provider/identity race tests and
|
||||||
|
notification bus/app race tests. The HTTP 201 regression first failed against the
|
||||||
|
old adapter and passed after correction. An earlier full run hit a database
|
||||||
|
credential-rotation timing failure at `rotation_test.go:249` during the Docker
|
||||||
|
build; the isolated race-test retry and both subsequent full runs passed.
|
||||||
|
Local BFF and notifications images were rebuilt with fresh upstream manifests
|
||||||
|
and resolved stable digest pins; the BFF, session sidecar and notifications
|
||||||
|
services are healthy. NATS shows the worker still waiting for deliveries beyond
|
||||||
|
the startup deadline, with no pending or unacknowledged jobs. Live email delivery
|
||||||
|
is blocked by provider authorization as described above. No security scans were
|
||||||
|
requested or run. Unrelated notifications runtime settings are excluded from
|
||||||
|
the task commits.
|
||||||
|
|
||||||
|
### Provider diagnostics follow-up
|
||||||
|
|
||||||
|
The same task/PR adds transient provider diagnostics to the worker's existing
|
||||||
|
structured outcome log: `provider_status`, `provider_error` and
|
||||||
|
`provider_message`. The adapter extracts bounded Resend/SendGrid error fields and
|
||||||
|
redacts credentials, request values, addresses, URLs and token/code fragments.
|
||||||
|
Transport errors have a sanitized cause; invalid or oversized responses have a
|
||||||
|
safe fallback. Diagnostics remain outside stored notification models and public
|
||||||
|
gRPC responses. The outcome is logged even when its result write fails. See
|
||||||
|
the [notification logging contract](../backend/notifications/README.md).
|
||||||
|
|
||||||
|
Runtime inspection confirmed `email.driver: resend`, an API key in Vault, and
|
||||||
|
sender `noreply@sxchnge.com`. A live diagnostic notification used Resend's
|
||||||
|
documented `delivered+sxchnge-config@resend.dev` test mailbox, without retrying
|
||||||
|
the customer's email. The rebuilt worker logged HTTP 403,
|
||||||
|
`provider_error: validation_error`, and the sanitized message:
|
||||||
|
`The sxchnge.com domain is not verified. Please, add and verify your domain on [redacted-url]`.
|
||||||
|
The precise blocker is sender-domain verification, not missing local Resend
|
||||||
|
configuration. A sending-only key is appropriate for sending; its inability to
|
||||||
|
read Resend's logs API does not explain the original send failure. Verify the
|
||||||
|
configured domain in Resend (and its required DNS records), or explicitly choose
|
||||||
|
an already verified sender domain. No sender/domain/DNS setting was changed by
|
||||||
|
this follow-up; the customer's account remains pending email confirmation.
|
||||||
|
|
||||||
|
Follow-up validation passed: `GOFLAGS=-p=2 make test`, `make lint-backend`, and
|
||||||
|
`go -C backend/notifications test -race ./email ./service -count=1`. Coverage
|
||||||
|
includes Resend/SendGrid messages, malformed/trailing/oversized response bodies,
|
||||||
|
credential/content/code redaction, Unicode-safe truncation, sanitized transport
|
||||||
|
errors, and exactly one provider outcome log even when persistence fails. The
|
||||||
|
notifications image was rebuilt from fresh stable digest pins and deployed
|
||||||
|
healthy; the live test verified both the diagnostic log and the unchanged generic
|
||||||
|
gRPC failure response. No security scan or real customer email retry was run.
|
||||||
|
|
||||||
## Behavior and boundaries
|
## Behavior and boundaries
|
||||||
|
|
||||||
|
### Local mail client
|
||||||
|
|
||||||
|
The user subsequently selected Sendico's existing mailbox instead of Resend for
|
||||||
|
local notifications. The authoritative Sendico configuration resolves to
|
||||||
|
`no-reply@sendico.io`, `mail.sendico.io:465`, authenticated implicit TLS and a
|
||||||
|
10-second network timeout. SXCHNGE's owned `smtp` adapter consumes rendered email
|
||||||
|
models through the existing sender contract and queue; it does not import or
|
||||||
|
access Sendico business modules, databases or messaging. The password was copied
|
||||||
|
once, without printing it, from the running Sendico notification environment into
|
||||||
|
`services/notifications/email/smtp` in SXCHNGE Vault. Only `password` is stored
|
||||||
|
there; host, port, username, display name and sender remain in runtime YAML.
|
||||||
|
|
||||||
|
The SMTP adapter verifies certificates, authenticates before submission, sends
|
||||||
|
UTF-8 MIME alternatives, and requires the final DATA 250 reply for acceptance.
|
||||||
|
SMTP 4xx replies retry through the existing worker policy; 5xx replies fail;
|
||||||
|
ambiguous transport outcomes remain UNKNOWN and are not immediately resent.
|
||||||
|
Message-ID is correlation only, not a provider idempotency guarantee. Existing
|
||||||
|
failed Resend notifications are not replayed or rewritten. The unverified
|
||||||
|
`sxchnge.com` Resend domain is no longer the selected local delivery path.
|
||||||
|
|
||||||
|
Live verified-TLS SMTP authentication and NOOP succeeded with reply 250 using
|
||||||
|
the password from SXCHNGE Vault, without submitting email. SMTP adapter race
|
||||||
|
tests, `make lint-backend`, `make test-dev-tooling` and `make test-dev-infra`
|
||||||
|
pass, including canonical Vault layout, service permission isolation, database
|
||||||
|
credential issuance/renewal/revocation and transactions. The final
|
||||||
|
`GOFLAGS=-p=2 make test` passes all backend race tests and frontend suites.
|
||||||
|
|
||||||
|
Earlier attempts hit Docker archive-copy/reaper timeouts, Go proxy TLS failures
|
||||||
|
and stalled cache extraction/container startup. The user authorized restarting
|
||||||
|
Docker Desktop. After recovery, the normal Compose `build --pull notifications`
|
||||||
|
succeeded with the resolved stable digest pins; no checks were disabled. The
|
||||||
|
generator stage shares the existing BuildKit Go module/build caches and retains
|
||||||
|
the pinned tools and canonical Make generation command. A discarded fallback
|
||||||
|
compiler attempt also failed to access its checksum-state directory; it was not
|
||||||
|
used for the deployed artifact.
|
||||||
|
|
||||||
|
The matching Vault Agent policy/template were applied, and notifications was
|
||||||
|
recreated healthy. Its running image matches the rebuilt image
|
||||||
|
`sha256:d4cbc0cb9005e9cef9381abdf2a13f672104d160536ee6d77ac73a909c0a8638`,
|
||||||
|
and its startup log confirms `email_driver: smtp`. No customer email was sent or
|
||||||
|
failed Resend job replayed; SMTP authentication is not a recipient-delivery
|
||||||
|
check. Sendico's Vault sealed during the Docker restart and was unsealed with
|
||||||
|
its existing local key, without reinitialization or exposing credentials.
|
||||||
|
Its affected services were restarted in dependency order. The local Sendico
|
||||||
|
session sidecar shares the BFF network namespace, so it was restarted after the
|
||||||
|
BFF to restore Docker DNS and the loopback bridge. Final
|
||||||
|
`sh infra/local/tools.sh verify` passes: seven healthy services, verified HTTPS
|
||||||
|
for frontend/proxied BFF, authenticated Sendico currency/ledger reads and the
|
||||||
|
Vault/database checks. No Sendico repository files were changed.
|
||||||
|
|
||||||
|
On October 18, the frontend had 15,897 processes, including 15,888 unreaped
|
||||||
|
`ssl_client` zombie children of Caddy PID 1. The three-second HTTPS health
|
||||||
|
probe used BusyBox `wget`, which left those children behind. This confirmed a
|
||||||
|
recurring local infrastructure leak, independently of SMTP authentication.
|
||||||
|
Compose now enables `init: true` for frontend, and only that container was
|
||||||
|
recreated. PID 1 is now `docker-init`; 40 consecutive HTTPS probes completed
|
||||||
|
with zero zombie processes and a bounded count of 15 processes afterward.
|
||||||
|
Compose configuration validation and `make test-dev-tooling` pass. The API
|
||||||
|
health endpoint returns HTTP 200; a diagnostic request using the existing
|
||||||
|
account and a generated incorrect password returned HTTP 400 `email_in_use`
|
||||||
|
without creating an account or sending email.
|
||||||
|
|
||||||
|
The contemporaneous full local verification attempt is blocked by MongoDB's
|
||||||
|
five-second health-check timeout under continuing host load; it is not recorded
|
||||||
|
as passing. The exact response to the reported Safari attempt was not captured,
|
||||||
|
and the frontend's generic unavailable text also covers some HTTP 400 input
|
||||||
|
errors. A fresh user retry is needed to confirm the visible signup outcome.
|
||||||
|
|
||||||
|
On October 19 the failing API was directly observed returning HTTP 502. The BFF
|
||||||
|
had restarted while the session worker retained its old network namespace;
|
||||||
|
`/proc/self/ns/net` differed between them, and the worker could not resolve
|
||||||
|
Vault. Sendico's public API itself returned HTTP 200. Restarting only the worker
|
||||||
|
reattached its namespace and restored SXCHNGE API HTTP 200. This is a confirmed
|
||||||
|
cause of that 502, separate from the previous process leak and host-load finding.
|
||||||
|
|
||||||
|
The session now monitors bounded DNS failures and exits after three consecutive
|
||||||
|
failures so Docker can reattach it automatically. Bootstrap and rotation pair
|
||||||
|
BFF/session recreation, with Compose dependency restart enabled. Offline shell
|
||||||
|
fixtures cover transient-failure reset, sustained-failure recovery, safe parent
|
||||||
|
targeting and conditional service pairing. A live BFF-only restart automatically
|
||||||
|
restarted the session and restored matching namespaces, fresh session readiness
|
||||||
|
and API HTTP 200. A second live test recreated BFF and its session together and
|
||||||
|
confirmed healthy services with matching namespaces. No provider bindings,
|
||||||
|
customer records, credentials or Sendico source files were changed.
|
||||||
|
|
||||||
|
Final October 19 validation passes: `make test-dev-tooling`, Compose config
|
||||||
|
validation, `make test-dev-rotation` (three real rotations, unchanged BFF process,
|
||||||
|
534 transaction-backed requests and zero failures), and
|
||||||
|
`sh infra/local/tools.sh verify`. The last confirms all seven healthy services,
|
||||||
|
verified frontend/BFF HTTPS, Vault/database checks and authenticated Sendico
|
||||||
|
currency/ledger reads. An initial ledger-read failure was resolved by restoring
|
||||||
|
Sendico Discovery and its announcers in their documented dependency order after
|
||||||
|
closed NATS connections. No email was sent during validation. The recovery fix
|
||||||
|
and its validation are included in [PR #59](https://git.sendico.io/tech/sxchnge/pulls/59).
|
||||||
|
|
||||||
|
|
||||||
With `api.sendico` configured, the product BFF creates the local pending customer
|
With `api.sendico` configured, the product BFF creates the local pending customer
|
||||||
and activation token atomically, then registers an independent individual through
|
and activation token atomically, then registers an independent individual through
|
||||||
Sendico's public HTTP API and submits the sign-up assertions. It persists the
|
Sendico's public HTTP API and submits the sign-up assertions. It persists the
|
||||||
@@ -18,7 +201,7 @@ backfilled or blocked at login.
|
|||||||
|
|
||||||
The adapter consumes these existing Sendico contracts:
|
The adapter consumes these existing Sendico contracts:
|
||||||
|
|
||||||
- `POST /individuals/{organization_ref}`, empty body, HTTP 200 registration
|
- `POST /individuals/{organization_ref}`, empty body, HTTP 201 registration
|
||||||
receipt containing `subject_ref` and `registered_at`.
|
receipt containing `subject_ref` and `registered_at`.
|
||||||
- `POST /identity_facts/{organization_ref}`, HTTP 200 immutable intake receipt
|
- `POST /identity_facts/{organization_ref}`, HTTP 200 immutable intake receipt
|
||||||
containing `submission_ref`, `subject_ref` and `accepted_at`.
|
containing `submission_ref`, `subject_ref` and `accepted_at`.
|
||||||
@@ -73,9 +256,10 @@ the adapter rereads it on every request. Startup rejects missing, expired or
|
|||||||
mismatched credentials and rejects Sendico configuration without notifications.
|
mismatched credentials and rejects Sendico configuration without notifications.
|
||||||
|
|
||||||
HTTPS is required except for loopback HTTP test/development endpoints. Redirects
|
HTTPS is required except for loopback HTTP test/development endpoints. Redirects
|
||||||
are rejected and each HTTP call has a four-second timeout. Non-200 responses,
|
are rejected and each HTTP call has a four-second timeout. Unexpected response
|
||||||
malformed/truncated receipts, invalid identifiers, wrong subjects and unsuccessful
|
statuses (registration requires 201; Facts intake requires 200), malformed/truncated
|
||||||
envelopes never count as acceptance. Upstream error bodies are not surfaced.
|
receipts, invalid identifiers, wrong subjects and unsuccessful envelopes never
|
||||||
|
count as acceptance. Upstream error bodies are not surfaced.
|
||||||
|
|
||||||
Omitting `api.sendico` keeps existing local-only behavior. Enabling it also gates
|
Omitting `api.sendico` keeps existing local-only behavior. Enabling it also gates
|
||||||
activation for previously pending customers; use activation resend to submit
|
activation for previously pending customers; use activation resend to submit
|
||||||
@@ -85,6 +269,34 @@ adapter fingerprint and blocks existing bindings for explicit reconciliation.
|
|||||||
Token rotation for the same account retains that fingerprint. Plan such changes
|
Token rotation for the same account retains that fingerprint. Plan such changes
|
||||||
as migrations; never reset a binding just to bypass the check.
|
as migrations; never reset a binding just to bypass the check.
|
||||||
|
|
||||||
|
## Local signup request diagnosis
|
||||||
|
|
||||||
|
The October 19 follow-up screenshot still shows the generic unavailable message
|
||||||
|
after the network recovery. That screenshot alone does not establish a 503:
|
||||||
|
the frontend also maps some rejected inputs and browser transport failures to
|
||||||
|
that message. At the follow-up check the health endpoint returned 200, and BFF
|
||||||
|
signup counters contained only the operator's two deliberately invalid probes.
|
||||||
|
The user's current browser failure has not yet been reproduced or resolved.
|
||||||
|
|
||||||
|
Local Caddy now logs requests to exactly `/api/v1/accounts/signup` under
|
||||||
|
`http.log.access.signup`, including HTTP status and duration. The complete
|
||||||
|
request object, response headers and user identifier are removed before log
|
||||||
|
encoding, so paths/queries, cookies, authorization, addresses and user-supplied
|
||||||
|
headers cannot enter these access records. Bodies are never logged. Other
|
||||||
|
requests, including health probes and email confirmation URLs, are skipped.
|
||||||
|
Inspect with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker logs --since 5m sxchnge-local-frontend-1 2>&1 |
|
||||||
|
jq -Rrc 'fromjson? | select(.logger=="http.log.access.signup")'
|
||||||
|
```
|
||||||
|
|
||||||
|
Validation: Caddy configuration validation and reload passed; a deliberately
|
||||||
|
invalid signup returned 400 and produced the expected status/duration record.
|
||||||
|
Sentinel query, cookie and custom-header values were absent from its log.
|
||||||
|
The frontend was rebuilt with freshly resolved stable image pins. No real
|
||||||
|
signup or customer email was submitted during this diagnostic work.
|
||||||
|
|
||||||
## Persistence, retries and recovery
|
## Persistence, retries and recovery
|
||||||
|
|
||||||
The local `signup_facts` collection uses the customer ID as `_id`, so its unique
|
The local `signup_facts` collection uses the customer ID as `_id`, so its unique
|
||||||
|
|||||||
@@ -149,3 +149,213 @@ text uses a normal text node to remain exposed to assistive technology.
|
|||||||
Delivered in [PR #13](https://git.sendico.io/tech/sxchnge/pulls/13), branch
|
Delivered in [PR #13](https://git.sendico.io/tech/sxchnge/pulls/13), branch
|
||||||
`onboarding-12`. The final browser accessibility tree exposes the recipient text,
|
`onboarding-12`. The final browser accessibility tree exposes the recipient text,
|
||||||
demo status, status heading and recovery actions. PR is open for review.
|
demo status, status heading and recovery actions. PR is open for review.
|
||||||
|
|
||||||
|
## Browser credential autofill
|
||||||
|
|
||||||
|
Task: [#56](https://git.sendico.io/tech/sxchnge/issues/56), branch `auth-56`.
|
||||||
|
Delivery: [PR #58](https://git.sendico.io/tech/sxchnge/pulls/58), implementation
|
||||||
|
commit `86b6aaf`. Open for review; no merge or deployment is included.
|
||||||
|
The consumer auth form groups its mounted inputs in one `AutofillGroup` and
|
||||||
|
declares their purpose explicitly:
|
||||||
|
|
||||||
|
| Input | Flutter hints | Browser autocomplete |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Signup name | `name` with the name keyboard | `name` |
|
||||||
|
| Account email | `username`, then `email`, with the email keyboard | `username` |
|
||||||
|
| Login password | `password` | `current-password` |
|
||||||
|
| Signup password and confirmation | `newPassword` on both | `new-password` |
|
||||||
|
|
||||||
|
Flutter uses the first hint on web and Apple platforms. Email is the credential
|
||||||
|
username; the email keyboard remains configured separately. Visibility toggles
|
||||||
|
keep the password role, so autofill does not depend on detecting masked input.
|
||||||
|
The confirmation field participates in the same group as the first password,
|
||||||
|
allowing the browser to fill both without copying one controller into the other.
|
||||||
|
|
||||||
|
Signup acceptance finalizes autofill before clearing password controllers and
|
||||||
|
navigating to email verification. Login finalizes on success; when a second
|
||||||
|
factor is required, it waits for verification and login completion. The password
|
||||||
|
remains only in the existing form controller during that challenge, then clears
|
||||||
|
on completed navigation or cancellation. Invalid input, rejected credentials and
|
||||||
|
failed verification do not request saving. Switching auth mode, abandoning the
|
||||||
|
form or leaving for unverified-account recovery cancels the autofill context.
|
||||||
|
Provider/service/model boundaries and existing request/outcome telemetry are
|
||||||
|
unchanged; no field events or credential telemetry are added. Browser popup
|
||||||
|
display and pre-submit autofill abandonment remain outside current measurement.
|
||||||
|
|
||||||
|
### Verification and release check
|
||||||
|
|
||||||
|
Focused widget tests send a platform autofill response with all four signup
|
||||||
|
values and check EN/RU controller updates, grouped credential metadata, visibility
|
||||||
|
changes, mode switching and abandonment. Auth tests cover validation failures,
|
||||||
|
service rejection/retry, stale responses, signup/login acceptance, unverified
|
||||||
|
recovery, invalid second-factor codes and cancellation without premature saving.
|
||||||
|
Existing narrow/desktop layouts and enlarged-text tests cover the unchanged UI.
|
||||||
|
|
||||||
|
Native Safari verification remains a release check: computer-control permission
|
||||||
|
was unavailable in this session. On the deployed HTTPS origin with Safari
|
||||||
|
AutoFill enabled, focus the signup email and password fields, choose a suggested
|
||||||
|
strong password, verify that both password fields populate, then confirm that
|
||||||
|
login offers the saved credential. Repeat after showing/hiding passwords and
|
||||||
|
switching modes. Browser settings, stored credentials and browser heuristics
|
||||||
|
determine whether suggestions appear; automated Flutter tests cannot establish
|
||||||
|
that the system popup was displayed or that credentials were saved.
|
||||||
|
|
||||||
|
References: [Apple Password AutoFill](https://developer.apple.com/documentation/security/enabling-password-autofill-on-an-html-input-element),
|
||||||
|
[Flutter autofill hints](https://api.flutter.dev/flutter/material/TextField/autofillHints.html),
|
||||||
|
and [AutofillGroup](https://api.flutter.dev/flutter/widgets/AutofillGroup-class.html).
|
||||||
|
|
||||||
|
Recorded validation: `dart format --output=none --set-exit-if-changed` passes for
|
||||||
|
all five changed Dart files; `flutter analyze` passes in `frontend/app`;
|
||||||
|
`make test-frontend` passes all 212 tests across the app and shared packages.
|
||||||
|
`flutter build web` passes. The built page's default rendering path was inspected
|
||||||
|
in the in-app browser: login exposes `username`/`current-password` in one HTML
|
||||||
|
form; signup exposes `username`, `name` and two `new-password` inputs in one form.
|
||||||
|
This verifies the browser metadata, not Safari's credential popup. With Flutter
|
||||||
|
web accessibility semantics enabled, unfocused semantic inputs initially use
|
||||||
|
framework-default autocomplete values until focused; password-manager behavior
|
||||||
|
with assistive technology still requires a native browser audit.
|
||||||
|
|
||||||
|
## Verification links and login handoff
|
||||||
|
|
||||||
|
Task: [#60](https://git.sendico.io/tech/sxchnge/issues/60), branch
|
||||||
|
`email-verification-60`. Email links use `/verify-email?token=…`; the web entry
|
||||||
|
point installs Flutter's path URL strategy before initializing the application.
|
||||||
|
This matches Caddy's existing SPA fallback and preserves the path and query on a
|
||||||
|
fresh page load. Previously the default hash strategy could start at login and
|
||||||
|
never submit the activation token. Unknown routes retain the canonical login
|
||||||
|
fallback; invalid, expired and failed confirmation attempts remain on the
|
||||||
|
verification page with the existing recovery actions.
|
||||||
|
|
||||||
|
Activation consumes the token, checks the pending account and accepted signup
|
||||||
|
facts, and activates the account in the existing database transaction. Only
|
||||||
|
after a successful commit does the API return `{verified: true, email: ...}`.
|
||||||
|
Failure responses do not disclose the account email. Responses retain the
|
||||||
|
shared no-store policy. The frontend backend DTO and mapper produce a
|
||||||
|
`VerificationResult`; the verification provider exposes that result only in the
|
||||||
|
verified state and clears it on reset, loading and failure. A verified response
|
||||||
|
from an older backend without the optional email can still show success.
|
||||||
|
|
||||||
|
Success stays visible until the user chooses **Continue to login**. This replaces
|
||||||
|
the misleading workspace action that could redirect an unauthenticated visitor
|
||||||
|
back to login. The verified email travels through transient router extras into
|
||||||
|
the editable login controller; the password starts empty and receives focus.
|
||||||
|
Normal password and second-factor checks remain required. The email is not
|
||||||
|
added to URLs, serialized history, persistent storage or analytics. A link opened
|
||||||
|
in another tab or device can receive the verified address from the API without
|
||||||
|
depending on the originating signup form's memory.
|
||||||
|
|
||||||
|
Existing requested/succeeded/failed email-confirmation journey signals remain
|
||||||
|
the measurement source. Success continues to mean that the confirmation service
|
||||||
|
returned a verified result, with simulated outcomes kept separate. No field or
|
||||||
|
prefill event is needed. Existing limits around pre-submit abandonment,
|
||||||
|
cross-device journey correlation and the in-memory analytics sink still apply.
|
||||||
|
|
||||||
|
Regression coverage includes transactional activation and rollback, returned
|
||||||
|
email and failure privacy, used-token rejection, backend response mapping,
|
||||||
|
provider identity clearing, EN/RU success-to-login navigation, editable prefill,
|
||||||
|
empty focused password, and exclusion of email from serialized navigation state.
|
||||||
|
The supplied private diagnostic token is not a test fixture or documentation
|
||||||
|
input; it is kept out of repository sources and tracker records.
|
||||||
|
|
||||||
|
### Validation and local release (#60)
|
||||||
|
|
||||||
|
Delivery: [PR #61](https://git.sendico.io/tech/sxchnge/pulls/61), implementation
|
||||||
|
commit `5f3b443`. Reviewed the complete backend and frontend contribution against
|
||||||
|
their module guidelines and the UX/observability checklists. No task-caused
|
||||||
|
review findings remain. Existing recovery, loading and accessible status states
|
||||||
|
are retained; EN/RU widget tests and existing small-screen/text-scale coverage
|
||||||
|
pass. Browser startup was checked against a separately built demo with synthetic
|
||||||
|
tokens: direct success remains visible, Continue to login shows the verified
|
||||||
|
email with an empty focused password, and an expired link offers resend/back.
|
||||||
|
Native Safari was unavailable to automation, so browser-specific helper behavior
|
||||||
|
is not claimed as verified by this check.
|
||||||
|
|
||||||
|
On 2026-10-19, changed-file Dart formatting, `flutter analyze` in both affected
|
||||||
|
packages (`frontend/app`, `frontend/packages/sxchnge_auth`), `make test-frontend`
|
||||||
|
(217 tests), `GOFLAGS=-p=1 make test`, and `make lint-backend` passed. The focused
|
||||||
|
transactional activation integration test also passed independently. Initial
|
||||||
|
test-container startup and image-resolution attempts failed while Docker was
|
||||||
|
stalled. After the user-authorized Docker Desktop restart, the unchanged checks
|
||||||
|
passed; no assertions, timeouts or gates were weakened. No security scan was
|
||||||
|
requested or run.
|
||||||
|
|
||||||
|
The task branch was integrated locally into the existing `identity-facts-57`
|
||||||
|
stand checkout, preserving its unrelated configuration. Fresh stable image
|
||||||
|
versions/digests were resolved successfully with `sh infra/local/tools.sh images`.
|
||||||
|
Compose rebuilt BFF and frontend with `build --pull bff frontend`, then recreated
|
||||||
|
them and the Sendico session sidecar (which shares BFF's network namespace).
|
||||||
|
Docker restart recovery also unsealed Sendico's existing Vault with its saved
|
||||||
|
local key and restarted affected existing services in dependency order.
|
||||||
|
|
||||||
|
The updated BFF, frontend and Sendico sidecar are healthy. The authenticated
|
||||||
|
Sendico currency/ledger check passes. HTTPS serves the direct verification path;
|
||||||
|
the served JavaScript matches the new frontend image with SHA-256
|
||||||
|
`b194c0cf5dcc62634c13c7fd3d2f7e482272cb7abc1e470c62629400bf2d5d21`.
|
||||||
|
A live synthetic invalid token returns HTTP 400 without an email. The supplied
|
||||||
|
real token was checked read-only and remains unused and unexpired, with its
|
||||||
|
account pending verification, ready for the user to reopen the link. PR #61 has
|
||||||
|
not been merged into remote main; this local deployment does not imply a main
|
||||||
|
branch merge.
|
||||||
|
|
||||||
|
### Accepted signup delivery (#62)
|
||||||
|
|
||||||
|
Task [#62](https://git.sendico.io/tech/sxchnge/issues/62), branch
|
||||||
|
`email-verification-62`, delivered in
|
||||||
|
[PR #63](https://git.sendico.io/tech/sxchnge/pulls/63). On October 23, signup
|
||||||
|
returned HTTP 202 and the local
|
||||||
|
SMTP provider accepted its message with status 250, while the user reported an
|
||||||
|
internal-error screen immediately after Create account. No subsequent
|
||||||
|
verification request from that attempt reached the BFF. The precise original
|
||||||
|
client exception was not captured; an ordinary isolated release reproduction
|
||||||
|
succeeded. The regression below deliberately injects an unavailable follow-up
|
||||||
|
send service to cover the post-acceptance failure path without claiming that
|
||||||
|
injected exception was the original cause.
|
||||||
|
|
||||||
|
Backend and preview signup mappers now return a `VerificationDelivery` domain
|
||||||
|
receipt with the recipient and simulation status. It means that signup accepted
|
||||||
|
the email-queue operation, not inbox delivery, account activation or a session.
|
||||||
|
The signup provider exposes it through its operation result. The form captures
|
||||||
|
the receipt before clearing password controllers, whose change listeners reset
|
||||||
|
auth state, and passes it as transient router extra. The verification provider
|
||||||
|
initializes directly in the sent state; mounting the page performs no send.
|
||||||
|
This replaces the backend adapter's hidden one-use signup cache. Every explicit
|
||||||
|
resend now invokes its service, including the first one after signup.
|
||||||
|
|
||||||
|
Routes without a receipt retain their existing behavior: unverified login may
|
||||||
|
request an email, a fresh verification page asks for an address, and emailed
|
||||||
|
links run confirmation. Recipient changes invalidate the displayed claim until
|
||||||
|
another send succeeds. New receipts replace stale recipients. The route codec
|
||||||
|
discards the receipt when serializing history, so reloads do not invent a queued
|
||||||
|
result or retain the address. No token or password is included in the receipt.
|
||||||
|
|
||||||
|
The existing email-delivery requested/succeeded signals now follow the accepted
|
||||||
|
signup receipt inside the instrumented service instead of a synthetic send on
|
||||||
|
page mount. They stay in the signup journey, occur once for the accepted
|
||||||
|
operation, and remain intermediate queue-acceptance signals. Resends retain
|
||||||
|
separate attempts and failures; confirmation remains the terminal outcome.
|
||||||
|
Simulation and privacy rules are unchanged, including the in-memory analytics
|
||||||
|
and cross-device correlation limitations above.
|
||||||
|
|
||||||
|
Frontend review covered the complete app/auth contribution and preserved the
|
||||||
|
DTO, mapper, service, domain model, provider and UI boundaries. Regressions cover
|
||||||
|
accepted signup followed by unavailable resend, explicit retry, normalized
|
||||||
|
receipt mapping, provider initialization/reset, recipient replacement, transient
|
||||||
|
history, event correlation and exclusion of form values from analytics. EN/RU
|
||||||
|
flow tests run at 390px with 1.5x text scaling; existing keyboard, autofill and
|
||||||
|
accessibility suites also pass. Changed-file Dart format checks, analysis in
|
||||||
|
`frontend/app` and `frontend/packages/sxchnge_auth`, and `make test-frontend`
|
||||||
|
(221 tests) pass. The new injected-failure regression failed on the previous
|
||||||
|
implementation and passes with the receipt handoff. A release-browser fixture
|
||||||
|
with the same backend adapter and a failing resend service shows Check your
|
||||||
|
inbox after signup with no send call, then the expected error only after an
|
||||||
|
explicit resend. It sends no email. Native Safari was not automated.
|
||||||
|
|
||||||
|
The frontend image was rebuilt with freshly resolved stable digest pins and
|
||||||
|
recreated without restarting BFF. The served JavaScript matches the new image
|
||||||
|
at SHA-256 `0a45ce6f0b4658b1f03d8e39b9e39fd3fbfcf6f1626cb3f17705dce2da130a6c`.
|
||||||
|
Normal browser-trust HTTPS serves the frontend and API with HTTP 200.
|
||||||
|
`sh infra/local/tools.sh verify` passes all seven services, Vault/database
|
||||||
|
checks and authenticated Sendico currency/ledger reads. No new customer email,
|
||||||
|
activation or payment was submitted for this change, and no security scan was
|
||||||
|
requested or run. The user's pre-existing browser tab must reload to use the
|
||||||
|
new bundle; their original Safari exception remains unobserved.
|
||||||
@@ -302,6 +302,55 @@ that contradicts displayed balances or usage without explaining the fixtures.
|
|||||||
- Keep unrelated accounts/history accessible during a pending operation. Do not
|
- Keep unrelated accounts/history accessible during a pending operation. Do not
|
||||||
block the whole product behind an exchange spinner.
|
block the whole product behind an exchange spinner.
|
||||||
|
|
||||||
|
## Automatic currency accounts (task #72)
|
||||||
|
|
||||||
|
New customers receive RUB, USDT and USDC accounts through backend-owned setup
|
||||||
|
during signup. Existing customers receive missing defaults on their next wallet
|
||||||
|
load. The UI requires no account-creation form or support contact. During initial
|
||||||
|
loading it announces that the accounts are being prepared. Failure shows one
|
||||||
|
clear load error and a **Try again** action that reruns the same idempotent setup.
|
||||||
|
An unexpected empty snapshot has its own setup-failure fallback, rather than
|
||||||
|
being shown simultaneously with an error. A failed refresh retains existing
|
||||||
|
cards and the stale-data warning. Account-not-found copy requires loaded data.
|
||||||
|
|
||||||
|
The existing DTO → mapper → service → model → provider → UI boundaries remain
|
||||||
|
intact. Account creation belongs to the backend/provider, and widgets invoke the
|
||||||
|
existing provider refresh action. English and Russian use wrapping text and the
|
||||||
|
existing accessible button pattern; status text uses a live region. Dedicated
|
||||||
|
widget regressions cover loading, failure, retry and successful default accounts
|
||||||
|
on Accounts, Top up, Exchange and Payout at 320px with 1.6× text. Native device and
|
||||||
|
screen-reader QA remain a release check; tests do not claim physical-device QA.
|
||||||
|
|
||||||
|
Measurement: `sxchnge_currency_accounts_setup_total{outcome="ready|failed"}` and
|
||||||
|
`sxchnge_currency_accounts_setup_duration_seconds` cover the server setup attempt.
|
||||||
|
`ready` is emitted only after all default bindings are confirmed, including
|
||||||
|
idempotent completion of provider wallet/route setup for newly bound ledgers.
|
||||||
|
Each signup/resend/wallet load is a separate attempt; a retry is not a new unique
|
||||||
|
customer conversion. The failure fraction uses all attempts as its denominator;
|
||||||
|
the histogram measures one attempt, not time across multiple browser sessions.
|
||||||
|
No customer, ledger, currency, balance, credentials or form input is a metric
|
||||||
|
label. These server metrics do not measure client abandonment or prove that a
|
||||||
|
balance card was viewed. Existing transaction journey outcomes below are reused;
|
||||||
|
no speculative button-click events are added.
|
||||||
|
|
||||||
|
Scope and rollout: [task #72](https://git.sendico.io/tech/sxchnge/issues/72), branch
|
||||||
|
`wallet-72`, [PR #73](https://git.sendico.io/tech/sxchnge/pulls/73). The provider
|
||||||
|
prerequisite and deployment order are documented in [payments](../payments.md).
|
||||||
|
Merge and deployment remain separate. Final validation is recorded in the task
|
||||||
|
and PR, including any transient failures and environmental blockers.
|
||||||
|
|
||||||
|
Final validation (2026-10-23): `flutter gen-l10n`, `flutter analyze` in
|
||||||
|
`frontend/app`, the changed-Dart formatting check, `make lint-backend`, and
|
||||||
|
`git diff --check` passed. `make test-frontend` passed all 242 tests on the current
|
||||||
|
app base, including the eight setup recovery scenarios. The full `make test`
|
||||||
|
passed with `TESTCONTAINERS_RYUK_DISABLED=true GOFLAGS='-p=2'`: earlier attempts
|
||||||
|
hit dependency DNS and Mongo/cleanup-helper startup timeouts. Disabling only the
|
||||||
|
failing helper retained every integration assertion, test timeout and race check;
|
||||||
|
each test's existing container cleanup still ran. No security scan was requested.
|
||||||
|
The live local provider catalog confirms RUB scale 2 and USDT/USDC scale 6.
|
||||||
|
Provider-side concurrency and frozen-binding checks passed against a disposable
|
||||||
|
Mongo replica set; see [Sendico PR #2970](https://git.sendico.io/tech/sendico/pulls/2970).
|
||||||
|
|
||||||
## Journey measurement plan
|
## Journey measurement plan
|
||||||
|
|
||||||
Reuse the typed analytics abstraction and maintain a separate journey per active
|
Reuse the typed analytics abstraction and maintain a separate journey per active
|
||||||
|
|||||||
@@ -167,3 +167,95 @@ This is a display correction within the existing KYC journey, so no additional
|
|||||||
analytics events or financial data collection are introduced. Tests cover mapping
|
analytics events or financial data collection are introduced. Tests cover mapping
|
||||||
without inferring remaining, unused/exhausted/over-cap/zero-cap ratios and EN/RU
|
without inferring remaining, unused/exhausted/over-cap/zero-cap ratios and EN/RU
|
||||||
usage display at 320px with 1.6 text scaling.
|
usage display at 320px with 1.6 text scaling.
|
||||||
|
|
||||||
|
## Residence country selection — task #64
|
||||||
|
|
||||||
|
Task [#64](https://git.sendico.io/tech/sxchnge/issues/64), branch `kyc-64`.
|
||||||
|
Delivery: [PR #65](https://git.sendico.io/tech/sxchnge/pulls/65).
|
||||||
|
The profile now shows a country name and opens a searchable country list instead
|
||||||
|
of asking customers to type a two-letter code. Names follow the English/Russian
|
||||||
|
app locale. Search accepts localized or English names and two-letter codes,
|
||||||
|
ignoring case and surrounding whitespace. Selection updates only
|
||||||
|
`IdentityProfile.residenceCountry` with the country code; the existing provider,
|
||||||
|
service, mapper and transport contracts remain unchanged.
|
||||||
|
|
||||||
|
The `country_picker` dependency supplies bundled country data, code lookup and
|
||||||
|
translations through `CountryService` and `CountryLocalizations`. Flutter Material
|
||||||
|
controls provide the search interface, explicit no-results recovery, minimum
|
||||||
|
touch targets, keyboard selection, a selected-state checkmark, Back and Escape.
|
||||||
|
The package's built-in list omits explicit no-results feedback and uses shorter
|
||||||
|
row targets, so its data/localizations are reused with standard Material controls.
|
||||||
|
There is no remote lookup, copied country table or extra application provider.
|
||||||
|
Cancelling preserves the current profile, prefilled codes display names, busy
|
||||||
|
state disables opening the selector, and the search controller is disposed with
|
||||||
|
its dialog. The list is a data catalogue; backend eligibility remains authoritative.
|
||||||
|
|
||||||
|
This change stays within the existing KYC preparation/submission journey and its
|
||||||
|
measurement definitions above. No selector events, queries, names or country
|
||||||
|
codes are added to telemetry. Existing acceptance/failure/retry/abandonment
|
||||||
|
signals and documented server-side review measurement gaps still apply.
|
||||||
|
|
||||||
|
Focused widget checks cover name/code search, code-only updates, reselection,
|
||||||
|
cancel, no-results recovery, clearing search, saved values, locale changes,
|
||||||
|
Russian names, disabled state, keyboard activation/selection/Escape and labeled
|
||||||
|
touch targets. A long country name fits at 320px with 1.6 text scale. Existing
|
||||||
|
EN/RU verification journeys now select a country and assert the profile code
|
||||||
|
before continuing through document submission. Native screen-reader and device
|
||||||
|
keyboard testing remain release checks; widget semantics checks do not establish
|
||||||
|
those device outcomes.
|
||||||
|
|
||||||
|
Validation: five changed Dart files passed the format check; `flutter analyze`
|
||||||
|
in `frontend/app` reported no issues; `make test-frontend` passed all 222 tests;
|
||||||
|
and `flutter build web` passed, including its Wasm compatibility dry run. Browser
|
||||||
|
smoke testing on the local demo opened the selector, searched for Poland and
|
||||||
|
confirmed that the profile displays Poland after selection. Focused and full
|
||||||
|
journey tests verify the stored `PL` code. Generated localization/build outputs
|
||||||
|
remain ignored and uncommitted.
|
||||||
|
|
||||||
|
## Birthday entry — task #67
|
||||||
|
|
||||||
|
Task [#67](https://git.sendico.io/tech/sxchnge/issues/67), branch `kyc-67`.
|
||||||
|
Delivery: [PR #70](https://git.sendico.io/tech/sxchnge/pulls/70).
|
||||||
|
This change is stacked on the residence-country selector in PR #65; merge #65
|
||||||
|
before the birthday PR.
|
||||||
|
|
||||||
|
The profile uses labeled Day, Month and Year controls. Day and year accept numeric
|
||||||
|
entry with birthday autofill hints; month selection uses localized month names
|
||||||
|
and standard Material keyboard/touch interaction. Customers can enter their birth
|
||||||
|
year directly. No calendar paging or transport-format punctuation is required.
|
||||||
|
The controls share a row when space allows and stack at narrow widths or enlarged
|
||||||
|
text sizes. English and Russian labels, month names and errors follow the app locale.
|
||||||
|
|
||||||
|
Valid calendar dates are normalized to the existing `IdentityProfile.dateOfBirth`
|
||||||
|
`YYYY-MM-DD` value through `IdentityProvider.updateProfile`. Partial, impossible
|
||||||
|
and future dates clear that profile value so Continue cannot retain an earlier
|
||||||
|
valid birthday. Local input components remain visible while correcting a date;
|
||||||
|
complete invalid dates receive a specific live-region error below the group.
|
||||||
|
Leap years, zero day/year and calendar rollover are checked. Today's date follows
|
||||||
|
the existing model policy; no minimum age or arbitrary oldest birth year is added.
|
||||||
|
Prefilled dates, locale changes and external profile replacement synchronize the
|
||||||
|
controls. Back navigation restores the valid saved date. Busy state disables all
|
||||||
|
three controls. Controllers are disposed with the widget.
|
||||||
|
|
||||||
|
The existing DTO, mapper, service, domain model and provider boundaries are retained.
|
||||||
|
No dependency, transport or backend change is needed. This remains part of the KYC
|
||||||
|
preparation-to-submission journey defined above: existing acceptance, failure,
|
||||||
|
retry and abandonment signals apply, without new field events or date collection.
|
||||||
|
The existing server-side review measurement gaps remain unchanged.
|
||||||
|
|
||||||
|
UX review and automated checks cover entry/correction, leap dates, invalid and
|
||||||
|
future-date recovery, numeric restrictions, prefill/replacement/clearing, locale
|
||||||
|
switching, disabled state, Tab/Enter/arrow-key month selection, Escape cancellation,
|
||||||
|
and Russian controls/errors at 320px with 1.6 text scale. Labeled and minimum-size
|
||||||
|
tap-target guidelines pass. Integration checks verify that invalid dates block
|
||||||
|
Continue, Back preserves the birthday, and EN/RU flows still reach submission
|
||||||
|
acceptance. Native screen-reader and mobile keyboard/autofill testing remain
|
||||||
|
release checks; widget semantics are not a claim of device-level validation.
|
||||||
|
|
||||||
|
Validation: all four changed Dart files pass the format check; `flutter analyze`
|
||||||
|
in `frontend/app` reports no issues; `make test-frontend` passes all 230 tests,
|
||||||
|
including the seven new birthday widget tests and the new navigation/gating test.
|
||||||
|
`flutter build web` passes, including its Wasm compatibility dry run. Browser
|
||||||
|
smoke testing of the release build entered 23 April 1990, verified the inline
|
||||||
|
error for 31 April and restored 23 April without losing month or year.
|
||||||
|
Generated localization and build outputs remain ignored and uncommitted.
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# Saved payout destinations
|
||||||
|
|
||||||
|
Task: tech/sxchnge#54. Branch: `payout-destinations-54`.
|
||||||
|
|
||||||
|
## Delivery and integration boundary
|
||||||
|
|
||||||
|
The Flutter app implements Settings management and payout selection/new/one-time
|
||||||
|
flows with the injectable `DestinationService` in `sxchnge_wallet`. The included
|
||||||
|
customer-scoped **demo** service is process-local: reload discards its saved
|
||||||
|
records, attempts, directory fixtures and audit. No real card data may be entered.
|
||||||
|
The existing live wallet remains connected to provisioned Sendico destinations,
|
||||||
|
which now require explicit selection. Its existing financial adapter is unchanged.
|
||||||
|
|
||||||
|
This is UX and supporting contract implementation, not a production destination
|
||||||
|
registry or new payment-provider integration. Live mode never falls back to a
|
||||||
|
simulated destination store. It exposes no new destination creation capabilities
|
||||||
|
until the BFF implements the contract below. `BackendWalletService` explicitly
|
||||||
|
rejects new typed inputs rather than dropping them or using a different destination.
|
||||||
|
Actual persistent storage, public Sendico capability/directory adapters, secure
|
||||||
|
card collection, binding recovery and atomic financial acceptance are integration
|
||||||
|
requirements, not claims established by client tests. The injected service boundary
|
||||||
|
lets a production implementation replace the demo without DTO/networking in widgets.
|
||||||
|
|
||||||
|
## Screen annotations
|
||||||
|
|
||||||
|
The four primary destinations remain Accounts, Activity, Limits and Settings.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Settings → Payout methods
|
||||||
|
Add payout method [primary action]
|
||||||
|
Search saved methods [nickname and safe display metadata]
|
||||||
|
Crypto addresses [type group; recently used first]
|
||||||
|
nickname / asset · network · shortened address
|
||||||
|
availability / required action [text, not color alone]
|
||||||
|
Bank cards
|
||||||
|
nickname / brand · masked suffix
|
||||||
|
Russian bank accounts
|
||||||
|
nickname / bank · masked suffix
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Add payout method
|
||||||
|
Type [explicit selection]
|
||||||
|
Type-specific destination fields [labels and inline validation]
|
||||||
|
Save for future payouts [payout only; unchecked]
|
||||||
|
Name for this method (optional) [Settings or saving enabled]
|
||||||
|
Validation / eligibility / saving [separate feedback]
|
||||||
|
Save payout method | Continue [no ordinary confirmation dialog]
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Pay out
|
||||||
|
Source account / available funds
|
||||||
|
Payout destination [explicit selection every time]
|
||||||
|
Saved methods | New destination
|
||||||
|
Amount [preserved during navigation/recovery]
|
||||||
|
Review
|
||||||
|
full destination, source debit, fee, receiving amount/asset
|
||||||
|
network and memo/tag for crypto; card remains masked
|
||||||
|
Pay out [amount/currency] [explicit consequential confirmation]
|
||||||
|
Processing → confirmed settlement [existing separate wallet states]
|
||||||
|
```
|
||||||
|
|
||||||
|
Settings: select type → details → Save. Payout: new destination → details → optional
|
||||||
|
saving → Continue. Reuse: select saved method → amount → review → confirm. No
|
||||||
|
mandatory nickname, routine ownership warning, automatic selection/substitution,
|
||||||
|
or extra verification screen is added. Removal has a consequential confirmation.
|
||||||
|
A replacement creates a new record; the original remains until explicitly removed.
|
||||||
|
|
||||||
|
Lists group by destination type, sort recent use within groups and search safe
|
||||||
|
labels. Empty category, search no-match, unavailable data and no eligible methods
|
||||||
|
are distinct. Opening creation/management uses transient Navigator state, preserving
|
||||||
|
payout input without destination details in URLs or serialized browser history.
|
||||||
|
|
||||||
|
## Domain and service contract
|
||||||
|
|
||||||
|
`DestinationInput` is `SavedReference(id, executionRevision)` or
|
||||||
|
`OneTimeDestination(details)`. Validation and contextual eligibility apply equally
|
||||||
|
to both; quoting/submitting a one-time input must not create a reusable record.
|
||||||
|
|
||||||
|
A destination has a customer-owned opaque ID independent of provider bindings.
|
||||||
|
Ownership declaration, service verification and payout eligibility are separate.
|
||||||
|
Unverified details may be saved when saving policy permits. Ownership status is
|
||||||
|
shown only when required/actionable. Format checks never establish ownership or
|
||||||
|
receiving capability, and successful persistence never establishes payout eligibility.
|
||||||
|
|
||||||
|
`metadataRevision` protects nickname updates; `executionRevision` protects identity,
|
||||||
|
lifecycle and binding changes. Rename cannot invalidate a valid execution quote.
|
||||||
|
Removal and binding changes require execution revalidation. Immutable identities
|
||||||
|
are changed by replacement. Last-use ordering is not an execution change.
|
||||||
|
|
||||||
|
Required service operations: capabilities; list/get; typed field validation;
|
||||||
|
contextual assessment; create; creation-attempt lookup; duplicate resolution;
|
||||||
|
rename with expected metadata revision; replacement; removal with expected
|
||||||
|
revisions; bank lookup with cancellation; immutable quote preparation. Remediation
|
||||||
|
capabilities must come from the service, never provider-specific widget logic.
|
||||||
|
|
||||||
|
Eligibility uses current capabilities, available routes, destination requirements,
|
||||||
|
ownership/security policy, bindings and payout context including amount when relevant.
|
||||||
|
States are eligible, temporarily unavailable, requires action and unsupported.
|
||||||
|
Settings retains active unavailable methods. A contextless Settings assessment is
|
||||||
|
informational and never proof of eligibility for a particular payout. Selection,
|
||||||
|
quote preparation and acceptance revalidate independently. Losing eligibility or
|
||||||
|
removal preserves inputs and requires explicit reselection; never substitute.
|
||||||
|
|
||||||
|
On Continue: validate fields; assess contextual eligibility; optionally persist
|
||||||
|
under saving policy; proceed only if eligible. Persistence may succeed while the
|
||||||
|
payout is ineligible. Definitive optional save failure allows retry or explicit
|
||||||
|
one-time continuation. Unknown outcomes offer status lookup; eligible one-time
|
||||||
|
continuation does not cancel/repeat a potentially successful save. Confirmation of
|
||||||
|
saving, validation, submission and settlement are separate states.
|
||||||
|
|
||||||
|
### Canonical identities and duplicate outcomes
|
||||||
|
|
||||||
|
- Crypto: service-defined asset ID (including token-contract identity), canonical
|
||||||
|
network ID, network-normalized address and applicable memo/tag. Identical address
|
||||||
|
strings across networks/assets are distinct. Preserve presentation case and
|
||||||
|
meaningful memo contents. Display enough metadata to distinguish same-symbol tokens.
|
||||||
|
- Russian bank: service-normalized BIK, account number and holder identity. No
|
||||||
|
frontend fuzzy matching or inference from an account prefix.
|
||||||
|
- Card: reliable service/provider fingerprint where available, never brand/last-four,
|
||||||
|
expiry or capture token. Provider-scoped fingerprints are not globally portable.
|
||||||
|
Missing reliable fingerprint permits distinct records; never guess/merge.
|
||||||
|
|
||||||
|
Creation keys identify immutable payloads. Replay returns the same **created** or
|
||||||
|
**duplicate** result even after the referenced record is renamed/removed. Attempt
|
||||||
|
lookup distinguishes these outcomes. Concurrent creation must serialize duplicate
|
||||||
|
resolution within the authenticated customer scope. Use existing method revalidates
|
||||||
|
that record and never creates another. Existing nicknames remain unchanged.
|
||||||
|
|
||||||
|
The demo uses a synchronous customer-scoped mutation boundary; production needs
|
||||||
|
persistent unique identity/idempotency constraints with transactions or equivalent.
|
||||||
|
No duplicate endpoint may reveal another customer's destination or attempt.
|
||||||
|
|
||||||
|
### Bank input adapted from Sendico
|
||||||
|
|
||||||
|
Reviewed local Sendico checkout `c5a3440fa`, especially
|
||||||
|
`docs/russian-bank-methods.md` and its form, lookup, parsing and validation tests.
|
||||||
|
The form has Account holder, Bank details and optional nickname sections. Structured
|
||||||
|
first/last names and optional patronymic preserve compound names; arbitrary full
|
||||||
|
names are shown unsplit. Business/tax selectors are excluded from personal-account UX.
|
||||||
|
Bank search is debounced 350 ms from two characters, with cancellation and stale
|
||||||
|
response rejection. Results identify name, BIK and city. Selected bank is compact,
|
||||||
|
with Change bank and expandable correspondent details. Account number accepts
|
||||||
|
whitespace and preserves leading zeros; 20-digit/control-key checking is not ownership
|
||||||
|
verification. Prefix hints remain advisory.
|
||||||
|
|
||||||
|
Local paste import recognizes explicit EN/RU labels, previews recognized values,
|
||||||
|
flags conflicts, preserves untouched fields and never persists/transmits raw pasted
|
||||||
|
text. Directory/name/correspondent mismatches remain visible. Invalid BIK, unknown
|
||||||
|
bank, unsupported institution and service failure are distinct. On reuse, cosmetic
|
||||||
|
directory labels may refresh; execution-material changes require review/requote.
|
||||||
|
Never silently replace BIK, account or holder. Mergers/BIK changes can require a
|
||||||
|
replacement; discontinued banks remain saved but ineligible.
|
||||||
|
|
||||||
|
### Cards and binding lifecycle
|
||||||
|
|
||||||
|
Card saving requires reusable support or a defined restoration mechanism. An existing
|
||||||
|
card remains saved after card/binding expiry, revocation or provider outage.
|
||||||
|
Requires action means a supported recovery exists; operational outages are temporary;
|
||||||
|
without a reusable binding or supported recovery the UI must not promise reuse.
|
||||||
|
Bindings may be multiple, provider-scoped and nonportable. Re-tokenization requires
|
||||||
|
trusted same-card identification; otherwise create a replacement. Secure capture is
|
||||||
|
transient. Saved card details contain local identity/display metadata, and bindings
|
||||||
|
contain separate opaque references; PAN/CVV never enter application storage.
|
||||||
|
|
||||||
|
## Acceptance, security and retention
|
||||||
|
|
||||||
|
The BFF's financial acceptance boundary must atomically validate current quote,
|
||||||
|
customer authorization, execution revision, lifecycle, eligibility and requirements,
|
||||||
|
then persist the accepted immutable snapshot/intent. A preceding API assessment or
|
||||||
|
frontend check cannot guarantee financial correctness. Removal committed first rejects
|
||||||
|
acceptance; acceptance committed first survives later removal. Rejection preserves
|
||||||
|
drafts and returns reselect/review/refresh/requote actions. Expired or materially changed
|
||||||
|
quotes need explicit confirmation of recalculated terms. This is a required backend
|
||||||
|
contract; the demo's synchronous acceptance guard only demonstrates its semantics.
|
||||||
|
|
||||||
|
Removal from the active list, binding deactivation and data erasure are separate.
|
||||||
|
Removed records cannot serve new payouts; accepted payouts retain immutable snapshots.
|
||||||
|
Production retention must assign explicit expiry/cleanup policies to abandoned quotes,
|
||||||
|
capture references, idempotency records and required financial history. Do not promise
|
||||||
|
complete erasure of retained financial records. The demo stores no browser data and
|
||||||
|
expires transient quote leases; all remaining simulation data ends on reload.
|
||||||
|
|
||||||
|
Authorize every operation server-side and audit actor, timestamp, opaque method/attempt,
|
||||||
|
action and outcome, without destination details. Metadata edits use optimistic concurrency.
|
||||||
|
Risk-based step-up is service-policy driven; no mandatory MFA is introduced. No raw
|
||||||
|
destination, pasted text, nickname, amount or account data enters logs, analytics or replay.
|
||||||
|
|
||||||
|
## Localization, accessibility and measurement
|
||||||
|
|
||||||
|
EN/RU copy is maintained in the app ARB sources. Specific states cover saving,
|
||||||
|
uncertainty, duplicate reuse, changed/removed methods, directory failures, binding
|
||||||
|
authorization and quote recovery. Ordinary success says only "Payout method saved."
|
||||||
|
Forms use associated labels, field errors, a focusable live feedback area, text status
|
||||||
|
and keyboard controls. Names stack below 480 px in a 720 px maximum form. Review
|
||||||
|
320 px layouts at 1.6× text scale, long labels, full-address wrapping and 48 px targets.
|
||||||
|
|
||||||
|
`JourneyKind.destination` / `destinationSave` measures service-confirmed saving/resolution
|
||||||
|
separately from the existing payout quote/submission/settlement signals. Retries share
|
||||||
|
recorder correlation; confirmed save closes its journey. Idle/discard attempts use the
|
||||||
|
shared 30-minute inactivity/disposal rule. Simulation events are explicitly marked.
|
||||||
|
No production persistence, cross-session funnel or financial outcome is inferred from
|
||||||
|
the demo recorder. Actual outcome events and backend audit are integration requirements.
|
||||||
|
|
||||||
|
## Validation scenarios and recorded review
|
||||||
|
|
||||||
|
Tests exercise one-time nonpersistence, idempotent/repeated/concurrent creation, stable
|
||||||
|
duplicate replay after rename/removal, lost-response reconciliation, customer isolation,
|
||||||
|
metadata concurrency, execution invalidation at acceptance, asset/network/token identity,
|
||||||
|
case/memo handling, fingerprint fallback, card binding recovery states, bank control keys,
|
||||||
|
directory changes, paste conflicts and DTO mappings. Widget tests exercise Settings
|
||||||
|
save/rename/remove, explicit selection, one-time payout, optional-save failure recovery,
|
||||||
|
removal during review, privacy-safe outcomes and EN/RU narrow layouts. Existing auth,
|
||||||
|
factor, wallet and workspace regressions are included in the required frontend suite.
|
||||||
|
|
||||||
|
Research: [Wise recipient management](https://wise.com/help/articles/2491526/how-do-i-add-or-delete-recipients),
|
||||||
|
[Coinbase address books](https://help.coinbase.com/en/exchange/managing-my-account/how-does-the-address-book-work),
|
||||||
|
[Coinbase memo/tag requirements](https://help.coinbase.com/en/coinbase/trading-and-funding/sending-or-receiving-cryptocurrency/destination-tag-memo-faq),
|
||||||
|
[Stripe tokenization](https://docs.stripe.com/api/tokens),
|
||||||
|
[W3C form validation](https://www.w3.org/WAI/tutorials/forms/validation/).
|
||||||
|
|
||||||
|
Final validation commands and PR links are recorded below at delivery.
|
||||||
|
|
||||||
|
### Completion review — 2026-10-16
|
||||||
|
|
||||||
|
- Applied `review-frontend-change`, UX Design and UX Observability reviews across
|
||||||
|
app, wallet and analytics. Extracted form/search/selection/management providers;
|
||||||
|
widgets consume typed models and provider actions. Fixed narrow bank-header
|
||||||
|
overflow and bottom feedback overlapping management controls.
|
||||||
|
- Added structured immutable destination review, explicit simulated receiving
|
||||||
|
asset/amount/timing terms, independent card-binding restoration and a typed
|
||||||
|
created/duplicate analytics outcome. Nickname edits do not change execution
|
||||||
|
revisions. New typed inputs are explicitly rejected by the legacy live adapter.
|
||||||
|
- `dart format --output=none --set-exit-if-changed` over 44 changed Dart files:
|
||||||
|
passed, no changes. `git diff --check`: passed.
|
||||||
|
- `flutter analyze` in `frontend/app`, `frontend/packages/sxchnge_wallet` and
|
||||||
|
`frontend/packages/sxchnge_analytics`: passed, no issues.
|
||||||
|
- `make test-frontend`: passed all seven package/application suites, including
|
||||||
|
89 application tests and the destination domain/recovery/concurrency scenarios.
|
||||||
|
- `flutter build web` in `frontend/app`: passed, including the Wasm dry run.
|
||||||
|
- Browser smoke check on a temporary local web preview: method selection and bank
|
||||||
|
form rendered; accessibility labels exposed; Tab moved from type to First name.
|
||||||
|
Automated EN/RU forms at 320 px and 1.6× scale passed. A full assistive-technology
|
||||||
|
audit and production/provider validation were not performed.
|
||||||
|
- An exploratory repository-root `flutter analyze` also inspected the unrelated
|
||||||
|
standalone `website/flutter/brand_palette.dart`, which lacks Flutter package
|
||||||
|
context and reported `dart:ui` errors. The required affected-package analyses
|
||||||
|
above passed; no unrelated website code or analyzer settings were changed.
|
||||||
|
- No backend code or infrastructure changes, live payments, deployment, merge or
|
||||||
|
security scanner runs were performed. Live persistence/atomic acceptance remain
|
||||||
|
the explicitly documented integration boundary.
|
||||||
|
|
||||||
|
Delivery: [task #54](https://git.sendico.io/tech/sxchnge/issues/54),
|
||||||
|
[PR #55](https://git.sendico.io/tech/sxchnge/pulls/55), implementation commit
|
||||||
|
`24af794`. PR base is `local-stand-52`; merge after #47 → #49 → #51 → #53.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Identity photo preview correction
|
||||||
|
|
||||||
|
Task [#69](https://git.sendico.io/tech/sxchnge/issues/69), branch `kyc-69`.
|
||||||
|
Delivery: [PR #71](https://git.sendico.io/tech/sxchnge/pulls/71).
|
||||||
|
This frontend change builds on the [identity verification journey](kyc.md)
|
||||||
|
and is independent of the birthday controls in task #67.
|
||||||
|
|
||||||
|
## Cause and correction
|
||||||
|
|
||||||
|
A synthetic valid PNG reproduced this Flutter 3.47.7 browser error:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TypeError: Instance of '_UnmodifiableUint8ListView': type
|
||||||
|
'_UnmodifiableUint8ListView' is not a subtype of type 'NativeUint8List'
|
||||||
|
```
|
||||||
|
|
||||||
|
The stack reached `Uint8ListToJSUint8Array.toJS` from CanvasKit's browser
|
||||||
|
image decoder through `MemoryImage` and `ResizeImage`. File selection had
|
||||||
|
successfully decoded the original typed bytes. The `Photo` constructor then
|
||||||
|
copied them into an unmodifiable view, which was passed directly into the
|
||||||
|
preview's web decoder. Native decoding accepted that view, hiding the bug in
|
||||||
|
native-only tests. The generic image error builder presented the failure as a
|
||||||
|
damaged document even though the selected bytes were valid.
|
||||||
|
|
||||||
|
`PhotoPreview` now creates a native `Uint8List` copy for `MemoryImage` once per
|
||||||
|
selected photo. The shared domain model stays immutable, and validation,
|
||||||
|
providers, services and transport contracts remain unchanged. The preview still
|
||||||
|
requests a decode width of at most 1000 pixels and evicts its decoded cache entry
|
||||||
|
on replacement or disposal. The extra encoded-byte copy belongs to the preview
|
||||||
|
and is released with its provider/cache references; no secure-erasure guarantee
|
||||||
|
is made.
|
||||||
|
|
||||||
|
An actual preview failure now says that the preview could not be displayed and
|
||||||
|
suggests selecting the photo again. The English/Russian fallback keeps at least
|
||||||
|
the preview's usual height, can grow for long text, and has a separate live-region
|
||||||
|
semantic node. Existing replacement/removal controls stay available and selected
|
||||||
|
input is retained. This is distinct from validation's corrupt-file message.
|
||||||
|
|
||||||
|
## UX and measurement review
|
||||||
|
|
||||||
|
The outcome remains selecting readable ID and selfie photos, checking both on
|
||||||
|
review, and submitting for confirmed service acceptance. Existing KYC journey
|
||||||
|
attempts, retries, failure, submission and abandonment signals are reused. No
|
||||||
|
control-level events, image bytes, filenames, form values or raw exceptions are
|
||||||
|
added to telemetry. A selection success measures validation; it does not establish
|
||||||
|
that pixels were painted. Preview-only rendering failures are not separately
|
||||||
|
counted by existing telemetry. Server review/approval measurement and native
|
||||||
|
screen-reader QA retain the limitations documented in the original journey.
|
||||||
|
|
||||||
|
Regression tests use synthetic JPEG/PNG content only. They start and await real
|
||||||
|
image decoding outside the widget test's fake async zone, inspect the actual
|
||||||
|
rendered images, and surface stream errors so an error fallback cannot conceal a
|
||||||
|
failure. Checks cover portrait/landscape decoding, model immutability, bounded
|
||||||
|
decode width, stable provider reuse, replacement/removal cache eviction, both
|
||||||
|
review slots, returning to review, localized fallback/replacement, a 320px view
|
||||||
|
at 1.6 text scale, and error announcements isolated from surrounding instructions.
|
||||||
|
The `image` package is already a KYC runtime dependency; its direct app test
|
||||||
|
dependency is used only to create synthetic fixtures in memory.
|
||||||
|
|
||||||
|
## Validation and delivery
|
||||||
|
|
||||||
|
The original browser regression failed before the fix while its native equivalent
|
||||||
|
passed. The complete task diff was reviewed against frontend architecture, UX,
|
||||||
|
measurement, lifecycle and privacy requirements. Final validation passed:
|
||||||
|
|
||||||
|
- In `frontend/app`, `dart format --output=none --set-exit-if-changed
|
||||||
|
lib/kyc/preview.dart test/preview_test.dart`: two files, no changes.
|
||||||
|
- In `frontend/app`, `flutter analyze --no-pub`: no issues.
|
||||||
|
- In `frontend/app`, `flutter test --platform chrome test/preview_test.dart
|
||||||
|
--reporter expanded --no-pub --verbose`: all six regression tests passed.
|
||||||
|
- At the repository root, `make test-frontend`: all 228 tests passed across
|
||||||
|
the shared packages, theme example and app, including the six native preview
|
||||||
|
regressions and existing KYC journey/analytics tests.
|
||||||
|
- In `frontend/app`, `flutter build web --no-pub`: release build and Wasm
|
||||||
|
compatibility dry run succeeded.
|
||||||
|
|
||||||
|
Browser regression coverage uses Chrome; Safari and native assistive-technology
|
||||||
|
testing remain release checks. Generated localization and build code stays
|
||||||
|
ignored; the tracked app lockfile changes only `image` from transitive to direct
|
||||||
|
test dependency. The separate birthday worktree was preserved.
|
||||||
|
|
||||||
|
## Follow-up contribution review
|
||||||
|
|
||||||
|
The complete seven-file contribution was reviewed again before the authorized
|
||||||
|
merge and frontend rollout. No additional defects were found in the byte-copy
|
||||||
|
boundary, provider reuse, cache disposal, error semantics/localization, privacy,
|
||||||
|
architecture or regression coverage. No source changes were needed.
|
||||||
|
|
||||||
|
Current `main` (`ec4115d`, including the birthday, signup-delivery and account
|
||||||
|
setup changes) was merged into `kyc-69` before final verification. The two-file
|
||||||
|
format check and app analyzer passed; all six Chrome preview regressions and
|
||||||
|
all 240 tests in `make test-frontend` passed on the combined code. The original
|
||||||
|
228-test result above describes the earlier isolated branch.
|
||||||
|
|
||||||
|
The authorized rollout rebuilds only `frontend` from clean, pulled `main`, using
|
||||||
|
the repository's fresh stable-release resolver and digest-pinned local inputs.
|
||||||
|
Runtime image, HTTPS and served-bundle verification are recorded in task #69
|
||||||
|
after the rollout; generated local pins remain ignored.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -18,7 +19,8 @@ import 'validation.dart';
|
|||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
class AuthForm extends StatefulWidget {
|
class AuthForm extends StatefulWidget {
|
||||||
const AuthForm({super.key});
|
const AuthForm({super.key, this.email});
|
||||||
|
final String? email;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AuthForm> createState() => _AuthFormState();
|
State<AuthForm> createState() => _AuthFormState();
|
||||||
@@ -32,7 +34,7 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
bool _showPassword = false;
|
bool _showPassword = false;
|
||||||
bool _showConfirmation = false;
|
bool _showConfirmation = false;
|
||||||
bool _submitted = false;
|
bool _submitted = false;
|
||||||
final _email = TextEditingController();
|
late final _email = TextEditingController(text: widget.email);
|
||||||
final _name = TextEditingController();
|
final _name = TextEditingController();
|
||||||
|
|
||||||
AuthProvider get _provider =>
|
AuthProvider get _provider =>
|
||||||
@@ -63,6 +65,8 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
|
|
||||||
void _switch(bool signUp) {
|
void _switch(bool signUp) {
|
||||||
if (signUp == _signUp) return;
|
if (signUp == _signUp) return;
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
TextInput.finishAutofillContext(shouldSave: false);
|
||||||
context.read<JourneyRecorder>().abandon(JourneyExit.modeChange);
|
context.read<JourneyRecorder>().abandon(JourneyExit.modeChange);
|
||||||
context.read<LoginProvider>().reset();
|
context.read<LoginProvider>().reset();
|
||||||
context.read<SignupProvider>().reset();
|
context.read<SignupProvider>().reset();
|
||||||
@@ -95,9 +99,11 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final email = _email.text.trim();
|
final email = _email.text.trim();
|
||||||
|
final delivery = signup.state.result!.delivery;
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
_password.clear();
|
_password.clear();
|
||||||
_confirmation.clear();
|
_confirmation.clear();
|
||||||
context.goNamed(AppRoutes.verificationSent, extra: email);
|
context.goNamed(AppRoutes.verificationSent, extra: delivery ?? email);
|
||||||
} else {
|
} else {
|
||||||
final login = context.read<LoginProvider>();
|
final login = context.read<LoginProvider>();
|
||||||
await login.login(
|
await login.login(
|
||||||
@@ -107,13 +113,13 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
);
|
);
|
||||||
if (!mounted || _signUp) return;
|
if (!mounted || _signUp) return;
|
||||||
if (login.state.error == AuthError.accountNotVerified) {
|
if (login.state.error == AuthError.accountNotVerified) {
|
||||||
|
TextInput.finishAutofillContext(shouldSave: false);
|
||||||
_password.clear();
|
_password.clear();
|
||||||
context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim());
|
context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final challenge = login.state.result?.challenge;
|
final challenge = login.state.result?.challenge;
|
||||||
if (challenge != null) {
|
if (challenge != null) {
|
||||||
_password.clear();
|
|
||||||
final grant = await verifyFactor(
|
final grant = await verifyFactor(
|
||||||
context,
|
context,
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
@@ -123,6 +129,8 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
);
|
);
|
||||||
if (!mounted || _signUp) return;
|
if (!mounted || _signUp) return;
|
||||||
if (grant == null) {
|
if (grant == null) {
|
||||||
|
TextInput.finishAutofillContext(shouldSave: false);
|
||||||
|
_password.clear();
|
||||||
login.reset();
|
login.reset();
|
||||||
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
||||||
return;
|
return;
|
||||||
@@ -137,9 +145,13 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
!_signUp &&
|
!_signUp &&
|
||||||
identical(login.state.result, accepted) &&
|
identical(login.state.result, accepted) &&
|
||||||
login.state.status == AuthStatus.success) {
|
login.state.status == AuthStatus.success) {
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
|
_password.clear();
|
||||||
context.go(offer ? '/identity?onboarding=1' : '/accounts');
|
context.go(offer ? '/identity?onboarding=1' : '/accounts');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (login.state.status == AuthStatus.success) {
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,7 +207,10 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
expandedInsets: EdgeInsets.zero,
|
expandedInsets: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Form(
|
AutofillGroup(
|
||||||
|
// Leaving an unfinished form must not offer to save its credentials.
|
||||||
|
onDisposeAction: AutofillContextAction.cancel,
|
||||||
|
child: Form(
|
||||||
key: _form,
|
key: _form,
|
||||||
autovalidateMode: _submitted
|
autovalidateMode: _submitted
|
||||||
? AutovalidateMode.onUserInteraction
|
? AutovalidateMode.onUserInteraction
|
||||||
@@ -220,6 +235,8 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
errorMaxLines: 3,
|
errorMaxLines: 3,
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
|
keyboardType: TextInputType.name,
|
||||||
|
autofillHints: const [AutofillHints.name],
|
||||||
textCapitalization: TextCapitalization.words,
|
textCapitalization: TextCapitalization.words,
|
||||||
validator: (value) => AuthValidation.name(value, l),
|
validator: (value) => AuthValidation.name(value, l),
|
||||||
),
|
),
|
||||||
@@ -236,6 +253,10 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
errorMaxLines: 3,
|
errorMaxLines: 3,
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
autofillHints: const [
|
||||||
|
AutofillHints.username,
|
||||||
|
AutofillHints.email,
|
||||||
|
],
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
validator: (value) => AuthValidation.email(value, l),
|
validator: (value) => AuthValidation.email(value, l),
|
||||||
@@ -245,7 +266,13 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
key: const ValueKey('password'),
|
key: const ValueKey('password'),
|
||||||
enabled: !state.isLoading,
|
enabled: !state.isLoading,
|
||||||
controller: _password,
|
controller: _password,
|
||||||
|
autofocus: widget.email?.isNotEmpty ?? false,
|
||||||
obscureText: !_showPassword,
|
obscureText: !_showPassword,
|
||||||
|
autofillHints: [
|
||||||
|
_signUp
|
||||||
|
? AutofillHints.newPassword
|
||||||
|
: AutofillHints.password,
|
||||||
|
],
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
enableSuggestions: false,
|
enableSuggestions: false,
|
||||||
textInputAction: _signUp
|
textInputAction: _signUp
|
||||||
@@ -281,6 +308,7 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
enabled: !state.isLoading,
|
enabled: !state.isLoading,
|
||||||
controller: _confirmation,
|
controller: _confirmation,
|
||||||
obscureText: !_showConfirmation,
|
obscureText: !_showConfirmation,
|
||||||
|
autofillHints: const [AutofillHints.newPassword],
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
enableSuggestions: false,
|
enableSuggestions: false,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
@@ -310,7 +338,8 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
key: const ValueKey('submit'),
|
key: const ValueKey('submit'),
|
||||||
onPressed: state.isLoading || state.status == AuthStatus.success
|
onPressed:
|
||||||
|
state.isLoading || state.status == AuthStatus.success
|
||||||
? null
|
? null
|
||||||
: _submit,
|
: _submit,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -331,7 +360,9 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.arrow_forward, size: 20),
|
: const Icon(Icons.arrow_forward, size: 20),
|
||||||
],
|
],
|
||||||
@@ -341,6 +372,7 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
if (state.status == AuthStatus.success ||
|
if (state.status == AuthStatus.success ||
|
||||||
state.status == AuthStatus.error) ...[
|
state.status == AuthStatus.error) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import 'form.dart';
|
|||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
class AuthPage extends StatefulWidget {
|
class AuthPage extends StatefulWidget {
|
||||||
const AuthPage({super.key});
|
const AuthPage({super.key, this.email});
|
||||||
|
final String? email;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AuthPage> createState() => _AuthPageState();
|
State<AuthPage> createState() => _AuthPageState();
|
||||||
@@ -56,7 +57,7 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
Center(
|
Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 440),
|
constraints: const BoxConstraints(maxWidth: 440),
|
||||||
child: AuthForm(key: _formKey),
|
child: AuthForm(key: _formKey, email: widget.email),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'paste.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class BankFields extends StatefulWidget {
|
||||||
|
const BankFields({
|
||||||
|
super.key,
|
||||||
|
required this.onChanged,
|
||||||
|
this.initial,
|
||||||
|
this.errorField,
|
||||||
|
});
|
||||||
|
final ValueChanged<DestinationDetails?> onChanged;
|
||||||
|
final RussianBankDestination? initial;
|
||||||
|
final String? errorField;
|
||||||
|
@override
|
||||||
|
State<BankFields> createState() => _BankFieldsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BankFieldsState extends State<BankFields> {
|
||||||
|
late final first = TextEditingController(text: widget.initial?.firstName);
|
||||||
|
late final last = TextEditingController(text: widget.initial?.lastName);
|
||||||
|
late final patronymic = TextEditingController(
|
||||||
|
text: widget.initial?.patronymic,
|
||||||
|
);
|
||||||
|
late final account = TextEditingController(text: widget.initial?.account);
|
||||||
|
final query = TextEditingController();
|
||||||
|
late final search = BankSearchProvider(
|
||||||
|
context.read<DestinationsProvider>().service,
|
||||||
|
);
|
||||||
|
late Bank? bank = widget.initial?.bank;
|
||||||
|
String? pastedHolder;
|
||||||
|
bool conflict = false;
|
||||||
|
Map<String, String> pasted = {};
|
||||||
|
bool get mismatch =>
|
||||||
|
bank != null &&
|
||||||
|
((pasted['bank'] != null && pasted['bank'] != bank!.name) ||
|
||||||
|
(pasted['correspondent'] != null &&
|
||||||
|
pasted['correspondent'] != bank!.correspondent));
|
||||||
|
void changed() => widget.onChanged(
|
||||||
|
bank == null
|
||||||
|
? null
|
||||||
|
: RussianBankDestination(
|
||||||
|
firstName: first.text,
|
||||||
|
lastName: last.text,
|
||||||
|
patronymic: patronymic.text,
|
||||||
|
bank: bank!,
|
||||||
|
account: account.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Future<void> paste() async {
|
||||||
|
final parsed = await showDialog<BankPaste>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => const BankPasteDialog(),
|
||||||
|
);
|
||||||
|
if (!mounted || parsed == null) return;
|
||||||
|
for (final entry in {
|
||||||
|
'firstName': first,
|
||||||
|
'lastName': last,
|
||||||
|
'patronymic': patronymic,
|
||||||
|
'account': account,
|
||||||
|
}.entries) {
|
||||||
|
if (parsed.conflicts.contains(entry.key)) {
|
||||||
|
entry.value.clear();
|
||||||
|
} else if (parsed.values[entry.key] case final String value) {
|
||||||
|
entry.value.text = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
pasted = parsed.values;
|
||||||
|
conflict = parsed.conflicts.isNotEmpty;
|
||||||
|
pastedHolder = parsed.values['holder'];
|
||||||
|
});
|
||||||
|
if (parsed.conflicts.contains('bik')) {
|
||||||
|
setState(() => bank = null);
|
||||||
|
}
|
||||||
|
if (parsed.values['bik'] case final String bik) {
|
||||||
|
query.text = bik;
|
||||||
|
search.search(bik);
|
||||||
|
setState(() => bank = null);
|
||||||
|
}
|
||||||
|
changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final c in [first, last, patronymic, account, query]) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
search.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget field(
|
||||||
|
String label,
|
||||||
|
TextEditingController c, {
|
||||||
|
String? id,
|
||||||
|
bool numeric = false,
|
||||||
|
String? error,
|
||||||
|
}) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: TextField(
|
||||||
|
key: id == null ? null : ValueKey(id),
|
||||||
|
controller: c,
|
||||||
|
keyboardType: numeric ? TextInputType.number : TextInputType.name,
|
||||||
|
decoration: InputDecoration(labelText: label, errorText: error),
|
||||||
|
onChanged: (_) => changed(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(l.dHolder, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, b) {
|
||||||
|
final a = field(
|
||||||
|
l.dFirst,
|
||||||
|
first,
|
||||||
|
id: 'destination-first',
|
||||||
|
error: widget.errorField == 'holder' ? l.dInvalid : null,
|
||||||
|
);
|
||||||
|
final z = field(l.dLast, last, id: 'destination-last');
|
||||||
|
return b.maxWidth >= 480
|
||||||
|
? Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(child: a),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: z),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(children: [a, z]);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
field(l.dPatronymic, patronymic),
|
||||||
|
if (pastedHolder != null) SelectableText(pastedHolder!),
|
||||||
|
if (conflict) Text(l.dPasteConflict),
|
||||||
|
Text(l.dBankDetails, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: paste,
|
||||||
|
icon: const Icon(Icons.content_paste),
|
||||||
|
label: Text(l.dPaste),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListenableBuilder(
|
||||||
|
listenable: search,
|
||||||
|
builder: (context, _) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (bank == null) ...[
|
||||||
|
TextField(
|
||||||
|
controller: query,
|
||||||
|
decoration: InputDecoration(labelText: l.dBankSearch),
|
||||||
|
onChanged: search.search,
|
||||||
|
),
|
||||||
|
if (search.loading) const LinearProgressIndicator(),
|
||||||
|
for (final b in search.results)
|
||||||
|
ListTile(
|
||||||
|
title: Text(b.name),
|
||||||
|
subtitle: Text('${b.bik} · ${b.city}'),
|
||||||
|
onTap: () {
|
||||||
|
setState(() => bank = b);
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (search.error != null)
|
||||||
|
Text(
|
||||||
|
search.error == DestinationIssue.unavailable
|
||||||
|
? l.dBankFailure
|
||||||
|
: l.dBankUnknown,
|
||||||
|
),
|
||||||
|
if (search.error == DestinationIssue.unavailable)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => search.search(query.text),
|
||||||
|
child: Text(l.wRefresh),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(bank!.name),
|
||||||
|
subtitle: Text('${l.dBik} ${bank!.bik}'),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => bank = null);
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
child: Text(l.dChangeBank),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ExpansionTile(
|
||||||
|
title: Text(l.dBankDetails),
|
||||||
|
children: [
|
||||||
|
SelectableText(
|
||||||
|
'${l.dCorrespondent}: ${bank!.correspondent}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (!bank!.supported) Text(l.dUnsupported),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
field(
|
||||||
|
l.dAccount,
|
||||||
|
account,
|
||||||
|
id: 'destination-account',
|
||||||
|
numeric: true,
|
||||||
|
error: widget.errorField == 'account' ? l.dInvalid : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
String destinationType(AppLocalizations l, DestinationType type) =>
|
||||||
|
switch (type) {
|
||||||
|
DestinationType.crypto => l.dCrypto,
|
||||||
|
DestinationType.card => l.dCard,
|
||||||
|
DestinationType.russianBank => l.dBank,
|
||||||
|
};
|
||||||
|
String destinationIssue(AppLocalizations l, DestinationIssue issue) =>
|
||||||
|
switch (issue) {
|
||||||
|
DestinationIssue.invalid => l.dInvalid,
|
||||||
|
DestinationIssue.unavailable => l.dTemporary,
|
||||||
|
DestinationIssue.uncertain => l.dUncertain,
|
||||||
|
DestinationIssue.conflict => l.dConflict,
|
||||||
|
DestinationIssue.removed => l.dRemoved,
|
||||||
|
DestinationIssue.expired => l.dCardExpired,
|
||||||
|
DestinationIssue.bankChanged => l.dBankChanged,
|
||||||
|
DestinationIssue.authorization => l.dAuthorization,
|
||||||
|
DestinationIssue.unsupported => l.dUnsupported,
|
||||||
|
DestinationIssue.duplicate => l.dDuplicate,
|
||||||
|
DestinationIssue.replacementRequired => l.dReplacementRequired,
|
||||||
|
};
|
||||||
|
String eligibilityText(AppLocalizations l, PayoutEligibility e) =>
|
||||||
|
e.reason != null
|
||||||
|
? destinationIssue(l, e.reason!)
|
||||||
|
: switch (e.state) {
|
||||||
|
EligibilityState.eligible => l.dEligible,
|
||||||
|
EligibilityState.temporarilyUnavailable => l.dTemporary,
|
||||||
|
EligibilityState.requiresAction => l.dAction,
|
||||||
|
EligibilityState.unsupported => l.dUnsupported,
|
||||||
|
};
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class CryptoFields extends StatefulWidget {
|
||||||
|
const CryptoFields({
|
||||||
|
super.key,
|
||||||
|
required this.capabilities,
|
||||||
|
required this.onChanged,
|
||||||
|
this.initial,
|
||||||
|
this.errorField,
|
||||||
|
});
|
||||||
|
final DestinationCapabilities capabilities;
|
||||||
|
final ValueChanged<DestinationDetails?> onChanged;
|
||||||
|
final CryptoDestination? initial;
|
||||||
|
final String? errorField;
|
||||||
|
@override
|
||||||
|
State<CryptoFields> createState() => _CryptoFieldsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CryptoFieldsState extends State<CryptoFields> {
|
||||||
|
late final address = TextEditingController(text: widget.initial?.address);
|
||||||
|
late final memo = TextEditingController(text: widget.initial?.memo);
|
||||||
|
late String? assetId = widget.initial?.assetId;
|
||||||
|
late String? networkId = widget.initial?.networkId;
|
||||||
|
void changed() {
|
||||||
|
final a = widget.capabilities.assets
|
||||||
|
.where((a) => a.id == assetId && a.networkId == networkId)
|
||||||
|
.firstOrNull;
|
||||||
|
widget.onChanged(
|
||||||
|
a == null
|
||||||
|
? null
|
||||||
|
: CryptoDestination(
|
||||||
|
assetId: a.id,
|
||||||
|
networkId: a.networkId,
|
||||||
|
address: address.text,
|
||||||
|
memo: memo.text,
|
||||||
|
assetLabel: a.label,
|
||||||
|
networkLabel: a.networkLabel,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
address.dispose();
|
||||||
|
memo.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final assets = widget.capabilities.assets;
|
||||||
|
final selected = assets
|
||||||
|
.where((a) => a.id == assetId && a.networkId == networkId)
|
||||||
|
.firstOrNull;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
key: ValueKey('asset-$assetId'),
|
||||||
|
initialValue: assets.any((a) => a.id == assetId) ? assetId : null,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: InputDecoration(labelText: l.dAsset),
|
||||||
|
items: [
|
||||||
|
for (final a in assets)
|
||||||
|
DropdownMenuItem(value: a.id, child: Text(a.label)),
|
||||||
|
],
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() {
|
||||||
|
assetId = v;
|
||||||
|
networkId = null;
|
||||||
|
memo.clear();
|
||||||
|
});
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
key: ValueKey('network-$assetId-$networkId'),
|
||||||
|
initialValue:
|
||||||
|
assets.any((a) => a.id == assetId && a.networkId == networkId)
|
||||||
|
? networkId
|
||||||
|
: null,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: InputDecoration(labelText: l.dNetwork),
|
||||||
|
items: [
|
||||||
|
for (final a in assets.where((a) => a.id == assetId))
|
||||||
|
DropdownMenuItem(value: a.networkId, child: Text(a.networkLabel)),
|
||||||
|
],
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => networkId = v);
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
key: const ValueKey('destination-address'),
|
||||||
|
controller: address,
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.dAddress,
|
||||||
|
errorText: widget.errorField == 'address' ? l.dInvalid : null,
|
||||||
|
),
|
||||||
|
onChanged: (_) => changed(),
|
||||||
|
),
|
||||||
|
if (selected?.memoSupported == true) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
key: const ValueKey('destination-memo'),
|
||||||
|
controller: memo,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.dMemo,
|
||||||
|
errorText: widget.errorField == 'memo' ? l.dInvalid : null,
|
||||||
|
),
|
||||||
|
onChanged: (_) => changed(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(l.dCryptoHelp),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'copy.dart';
|
||||||
|
import 'details.dart';
|
||||||
|
import 'form.dart';
|
||||||
|
import 'nickname.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class DestinationDetailPage extends StatefulWidget {
|
||||||
|
const DestinationDetailPage({super.key, required this.destination});
|
||||||
|
final SavedDestination destination;
|
||||||
|
@override
|
||||||
|
State<DestinationDetailPage> createState() => _DestinationDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DestinationDetailPageState extends State<DestinationDetailPage> {
|
||||||
|
late final p = DestinationManagementProvider(
|
||||||
|
context.read<DestinationsProvider>().service,
|
||||||
|
widget.destination,
|
||||||
|
);
|
||||||
|
late final nickname = TextEditingController(
|
||||||
|
text: widget.destination.nickname,
|
||||||
|
);
|
||||||
|
Future<void> remove() async {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(l.dRemoveTitle),
|
||||||
|
content: Text(l.dRemoveHelp),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: Text(l.wStay),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: Text(l.dRemove),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!mounted || confirmed != true) return;
|
||||||
|
await p.remove();
|
||||||
|
if (mounted && p.removed) Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
p.dispose();
|
||||||
|
nickname.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: p,
|
||||||
|
builder: (context, _) => Scaffold(
|
||||||
|
appBar: AppBar(title: Text(l.dDetails)),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 120),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
DestinationDetailsView(details: p.destination.details),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
DestinationNickname(
|
||||||
|
controller: nickname,
|
||||||
|
enabled: !p.busy,
|
||||||
|
onChanged: (_) {},
|
||||||
|
),
|
||||||
|
if (p.busy) const LinearProgressIndicator(),
|
||||||
|
if (p.error != null) ...[
|
||||||
|
Semantics(
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(destinationIssue(l, p.error!)),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy ? null : p.refresh,
|
||||||
|
child: Text(l.dReload),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await p.rename(nickname.text);
|
||||||
|
if (context.mounted && p.error == null) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(l.dSaved)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(l.dRename),
|
||||||
|
),
|
||||||
|
if (p.destination.details is CardDestination &&
|
||||||
|
context
|
||||||
|
.read<DestinationsProvider>()
|
||||||
|
.capabilities
|
||||||
|
?.cardRecovery ==
|
||||||
|
true)
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final restored =
|
||||||
|
await Navigator.push<DestinationInput>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => DestinationFormPage(
|
||||||
|
initial: p.destination.details,
|
||||||
|
restoring: p.destination.input,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (mounted && restored != null) {
|
||||||
|
await p.refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(l.dUpdateAuthorization),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await Navigator.push<DestinationInput>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => DestinationFormPage(
|
||||||
|
initial: p.destination.details,
|
||||||
|
replacing: p.destination.id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text(l.dReplace),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy ? null : remove,
|
||||||
|
child: Text(l.dRemove),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Structured review renders the immutable service snapshot, never a nickname.
|
||||||
|
class DestinationDetailsView extends StatelessWidget {
|
||||||
|
const DestinationDetailsView({super.key, required this.details});
|
||||||
|
final DestinationDetails details;
|
||||||
|
Widget field(String label, String value) => Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [Text(label), SelectableText(value)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: switch (details) {
|
||||||
|
CryptoDestination d => [
|
||||||
|
field(l.dAsset, d.assetLabel),
|
||||||
|
field(l.dNetwork, d.networkLabel),
|
||||||
|
field(l.dAddress, d.address),
|
||||||
|
field(l.dMemo, d.memo.isEmpty ? l.dNoMemo : d.memo),
|
||||||
|
],
|
||||||
|
CardDestination d => [field(l.dCard, d.display)],
|
||||||
|
RussianBankDestination d => [
|
||||||
|
field(l.dHolder, d.holder),
|
||||||
|
field(l.dBankSearch, d.bank.name),
|
||||||
|
field(l.dBik, d.bank.bik),
|
||||||
|
field(l.dAccount, d.account),
|
||||||
|
field(l.dCorrespondent, d.bank.correspondent),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'bank.dart';
|
||||||
|
import 'copy.dart';
|
||||||
|
import 'crypto.dart';
|
||||||
|
import 'nickname.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class DestinationFormPage extends StatefulWidget {
|
||||||
|
const DestinationFormPage({
|
||||||
|
super.key,
|
||||||
|
this.payoutContext,
|
||||||
|
this.initial,
|
||||||
|
this.replacing,
|
||||||
|
this.restoring,
|
||||||
|
});
|
||||||
|
final PayoutContext? payoutContext;
|
||||||
|
final DestinationDetails? initial;
|
||||||
|
final String? replacing;
|
||||||
|
final SavedReference? restoring;
|
||||||
|
@override
|
||||||
|
State<DestinationFormPage> createState() => _DestinationFormPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DestinationFormPageState extends State<DestinationFormPage> {
|
||||||
|
late final p = DestinationFormProvider(
|
||||||
|
context.read<DestinationsProvider>().service,
|
||||||
|
context.read<AnalyticsService>(),
|
||||||
|
context: widget.payoutContext,
|
||||||
|
initial: widget.initial,
|
||||||
|
replacing: widget.replacing,
|
||||||
|
restoring: widget.restoring,
|
||||||
|
);
|
||||||
|
late DestinationType? type = widget.initial?.type;
|
||||||
|
final nickname = TextEditingController();
|
||||||
|
final focus = FocusNode();
|
||||||
|
Future<void> proceed({
|
||||||
|
bool withoutSaving = false,
|
||||||
|
bool existing = false,
|
||||||
|
}) async {
|
||||||
|
if (existing) {
|
||||||
|
await p.useExisting();
|
||||||
|
} else {
|
||||||
|
await p.proceed(withoutSaving: withoutSaving);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
if (p.result != null) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
if (p.saved?.outcome == SaveOutcome.created || widget.restoring != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
widget.restoring == null ? l.dSaved : l.dAuthorizationUpdated,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Navigator.pop(context, p.result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
focus.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
p.dispose();
|
||||||
|
nickname.dispose();
|
||||||
|
focus.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final caps = context.watch<DestinationsProvider>().capabilities;
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: p,
|
||||||
|
builder: (context, _) => PopScope(
|
||||||
|
canPop: !p.busy,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(title: Text(l.dAdd)),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (caps?.simulated == true) ...[
|
||||||
|
Text(l.dDemo),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
Text(l.dOwn),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
if (caps == null || caps.types.isEmpty)
|
||||||
|
Text(l.dNoCapabilities)
|
||||||
|
else ...[
|
||||||
|
DropdownButtonFormField<DestinationType>(
|
||||||
|
initialValue: caps.types.contains(type) ? type : null,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: InputDecoration(labelText: l.dType),
|
||||||
|
items: [
|
||||||
|
for (final t in caps.types)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: t,
|
||||||
|
child: Text(
|
||||||
|
destinationType(l, t),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged:
|
||||||
|
p.busy || p.uncertain || widget.restoring != null
|
||||||
|
? null
|
||||||
|
: (v) {
|
||||||
|
setState(() => type = v);
|
||||||
|
p.change(null);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
AbsorbPointer(
|
||||||
|
absorbing: p.busy || p.uncertain,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (type == DestinationType.crypto)
|
||||||
|
CryptoFields(
|
||||||
|
capabilities: caps,
|
||||||
|
initial: widget.initial is CryptoDestination
|
||||||
|
? widget.initial as CryptoDestination
|
||||||
|
: null,
|
||||||
|
errorField: p.errorField,
|
||||||
|
onChanged: p.change,
|
||||||
|
),
|
||||||
|
if (type == DestinationType.russianBank)
|
||||||
|
BankFields(
|
||||||
|
initial:
|
||||||
|
widget.initial is RussianBankDestination
|
||||||
|
? widget.initial as RussianBankDestination
|
||||||
|
: null,
|
||||||
|
errorField: p.errorField,
|
||||||
|
onChanged: p.change,
|
||||||
|
),
|
||||||
|
if (type == DestinationType.card &&
|
||||||
|
caps.simulated) ...[
|
||||||
|
Text(l.dCardDemo),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
RadioGroup<String>(
|
||||||
|
groupValue: p.details is CardDestination
|
||||||
|
? (p.details as CardDestination).capture
|
||||||
|
: null,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
p.change(
|
||||||
|
CardDestination(
|
||||||
|
capture: value,
|
||||||
|
brand: 'Demo card',
|
||||||
|
last4: '4242',
|
||||||
|
expiryMonth: 12,
|
||||||
|
expiryYear: 2030,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
RadioListTile<String>(
|
||||||
|
value: 'demo-card-ready',
|
||||||
|
title: Text(l.dCardReady),
|
||||||
|
),
|
||||||
|
RadioListTile<String>(
|
||||||
|
value: 'demo-card-recover',
|
||||||
|
title: Text(l.dCardRecover),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (type == DestinationType.card && !caps.simulated)
|
||||||
|
Text(l.dNoCapabilities),
|
||||||
|
if (widget.payoutContext != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
CheckboxListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
value: p.save,
|
||||||
|
onChanged: (v) => p.preferences(save: v),
|
||||||
|
title: Text(l.dSaveFuture),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (widget.restoring == null &&
|
||||||
|
(widget.payoutContext == null || p.save)) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DestinationNickname(
|
||||||
|
controller: nickname,
|
||||||
|
enabled: !p.busy && !p.uncertain,
|
||||||
|
onChanged: (v) => p.preferences(nickname: v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Focus(
|
||||||
|
focusNode: focus,
|
||||||
|
skipTraversal: true,
|
||||||
|
child: Semantics(
|
||||||
|
liveRegion: true,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (p.error != null)
|
||||||
|
Text(
|
||||||
|
p.uncertain
|
||||||
|
? l.dUncertain
|
||||||
|
: p.errorField != null
|
||||||
|
? l.dInvalid
|
||||||
|
: '${p.savingFailed ? '${l.dSaveFailed}\n' : ''}${destinationIssue(l, p.error!)}',
|
||||||
|
key: const ValueKey('destination-error'),
|
||||||
|
),
|
||||||
|
if (p.eligibility != null &&
|
||||||
|
!p.eligibility!.eligible)
|
||||||
|
Text(
|
||||||
|
'${p.saved != null ? '${l.dSavedIneligible}\n' : ''}${eligibilityText(l, p.eligibility!)}',
|
||||||
|
),
|
||||||
|
if (p.saved?.outcome ==
|
||||||
|
SaveOutcome.duplicate) ...[
|
||||||
|
Text(l.dDuplicate),
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () => proceed(existing: true),
|
||||||
|
child: Text(l.dUseExisting),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (p.saved?.outcome == SaveOutcome.created &&
|
||||||
|
p.result == null)
|
||||||
|
Text(l.dSaved),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (p.busy) const LinearProgressIndicator(),
|
||||||
|
if (p.uncertain)
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy ? null : () => proceed(),
|
||||||
|
child: Text(l.dCheckSave),
|
||||||
|
),
|
||||||
|
if (p.savingFailed && widget.payoutContext != null)
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () => proceed(withoutSaving: true),
|
||||||
|
child: Text(l.dWithoutSave),
|
||||||
|
),
|
||||||
|
if (p.save && widget.payoutContext != null)
|
||||||
|
Text(l.dSaveHelp),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton(
|
||||||
|
key: const ValueKey('destination-continue'),
|
||||||
|
onPressed: p.busy || p.details == null
|
||||||
|
? null
|
||||||
|
: () => proceed(),
|
||||||
|
child: Text(
|
||||||
|
widget.restoring != null
|
||||||
|
? l.dUpdateAuthorization
|
||||||
|
: widget.payoutContext == null
|
||||||
|
? l.dSave
|
||||||
|
: p.error != null && !p.uncertain
|
||||||
|
? l.dRetrySave
|
||||||
|
: l.dContinue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'copy.dart';
|
||||||
|
import 'detail.dart';
|
||||||
|
import 'form.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class DestinationsPage extends StatefulWidget {
|
||||||
|
const DestinationsPage({super.key, this.payoutContext});
|
||||||
|
final PayoutContext? payoutContext;
|
||||||
|
@override
|
||||||
|
State<DestinationsPage> createState() => _DestinationsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DestinationsPageState extends State<DestinationsPage> {
|
||||||
|
String query = '';
|
||||||
|
late final selection = DestinationSelectionProvider(
|
||||||
|
context.read<DestinationsProvider>().service,
|
||||||
|
widget.payoutContext ?? const PayoutContext(''),
|
||||||
|
);
|
||||||
|
Future<void> refresh() async {
|
||||||
|
final p = context.read<DestinationsProvider>();
|
||||||
|
await p.refresh();
|
||||||
|
if (!mounted) return;
|
||||||
|
await selection.assess(p.methods);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> add() async {
|
||||||
|
final result = await Navigator.push<DestinationInput>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
DestinationFormPage(payoutContext: widget.payoutContext),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (result != null && widget.payoutContext != null) {
|
||||||
|
Navigator.pop(context, result);
|
||||||
|
} else {
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> choose(SavedDestination d) async {
|
||||||
|
if (widget.payoutContext == null) {
|
||||||
|
await Navigator.push<void>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => DestinationDetailPage(destination: d),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (mounted) await refresh();
|
||||||
|
} else {
|
||||||
|
final ref = await selection.select(d);
|
||||||
|
if (mounted && ref != null) Navigator.pop(context, ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
selection.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final p = context.watch<DestinationsProvider>();
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(widget.payoutContext == null ? l.dTitle : l.dChoose),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: l.dReload,
|
||||||
|
onPressed: p.loading ? null : refresh,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: ListenableBuilder(
|
||||||
|
listenable: selection,
|
||||||
|
builder: (context, _) => ListView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
children: [
|
||||||
|
if (p.capabilities?.simulated == true) ...[
|
||||||
|
Text(l.dDemo),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: p.loading || (p.capabilities?.types.isEmpty ?? true)
|
||||||
|
? null
|
||||||
|
: add,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: Text(widget.payoutContext == null ? l.dAdd : l.dNew),
|
||||||
|
),
|
||||||
|
if (p.capabilities?.types.isEmpty == true)
|
||||||
|
Text(l.dManagementUnavailable),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
TextField(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.dSearch,
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
),
|
||||||
|
onChanged: (v) => setState(() => query = v),
|
||||||
|
),
|
||||||
|
if (p.loading || selection.busy) const LinearProgressIndicator(),
|
||||||
|
if (p.failed) Text(l.dUnavailable),
|
||||||
|
if (selection.error != null)
|
||||||
|
Semantics(
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(destinationIssue(l, selection.error!)),
|
||||||
|
),
|
||||||
|
if (widget.payoutContext != null &&
|
||||||
|
!p.loading &&
|
||||||
|
p.methods.isNotEmpty &&
|
||||||
|
!selection.assessments.values.any((a) => a.eligible))
|
||||||
|
Text(l.dNoEligible),
|
||||||
|
for (final type
|
||||||
|
in p.capabilities?.types ?? <DestinationType>[]) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
destinationType(l, type),
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (p.search(query, type).isEmpty && !p.loading && !p.failed)
|
||||||
|
Text(query.isEmpty ? l.dEmpty : l.dNoMatches),
|
||||||
|
for (final d in p.search(query, type))
|
||||||
|
Card(
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.all(16),
|
||||||
|
title: Text(d.label),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (d.nickname.isNotEmpty) Text(d.details.display),
|
||||||
|
if (selection.assessments[d.id]
|
||||||
|
case final PayoutEligibility e)
|
||||||
|
Text(eligibilityText(l, e)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: selection.busy ? null : () => choose(d),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class DestinationNickname extends StatelessWidget {
|
||||||
|
const DestinationNickname({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.onChanged,
|
||||||
|
this.enabled = true,
|
||||||
|
});
|
||||||
|
final TextEditingController controller;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
final bool enabled;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => TextField(
|
||||||
|
key: const ValueKey('destination-nickname'),
|
||||||
|
controller: controller,
|
||||||
|
enabled: enabled,
|
||||||
|
maxLength: 80,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: AppLocalizations.of(context)!.dNickname,
|
||||||
|
),
|
||||||
|
onChanged: onChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class BankPasteDialog extends StatefulWidget {
|
||||||
|
const BankPasteDialog({super.key});
|
||||||
|
@override
|
||||||
|
State<BankPasteDialog> createState() => _BankPasteDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BankPasteDialogState extends State<BankPasteDialog> {
|
||||||
|
final text = TextEditingController();
|
||||||
|
BankPaste? result;
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
text.clear();
|
||||||
|
text.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final labels = {
|
||||||
|
'firstName': l.dFirst,
|
||||||
|
'lastName': l.dLast,
|
||||||
|
'patronymic': l.dPatronymic,
|
||||||
|
'account': l.dAccount,
|
||||||
|
'bik': 'BIK',
|
||||||
|
'bank': l.dBankSearch,
|
||||||
|
'holder': l.dHolder,
|
||||||
|
'correspondent': l.dCorrespondent,
|
||||||
|
};
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(l.dPaste),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 480,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(l.dPasteHelp),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: text,
|
||||||
|
maxLines: 6,
|
||||||
|
decoration: InputDecoration(labelText: l.dBankDetails),
|
||||||
|
onChanged: (_) => setState(() => result = null),
|
||||||
|
),
|
||||||
|
if (result != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (result!.conflicts.isNotEmpty) Text(l.dPasteConflict),
|
||||||
|
for (final e in result!.values.entries)
|
||||||
|
Text('${labels[e.key]}: ${e.value}'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(l.wClose),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: result == null
|
||||||
|
? () => setState(() => result = BankPaste.parse(text.text))
|
||||||
|
: result!.values.isEmpty
|
||||||
|
? null
|
||||||
|
: () => Navigator.pop(context, result),
|
||||||
|
child: Text(result == null ? l.dPasteReview : l.dPasteApply),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Birthday entry with a canonical date string at the profile boundary.
|
||||||
|
class BirthField extends StatefulWidget {
|
||||||
|
const BirthField({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
this.enabled = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BirthField> createState() => _BirthFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BirthFieldState extends State<BirthField> {
|
||||||
|
final _day = TextEditingController();
|
||||||
|
final _year = TextEditingController();
|
||||||
|
final _monthField = GlobalKey<FormFieldState<int>>();
|
||||||
|
int? _month;
|
||||||
|
late String _emitted;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_readValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(BirthField oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.value != _emitted) {
|
||||||
|
_readValue();
|
||||||
|
_monthField.currentState?.didChange(_month);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _readValue() {
|
||||||
|
_emitted = widget.value;
|
||||||
|
final parts = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(widget.value);
|
||||||
|
_year.text = parts?.group(1) ?? '';
|
||||||
|
final month = int.tryParse(parts?.group(2) ?? '');
|
||||||
|
_month = month != null && month >= 1 && month <= 12 ? month : null;
|
||||||
|
_day.text = parts?.group(3) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? get _date {
|
||||||
|
final day = int.tryParse(_day.text);
|
||||||
|
final year = int.tryParse(_year.text);
|
||||||
|
if (day == null ||
|
||||||
|
year == null ||
|
||||||
|
year < 1 ||
|
||||||
|
_year.text.length != 4 ||
|
||||||
|
_month == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final date = DateTime(year, _month!, day);
|
||||||
|
return date.year == year && date.month == _month && date.day == day
|
||||||
|
? date
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _future(DateTime date) =>
|
||||||
|
date.isAfter(DateUtils.dateOnly(DateTime.now()));
|
||||||
|
|
||||||
|
void _change() {
|
||||||
|
setState(() {});
|
||||||
|
final date = _date;
|
||||||
|
_emitted = date == null || _future(date)
|
||||||
|
? ''
|
||||||
|
: '${_year.text}-${_month.toString().padLeft(2, '0')}-'
|
||||||
|
'${_day.text.padLeft(2, '0')}';
|
||||||
|
widget.onChanged(_emitted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_day.dispose();
|
||||||
|
_year.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final date = _date;
|
||||||
|
final complete =
|
||||||
|
_day.text.isNotEmpty && _month != null && _year.text.length == 4;
|
||||||
|
final error = !complete
|
||||||
|
? null
|
||||||
|
: date == null
|
||||||
|
? l.kycBirthInvalid
|
||||||
|
: _future(date)
|
||||||
|
? l.kycBirthFuture
|
||||||
|
: null;
|
||||||
|
final months = DateFormat.MMMM(Localizations.localeOf(context).toString());
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(l.kycBirth, style: Theme.of(context).textTheme.labelLarge),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final day = _number(
|
||||||
|
key: 'birth-day',
|
||||||
|
controller: _day,
|
||||||
|
label: l.kycBirthDay,
|
||||||
|
length: 2,
|
||||||
|
autofill: AutofillHints.birthdayDay,
|
||||||
|
);
|
||||||
|
final month = DropdownButtonFormField<int>(
|
||||||
|
key: _monthField,
|
||||||
|
initialValue: _month,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: InputDecoration(labelText: l.kycBirthMonth),
|
||||||
|
items: [
|
||||||
|
for (var month = 1; month <= 12; month++)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: month,
|
||||||
|
child: Text(months.format(DateTime(2000, month))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: !widget.enabled
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
_month = value;
|
||||||
|
_change();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final year = _number(
|
||||||
|
key: 'birth-year',
|
||||||
|
controller: _year,
|
||||||
|
label: l.kycBirthYear,
|
||||||
|
length: 4,
|
||||||
|
autofill: AutofillHints.birthdayYear,
|
||||||
|
);
|
||||||
|
final scale = MediaQuery.textScalerOf(context).scale(16) / 16;
|
||||||
|
if (constraints.maxWidth < 400 * scale) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
day,
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
month,
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
year,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(flex: 2, child: day),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(flex: 4, child: month),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(flex: 3, child: year),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (error != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Semantics(
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(
|
||||||
|
error,
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _number({
|
||||||
|
required String key,
|
||||||
|
required TextEditingController controller,
|
||||||
|
required String label,
|
||||||
|
required int length,
|
||||||
|
required String autofill,
|
||||||
|
}) => TextFormField(
|
||||||
|
key: ValueKey(key),
|
||||||
|
controller: controller,
|
||||||
|
enabled: widget.enabled,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
autofillHints: [autofill],
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(length),
|
||||||
|
],
|
||||||
|
decoration: InputDecoration(labelText: label),
|
||||||
|
onChanged: (_) => _change(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'package:country_picker/country_picker.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class CountryField extends StatelessWidget {
|
||||||
|
const CountryField({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
this.enabled = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final country = CountryService().findByCode(value);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(l.kycCountry, style: Theme.of(context).textTheme.labelLarge),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Semantics(
|
||||||
|
label: l.kycCountry,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: !enabled
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final code = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _CountrySearch(selected: value),
|
||||||
|
);
|
||||||
|
if (context.mounted && code != null) onChanged(code);
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
country == null
|
||||||
|
? l.kycCountrySelect
|
||||||
|
: _name(context, country),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(Icons.expand_more),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _name(BuildContext context, Country country) =>
|
||||||
|
country.getTranslatedName(context) ?? country.name;
|
||||||
|
|
||||||
|
class _CountrySearch extends StatefulWidget {
|
||||||
|
const _CountrySearch({required this.selected});
|
||||||
|
|
||||||
|
final String selected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CountrySearch> createState() => _CountrySearchState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CountrySearchState extends State<_CountrySearch> {
|
||||||
|
final _query = TextEditingController();
|
||||||
|
final _countries = {
|
||||||
|
for (final country in CountryService().getAll())
|
||||||
|
country.countryCode: country,
|
||||||
|
}.values.toList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_query.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
return Focus(
|
||||||
|
autofocus: true,
|
||||||
|
onKeyEvent: (_, event) {
|
||||||
|
if (event is KeyDownEvent &&
|
||||||
|
event.logicalKey == LogicalKeyboardKey.escape) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
},
|
||||||
|
child: Dialog.fullscreen(
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: BackButton(onPressed: () => Navigator.of(context).pop()),
|
||||||
|
title: TextField(
|
||||||
|
key: const ValueKey('country-search'),
|
||||||
|
controller: _query,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.kycCountrySearch,
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
),
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
),
|
||||||
|
toolbarHeight: 96,
|
||||||
|
actions: [
|
||||||
|
if (_query.text.isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
tooltip: l.kycCountryClearSearch,
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () => setState(_query.clear),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _list(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _list(BuildContext context) {
|
||||||
|
final search = _query.text.trim().toLowerCase();
|
||||||
|
final countries = _countries.where((country) {
|
||||||
|
return _name(context, country).toLowerCase().contains(search) ||
|
||||||
|
country.name.toLowerCase().contains(search) ||
|
||||||
|
country.countryCode.toLowerCase() == search;
|
||||||
|
}).toList()..sort((a, b) => _name(context, a).compareTo(_name(context, b)));
|
||||||
|
if (countries.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(AppLocalizations.of(context)!.kycCountryEmpty),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: countries.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final country = countries[index];
|
||||||
|
final isSelected = country.countryCode == widget.selected;
|
||||||
|
return ListTile(
|
||||||
|
title: Text(_name(context, country)),
|
||||||
|
selected: isSelected,
|
||||||
|
trailing: isSelected ? const Icon(Icons.check) : null,
|
||||||
|
onTap: () => Navigator.of(context).pop(country.countryCode),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
@@ -15,8 +17,12 @@ class PhotoPreview extends StatefulWidget {
|
|||||||
|
|
||||||
class _PhotoPreviewState extends State<PhotoPreview> {
|
class _PhotoPreviewState extends State<PhotoPreview> {
|
||||||
late ImageProvider _image = _provider();
|
late ImageProvider _image = _provider();
|
||||||
ImageProvider _provider() =>
|
ImageProvider _provider() => ResizeImage.resizeIfNeeded(
|
||||||
ResizeImage.resizeIfNeeded(1000, null, MemoryImage(widget.photo.bytes));
|
1000,
|
||||||
|
null,
|
||||||
|
// Web decoders require a native typed array, not the model's read-only view.
|
||||||
|
MemoryImage(Uint8List.fromList(widget.photo.bytes)),
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant PhotoPreview oldWidget) {
|
void didUpdateWidget(covariant PhotoPreview oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
@@ -38,7 +44,15 @@ class _PhotoPreviewState extends State<PhotoPreview> {
|
|||||||
height: 220,
|
height: 220,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
semanticLabel: widget.label,
|
semanticLabel: widget.label,
|
||||||
errorBuilder: (_, _, _) =>
|
errorBuilder: (_, _, _) => ConstrainedBox(
|
||||||
Text(AppLocalizations.of(context)!.kycErrorUnreadable),
|
constraints: const BoxConstraints(minHeight: 220),
|
||||||
|
child: Center(
|
||||||
|
child: Semantics(
|
||||||
|
container: true,
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(AppLocalizations.of(context)!.kycErrorPreview),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'birth.dart';
|
||||||
|
import 'country.dart';
|
||||||
|
|
||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
class ProfileForm extends StatelessWidget {
|
class ProfileForm extends StatelessWidget {
|
||||||
@@ -35,30 +38,18 @@ class ProfileForm extends StatelessWidget {
|
|||||||
onChanged: (value) => update(name: value),
|
onChanged: (value) => update(name: value),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
BirthField(
|
||||||
key: const ValueKey('kyc-birth'),
|
key: const ValueKey('kyc-birth'),
|
||||||
initialValue: p.profile.dateOfBirth,
|
value: p.profile.dateOfBirth,
|
||||||
enabled: !p.busy,
|
enabled: !p.busy,
|
||||||
keyboardType: TextInputType.datetime,
|
onChanged: (value) => update(birth: value),
|
||||||
autofillHints: const [AutofillHints.birthday],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: l.kycBirth,
|
|
||||||
helperText: l.kycBirthHelp,
|
|
||||||
),
|
|
||||||
onChanged: (value) => update(birth: value.trim()),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
CountryField(
|
||||||
key: const ValueKey('kyc-country'),
|
key: const ValueKey('kyc-country'),
|
||||||
initialValue: p.profile.residenceCountry,
|
value: p.profile.residenceCountry,
|
||||||
enabled: !p.busy,
|
enabled: !p.busy,
|
||||||
maxLength: 2,
|
onChanged: (value) => update(country: value),
|
||||||
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),
|
if (!p.profile.isComplete) Text(l.kycProfileRequired),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"verificationRequest": "Send a new verification email",
|
"verificationRequest": "Send a new verification email",
|
||||||
"verificationRetry": "Retry verification",
|
"verificationRetry": "Retry verification",
|
||||||
"verificationBack": "Back to login",
|
"verificationBack": "Back to login",
|
||||||
|
"verificationContinue": "Continue to login",
|
||||||
"verificationHint": "No email? Check your spam folder or request another below.",
|
"verificationHint": "No email? Check your spam folder or request another below.",
|
||||||
"verificationDemoLink": "Continue to confirmation",
|
"verificationDemoLink": "Continue to confirmation",
|
||||||
"verificationStep": "CONFIRM YOUR EMAIL",
|
"verificationStep": "CONFIRM YOUR EMAIL",
|
||||||
@@ -127,6 +128,7 @@
|
|||||||
"kycErrorFormat": "Choose a JPEG or PNG image. Other formats are not supported.",
|
"kycErrorFormat": "Choose a JPEG or PNG image. Other formats are not supported.",
|
||||||
"kycErrorLarge": "This image is too large. Choose one under 10 MB and 40 megapixels.",
|
"kycErrorLarge": "This image is too large. Choose one under 10 MB and 40 megapixels.",
|
||||||
"kycErrorUnreadable": "This image is damaged or cannot be read. Choose another photo.",
|
"kycErrorUnreadable": "This image is damaged or cannot be read. Choose another photo.",
|
||||||
|
"kycErrorPreview": "The photo preview could not be displayed. Try choosing the photo again.",
|
||||||
"kycErrorSmall": "The photo is too small. Use an image at least 600 pixels wide and high.",
|
"kycErrorSmall": "The photo is too small. Use an image at least 600 pixels wide and high.",
|
||||||
"kycErrorIncomplete": "Choose both photos and confirm they belong to you before submitting.",
|
"kycErrorIncomplete": "Choose both photos and confirm they belong to you before submitting.",
|
||||||
"kycErrorSession": "Your session has expired. Return to login, then check your verification status.",
|
"kycErrorSession": "Your session has expired. Return to login, then check your verification status.",
|
||||||
@@ -159,7 +161,10 @@
|
|||||||
"wRecent": "Recent activity",
|
"wRecent": "Recent activity",
|
||||||
"wSeeAll": "See all",
|
"wSeeAll": "See all",
|
||||||
"wEmptyActivity": "Your next move starts here. Your transactions will appear below.",
|
"wEmptyActivity": "Your next move starts here. Your transactions will appear below.",
|
||||||
"wEmptyAccounts": "Your accounts are not ready yet. Try refreshing.",
|
"wEmptyAccounts": "We couldn’t finish setting up your currency accounts. Try again to continue.",
|
||||||
|
"wAccountsLoading": "Getting your RUB, USDT and USDC accounts ready…",
|
||||||
|
"wAccountsFailed": "We couldn’t load your currency accounts. Try again to continue.",
|
||||||
|
"wAccountsRetry": "Try again",
|
||||||
"wRefresh": "Refresh",
|
"wRefresh": "Refresh",
|
||||||
"wSource": "From account",
|
"wSource": "From account",
|
||||||
"wDestination": "To account",
|
"wDestination": "To account",
|
||||||
@@ -268,10 +273,17 @@
|
|||||||
"kycProfileHelp": "Enter your details exactly as shown on your identity document.",
|
"kycProfileHelp": "Enter your details exactly as shown on your identity document.",
|
||||||
"kycFullName": "Full legal name",
|
"kycFullName": "Full legal name",
|
||||||
"kycBirth": "Date of birth",
|
"kycBirth": "Date of birth",
|
||||||
"kycBirthHelp": "YYYY-MM-DD, for example 1990-04-23",
|
"kycBirthDay": "Day",
|
||||||
|
"kycBirthMonth": "Month",
|
||||||
|
"kycBirthYear": "Year",
|
||||||
|
"kycBirthInvalid": "Enter a valid date of birth.",
|
||||||
|
"kycBirthFuture": "Date of birth cannot be in the future.",
|
||||||
"kycCountry": "Country of residence",
|
"kycCountry": "Country of residence",
|
||||||
"kycCountryHelp": "Two-letter country code, for example PL or DE",
|
"kycCountrySelect": "Select a country",
|
||||||
"kycProfileRequired": "Enter your full name, a valid date of birth and a two-letter country code to continue.",
|
"kycCountrySearch": "Search countries",
|
||||||
|
"kycCountryEmpty": "No countries found. Try another name or country code.",
|
||||||
|
"kycCountryClearSearch": "Clear search",
|
||||||
|
"kycProfileRequired": "Enter your full name, a valid date of birth and select your country of residence to continue.",
|
||||||
"kycContinue": "Continue to my account",
|
"kycContinue": "Continue to my account",
|
||||||
"kycOperationMinimum": "Per operation minimum",
|
"kycOperationMinimum": "Per operation minimum",
|
||||||
"kycOperationMaximum": "Per operation maximum",
|
"kycOperationMaximum": "Per operation maximum",
|
||||||
@@ -289,5 +301,95 @@
|
|||||||
"type": "int"
|
"type": "int"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dTitle": "Payout methods",
|
||||||
|
"dAdd": "Add payout method",
|
||||||
|
"dChoose": "Payout destination",
|
||||||
|
"dNew": "Use a new destination",
|
||||||
|
"dSave": "Save payout method",
|
||||||
|
"dContinue": "Continue",
|
||||||
|
"dSaveFuture": "Save for future payouts",
|
||||||
|
"dSaveHelp": "Continue will also save this destination.",
|
||||||
|
"dNickname": "Name for this method (optional)",
|
||||||
|
"dSaved": "Payout method saved.",
|
||||||
|
"dSaveFailed": "Couldn’t save these details. Your input is still here.",
|
||||||
|
"dRetrySave": "Retry saving",
|
||||||
|
"dWithoutSave": "Continue without saving",
|
||||||
|
"dUncertain": "We couldn’t confirm saving. This method may already be saved.",
|
||||||
|
"dCheckSave": "Check save status",
|
||||||
|
"dDuplicate": "This destination is already saved. Use the existing method.",
|
||||||
|
"dUseExisting": "Use existing method",
|
||||||
|
"dCrypto": "Crypto address",
|
||||||
|
"dCard": "Bank card",
|
||||||
|
"dBank": "Russian bank account",
|
||||||
|
"dType": "Destination type",
|
||||||
|
"dSearch": "Search saved methods",
|
||||||
|
"dEmpty": "No saved methods in this category.",
|
||||||
|
"dNoMatches": "No matching methods.",
|
||||||
|
"dEligible": "Available",
|
||||||
|
"dTemporary": "Temporarily unavailable for this payout.",
|
||||||
|
"dAction": "Action required",
|
||||||
|
"dUnsupported": "This method isn’t supported for this payout.",
|
||||||
|
"dUnavailable": "Couldn’t load methods. Try again.",
|
||||||
|
"dNoCapabilities": "No destination types are currently available.",
|
||||||
|
"dDemo": "Demo destinations — saving lasts until reload. No real payout or card collection is connected.",
|
||||||
|
"dAsset": "Receiving asset",
|
||||||
|
"dNetwork": "Network",
|
||||||
|
"dAddress": "Wallet address",
|
||||||
|
"dMemo": "Memo / destination tag",
|
||||||
|
"dNoMemo": "No memo / tag",
|
||||||
|
"dCryptoHelp": "Use the network supported by your receiving wallet. A valid address format does not guarantee delivery.",
|
||||||
|
"dHolder": "Account holder",
|
||||||
|
"dFirst": "First name",
|
||||||
|
"dLast": "Last name",
|
||||||
|
"dPatronymic": "Patronymic (optional)",
|
||||||
|
"dBankDetails": "Bank details",
|
||||||
|
"dBankSearch": "Bank name or BIK",
|
||||||
|
"dAccount": "20-digit account number",
|
||||||
|
"dCorrespondent": "Correspondent account",
|
||||||
|
"dChangeBank": "Change bank",
|
||||||
|
"dBankUnknown": "Bank not found. Check the name or BIK.",
|
||||||
|
"dBankFailure": "Bank directory is unavailable. Your input is preserved.",
|
||||||
|
"dBankChanged": "Bank details changed. Review them and add a replacement destination.",
|
||||||
|
"dPaste": "Paste bank details",
|
||||||
|
"dPasteHelp": "Paste labelled bank details. Review recognized values before applying them.",
|
||||||
|
"dPasteReview": "Review recognized details",
|
||||||
|
"dPasteApply": "Use these details",
|
||||||
|
"dPasteConflict": "Conflicting values were found. Correct the affected fields in the form.",
|
||||||
|
"dInvalid": "Check the entered details and correct the highlighted field.",
|
||||||
|
"dCardDemo": "Choose a simulated secure card capture. Do not enter a real card number.",
|
||||||
|
"dCardReady": "Demo card •••• 4242",
|
||||||
|
"dCardRecover": "Demo card requiring renewed authorization",
|
||||||
|
"dCardExpired": "This card has expired. Add a replacement card.",
|
||||||
|
"dAuthorization": "Authorization needs updating. Follow the service-supported recovery or add a replacement.",
|
||||||
|
"dRemoved": "This method was removed. Your draft is preserved. Choose a destination to continue.",
|
||||||
|
"dConflict": "This method changed in another session. Refresh and review the latest version.",
|
||||||
|
"dRename": "Rename",
|
||||||
|
"dRemove": "Remove",
|
||||||
|
"dReplace": "Add replacement",
|
||||||
|
"dRemoveTitle": "Remove payout method?",
|
||||||
|
"dRemoveHelp": "Removed methods cannot be used for new payouts. Submitted payouts and required financial records are retained.",
|
||||||
|
"dDetails": "Destination details",
|
||||||
|
"dReload": "Refresh methods",
|
||||||
|
"dSelect": "Select destination",
|
||||||
|
"dQuoteChanged": "Payout terms changed. Your draft is preserved. Select a destination and review a new quote.",
|
||||||
|
"dOwn": "Use a wallet, card, or account that belongs to you.",
|
||||||
|
"dSavedIneligible": "Saved. This method is unavailable for the current payout.",
|
||||||
|
"dSaveFailedLabel": "Saving failed",
|
||||||
|
"dNoEligible": "No methods are available for this payout. You can add a destination or refresh.",
|
||||||
|
"dDemoTerms": "Demo receiving amounts use a fixed 1:1 ratio. No real payout is made.",
|
||||||
|
"dTiming": "Expected processing time: {seconds} seconds",
|
||||||
|
"@dTiming": {
|
||||||
|
"placeholders": {
|
||||||
|
"seconds": {
|
||||||
|
"type": "int"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dBik": "BIK",
|
||||||
|
"dBankMismatch": "Pasted bank details differ from the selected bank. Check the details before continuing.",
|
||||||
|
"dManagementUnavailable": "Saving payout methods isn’t available yet. You can still use your configured payout destination.",
|
||||||
|
"dUpdateAuthorization": "Update authorization",
|
||||||
|
"dAuthorizationUpdated": "Authorization updated.",
|
||||||
|
"dReplacementRequired": "Couldn’t confirm this is the same card. Add a replacement destination."
|
||||||
}
|
}
|
||||||
@@ -63,6 +63,7 @@
|
|||||||
"verificationRequest": "Отправить новое письмо",
|
"verificationRequest": "Отправить новое письмо",
|
||||||
"verificationRetry": "Повторить подтверждение",
|
"verificationRetry": "Повторить подтверждение",
|
||||||
"verificationBack": "Вернуться ко входу",
|
"verificationBack": "Вернуться ко входу",
|
||||||
|
"verificationContinue": "Перейти ко входу",
|
||||||
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
|
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
|
||||||
"verificationDemoLink": "Перейти к подтверждению",
|
"verificationDemoLink": "Перейти к подтверждению",
|
||||||
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
|
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
|
||||||
@@ -127,6 +128,7 @@
|
|||||||
"kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.",
|
"kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.",
|
||||||
"kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.",
|
"kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.",
|
||||||
"kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.",
|
"kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.",
|
||||||
|
"kycErrorPreview": "Не удалось показать фото. Попробуйте выбрать его ещё раз.",
|
||||||
"kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.",
|
"kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.",
|
||||||
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш документ.",
|
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш документ.",
|
||||||
"kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.",
|
"kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.",
|
||||||
@@ -159,7 +161,10 @@
|
|||||||
"wRecent": "Последние операции",
|
"wRecent": "Последние операции",
|
||||||
"wSeeAll": "Смотреть все",
|
"wSeeAll": "Смотреть все",
|
||||||
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
|
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
|
||||||
"wEmptyAccounts": "Ваши счета ещё не готовы. Попробуйте обновить страницу.",
|
"wEmptyAccounts": "Не удалось завершить настройку валютных счетов. Повторите попытку, чтобы продолжить.",
|
||||||
|
"wAccountsLoading": "Подготавливаем ваши счета в RUB, USDT и USDC…",
|
||||||
|
"wAccountsFailed": "Не удалось загрузить валютные счета. Повторите попытку, чтобы продолжить.",
|
||||||
|
"wAccountsRetry": "Повторить попытку",
|
||||||
"wRefresh": "Обновить",
|
"wRefresh": "Обновить",
|
||||||
"wSource": "Со счёта",
|
"wSource": "Со счёта",
|
||||||
"wDestination": "На счёт",
|
"wDestination": "На счёт",
|
||||||
@@ -268,10 +273,17 @@
|
|||||||
"kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.",
|
"kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.",
|
||||||
"kycFullName": "Полное имя по документу",
|
"kycFullName": "Полное имя по документу",
|
||||||
"kycBirth": "Дата рождения",
|
"kycBirth": "Дата рождения",
|
||||||
"kycBirthHelp": "ГГГГ-ММ-ДД, например 1990-04-23",
|
"kycBirthDay": "День",
|
||||||
|
"kycBirthMonth": "Месяц",
|
||||||
|
"kycBirthYear": "Год",
|
||||||
|
"kycBirthInvalid": "Укажите корректную дату рождения.",
|
||||||
|
"kycBirthFuture": "Дата рождения не может быть в будущем.",
|
||||||
"kycCountry": "Страна проживания",
|
"kycCountry": "Страна проживания",
|
||||||
"kycCountryHelp": "Код страны из двух букв, например PL или DE",
|
"kycCountrySelect": "Выберите страну",
|
||||||
"kycProfileRequired": "Укажите полное имя, корректную дату рождения и код страны из двух букв.",
|
"kycCountrySearch": "Поиск страны",
|
||||||
|
"kycCountryEmpty": "Страны не найдены. Попробуйте другое название или код страны.",
|
||||||
|
"kycCountryClearSearch": "Очистить поиск",
|
||||||
|
"kycProfileRequired": "Укажите полное имя, корректную дату рождения и выберите страну проживания.",
|
||||||
"kycContinue": "Перейти в личный кабинет",
|
"kycContinue": "Перейти в личный кабинет",
|
||||||
"kycOperationMinimum": "Минимум на операцию",
|
"kycOperationMinimum": "Минимум на операцию",
|
||||||
"kycOperationMaximum": "Максимум на операцию",
|
"kycOperationMaximum": "Максимум на операцию",
|
||||||
@@ -289,5 +301,95 @@
|
|||||||
"type": "int"
|
"type": "int"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dTitle": "Способы вывода",
|
||||||
|
"dAdd": "Добавить способ вывода",
|
||||||
|
"dChoose": "Реквизиты для вывода",
|
||||||
|
"dNew": "Использовать новые реквизиты",
|
||||||
|
"dSave": "Сохранить способ вывода",
|
||||||
|
"dContinue": "Продолжить",
|
||||||
|
"dSaveFuture": "Сохранить для будущих выводов",
|
||||||
|
"dSaveHelp": "При продолжении реквизиты также будут сохранены.",
|
||||||
|
"dNickname": "Название способа вывода (необязательно)",
|
||||||
|
"dSaved": "Способ вывода сохранён.",
|
||||||
|
"dSaveFailed": "Не удалось сохранить реквизиты. Введённые данные остались в форме.",
|
||||||
|
"dRetrySave": "Повторить сохранение",
|
||||||
|
"dWithoutSave": "Продолжить без сохранения",
|
||||||
|
"dUncertain": "Не удалось подтвердить сохранение. Возможно, способ уже сохранён.",
|
||||||
|
"dCheckSave": "Проверить статус сохранения",
|
||||||
|
"dDuplicate": "Эти реквизиты уже сохранены. Используйте существующий способ.",
|
||||||
|
"dUseExisting": "Использовать существующий способ",
|
||||||
|
"dCrypto": "Криптоадрес",
|
||||||
|
"dCard": "Банковская карта",
|
||||||
|
"dBank": "Российский банковский счёт",
|
||||||
|
"dType": "Тип реквизитов",
|
||||||
|
"dSearch": "Поиск сохранённых способов",
|
||||||
|
"dEmpty": "В этой категории нет сохранённых способов.",
|
||||||
|
"dNoMatches": "Подходящие способы не найдены.",
|
||||||
|
"dEligible": "Доступно",
|
||||||
|
"dTemporary": "Временно недоступно для этого вывода.",
|
||||||
|
"dAction": "Требуется действие",
|
||||||
|
"dUnsupported": "Этот способ не поддерживается для данного вывода.",
|
||||||
|
"dUnavailable": "Не удалось загрузить способы. Повторите попытку.",
|
||||||
|
"dNoCapabilities": "Сейчас нет доступных типов реквизитов.",
|
||||||
|
"dDemo": "Демонстрационные реквизиты — сохранение действует до перезагрузки. Реальный вывод и ввод карт не подключены.",
|
||||||
|
"dAsset": "Получаемый актив",
|
||||||
|
"dNetwork": "Сеть",
|
||||||
|
"dAddress": "Адрес кошелька",
|
||||||
|
"dMemo": "Memo / тег назначения",
|
||||||
|
"dNoMemo": "Без memo / тега",
|
||||||
|
"dCryptoHelp": "Выберите сеть, поддерживаемую кошельком получателя. Верный формат адреса не гарантирует зачисление.",
|
||||||
|
"dHolder": "Владелец счёта",
|
||||||
|
"dFirst": "Имя",
|
||||||
|
"dLast": "Фамилия",
|
||||||
|
"dPatronymic": "Отчество (необязательно)",
|
||||||
|
"dBankDetails": "Банковские реквизиты",
|
||||||
|
"dBankSearch": "Название банка или БИК",
|
||||||
|
"dAccount": "20-значный номер счёта",
|
||||||
|
"dCorrespondent": "Корреспондентский счёт",
|
||||||
|
"dChangeBank": "Изменить банк",
|
||||||
|
"dBankUnknown": "Банк не найден. Проверьте название или БИК.",
|
||||||
|
"dBankFailure": "Справочник банков недоступен. Введённые данные сохранены.",
|
||||||
|
"dBankChanged": "Банковские реквизиты изменились. Проверьте их и добавьте новые реквизиты.",
|
||||||
|
"dPaste": "Вставить банковские реквизиты",
|
||||||
|
"dPasteHelp": "Вставьте реквизиты с названиями полей. Проверьте распознанные значения перед применением.",
|
||||||
|
"dPasteReview": "Проверить распознанные реквизиты",
|
||||||
|
"dPasteApply": "Использовать эти реквизиты",
|
||||||
|
"dPasteConflict": "Найдены противоречивые значения. Исправьте соответствующие поля в форме.",
|
||||||
|
"dInvalid": "Проверьте введённые реквизиты и исправьте выделенное поле.",
|
||||||
|
"dCardDemo": "Выберите демонстрационный результат безопасного ввода карты. Не вводите настоящий номер карты.",
|
||||||
|
"dCardReady": "Демо-карта •••• 4242",
|
||||||
|
"dCardRecover": "Демо-карта с истёкшей авторизацией",
|
||||||
|
"dCardExpired": "Срок действия карты истёк. Добавьте новую карту.",
|
||||||
|
"dAuthorization": "Необходимо обновить авторизацию. Используйте доступное восстановление или добавьте новые реквизиты.",
|
||||||
|
"dRemoved": "Этот способ удалён. Черновик сохранён. Выберите реквизиты для продолжения.",
|
||||||
|
"dConflict": "Этот способ изменён в другой сессии. Обновите и проверьте актуальную версию.",
|
||||||
|
"dRename": "Переименовать",
|
||||||
|
"dRemove": "Удалить",
|
||||||
|
"dReplace": "Добавить новые реквизиты",
|
||||||
|
"dRemoveTitle": "Удалить способ вывода?",
|
||||||
|
"dRemoveHelp": "Удалённые способы недоступны для новых выводов. Отправленные заявки и обязательные финансовые записи сохраняются.",
|
||||||
|
"dDetails": "Реквизиты",
|
||||||
|
"dReload": "Обновить способы",
|
||||||
|
"dSelect": "Выбрать реквизиты",
|
||||||
|
"dQuoteChanged": "Условия вывода изменились. Черновик сохранён. Выберите реквизиты и проверьте новую котировку.",
|
||||||
|
"dOwn": "Используйте принадлежащий вам кошелёк, карту или счёт.",
|
||||||
|
"dSavedIneligible": "Сохранено. Этот способ недоступен для текущего вывода.",
|
||||||
|
"dSaveFailedLabel": "Ошибка сохранения",
|
||||||
|
"dNoEligible": "Для этого вывода нет доступных способов. Добавьте реквизиты или обновите список.",
|
||||||
|
"dDemoTerms": "Демонстрационные суммы зачисления рассчитаны по фиксированному курсу 1:1. Реальный вывод не выполняется.",
|
||||||
|
"dTiming": "Ожидаемое время обработки: {seconds} сек.",
|
||||||
|
"@dTiming": {
|
||||||
|
"placeholders": {
|
||||||
|
"seconds": {
|
||||||
|
"type": "int"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dBik": "БИК",
|
||||||
|
"dBankMismatch": "Вставленные реквизиты отличаются от выбранного банка. Проверьте их перед продолжением.",
|
||||||
|
"dManagementUnavailable": "Сохранение способов вывода пока недоступно. Вы можете использовать ранее настроенные реквизиты для вывода.",
|
||||||
|
"dUpdateAuthorization": "Обновить авторизацию",
|
||||||
|
"dAuthorizationUpdated": "Авторизация обновлена.",
|
||||||
|
"dReplacementRequired": "Не удалось подтвердить, что это та же карта. Добавьте новые реквизиты."
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||||
|
|
||||||
|
import 'package:country_picker/country_picker.dart';
|
||||||
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
@@ -18,6 +22,7 @@ import 'routing/router.dart';
|
|||||||
import 'generated/i18n/app_localizations.dart';
|
import 'generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
|
usePathUrlStrategy();
|
||||||
final binding = WidgetsFlutterBinding.ensureInitialized();
|
final binding = WidgetsFlutterBinding.ensureInitialized();
|
||||||
final locale = await LocaleProvider.load(binding.platformDispatcher.locales);
|
final locale = await LocaleProvider.load(binding.platformDispatcher.locales);
|
||||||
const apiUrl = String.fromEnvironment('SXCHNGE_API_URL');
|
const apiUrl = String.fromEnvironment('SXCHNGE_API_URL');
|
||||||
@@ -37,6 +42,7 @@ class SxchngeApp extends StatefulWidget {
|
|||||||
this.identityService,
|
this.identityService,
|
||||||
this.limitsService,
|
this.limitsService,
|
||||||
this.walletService,
|
this.walletService,
|
||||||
|
this.destinationService,
|
||||||
this.twoFactorService,
|
this.twoFactorService,
|
||||||
this.photoService = const FilePhotoService(),
|
this.photoService = const FilePhotoService(),
|
||||||
this.loginService = const DummyLoginService(),
|
this.loginService = const DummyLoginService(),
|
||||||
@@ -49,6 +55,7 @@ class SxchngeApp extends StatefulWidget {
|
|||||||
final IdentityService? identityService;
|
final IdentityService? identityService;
|
||||||
final LimitsService? limitsService;
|
final LimitsService? limitsService;
|
||||||
final WalletService? walletService;
|
final WalletService? walletService;
|
||||||
|
final DestinationService? destinationService;
|
||||||
final TwoFactorService? twoFactorService;
|
final TwoFactorService? twoFactorService;
|
||||||
final PhotoService photoService;
|
final PhotoService photoService;
|
||||||
final LoginService loginService;
|
final LoginService loginService;
|
||||||
@@ -89,10 +96,17 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
initialLocale: _locale.locale.languageCode,
|
initialLocale: _locale.locale.languageCode,
|
||||||
factors: _authFactors,
|
factors: _authFactors,
|
||||||
));
|
));
|
||||||
|
late final _destinationService =
|
||||||
|
widget.destinationService ??
|
||||||
|
(_walletService.isSimulated && _walletService is DummyWalletService
|
||||||
|
? _walletService.destinations
|
||||||
|
: const UnavailableDestinationService());
|
||||||
|
late final _destinations = DestinationsProvider(_destinationService);
|
||||||
late final _wallet = WalletProvider(
|
late final _wallet = WalletProvider(
|
||||||
_walletService,
|
_walletService,
|
||||||
_analytics,
|
_analytics,
|
||||||
factors: _factors,
|
factors: _factors,
|
||||||
|
destinations: _destinationService,
|
||||||
);
|
);
|
||||||
late final _limits =
|
late final _limits =
|
||||||
widget.limitsService ??
|
widget.limitsService ??
|
||||||
@@ -131,6 +145,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
if (customer != _walletCustomer) {
|
if (customer != _walletCustomer) {
|
||||||
_walletCustomer = customer;
|
_walletCustomer = customer;
|
||||||
_wallet.reset();
|
_wallet.reset();
|
||||||
|
_destinations.reset();
|
||||||
}
|
}
|
||||||
final path = _router.routeInformationProvider.value.uri.path;
|
final path = _router.routeInformationProvider.value.uri.path;
|
||||||
if (path == '/' && _previousPath != '/' && _previousPath != '/two-factor') {
|
if (path == '/' && _previousPath != '/' && _previousPath != '/two-factor') {
|
||||||
@@ -145,6 +160,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
_router.dispose();
|
_router.dispose();
|
||||||
_journeys.dispose();
|
_journeys.dispose();
|
||||||
_wallet.dispose();
|
_wallet.dispose();
|
||||||
|
_destinations.dispose();
|
||||||
if (_walletService is BackendWalletService) {
|
if (_walletService is BackendWalletService) {
|
||||||
_walletService.close();
|
_walletService.close();
|
||||||
}
|
}
|
||||||
@@ -161,6 +177,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
Widget build(BuildContext context) => MultiProvider(
|
Widget build(BuildContext context) => MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider.value(value: _wallet),
|
ChangeNotifierProvider.value(value: _wallet),
|
||||||
|
ChangeNotifierProvider.value(value: _destinations),
|
||||||
ChangeNotifierProvider.value(value: _security),
|
ChangeNotifierProvider.value(value: _security),
|
||||||
Provider<TwoFactorService>.value(value: _factors),
|
Provider<TwoFactorService>.value(value: _factors),
|
||||||
Provider<JourneyRecorder>.value(value: _journeys),
|
Provider<JourneyRecorder>.value(value: _journeys),
|
||||||
@@ -180,7 +197,10 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: SxchngeTheme.light(),
|
theme: SxchngeTheme.light(),
|
||||||
locale: locale.locale,
|
locale: locale.locale,
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: const [
|
||||||
|
...AppLocalizations.localizationsDelegates,
|
||||||
|
CountryLocalizations.delegate,
|
||||||
|
],
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
routerConfig: _router,
|
routerConfig: _router,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import 'package:flutter/widgets.dart';
|
|||||||
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
import '../auth/page.dart';
|
import '../auth/page.dart';
|
||||||
|
import '../destinations/list.dart';
|
||||||
import '../kyc/page.dart';
|
import '../kyc/page.dart';
|
||||||
import '../security/challenge.dart';
|
import '../security/challenge.dart';
|
||||||
import '../verification/page.dart';
|
import '../verification/page.dart';
|
||||||
@@ -82,6 +85,12 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
builder: (context, state) => const SettingsPage(),
|
builder: (context, state) => const SettingsPage(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'payout-methods',
|
||||||
|
builder: (context, state) => const DestinationsPage(),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/top-up',
|
path: '/top-up',
|
||||||
@@ -128,13 +137,18 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
name: AppRoutes.login,
|
name: AppRoutes.login,
|
||||||
builder: (context, state) => const AuthPage(),
|
builder: (context, state) => AuthPage(
|
||||||
|
email: state.extra is String ? state.extra as String : null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/verification-sent',
|
path: '/verification-sent',
|
||||||
name: AppRoutes.verificationSent,
|
name: AppRoutes.verificationSent,
|
||||||
builder: (context, state) => VerificationPage(
|
builder: (context, state) => VerificationPage(
|
||||||
email: state.extra is String ? state.extra as String : null,
|
email: state.extra is String ? state.extra as String : null,
|
||||||
|
delivery: state.extra is VerificationDelivery
|
||||||
|
? state.extra as VerificationDelivery
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import '../routing/routes.dart';
|
|||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
class VerificationPage extends StatefulWidget {
|
class VerificationPage extends StatefulWidget {
|
||||||
const VerificationPage({super.key, this.email, this.token});
|
const VerificationPage({super.key, this.email, this.token, this.delivery});
|
||||||
final String? email;
|
final String? email;
|
||||||
final String? token;
|
final String? token;
|
||||||
|
final VerificationDelivery? delivery;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<VerificationPage> createState() => _VerificationPageState();
|
State<VerificationPage> createState() => _VerificationPageState();
|
||||||
@@ -25,9 +26,12 @@ class VerificationPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _VerificationPageState extends State<VerificationPage> {
|
class _VerificationPageState extends State<VerificationPage> {
|
||||||
final _form = GlobalKey<FormState>();
|
final _form = GlobalKey<FormState>();
|
||||||
late final _email = TextEditingController(text: widget.email);
|
late final _email = TextEditingController(
|
||||||
|
text: widget.delivery?.email ?? widget.email,
|
||||||
|
);
|
||||||
late final _provider = VerificationProvider(
|
late final _provider = VerificationProvider(
|
||||||
context.read<VerificationService>(),
|
context.read<VerificationService>(),
|
||||||
|
delivery: widget.delivery,
|
||||||
);
|
);
|
||||||
final _emailFocus = FocusNode();
|
final _emailFocus = FocusNode();
|
||||||
String? _recipient;
|
String? _recipient;
|
||||||
@@ -44,7 +48,7 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (widget.token != null) {
|
if (widget.token != null) {
|
||||||
_provider.confirm(widget.token!);
|
_provider.confirm(widget.token!);
|
||||||
} else if (_email.text.isNotEmpty) {
|
} else if (widget.delivery == null && _email.text.isNotEmpty) {
|
||||||
_send();
|
_send();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -58,14 +62,26 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant VerificationPage oldWidget) {
|
void didUpdateWidget(covariant VerificationPage oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.token == widget.token) return;
|
if (oldWidget.token == widget.token &&
|
||||||
|
oldWidget.email == widget.email &&
|
||||||
|
identical(oldWidget.delivery, widget.delivery)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (oldWidget.token != widget.token) {
|
||||||
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
||||||
_provider.reset();
|
}
|
||||||
|
_provider.reset(delivery: widget.delivery);
|
||||||
_lastWasSend = false;
|
_lastWasSend = false;
|
||||||
_validated = false;
|
_validated = false;
|
||||||
_email.clear();
|
_recipient = null;
|
||||||
|
_email.text = widget.delivery?.email ?? widget.email ?? '';
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted && widget.token != null) _provider.confirm(widget.token!);
|
if (!mounted) return;
|
||||||
|
if (widget.token != null) {
|
||||||
|
_provider.confirm(widget.token!);
|
||||||
|
} else if (widget.delivery == null && _email.text.isNotEmpty) {
|
||||||
|
_send();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,16 +334,22 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
if (verified) ...[
|
if (verified) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => context.go('/accounts'),
|
key: const ValueKey('verification-continue'),
|
||||||
child: Text(l.wEnter),
|
onPressed: () => context.goNamed(
|
||||||
|
AppRoutes.login,
|
||||||
|
extra: state.result?.verifiedEmail,
|
||||||
|
),
|
||||||
|
child: Text(l.verificationContinue),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
if (!verified) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context.goNamed(AppRoutes.login),
|
onPressed: () => context.goNamed(AppRoutes.login),
|
||||||
child: Text(l.verificationBack),
|
child: Text(l.verificationBack),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -65,15 +65,34 @@ class AccountsPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
if (p.loading) const LinearProgressIndicator(),
|
if (p.loading) const LinearProgressIndicator(),
|
||||||
if (p.failed)
|
if ((p.loading || (!p.failed && p.data == null)) &&
|
||||||
|
accounts.isEmpty)
|
||||||
|
Semantics(liveRegion: true, child: Text(l.wAccountsLoading)),
|
||||||
|
if (p.failed && accounts.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
child: Text(l.wUnavailable, style: TextStyle(color: c.error)),
|
child: Text(l.wUnavailable, style: TextStyle(color: c.error)),
|
||||||
),
|
),
|
||||||
if (!p.loading && accounts.isEmpty) Text(l.wEmptyAccounts),
|
if (!p.loading &&
|
||||||
|
accounts.isEmpty &&
|
||||||
|
(p.failed || p.data != null)) ...[
|
||||||
|
Semantics(
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(p.failed ? l.wAccountsFailed : l.wEmptyAccounts),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: p.refresh,
|
||||||
|
child: Text(l.wAccountsRetry),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
if (accountId != null &&
|
if (accountId != null &&
|
||||||
p.account(accountId) == null &&
|
p.account(accountId) == null &&
|
||||||
!p.loading)
|
!p.loading &&
|
||||||
|
!p.failed &&
|
||||||
|
p.data != null)
|
||||||
Text(l.wNotFound),
|
Text(l.wNotFound),
|
||||||
if (selected != null) ...[
|
if (selected != null) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ String fundsText(
|
|||||||
_ => l.wFailedOutgoing,
|
_ => l.wFailedOutgoing,
|
||||||
};
|
};
|
||||||
String walletError(AppLocalizations l, WalletError e) => switch (e) {
|
String walletError(AppLocalizations l, WalletError e) => switch (e) {
|
||||||
|
WalletError.destinationChanged => l.dQuoteChanged,
|
||||||
WalletError.invalidAmount => l.wInvalidAmount,
|
WalletError.invalidAmount => l.wInvalidAmount,
|
||||||
WalletError.insufficientFunds => l.wInsufficient,
|
WalletError.insufficientFunds => l.wInsufficient,
|
||||||
WalletError.limitExceeded => l.wLimitError,
|
WalletError.limitExceeded => l.wLimitError,
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ class SettingsPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.account_balance_outlined),
|
||||||
|
title: Text(l.dTitle),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => context.push('/settings/payout-methods'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
const SecuritySettings(),
|
const SecuritySettings(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
@@ -6,6 +8,8 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import '../destinations/details.dart';
|
||||||
|
|
||||||
import 'copy.dart';
|
import 'copy.dart';
|
||||||
|
|
||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
@@ -49,12 +53,22 @@ class TransferSummary extends StatelessWidget {
|
|||||||
q.request.kind == TransferKind.payout
|
q.request.kind == TransferKind.payout
|
||||||
? l.wRecipientGets
|
? l.wRecipientGets
|
||||||
: l.wCredit,
|
: l.wCredit,
|
||||||
money(context, b, q.credit),
|
q.payoutTerms == null
|
||||||
|
? money(context, b, q.credit)
|
||||||
|
: '${NumberFormat.decimalPatternDigits(locale: Localizations.localeOf(context).toLanguageTag(), decimalDigits: q.payoutTerms!.precision).format(q.payoutTerms!.amount / pow(10, q.payoutTerms!.precision))} ${q.payoutTerms!.assetLabel}',
|
||||||
large: true,
|
large: true,
|
||||||
),
|
),
|
||||||
|
if (q.payoutTerms != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(l.dTiming(q.payoutTerms!.expectedSeconds)),
|
||||||
|
if (p.service.isSimulated) Text(l.dDemoTerms),
|
||||||
|
],
|
||||||
if (q.request.kind == TransferKind.payout) ...[
|
if (q.request.kind == TransferKind.payout) ...[
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
Text(l.wRecipient),
|
Text(l.wRecipient),
|
||||||
|
if (q.payoutSnapshot != null)
|
||||||
|
DestinationDetailsView(details: q.payoutSnapshot!)
|
||||||
|
else
|
||||||
SelectableText(q.request.recipient),
|
SelectableText(q.request.recipient),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
|
|||||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
import '../security/challenge.dart';
|
import '../security/challenge.dart';
|
||||||
|
import '../destinations/list.dart';
|
||||||
|
|
||||||
import 'copy.dart';
|
import 'copy.dart';
|
||||||
import 'summary.dart';
|
import 'summary.dart';
|
||||||
@@ -32,7 +33,7 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
source: widget.source,
|
source: widget.source,
|
||||||
);
|
);
|
||||||
final _amount = TextEditingController();
|
final _amount = TextEditingController();
|
||||||
final _recipient = TextEditingController();
|
|
||||||
final _focus = FocusNode();
|
final _focus = FocusNode();
|
||||||
final _content = GlobalKey();
|
final _content = GlobalKey();
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
@@ -41,7 +42,13 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) _provider.addListener(_changed);
|
if (mounted) {
|
||||||
|
_provider.addListener(_changed);
|
||||||
|
final destinations = context.read<DestinationsProvider>();
|
||||||
|
if (destinations.capabilities == null && !destinations.loading) {
|
||||||
|
unawaited(destinations.refresh());
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
if (mounted && _provider.quote != null) setState(() {});
|
if (mounted && _provider.quote != null) setState(() {});
|
||||||
@@ -118,7 +125,7 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
_provider.removeListener(_changed);
|
_provider.removeListener(_changed);
|
||||||
_provider.dispose();
|
_provider.dispose();
|
||||||
_amount.dispose();
|
_amount.dispose();
|
||||||
_recipient.dispose();
|
|
||||||
_focus.dispose();
|
_focus.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -134,14 +141,16 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
final accounts = wallet.data?.accounts ?? [];
|
final accounts = wallet.data?.accounts ?? [];
|
||||||
final source = wallet.account(p.source);
|
final source = wallet.account(p.source);
|
||||||
final live = !wallet.service.isSimulated;
|
final live = !wallet.service.isSimulated;
|
||||||
|
final destinations = context.watch<DestinationsProvider>();
|
||||||
|
final legacyPayout =
|
||||||
|
live &&
|
||||||
|
!(destinations.capabilities?.types.isNotEmpty ?? false) &&
|
||||||
|
destinations.methods.isEmpty;
|
||||||
final expiresIn =
|
final expiresIn =
|
||||||
p.quote?.expiresAt.difference(DateTime.now()).inSeconds ?? 0;
|
p.quote?.expiresAt.difference(DateTime.now()).inSeconds ?? 0;
|
||||||
final unavailableMethod =
|
final unavailableMethod =
|
||||||
live &&
|
live &&
|
||||||
((widget.kind == TransferKind.payout &&
|
((widget.kind == TransferKind.topUp && !(source?.canFund ?? false)));
|
||||||
(source?.payoutMethod.isEmpty ?? true)) ||
|
|
||||||
(widget.kind == TransferKind.topUp &&
|
|
||||||
!(source?.canFund ?? false)));
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -235,7 +244,11 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
FilledButton(
|
FilledButton(
|
||||||
key: const ValueKey('transfer-confirm'),
|
key: const ValueKey('transfer-confirm'),
|
||||||
onPressed: p.busy ? null : _confirm,
|
onPressed: p.busy ? null : _confirm,
|
||||||
child: Text(l.wConfirm),
|
child: Text(
|
||||||
|
widget.kind == TransferKind.payout
|
||||||
|
? '${l.wPayout} ${money(context, source!, p.quote!.debit)}'
|
||||||
|
: l.wConfirm,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: p.busy ? null : p.edit,
|
onPressed: p.busy ? null : p.edit,
|
||||||
@@ -243,10 +256,22 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
if (accounts.isEmpty) ...[
|
if (accounts.isEmpty) ...[
|
||||||
Text(wallet.loading ? l.wWorking : l.wEmptyAccounts),
|
Semantics(
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(
|
||||||
|
wallet.loading ||
|
||||||
|
(!wallet.failed && wallet.data == null)
|
||||||
|
? l.wAccountsLoading
|
||||||
|
: wallet.failed
|
||||||
|
? l.wAccountsFailed
|
||||||
|
: l.wEmptyAccounts,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!wallet.loading &&
|
||||||
|
(wallet.failed || wallet.data != null))
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: wallet.loading ? null : wallet.refresh,
|
onPressed: wallet.refresh,
|
||||||
child: Text(l.wRefresh),
|
child: Text(l.wAccountsRetry),
|
||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
@@ -316,23 +341,66 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
),
|
),
|
||||||
if (widget.kind == TransferKind.payout) ...[
|
if (widget.kind == TransferKind.payout) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
if (live && source?.payoutMethod.isNotEmpty == true)
|
if (legacyPayout) ...[
|
||||||
InputDecorator(
|
if (source?.payoutMethod.isNotEmpty == true)
|
||||||
decoration: InputDecoration(
|
OutlinedButton.icon(
|
||||||
labelText: l.wRecipient,
|
key: const ValueKey(
|
||||||
|
'choose-configured-destination',
|
||||||
),
|
),
|
||||||
child: Text(source!.payoutLabel),
|
icon: const Icon(
|
||||||
|
Icons.account_balance_outlined,
|
||||||
),
|
),
|
||||||
if (!live)
|
label: Text(source!.payoutLabel),
|
||||||
TextField(
|
onPressed: p.busy
|
||||||
key: const ValueKey('transfer-recipient'),
|
? null
|
||||||
controller: _recipient,
|
: () => p.change(
|
||||||
enabled: !p.busy,
|
recipient: source.payoutMethod,
|
||||||
decoration: InputDecoration(
|
payoutLabel: source.payoutLabel,
|
||||||
labelText: l.wRecipient,
|
|
||||||
hintText: l.wRecipientHint,
|
|
||||||
),
|
),
|
||||||
onChanged: (v) => p.change(recipient: v),
|
),
|
||||||
|
if (source?.payoutMethod.isEmpty ?? true)
|
||||||
|
Text(l.wNoPayoutMethod),
|
||||||
|
] else
|
||||||
|
OutlinedButton.icon(
|
||||||
|
key: const ValueKey(
|
||||||
|
'choose-payout-destination',
|
||||||
|
),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.account_balance_wallet_outlined,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
p.payoutDestination == null
|
||||||
|
? l.dSelect
|
||||||
|
: p.payoutLabel,
|
||||||
|
),
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final selected =
|
||||||
|
await Navigator.push<
|
||||||
|
DestinationInput
|
||||||
|
>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
DestinationsPage(
|
||||||
|
payoutContext:
|
||||||
|
PayoutContext(
|
||||||
|
p.source,
|
||||||
|
amount: source
|
||||||
|
?.parse(
|
||||||
|
p.amount,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!context.mounted ||
|
||||||
|
selected == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await p.selectPayout(selected);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (unavailableMethod) ...[
|
if (unavailableMethod) ...[
|
||||||
@@ -346,7 +414,13 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
key: const ValueKey('transfer-review'),
|
key: const ValueKey('transfer-review'),
|
||||||
onPressed: p.busy || unavailableMethod
|
onPressed:
|
||||||
|
p.busy ||
|
||||||
|
unavailableMethod ||
|
||||||
|
(widget.kind == TransferKind.payout &&
|
||||||
|
(legacyPayout
|
||||||
|
? p.recipient.isEmpty
|
||||||
|
: p.payoutDestination == null))
|
||||||
? null
|
? null
|
||||||
: p.review,
|
: p.review,
|
||||||
child: Text(l.wReview),
|
child: Text(l.wReview),
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
country_picker:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: country_picker
|
||||||
|
sha256: "8ef721183bd7e0a6017c413693cdc68051daceef5790f5420c3b4378b5c69115"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.28"
|
||||||
cross_file:
|
cross_file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -185,7 +193,7 @@ packages:
|
|||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
flutter_web_plugins:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
@@ -198,7 +206,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "18.0.1"
|
version: "18.0.1"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
@@ -214,7 +222,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
image:
|
image:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
|
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
|
||||||
@@ -525,6 +533,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
universal_io:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: universal_io
|
||||||
|
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.1"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ dependencies:
|
|||||||
path: ../packages/sxchnge_analytics
|
path: ../packages/sxchnge_analytics
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
flutter_web_plugins:
|
||||||
|
sdk: flutter
|
||||||
sxchnge_auth:
|
sxchnge_auth:
|
||||||
path: ../packages/sxchnge_auth
|
path: ../packages/sxchnge_auth
|
||||||
sxchnge_theme:
|
sxchnge_theme:
|
||||||
@@ -25,10 +27,13 @@ dependencies:
|
|||||||
shared_preferences: ^2.5.3
|
shared_preferences: ^2.5.3
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
go_router: ^18.0.1
|
go_router: ^18.0.1
|
||||||
|
country_picker: ^2.0.28
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
image: ^4.9.0
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
http: ^1.6.0
|
||||||
flutter:
|
flutter:
|
||||||
generate: true
|
generate: true
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/main.dart';
|
||||||
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
|
class SetupWallet extends DummyWalletService {
|
||||||
|
SetupWallet(String locale)
|
||||||
|
: super(
|
||||||
|
DummyIdentityService(delay: Duration.zero),
|
||||||
|
delay: Duration.zero,
|
||||||
|
initialLocale: locale,
|
||||||
|
);
|
||||||
|
|
||||||
|
Completer<WalletSnapshot> pending = Completer<WalletSnapshot>();
|
||||||
|
int calls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WalletSnapshot> fetch() {
|
||||||
|
calls++;
|
||||||
|
return pending.future;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WalletSnapshot readyAccounts() => WalletSnapshot(
|
||||||
|
accounts: [
|
||||||
|
for (final currency in ['RUB', 'USDT', 'USDC'])
|
||||||
|
CurrencyAccount(
|
||||||
|
id: currency.toLowerCase(),
|
||||||
|
currency: currency,
|
||||||
|
precision: currency == 'RUB' ? 2 : 6,
|
||||||
|
available: 0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
operations: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final locale in ['en', 'ru']) {
|
||||||
|
for (final path in ['/accounts', '/top-up', '/exchange', '/payout']) {
|
||||||
|
testWidgets(
|
||||||
|
'$path recovers account setup in $locale at narrow width and large text',
|
||||||
|
(t) async {
|
||||||
|
t.view.physicalSize = const Size(320, 1100);
|
||||||
|
t.view.devicePixelRatio = 1;
|
||||||
|
t.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||||
|
t.platformDispatcher.defaultRouteNameTestValue = path;
|
||||||
|
addTearDown(t.view.resetPhysicalSize);
|
||||||
|
addTearDown(t.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||||
|
final wallet = SetupWallet(locale);
|
||||||
|
await t.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
walletService: wallet,
|
||||||
|
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await t.pump();
|
||||||
|
await t.pump();
|
||||||
|
final loading = locale == 'en'
|
||||||
|
? 'Getting your RUB, USDT and USDC accounts ready…'
|
||||||
|
: 'Подготавливаем ваши счета в RUB, USDT и USDC…';
|
||||||
|
final failed = locale == 'en'
|
||||||
|
? 'We couldn’t load your currency accounts. Try again to continue.'
|
||||||
|
: 'Не удалось загрузить валютные счета. Повторите попытку, чтобы продолжить.';
|
||||||
|
final retry = locale == 'en' ? 'Try again' : 'Повторить попытку';
|
||||||
|
expect(find.text(loading), findsOneWidget);
|
||||||
|
expect(find.textContaining('Contact support'), findsNothing);
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
wallet.pending.completeError(
|
||||||
|
const WalletException(WalletError.unavailable),
|
||||||
|
);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.text(failed), findsOneWidget);
|
||||||
|
expect(find.text(loading), findsNothing);
|
||||||
|
expect(find.text(retry), findsOneWidget);
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
wallet.pending = Completer<WalletSnapshot>();
|
||||||
|
await t.ensureVisible(find.text(retry));
|
||||||
|
await t.tap(find.text(retry));
|
||||||
|
await t.pump();
|
||||||
|
expect(wallet.calls, 2);
|
||||||
|
expect(find.text(loading), findsOneWidget);
|
||||||
|
expect(find.text(failed), findsNothing);
|
||||||
|
wallet.pending.complete(readyAccounts());
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.text(loading), findsNothing);
|
||||||
|
expect(find.text(failed), findsNothing);
|
||||||
|
expect(find.text('RUB'), findsWidgets);
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
await t.pumpWidget(const SizedBox.shrink());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,6 +92,14 @@ void main() {
|
|||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
|
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
tester
|
tester
|
||||||
.widget<TextFormField>(find.byKey(const ValueKey('email')))
|
.widget<TextFormField>(find.byKey(const ValueKey('email')))
|
||||||
@@ -153,6 +161,14 @@ void main() {
|
|||||||
'/verification-sent',
|
'/verification-sent',
|
||||||
);
|
);
|
||||||
expect(backend.sends, 1);
|
expect(backend.sends, 1);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
find.textContaining('a verification email is on its way'),
|
find.textContaining('a verification email is on its way'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
@@ -198,6 +214,14 @@ void main() {
|
|||||||
find.text('Сервис недоступен. Попробуйте ещё раз.'),
|
find.text('Сервис недоступен. Попробуйте ещё раз.'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.tap(button);
|
await tester.tap(button);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(service.calls, hasLength(2));
|
expect(service.calls, hasLength(2));
|
||||||
@@ -207,6 +231,12 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
||||||
expect(find.byKey(const ValueKey('auth-error')), findsNothing);
|
expect(find.byKey(const ValueKey('auth-error')), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -237,6 +267,14 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const ValueKey('confirmation')), findsOneWidget);
|
expect(find.byKey(const ValueKey('confirmation')), findsOneWidget);
|
||||||
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -55,6 +55,12 @@ void main() {
|
|||||||
await submit(tester);
|
await submit(tester);
|
||||||
expect(find.text('Enter your email address.'), findsOneWidget);
|
expect(find.text('Enter your email address.'), findsOneWidget);
|
||||||
expect(find.text('Enter your password.'), findsOneWidget);
|
expect(find.text('Enter your password.'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log.where(
|
||||||
|
(call) => call.method == 'TextInput.finishAutofillContext',
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.byKey(const ValueKey('email')),
|
find.byKey(const ValueKey('email')),
|
||||||
'test@example.com',
|
'test@example.com',
|
||||||
@@ -79,6 +85,12 @@ void main() {
|
|||||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -104,12 +116,26 @@ void main() {
|
|||||||
);
|
);
|
||||||
await submit(tester);
|
await submit(tester);
|
||||||
expect(find.text('Passwords do not match.'), findsOneWidget);
|
expect(find.text('Passwords do not match.'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.byKey(const ValueKey('confirmation')),
|
find.byKey(const ValueKey('confirmation')),
|
||||||
'example-password',
|
'example-password',
|
||||||
);
|
);
|
||||||
await submit(tester);
|
await submit(tester);
|
||||||
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.ensureVisible(
|
await tester.ensureVisible(
|
||||||
find.byKey(const ValueKey('demo-confirmation')),
|
find.byKey(const ValueKey('demo-confirmation')),
|
||||||
);
|
);
|
||||||
@@ -119,8 +145,8 @@ void main() {
|
|||||||
find.byKey(const ValueKey('verification-verified')),
|
find.byKey(const ValueKey('verification-verified')),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
await tester.ensureVisible(find.text('Back to login'));
|
await tester.ensureVisible(find.text('Continue to login'));
|
||||||
await tester.tap(find.text('Back to login'));
|
await tester.tap(find.text('Continue to login'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const ValueKey('confirmation')), findsNothing);
|
expect(find.byKey(const ValueKey('confirmation')), findsNothing);
|
||||||
expect(find.text('example-password'), findsNothing);
|
expect(find.text('example-password'), findsNothing);
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/main.dart';
|
||||||
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
|
Finder field(String key) => find.byKey(ValueKey(key));
|
||||||
|
|
||||||
|
EditableTextState editor(WidgetTester tester, String key) =>
|
||||||
|
tester.state<EditableTextState>(
|
||||||
|
find.descendant(of: field(key), matching: find.byType(EditableText)),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final language in ['en', 'ru']) {
|
||||||
|
testWidgets(
|
||||||
|
'platform autofill fills the signup credential group ($language)',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1440, 1200);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
localeProvider: LocaleProvider(initialLocale: Locale(language)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(
|
||||||
|
find.text(language == 'ru' ? 'Регистрация' : 'Sign up'),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.showKeyboard(field('email'));
|
||||||
|
|
||||||
|
final configuration = tester.testTextInput.setClientArgs!;
|
||||||
|
expect(
|
||||||
|
configuration['inputType']['name'],
|
||||||
|
'TextInputType.emailAddress',
|
||||||
|
);
|
||||||
|
final fields = (configuration['fields'] as List).cast<Map>();
|
||||||
|
expect(fields, hasLength(4));
|
||||||
|
expect(
|
||||||
|
fields.map((field) => field['autofill']['hints'].first),
|
||||||
|
containsAll(['name', 'username', 'newPassword', 'newPassword']),
|
||||||
|
);
|
||||||
|
|
||||||
|
const values = {
|
||||||
|
'name': 'Alex Example',
|
||||||
|
'email': 'alex@example.com',
|
||||||
|
'password': 'generated-example-password',
|
||||||
|
'confirmation': 'generated-example-password',
|
||||||
|
};
|
||||||
|
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
|
||||||
|
SystemChannels.textInput.name,
|
||||||
|
SystemChannels.textInput.codec.encodeMethodCall(
|
||||||
|
MethodCall('TextInputClient.updateEditingStateWithTag', [
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
for (final entry in values.entries)
|
||||||
|
editor(tester, entry.key).autofillId: TextEditingValue(
|
||||||
|
text: entry.value,
|
||||||
|
).toJSON(),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
(_) {},
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
for (final entry in values.entries) {
|
||||||
|
expect(
|
||||||
|
editor(tester, entry.key).currentTextEditingValue.text,
|
||||||
|
entry.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A visibility change must retain both the supplied value and its role.
|
||||||
|
await tester.tap(
|
||||||
|
find.byTooltip(
|
||||||
|
language == 'ru' ? 'Показать пароль' : 'Show password',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(editor(tester, 'password').widget.obscureText, isFalse);
|
||||||
|
expect(editor(tester, 'password').widget.autofillHints, [
|
||||||
|
'newPassword',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
editor(tester, 'password').currentTextEditingValue.text,
|
||||||
|
values['password'],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text(language == 'ru' ? 'Войти' : 'Log in'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.showKeyboard(field('email'));
|
||||||
|
final loginFields =
|
||||||
|
(tester.testTextInput.setClientArgs!['fields'] as List).cast<Map>();
|
||||||
|
expect(loginFields, hasLength(2));
|
||||||
|
expect(loginFields.map((field) => field['autofill']['hints'].first), [
|
||||||
|
'username',
|
||||||
|
'password',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
editor(tester, 'password').currentTextEditingValue.text,
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('leaving an unfinished auth form cancels saving', (tester) async {
|
||||||
|
await tester.pumpWidget(const SxchngeApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(field('email'), 'alex@example.com');
|
||||||
|
await tester.enterText(field('password'), 'unfinished-password');
|
||||||
|
tester.testTextInput.log.clear();
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/kyc/birth.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final day = find.byKey(const ValueKey('birth-day'));
|
||||||
|
final year = find.byKey(const ValueKey('birth-year'));
|
||||||
|
final month = find.byType(DropdownButtonFormField<int>);
|
||||||
|
|
||||||
|
Future<void> mount(
|
||||||
|
WidgetTester t,
|
||||||
|
ValueNotifier<String> value, {
|
||||||
|
Locale locale = const Locale('en'),
|
||||||
|
bool enabled = true,
|
||||||
|
}) async {
|
||||||
|
await t.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: SxchngeTheme.light(),
|
||||||
|
locale: locale,
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: ValueListenableBuilder<String>(
|
||||||
|
valueListenable: value,
|
||||||
|
builder: (_, date, _) => BirthField(
|
||||||
|
value: date,
|
||||||
|
enabled: enabled,
|
||||||
|
onChanged: (date) => value.value = date,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> select(WidgetTester t, String name) async {
|
||||||
|
await t.ensureVisible(month);
|
||||||
|
await t.tap(month);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await t.tap(find.text(name).last);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('enters and corrects a birthday without ISO syntax', (t) async {
|
||||||
|
final value = ValueNotifier('');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(t, value);
|
||||||
|
expect(find.text('Date of birth'), findsOneWidget);
|
||||||
|
await t.enterText(day, '23');
|
||||||
|
await select(t, 'April');
|
||||||
|
expect(value.value, '');
|
||||||
|
await t.enterText(year, '1990');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '1990-04-23');
|
||||||
|
await t.enterText(day, '9');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '1990-04-09');
|
||||||
|
await t.enterText(year, '19');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '');
|
||||||
|
// A provider rebuild must retain the other components of an incomplete date.
|
||||||
|
expect(t.widget<TextFormField>(day).controller!.text, '9');
|
||||||
|
expect(find.text('April'), findsOneWidget);
|
||||||
|
await t.enterText(year, '1985');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '1985-04-09');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('leap dates, impossible days and future dates recover inline', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
final value = ValueNotifier('2000-02-29');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(t, value);
|
||||||
|
expect(find.text('February'), findsOneWidget);
|
||||||
|
await t.enterText(year, '2001');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '');
|
||||||
|
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
|
||||||
|
expect(t.widget<TextFormField>(day).controller!.text, '29');
|
||||||
|
await t.enterText(year, '2004');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '2004-02-29');
|
||||||
|
expect(find.text('Enter a valid date of birth.'), findsNothing);
|
||||||
|
await t.enterText(day, '31');
|
||||||
|
await select(t, 'April');
|
||||||
|
expect(value.value, '');
|
||||||
|
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
|
||||||
|
await t.enterText(day, '30');
|
||||||
|
await t.enterText(year, '${DateTime.now().year + 1}');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '');
|
||||||
|
expect(find.text('Date of birth cannot be in the future.'), findsOneWidget);
|
||||||
|
await t.enterText(year, '1990');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '1990-04-30');
|
||||||
|
await t.enterText(day, '0');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '');
|
||||||
|
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
|
||||||
|
await t.enterText(day, '1');
|
||||||
|
await t.enterText(year, '0000');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '');
|
||||||
|
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'prefill, external replacement, locale change and clear synchronize',
|
||||||
|
(t) async {
|
||||||
|
final value = ValueNotifier('1990-04-23');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(t, value);
|
||||||
|
expect(t.widget<TextFormField>(year).controller!.text, '1990');
|
||||||
|
value.value = '1985-12-09';
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(t.widget<TextFormField>(day).controller!.text, '09');
|
||||||
|
expect(t.widget<TextFormField>(year).controller!.text, '1985');
|
||||||
|
expect(find.text('December'), findsOneWidget);
|
||||||
|
await mount(t, value, locale: const Locale('ru'));
|
||||||
|
expect(find.text('Дата рождения'), findsOneWidget);
|
||||||
|
expect(find.text('декабрь'), findsOneWidget);
|
||||||
|
expect(value.value, '1985-12-09');
|
||||||
|
await select(t, 'апрель');
|
||||||
|
expect(value.value, '1985-04-09');
|
||||||
|
value.value = '';
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(t.widget<TextFormField>(day).controller!.text, '');
|
||||||
|
expect(t.widget<TextFormField>(year).controller!.text, '');
|
||||||
|
expect(find.text('апрель'), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('digits and length limits keep each component usable', (t) async {
|
||||||
|
final value = ValueNotifier('1990-04-23');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(t, value);
|
||||||
|
await t.enterText(day, '');
|
||||||
|
await t.enterText(day, 'a123');
|
||||||
|
await t.enterText(year, '');
|
||||||
|
await t.enterText(year, 'b19856');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(t.widget<TextFormField>(day).controller!.text, '12');
|
||||||
|
expect(t.widget<TextFormField>(year).controller!.text, '1985');
|
||||||
|
expect(value.value, '1985-04-12');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('busy state disables every birthday control', (t) async {
|
||||||
|
final value = ValueNotifier('1990-04-23');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(t, value, enabled: false);
|
||||||
|
expect(t.widget<TextFormField>(day).enabled, isFalse);
|
||||||
|
expect(t.widget<TextFormField>(year).enabled, isFalse);
|
||||||
|
expect(t.widget<DropdownButtonFormField<int>>(month).onChanged, isNull);
|
||||||
|
expect(value.value, '1990-04-23');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('keyboard can traverse, choose month, cancel and edit year', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
final value = ValueNotifier('1990-04-23');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(t, value);
|
||||||
|
await t.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await t.pump();
|
||||||
|
expect(
|
||||||
|
t
|
||||||
|
.widget<EditableText>(
|
||||||
|
find.descendant(of: day, matching: find.byType(EditableText)),
|
||||||
|
)
|
||||||
|
.focusNode
|
||||||
|
.hasFocus,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
await t.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await t.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await t.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||||
|
await t.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '1990-05-23');
|
||||||
|
await t.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await t.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(value.value, '1990-05-23');
|
||||||
|
await t.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await t.pump();
|
||||||
|
expect(
|
||||||
|
t
|
||||||
|
.widget<EditableText>(
|
||||||
|
find.descendant(of: year, matching: find.byType(EditableText)),
|
||||||
|
)
|
||||||
|
.focusNode
|
||||||
|
.hasFocus,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Russian controls and errors fit 320px with large text and labeled targets',
|
||||||
|
(t) async {
|
||||||
|
t.view.physicalSize = const Size(320, 900);
|
||||||
|
t.view.devicePixelRatio = 1;
|
||||||
|
t.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||||
|
addTearDown(t.view.resetPhysicalSize);
|
||||||
|
addTearDown(t.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
final semantics = t.ensureSemantics();
|
||||||
|
final value = ValueNotifier('1990-09-23');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(t, value, locale: const Locale('ru'));
|
||||||
|
expect(find.text('сентябрь'), findsOneWidget);
|
||||||
|
expect(t, meetsGuideline(labeledTapTargetGuideline));
|
||||||
|
expect(t, meetsGuideline(androidTapTargetGuideline));
|
||||||
|
await t.enterText(day, '31');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.text('Укажите корректную дату рождения.'), findsOneWidget);
|
||||||
|
await select(t, 'март');
|
||||||
|
expect(value.value, '1990-03-31');
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
semantics.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'package:country_picker/country_picker.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/kyc/country.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final search = find.byKey(const ValueKey('country-search'));
|
||||||
|
final button = find.byType(OutlinedButton);
|
||||||
|
|
||||||
|
Future<void> mount(
|
||||||
|
WidgetTester tester,
|
||||||
|
ValueNotifier<String> value, {
|
||||||
|
Locale locale = const Locale('en'),
|
||||||
|
bool enabled = true,
|
||||||
|
}) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: SxchngeTheme.light(),
|
||||||
|
locale: locale,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
...AppLocalizations.localizationsDelegates,
|
||||||
|
CountryLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: ValueListenableBuilder<String>(
|
||||||
|
valueListenable: value,
|
||||||
|
builder: (_, code, _) => CountryField(
|
||||||
|
value: code,
|
||||||
|
enabled: enabled,
|
||||||
|
onChanged: (code) => value.value = code,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> open(WidgetTester tester) async {
|
||||||
|
await tester.tap(button);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'name search stores code, reselection and cancel preserve value',
|
||||||
|
(tester) async {
|
||||||
|
final value = ValueNotifier('');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(tester, value);
|
||||||
|
expect(find.text('Select a country'), findsOneWidget);
|
||||||
|
await open(tester);
|
||||||
|
await tester.enterText(search, 'polAND');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Poland'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(value.value, 'PL');
|
||||||
|
expect(find.text('Poland'), findsOneWidget);
|
||||||
|
expect(find.text('0/2'), findsNothing);
|
||||||
|
|
||||||
|
await open(tester);
|
||||||
|
await tester.enterText(search, ' de ');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Germany'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(value.value, 'DE');
|
||||||
|
expect(find.text('Germany'), findsOneWidget);
|
||||||
|
|
||||||
|
await open(tester);
|
||||||
|
await tester.enterText(search, 'poland');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byType(BackButton));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(value.value, 'DE');
|
||||||
|
expect(find.text('Germany'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('no-results state recovers through clear and Escape cancels', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final value = ValueNotifier('PL');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(tester, value);
|
||||||
|
await open(tester);
|
||||||
|
await tester.enterText(search, 'no-such-country');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('No countries found'), findsOneWidget);
|
||||||
|
expect(find.byType(ListTile), findsNothing);
|
||||||
|
expect(value.value, 'PL');
|
||||||
|
await tester.tap(find.byTooltip('Clear search'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('No countries found'), findsNothing);
|
||||||
|
expect(find.byType(ListTile), findsWidgets);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(search, findsNothing);
|
||||||
|
expect(value.value, 'PL');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('saved code follows locale and Russian names are searchable', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final value = ValueNotifier('PL');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(tester, value);
|
||||||
|
expect(find.text('Poland'), findsOneWidget);
|
||||||
|
await mount(tester, value, locale: const Locale('ru'));
|
||||||
|
expect(find.text('Польша'), findsOneWidget);
|
||||||
|
expect(value.value, 'PL');
|
||||||
|
await open(tester);
|
||||||
|
await tester.enterText(search, 'Германия');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byType(ListTile));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(value.value, 'DE');
|
||||||
|
expect(find.text('Германия'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('busy field cannot open or change selection', (tester) async {
|
||||||
|
final value = ValueNotifier('PL');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(tester, value, enabled: false);
|
||||||
|
expect(tester.widget<OutlinedButton>(button).onPressed, isNull);
|
||||||
|
await tester.tap(button);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(search, findsNothing);
|
||||||
|
expect(value.value, 'PL');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'keyboard and labeled touch targets work at 320px with large text',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(320, 900);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
final semantics = tester.ensureSemantics();
|
||||||
|
final value = ValueNotifier('GS');
|
||||||
|
addTearDown(value.dispose);
|
||||||
|
await mount(tester, value);
|
||||||
|
expect(tester, meetsGuideline(labeledTapTargetGuideline));
|
||||||
|
expect(tester, meetsGuideline(androidTapTargetGuideline));
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(search, findsOneWidget);
|
||||||
|
await tester.enterText(search, 'GS');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.widget<ListTile>(find.byType(ListTile)).selected, isTrue);
|
||||||
|
expect(tester, meetsGuideline(labeledTapTargetGuideline));
|
||||||
|
expect(tester, meetsGuideline(androidTapTargetGuideline));
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(search, findsNothing);
|
||||||
|
expect(value.value, 'GS');
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
semantics.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
const testDestination = CryptoDestination(
|
||||||
|
assetId: 'demo:ethereum:native',
|
||||||
|
networkId: 'eip155:1',
|
||||||
|
address: '0x1234567890123456789012345678901234567890',
|
||||||
|
assetLabel: 'ETH',
|
||||||
|
networkLabel: 'Ethereum',
|
||||||
|
);
|
||||||
|
Future<void> seedDestination(DummyDestinationService service) async {
|
||||||
|
await service.create(
|
||||||
|
testDestination,
|
||||||
|
'Test wallet',
|
||||||
|
'test-destination-creation',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> selectPayout(WidgetTester t) async {
|
||||||
|
final button = find.byKey(const ValueKey('choose-payout-destination'));
|
||||||
|
await t.ensureVisible(button);
|
||||||
|
await t.tap(button);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
final row = find.text('Test wallet');
|
||||||
|
await t.ensureVisible(row);
|
||||||
|
await t.tap(row);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/main.dart';
|
||||||
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
|
import 'destination_helpers.dart';
|
||||||
|
|
||||||
|
Future<void> click(WidgetTester t, Finder finder) async {
|
||||||
|
await t.ensureVisible(finder);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await t.tap(finder);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<DummyWalletService> open(
|
||||||
|
WidgetTester t, {
|
||||||
|
String path = '/settings/payout-methods',
|
||||||
|
String locale = 'en',
|
||||||
|
DummyDestinationService? destinations,
|
||||||
|
DummyAnalyticsService? analytics,
|
||||||
|
}) async {
|
||||||
|
t.platformDispatcher.defaultRouteNameTestValue = path;
|
||||||
|
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||||
|
final wallet = DummyWalletService(
|
||||||
|
DummyIdentityService(delay: Duration.zero),
|
||||||
|
delay: Duration.zero,
|
||||||
|
destinations: destinations,
|
||||||
|
initialLocale: locale,
|
||||||
|
settlement: Duration.zero,
|
||||||
|
);
|
||||||
|
await t.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
walletService: wallet,
|
||||||
|
analyticsService: analytics,
|
||||||
|
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
addTearDown(() => t.pumpWidget(const SizedBox.shrink()));
|
||||||
|
return wallet;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cryptoForm(WidgetTester t, {bool payout = false}) async {
|
||||||
|
await click(
|
||||||
|
t,
|
||||||
|
find.text(payout ? 'Use a new destination' : 'Add payout method').first,
|
||||||
|
);
|
||||||
|
await click(t, find.byType(DropdownButtonFormField<DestinationType>));
|
||||||
|
await click(t, find.text('Crypto address').last);
|
||||||
|
await click(t, find.byKey(const ValueKey('asset-null')));
|
||||||
|
await click(t, find.text('ETH').last);
|
||||||
|
await click(t, find.byType(DropdownButtonFormField<String>).last);
|
||||||
|
await click(t, find.text('Ethereum').last);
|
||||||
|
await t.enterText(
|
||||||
|
find.byKey(const ValueKey('destination-address')),
|
||||||
|
testDestination.address,
|
||||||
|
);
|
||||||
|
await t.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('Settings saves without nickname and exposes renamed method', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
final wallet = await open(t);
|
||||||
|
await cryptoForm(t);
|
||||||
|
expect(find.text('Save for future payouts'), findsNothing);
|
||||||
|
await click(t, find.byKey(const ValueKey('destination-continue')));
|
||||||
|
expect((await wallet.destinations.list()).length, 1);
|
||||||
|
expect(find.textContaining('ETH · Ethereum'), findsOneWidget);
|
||||||
|
await click(t, find.textContaining('ETH · Ethereum'));
|
||||||
|
await t.enterText(
|
||||||
|
find.byKey(const ValueKey('destination-nickname')),
|
||||||
|
'My own wallet',
|
||||||
|
);
|
||||||
|
await click(t, find.text('Rename'));
|
||||||
|
expect((await wallet.destinations.list()).single.nickname, 'My own wallet');
|
||||||
|
await click(t, find.text('Remove'));
|
||||||
|
expect(find.textContaining('Submitted payouts'), findsOneWidget);
|
||||||
|
await click(t, find.widgetWithText(FilledButton, 'Remove'));
|
||||||
|
expect(await wallet.destinations.list(), isEmpty);
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
});
|
||||||
|
testWidgets('new one-time payout keeps saving unchecked and never persists', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
final wallet = await open(t, path: '/payout');
|
||||||
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||||
|
await click(t, find.byKey(const ValueKey('choose-payout-destination')));
|
||||||
|
await cryptoForm(t, payout: true);
|
||||||
|
expect(
|
||||||
|
t.widget<CheckboxListTile>(find.byType(CheckboxListTile)).value,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const ValueKey('destination-nickname')), findsNothing);
|
||||||
|
await click(t, find.byKey(const ValueKey('destination-continue')));
|
||||||
|
await click(t, find.byKey(const ValueKey('transfer-review')));
|
||||||
|
expect(find.textContaining(testDestination.address), findsOneWidget);
|
||||||
|
await click(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||||
|
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
||||||
|
expect(await wallet.destinations.list(), isEmpty);
|
||||||
|
});
|
||||||
|
testWidgets(
|
||||||
|
'optional saving failure permits explicit one-time continuation',
|
||||||
|
(t) async {
|
||||||
|
final service = DummyDestinationService();
|
||||||
|
final wallet = await open(t, path: '/payout', destinations: service);
|
||||||
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||||
|
await click(t, find.byKey(const ValueKey('choose-payout-destination')));
|
||||||
|
await cryptoForm(t, payout: true);
|
||||||
|
await click(t, find.text('Save for future payouts'));
|
||||||
|
service.nextSaveError = DestinationIssue.conflict;
|
||||||
|
await click(t, find.byKey(const ValueKey('destination-continue')));
|
||||||
|
expect(find.textContaining('Couldn’t save'), findsOneWidget);
|
||||||
|
await click(t, find.text('Continue without saving'));
|
||||||
|
await click(t, find.byKey(const ValueKey('transfer-review')));
|
||||||
|
await click(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||||
|
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
||||||
|
expect(await wallet.destinations.list(), isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets(
|
||||||
|
'saved destination is never selected automatically and removal preserves amount',
|
||||||
|
(t) async {
|
||||||
|
final service = DummyDestinationService();
|
||||||
|
await seedDestination(service);
|
||||||
|
final wallet = await open(t, path: '/payout', destinations: service);
|
||||||
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||||
|
expect(
|
||||||
|
t
|
||||||
|
.widget<FilledButton>(find.byKey(const ValueKey('transfer-review')))
|
||||||
|
.onPressed,
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
await selectPayout(t);
|
||||||
|
await click(t, find.byKey(const ValueKey('transfer-review')));
|
||||||
|
final d = (await service.list()).single;
|
||||||
|
await service.remove(d.id, d.metadataRevision, d.executionRevision);
|
||||||
|
await click(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||||
|
expect(find.textContaining('Payout terms changed'), findsOneWidget);
|
||||||
|
expect(find.text('5'), findsOneWidget);
|
||||||
|
expect((await t.runAsync(wallet.fetch))!.operations, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
for (final locale in ['en', 'ru']) {
|
||||||
|
testWidgets('all method forms fit $locale at 320px with large text', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
t.view.physicalSize = const Size(320, 1100);
|
||||||
|
t.view.devicePixelRatio = 1;
|
||||||
|
t.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||||
|
addTearDown(t.view.resetPhysicalSize);
|
||||||
|
addTearDown(t.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
await open(t, locale: locale);
|
||||||
|
await click(
|
||||||
|
t,
|
||||||
|
find
|
||||||
|
.text(
|
||||||
|
locale == 'en' ? 'Add payout method' : 'Добавить способ вывода',
|
||||||
|
)
|
||||||
|
.first,
|
||||||
|
);
|
||||||
|
for (final name
|
||||||
|
in locale == 'en'
|
||||||
|
? ['Crypto address', 'Bank card', 'Russian bank account']
|
||||||
|
: [
|
||||||
|
'Криптоадрес',
|
||||||
|
'Банковская карта',
|
||||||
|
'Российский банковский счёт',
|
||||||
|
]) {
|
||||||
|
await click(t, find.byType(DropdownButtonFormField<DestinationType>));
|
||||||
|
await click(t, find.text(name).last);
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
testWidgets('semantic save outcomes contain no destination fields', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
final analytics = DummyAnalyticsService();
|
||||||
|
await open(t, analytics: analytics);
|
||||||
|
await cryptoForm(t);
|
||||||
|
await click(t, find.byKey(const ValueKey('destination-continue')));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
final events = analytics.events
|
||||||
|
.where((e) => e.kind == JourneyKind.destination)
|
||||||
|
.toList();
|
||||||
|
expect(
|
||||||
|
events.any(
|
||||||
|
(e) =>
|
||||||
|
e.step == JourneyStep.destinationSave &&
|
||||||
|
e.signal == JourneySignal.simulated,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
events.map((e) => e.properties.toString()).join(),
|
||||||
|
isNot(contains(testDestination.address)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'binding recovery uses secure capture and preserves the saved card',
|
||||||
|
(t) async {
|
||||||
|
final service = DummyDestinationService();
|
||||||
|
final created = await service.create(
|
||||||
|
const CardDestination(
|
||||||
|
capture: 'demo-card-recover',
|
||||||
|
brand: '',
|
||||||
|
last4: '',
|
||||||
|
expiryMonth: 0,
|
||||||
|
expiryYear: 0,
|
||||||
|
),
|
||||||
|
'Own card',
|
||||||
|
'creation-key-000001',
|
||||||
|
);
|
||||||
|
await open(t, destinations: service);
|
||||||
|
await click(t, find.text('Own card'));
|
||||||
|
await click(t, find.text('Update authorization'));
|
||||||
|
expect(find.byKey(const ValueKey('destination-nickname')), findsNothing);
|
||||||
|
await click(t, find.text('Demo card •••• 4242'));
|
||||||
|
await click(t, find.byKey(const ValueKey('destination-continue')));
|
||||||
|
final saved = await service.get(created.destinationId);
|
||||||
|
expect(saved.executionRevision, 2);
|
||||||
|
expect(saved.nickname, 'Own card');
|
||||||
|
expect((await service.list()).length, 1);
|
||||||
|
expect(
|
||||||
|
(await service.assess(
|
||||||
|
saved.input,
|
||||||
|
const PayoutContext('eur'),
|
||||||
|
)).eligible,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
|||||||
|
|
||||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/kyc/birth.dart';
|
||||||
|
import 'package:sxchnge_app/kyc/country.dart';
|
||||||
import 'package:sxchnge_app/main.dart';
|
import 'package:sxchnge_app/main.dart';
|
||||||
import 'package:sxchnge_app/providers/locale.dart';
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
@@ -65,12 +67,35 @@ void main() {
|
|||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> submit(WidgetTester t) async {
|
Future<void> profile(WidgetTester t) async {
|
||||||
await tap(t, find.byKey(const ValueKey('kyc-start')));
|
await tap(t, find.byKey(const ValueKey('kyc-start')));
|
||||||
await t.enterText(find.byKey(const ValueKey('kyc-name')), 'Example Person');
|
await t.enterText(find.byKey(const ValueKey('kyc-name')), 'Example Person');
|
||||||
await t.enterText(find.byKey(const ValueKey('kyc-birth')), '1990-04-23');
|
await t.enterText(find.byKey(const ValueKey('birth-day')), '23');
|
||||||
await t.enterText(find.byKey(const ValueKey('kyc-country')), 'PL');
|
await tap(t, find.byType(DropdownButtonFormField<int>));
|
||||||
|
// The month label follows the selected app locale.
|
||||||
|
await tap(t, find.textContaining(RegExp(r'^(April|апрель)$')).last);
|
||||||
|
await t.enterText(find.byKey(const ValueKey('birth-year')), '1990');
|
||||||
await t.pumpAndSettle();
|
await t.pumpAndSettle();
|
||||||
|
expect(t.widget<BirthField>(find.byType(BirthField)).value, '1990-04-23');
|
||||||
|
await tap(
|
||||||
|
t,
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const ValueKey('kyc-country')),
|
||||||
|
matching: find.byType(OutlinedButton),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await t.enterText(find.byKey(const ValueKey('country-search')), 'PL');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await tap(t, find.byType(ListTile));
|
||||||
|
expect(
|
||||||
|
t.widget<CountryField>(find.byKey(const ValueKey('kyc-country'))).value,
|
||||||
|
'PL',
|
||||||
|
);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> submit(WidgetTester t) async {
|
||||||
|
await profile(t);
|
||||||
await tap(t, find.byKey(const ValueKey('pick-passport')));
|
await tap(t, find.byKey(const ValueKey('pick-passport')));
|
||||||
await tap(t, find.byKey(const ValueKey('kyc-next')));
|
await tap(t, find.byKey(const ValueKey('kyc-next')));
|
||||||
await tap(t, find.byKey(const ValueKey('pick-selfie')));
|
await tap(t, find.byKey(const ValueKey('pick-selfie')));
|
||||||
@@ -79,6 +104,41 @@ void main() {
|
|||||||
await tap(t, find.byKey(const ValueKey('kyc-submit')));
|
await tap(t, find.byKey(const ValueKey('kyc-submit')));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'invalid birthday blocks advancing and valid birthday survives Back',
|
||||||
|
(t) async {
|
||||||
|
await open(t);
|
||||||
|
await profile(t);
|
||||||
|
await tap(t, find.byKey(const ValueKey('pick-passport')));
|
||||||
|
final next = find.byKey(const ValueKey('kyc-next'));
|
||||||
|
await t.enterText(find.byKey(const ValueKey('birth-day')), '31');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(t.widget<FilledButton>(next).onPressed, isNull);
|
||||||
|
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
|
||||||
|
await t.enterText(find.byKey(const ValueKey('birth-day')), '23');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(t.widget<FilledButton>(next).onPressed, isNotNull);
|
||||||
|
await tap(t, next);
|
||||||
|
await tap(t, find.text('Back'));
|
||||||
|
expect(t.widget<BirthField>(find.byType(BirthField)).value, '1990-04-23');
|
||||||
|
expect(
|
||||||
|
t
|
||||||
|
.widget<TextFormField>(find.byKey(const ValueKey('birth-day')))
|
||||||
|
.controller!
|
||||||
|
.text,
|
||||||
|
'23',
|
||||||
|
);
|
||||||
|
expect(find.text('April'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
t
|
||||||
|
.widget<TextFormField>(find.byKey(const ValueKey('birth-year')))
|
||||||
|
.controller!
|
||||||
|
.text,
|
||||||
|
'1990',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('two photos, review, pending and approval with service limits', (
|
testWidgets('two photos, review, pending and approval with service limits', (
|
||||||
t,
|
t,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ void main() {
|
|||||||
await t.ensureVisible(review);
|
await t.ensureVisible(review);
|
||||||
if (hasMethod) {
|
if (hasMethod) {
|
||||||
expect(find.text(destinationLabel), findsOneWidget);
|
expect(find.text(destinationLabel), findsOneWidget);
|
||||||
|
final choose = find.byKey(
|
||||||
|
const ValueKey('choose-configured-destination'),
|
||||||
|
);
|
||||||
|
expect(t.widget<FilledButton>(review).onPressed, isNull);
|
||||||
|
await t.ensureVisible(choose);
|
||||||
|
await t.tap(choose);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await t.ensureVisible(review);
|
||||||
await t.tap(review);
|
await t.tap(review);
|
||||||
await t.pumpAndSettle();
|
await t.pumpAndSettle();
|
||||||
expect(wallet.received, 'method-ref');
|
expect(wallet.received, 'method-ref');
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:image/image.dart' as images;
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/kyc/photo.dart';
|
||||||
|
import 'package:sxchnge_app/kyc/preview.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
Widget host(Widget child, {Locale locale = const Locale('en')}) => MaterialApp(
|
||||||
|
locale: locale,
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(body: SingleChildScrollView(child: child)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start decoding outside FakeAsync as well as waiting outside it, including
|
||||||
|
// browser codec callbacks that otherwise remain pending in the test zone.
|
||||||
|
Future<void> show(WidgetTester tester, Widget widget) async {
|
||||||
|
await tester.runAsync(() => tester.pumpWidget(widget));
|
||||||
|
}
|
||||||
|
|
||||||
|
// pumpAndSettle alone does not wait for image codecs. Listen to the actual
|
||||||
|
// preview streams so a hidden errorBuilder cannot make this regression pass.
|
||||||
|
Future<void> decoded(WidgetTester tester, {bool succeeds = true}) async {
|
||||||
|
final providers = tester
|
||||||
|
.widgetList<Image>(find.byType(Image))
|
||||||
|
.map((image) => image.image)
|
||||||
|
.toList();
|
||||||
|
expect(providers, isNotEmpty);
|
||||||
|
for (final provider in providers) {
|
||||||
|
Object? renderError;
|
||||||
|
StackTrace? renderStack;
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
final stream = provider.resolve(ImageConfiguration.empty);
|
||||||
|
final ready = Completer<void>();
|
||||||
|
final listener = ImageStreamListener(
|
||||||
|
(info, _) {
|
||||||
|
info.dispose();
|
||||||
|
if (!ready.isCompleted) ready.complete();
|
||||||
|
},
|
||||||
|
onError: (Object error, StackTrace? stack) {
|
||||||
|
renderError = error;
|
||||||
|
renderStack = stack;
|
||||||
|
if (!ready.isCompleted) ready.complete();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
stream.addListener(listener);
|
||||||
|
try {
|
||||||
|
await ready.future.timeout(const Duration(seconds: 15));
|
||||||
|
} finally {
|
||||||
|
stream.removeListener(listener);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
renderError,
|
||||||
|
succeeds ? isNull : isNotNull,
|
||||||
|
reason: succeeds ? '$renderError\n$renderStack' : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Uint8List png;
|
||||||
|
late Uint8List jpeg;
|
||||||
|
setUpAll(() {
|
||||||
|
// Synthetic, non-sensitive portrait and landscape photos in both formats.
|
||||||
|
png = images.encodePng(images.Image(width: 1200, height: 1600));
|
||||||
|
jpeg = images.encodeJpg(images.Image(width: 1600, height: 1200));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final format in ['PNG', 'JPEG']) {
|
||||||
|
testWidgets('accepted $format renders from an immutable photo model', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final source = Uint8List.fromList(format == 'PNG' ? png : jpeg);
|
||||||
|
final photo = (await tester.runAsync(
|
||||||
|
() => FilePhotoService.validate(source),
|
||||||
|
))!;
|
||||||
|
final originalFirstByte = photo.bytes.first;
|
||||||
|
source[0] = 0;
|
||||||
|
expect(photo.bytes.first, originalFirstByte);
|
||||||
|
expect(() => photo.bytes[0] = 0, throwsUnsupportedError);
|
||||||
|
await show(
|
||||||
|
tester,
|
||||||
|
host(PhotoPreview(photo: photo, label: 'ID document photo')),
|
||||||
|
);
|
||||||
|
await decoded(tester);
|
||||||
|
final raw = tester.widget<RawImage>(find.byType(RawImage));
|
||||||
|
expect(raw.image, isNotNull);
|
||||||
|
expect(raw.image!.width, lessThanOrEqualTo(1000));
|
||||||
|
expect(
|
||||||
|
tester.widget<Image>(find.byType(Image)).semanticLabel,
|
||||||
|
'ID document photo',
|
||||||
|
);
|
||||||
|
expect(find.textContaining('damaged'), findsNothing);
|
||||||
|
expect(find.textContaining('could not be displayed'), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('replacement and removal evict sensitive preview cache entries', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final first = Photo(png);
|
||||||
|
await show(tester, host(PhotoPreview(photo: first, label: 'ID')));
|
||||||
|
await decoded(tester);
|
||||||
|
final initialProvider = tester.widget<Image>(find.byType(Image)).image;
|
||||||
|
final initialKey = await initialProvider.obtainKey(
|
||||||
|
ImageConfiguration.empty,
|
||||||
|
);
|
||||||
|
final cache = PaintingBinding.instance.imageCache;
|
||||||
|
expect(cache.statusForKey(initialKey).keepAlive, isTrue);
|
||||||
|
await show(tester, host(PhotoPreview(photo: first, label: 'Document')));
|
||||||
|
await decoded(tester);
|
||||||
|
expect(
|
||||||
|
identical(
|
||||||
|
tester.widget<Image>(find.byType(Image)).image,
|
||||||
|
initialProvider,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
await show(tester, host(PhotoPreview(photo: Photo(jpeg), label: 'ID')));
|
||||||
|
await decoded(tester);
|
||||||
|
expect(cache.statusForKey(initialKey).untracked, isTrue);
|
||||||
|
final replacement = tester.widget<Image>(find.byType(Image)).image;
|
||||||
|
final replacementKey = await replacement.obtainKey(
|
||||||
|
ImageConfiguration.empty,
|
||||||
|
);
|
||||||
|
expect(cache.statusForKey(replacementKey).keepAlive, isTrue);
|
||||||
|
expect(tester.widget<RawImage>(find.byType(RawImage)).image!.width, 1000);
|
||||||
|
await show(tester, host(const SizedBox()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(cache.statusForKey(replacementKey).untracked, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ID and selfie both render on review and after returning', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final passport = Photo(png);
|
||||||
|
final selfie = Photo(jpeg);
|
||||||
|
for (var visit = 0; visit < 2; visit++) {
|
||||||
|
await show(
|
||||||
|
tester,
|
||||||
|
host(
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
PhotoPanel(
|
||||||
|
kind: PhotoKind.passport,
|
||||||
|
photo: passport,
|
||||||
|
review: true,
|
||||||
|
),
|
||||||
|
PhotoPanel(kind: PhotoKind.selfie, photo: selfie, review: true),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await decoded(tester);
|
||||||
|
final previews = tester.widgetList<RawImage>(find.byType(RawImage));
|
||||||
|
expect(previews.length, 2);
|
||||||
|
expect(previews.every((image) => image.image != null), isTrue);
|
||||||
|
expect(find.text('Choose a different photo'), findsNothing);
|
||||||
|
await show(tester, host(const SizedBox()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final locale in ['en', 'ru']) {
|
||||||
|
testWidgets(
|
||||||
|
'$locale preview failure explains recovery and can be replaced',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(320, 900);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
var replacements = 0;
|
||||||
|
var removals = 0;
|
||||||
|
await show(
|
||||||
|
tester,
|
||||||
|
host(
|
||||||
|
PhotoPanel(
|
||||||
|
kind: PhotoKind.passport,
|
||||||
|
photo: Photo(Uint8List.fromList([1, 2, 3])),
|
||||||
|
onPick: () => replacements++,
|
||||||
|
onRemove: () => removals++,
|
||||||
|
),
|
||||||
|
locale: Locale(locale),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await decoded(tester, succeeds: false);
|
||||||
|
final l = AppLocalizations.of(tester.element(find.byType(PhotoPanel)))!;
|
||||||
|
expect(find.text(l.kycErrorPreview), findsOneWidget);
|
||||||
|
expect(find.text(l.kycErrorUnreadable), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.getSemantics(find.text(l.kycErrorPreview)),
|
||||||
|
matchesSemantics(isLiveRegion: true, label: l.kycErrorPreview),
|
||||||
|
);
|
||||||
|
final replace = find.byKey(const ValueKey('pick-passport'));
|
||||||
|
await tester.ensureVisible(replace);
|
||||||
|
await tester.tap(replace);
|
||||||
|
final remove = find.text(l.kycRemove);
|
||||||
|
await tester.ensureVisible(remove);
|
||||||
|
await tester.tap(remove);
|
||||||
|
expect(replacements, 1);
|
||||||
|
expect(removals, 1);
|
||||||
|
await show(
|
||||||
|
tester,
|
||||||
|
host(
|
||||||
|
PhotoPanel(kind: PhotoKind.passport, photo: Photo(jpeg)),
|
||||||
|
locale: Locale(locale),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await decoded(tester);
|
||||||
|
expect(find.text(l.kycErrorPreview), findsNothing);
|
||||||
|
expect(tester.widget<RawImage>(find.byType(RawImage)).image, isNotNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,105 @@ Future<GoRouter> openRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
testWidgets('queued signup receipt is transient and updates its recipient', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final router = await openRoute(tester, '/');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
router.goNamed(
|
||||||
|
AppRoutes.verificationSent,
|
||||||
|
extra: const VerificationDelivery(
|
||||||
|
email: 'first@example.com',
|
||||||
|
isSimulated: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||||
|
expect(find.text('first@example.com'), findsOneWidget);
|
||||||
|
router.goNamed(
|
||||||
|
AppRoutes.verificationSent,
|
||||||
|
extra: const VerificationDelivery(
|
||||||
|
email: 'second@example.com',
|
||||||
|
isSimulated: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('first@example.com'), findsNothing);
|
||||||
|
expect(find.text('second@example.com'), findsOneWidget);
|
||||||
|
final restored = router.routeInformationParser.restoreRouteInformation(
|
||||||
|
router.routerDelegate.currentConfiguration,
|
||||||
|
)!;
|
||||||
|
expect(restored.state.toString(), isNot(contains('second@example.com')));
|
||||||
|
expect(restored.uri.toString(), '/verification-sent');
|
||||||
|
router.goNamed(AppRoutes.login);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
router.goNamed(AppRoutes.verificationSent);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('verification-idle')), findsOneWidget);
|
||||||
|
expect(find.text('second@example.com'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final language in ['en', 'ru']) {
|
||||||
|
testWidgets(
|
||||||
|
'verified link keeps success visible and prefills login ($language)',
|
||||||
|
(tester) async {
|
||||||
|
tester.platformDispatcher.defaultRouteNameTestValue =
|
||||||
|
'/verify-email?token=demo-valid';
|
||||||
|
addTearDown(tester.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
localeProvider: LocaleProvider(initialLocale: Locale(language)),
|
||||||
|
verificationService: const DummyVerificationService(
|
||||||
|
delay: Duration.zero,
|
||||||
|
verifiedEmail: 'private-alias@example.com',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('verification-verified')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const ValueKey('email')), findsNothing);
|
||||||
|
final router = GoRouter.of(
|
||||||
|
tester.element(find.byType(VerificationPage)),
|
||||||
|
);
|
||||||
|
expect(router.routeInformationProvider.value.uri.path, '/verify-email');
|
||||||
|
await tester.ensureVisible(
|
||||||
|
find.byKey(const ValueKey('verification-continue')),
|
||||||
|
);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('verification-continue')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final email = tester.widget<TextFormField>(
|
||||||
|
find.byKey(const ValueKey('email')),
|
||||||
|
);
|
||||||
|
expect(email.controller!.text, 'private-alias@example.com');
|
||||||
|
final password = tester.state<EditableTextState>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const ValueKey('password')),
|
||||||
|
matching: find.byType(EditableText),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(password.currentTextEditingValue.text, isEmpty);
|
||||||
|
expect(password.widget.focusNode.hasFocus, isTrue);
|
||||||
|
expect(router.routeInformationProvider.value.uri.toString(), '/');
|
||||||
|
final restored = router.routeInformationParser.restoreRouteInformation(
|
||||||
|
router.routerDelegate.currentConfiguration,
|
||||||
|
)!;
|
||||||
|
expect(
|
||||||
|
restored.state.toString(),
|
||||||
|
isNot(contains('private-alias@example.com')),
|
||||||
|
);
|
||||||
|
expect(restored.state.toString(), isNot(contains('demo-valid')));
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('email')),
|
||||||
|
'changed@example.com',
|
||||||
|
);
|
||||||
|
expect(email.controller!.text, 'changed@example.com');
|
||||||
|
expect(find.byKey(const ValueKey('confirmation')), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
testWidgets('unknown deep link redirects to canonical login URL', (
|
testWidgets('unknown deep link redirects to canonical login URL', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/main.dart';
|
||||||
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
|
class DeliveryBackend extends BackendAuthServices {
|
||||||
|
DeliveryBackend(List<String> requests)
|
||||||
|
: super(
|
||||||
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||||
|
clientId: 'web',
|
||||||
|
client: MockClient((request) async {
|
||||||
|
requests.add(request.url.path);
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'data': request.url.path.endsWith('/signup')
|
||||||
|
? {'userId': 'customer'}
|
||||||
|
: {'accepted': true},
|
||||||
|
}),
|
||||||
|
202,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
int sends = 0;
|
||||||
|
bool failResend = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<VerificationResult> send({
|
||||||
|
required String email,
|
||||||
|
required String locale,
|
||||||
|
}) async {
|
||||||
|
sends++;
|
||||||
|
if (failResend) {
|
||||||
|
throw const VerificationException(VerificationError.internal);
|
||||||
|
}
|
||||||
|
return super.send(email: email, locale: locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final language in ['en', 'ru']) {
|
||||||
|
testWidgets(
|
||||||
|
'accepted signup survives an unavailable resend service ($language)',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.platformDispatcher.textScaleFactorTestValue = 1.5;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
final requests = <String>[];
|
||||||
|
final backend = DeliveryBackend(requests);
|
||||||
|
final analytics = DummyAnalyticsService();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
backend: backend,
|
||||||
|
analyticsService: analytics,
|
||||||
|
localeProvider: LocaleProvider(initialLocale: Locale(language)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final signup = find.text(language == 'ru' ? 'Регистрация' : 'Sign up');
|
||||||
|
await tester.ensureVisible(signup);
|
||||||
|
await tester.tap(signup);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
for (final entry in {
|
||||||
|
'name': 'Ada Lovelace',
|
||||||
|
'email': 'ada@example.com',
|
||||||
|
'password': 'password preserved',
|
||||||
|
'confirmation': 'password preserved',
|
||||||
|
}.entries) {
|
||||||
|
await tester.enterText(find.byKey(ValueKey(entry.key)), entry.value);
|
||||||
|
}
|
||||||
|
final submit = find.byKey(const ValueKey('submit'));
|
||||||
|
await tester.ensureVisible(submit);
|
||||||
|
await tester.tap(submit);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||||
|
expect(find.text('ada@example.com'), findsOneWidget);
|
||||||
|
expect(backend.sends, 0);
|
||||||
|
expect(requests, ['/api/v1/accounts/signup']);
|
||||||
|
final router = GoRouter.of(tester.element(find.byType(Scaffold)));
|
||||||
|
final restored = router.routeInformationParser.restoreRouteInformation(
|
||||||
|
router.routerDelegate.currentConfiguration,
|
||||||
|
)!;
|
||||||
|
expect(restored.uri.toString(), '/verification-sent');
|
||||||
|
expect(restored.state.toString(), isNot(contains('ada@example.com')));
|
||||||
|
expect(
|
||||||
|
analytics.events.where(
|
||||||
|
(event) => event.name == 'auth_signup_succeeded',
|
||||||
|
),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
analytics.events.where(
|
||||||
|
(event) => event.name == 'auth_email_delivery_succeeded',
|
||||||
|
),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
final resend = find.byKey(const ValueKey('verification-resend'));
|
||||||
|
await tester.ensureVisible(resend);
|
||||||
|
await tester.tap(resend);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('verification-error')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(backend.sends, 1);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<TextFormField>(
|
||||||
|
find.byKey(const ValueKey('verification-email')),
|
||||||
|
)
|
||||||
|
.controller!
|
||||||
|
.text,
|
||||||
|
'ada@example.com',
|
||||||
|
);
|
||||||
|
backend.failResend = false;
|
||||||
|
await tester.ensureVisible(resend);
|
||||||
|
await tester.tap(resend);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||||
|
expect(backend.sends, 2);
|
||||||
|
expect(requests.last, '/api/v1/accounts/verification/send');
|
||||||
|
expect(requests, hasLength(2));
|
||||||
|
expect(
|
||||||
|
analytics.events.map((event) => event.journeyId).toSet(),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
analytics.events.map((event) => event.properties).toString(),
|
||||||
|
isNot(contains('ada@example.com')),
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,15 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
|||||||
|
|
||||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_app/main.dart';
|
import 'package:sxchnge_app/main.dart';
|
||||||
import 'package:sxchnge_app/providers/locale.dart';
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
|
import 'destination_helpers.dart';
|
||||||
|
|
||||||
Future<void> tapFactor(WidgetTester t, String key) async {
|
Future<void> tapFactor(WidgetTester t, String key) async {
|
||||||
final finder = find.byKey(ValueKey(key));
|
final finder = find.byKey(ValueKey(key));
|
||||||
await t.ensureVisible(finder);
|
await t.ensureVisible(finder);
|
||||||
@@ -31,8 +37,16 @@ Future<DummyTwoFactorService> openFactor(
|
|||||||
delay: Duration.zero,
|
delay: Duration.zero,
|
||||||
method: method,
|
method: method,
|
||||||
);
|
);
|
||||||
|
final wallet = DummyWalletService(
|
||||||
|
DummyIdentityService(delay: Duration.zero),
|
||||||
|
factors: service,
|
||||||
|
initialLocale: locale,
|
||||||
|
delay: Duration.zero,
|
||||||
|
);
|
||||||
|
await seedDestination(wallet.destinations);
|
||||||
await t.pumpWidget(
|
await t.pumpWidget(
|
||||||
SxchngeApp(
|
SxchngeApp(
|
||||||
|
walletService: wallet,
|
||||||
twoFactorService: service,
|
twoFactorService: service,
|
||||||
analyticsService: analytics,
|
analyticsService: analytics,
|
||||||
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
|
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
|
||||||
@@ -134,13 +148,38 @@ void main() {
|
|||||||
await signIn(t);
|
await signIn(t);
|
||||||
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
|
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
|
||||||
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||||
|
expect(
|
||||||
|
t.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal),
|
analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal),
|
||||||
isEmpty,
|
isEmpty,
|
||||||
);
|
);
|
||||||
|
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '000000');
|
||||||
|
await tapFactor(t, 'two-factor-confirm');
|
||||||
|
expect(find.byKey(const ValueKey('two-factor-error')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
t.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456');
|
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456');
|
||||||
await tapFactor(t, 'two-factor-confirm');
|
await tapFactor(t, 'two-factor-confirm');
|
||||||
expect(find.byKey(const ValueKey('account-eur')), findsOneWidget);
|
expect(find.byKey(const ValueKey('account-eur')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
t.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
final login = analytics.events
|
final login = analytics.events
|
||||||
.where((e) => e.kind == JourneyKind.login)
|
.where((e) => e.kind == JourneyKind.login)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -158,6 +197,20 @@ void main() {
|
|||||||
await signIn(t);
|
await signIn(t);
|
||||||
await tapFactor(t, 'two-factor-cancel');
|
await tapFactor(t, 'two-factor-cancel');
|
||||||
expect(find.byKey(const ValueKey('email')), findsOneWidget);
|
expect(find.byKey(const ValueKey('email')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
t.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
t.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||||
expect(
|
expect(
|
||||||
t
|
t
|
||||||
@@ -174,10 +227,7 @@ void main() {
|
|||||||
await openFactor(t, path: '/$kind', enabled: true);
|
await openFactor(t, path: '/$kind', enabled: true);
|
||||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
|
||||||
if (kind == 'payout') {
|
if (kind == 'payout') {
|
||||||
await t.enterText(
|
await selectPayout(t);
|
||||||
find.byKey(const ValueKey('transfer-recipient')),
|
|
||||||
'Sample recipient',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
await tapFactor(t, 'transfer-review');
|
await tapFactor(t, 'transfer-review');
|
||||||
await tapFactor(t, 'transfer-confirm');
|
await tapFactor(t, 'transfer-confirm');
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
|||||||
import 'package:sxchnge_app/main.dart';
|
import 'package:sxchnge_app/main.dart';
|
||||||
import 'package:sxchnge_app/providers/locale.dart';
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
|
import 'destination_helpers.dart';
|
||||||
|
|
||||||
Future<void> tap(WidgetTester t, Finder f) async {
|
Future<void> tap(WidgetTester t, Finder f) async {
|
||||||
await t.ensureVisible(f);
|
await t.ensureVisible(f);
|
||||||
await t.tap(f);
|
await t.tap(f);
|
||||||
@@ -32,6 +34,7 @@ Future<DummyWalletService> open(
|
|||||||
settlement: settlement,
|
settlement: settlement,
|
||||||
initialLocale: locale,
|
initialLocale: locale,
|
||||||
);
|
);
|
||||||
|
await seedDestination(service.destinations);
|
||||||
await t.pumpWidget(
|
await t.pumpWidget(
|
||||||
SxchngeApp(
|
SxchngeApp(
|
||||||
identityService: identity,
|
identityService: identity,
|
||||||
@@ -78,10 +81,7 @@ void main() {
|
|||||||
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
||||||
await tap(t, find.text('Pay out').last);
|
await tap(t, find.text('Pay out').last);
|
||||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||||
await t.enterText(
|
await selectPayout(t);
|
||||||
find.byKey(const ValueKey('transfer-recipient')),
|
|
||||||
'Sample recipient',
|
|
||||||
);
|
|
||||||
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||||
expect(find.text('Recipient receives'), findsOneWidget);
|
expect(find.text('Recipient receives'), findsOneWidget);
|
||||||
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||||
@@ -101,10 +101,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
s.failNextOperation = true;
|
s.failNextOperation = true;
|
||||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
|
||||||
await t.enterText(
|
await selectPayout(t);
|
||||||
find.byKey(const ValueKey('transfer-recipient')),
|
|
||||||
'Sample recipient',
|
|
||||||
);
|
|
||||||
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||||
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||||
// Settlement is service-owned; refresh after the simulated processing period.
|
// Settlement is service-owned; refresh after the simulated processing period.
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ enum JourneyKind {
|
|||||||
exchange,
|
exchange,
|
||||||
payout,
|
payout,
|
||||||
security,
|
security,
|
||||||
|
destination,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum DestinationResolution { created, duplicate }
|
||||||
|
|
||||||
enum JourneyStep {
|
enum JourneyStep {
|
||||||
login,
|
login,
|
||||||
signup,
|
signup,
|
||||||
@@ -23,6 +26,10 @@ enum JourneyStep {
|
|||||||
kycSelfie,
|
kycSelfie,
|
||||||
kycSubmission,
|
kycSubmission,
|
||||||
kycStatus,
|
kycStatus,
|
||||||
|
destinationDraft,
|
||||||
|
destinationSave,
|
||||||
|
destinationReconciliation,
|
||||||
|
destinationBinding,
|
||||||
transferDraft,
|
transferDraft,
|
||||||
transferQuote,
|
transferQuote,
|
||||||
transferSubmission,
|
transferSubmission,
|
||||||
@@ -78,6 +85,7 @@ class AnalyticsEvent {
|
|||||||
required this.sequence,
|
required this.sequence,
|
||||||
required this.elapsedMs,
|
required this.elapsedMs,
|
||||||
this.step,
|
this.step,
|
||||||
|
this.resolution,
|
||||||
this.operation,
|
this.operation,
|
||||||
this.failure,
|
this.failure,
|
||||||
this.exit,
|
this.exit,
|
||||||
@@ -90,6 +98,7 @@ class AnalyticsEvent {
|
|||||||
final JourneyKind kind;
|
final JourneyKind kind;
|
||||||
final JourneySignal signal;
|
final JourneySignal signal;
|
||||||
final JourneyStep? step;
|
final JourneyStep? step;
|
||||||
|
final DestinationResolution? resolution;
|
||||||
final JourneyFailure? failure;
|
final JourneyFailure? failure;
|
||||||
final JourneyExit? exit;
|
final JourneyExit? exit;
|
||||||
final int sequence;
|
final int sequence;
|
||||||
@@ -105,6 +114,7 @@ class AnalyticsEvent {
|
|||||||
JourneyKind.exchange => 'exchange',
|
JourneyKind.exchange => 'exchange',
|
||||||
JourneyKind.payout => 'payout',
|
JourneyKind.payout => 'payout',
|
||||||
JourneyKind.security => 'security',
|
JourneyKind.security => 'security',
|
||||||
|
JourneyKind.destination => 'destination',
|
||||||
_ => 'auth',
|
_ => 'auth',
|
||||||
};
|
};
|
||||||
var stage = step == null ? 'journey' : _snake(step!.name);
|
var stage = step == null ? 'journey' : _snake(step!.name);
|
||||||
@@ -123,6 +133,7 @@ class AnalyticsEvent {
|
|||||||
'terminal': terminal,
|
'terminal': terminal,
|
||||||
'simulated': simulated,
|
'simulated': simulated,
|
||||||
if (step != null) 'step': _snake(step!.name),
|
if (step != null) 'step': _snake(step!.name),
|
||||||
|
if (resolution != null) 'destination_resolution': resolution!.name,
|
||||||
if (operation != null) 'operation': operation!,
|
if (operation != null) 'operation': operation!,
|
||||||
if (failure != null) 'failure': _snake(failure!.name),
|
if (failure != null) 'failure': _snake(failure!.name),
|
||||||
if (exit != null) 'exit': _snake(exit!.name),
|
if (exit != null) 'exit': _snake(exit!.name),
|
||||||
@@ -210,6 +221,7 @@ class JourneyRecorder {
|
|||||||
JourneyFailure? failure,
|
JourneyFailure? failure,
|
||||||
bool terminal = false,
|
bool terminal = false,
|
||||||
bool cancelled = false,
|
bool cancelled = false,
|
||||||
|
DestinationResolution? resolution,
|
||||||
}) {
|
}) {
|
||||||
final journey = operation._journey;
|
final journey = operation._journey;
|
||||||
if (_disposed || operation._finished || journey.closed) return;
|
if (_disposed || operation._finished || journey.closed) return;
|
||||||
@@ -227,6 +239,7 @@ class JourneyRecorder {
|
|||||||
? JourneySignal.simulated
|
? JourneySignal.simulated
|
||||||
: JourneySignal.succeeded,
|
: JourneySignal.succeeded,
|
||||||
operation: operation,
|
operation: operation,
|
||||||
|
resolution: resolution,
|
||||||
failure: failure,
|
failure: failure,
|
||||||
terminal: complete,
|
terminal: complete,
|
||||||
simulated: simulated || (complete && journey.simulated),
|
simulated: simulated || (complete && journey.simulated),
|
||||||
@@ -258,6 +271,7 @@ class JourneyRecorder {
|
|||||||
_Journey journey,
|
_Journey journey,
|
||||||
JourneySignal signal, {
|
JourneySignal signal, {
|
||||||
JourneyOperation? operation,
|
JourneyOperation? operation,
|
||||||
|
DestinationResolution? resolution,
|
||||||
JourneyFailure? failure,
|
JourneyFailure? failure,
|
||||||
JourneyExit? exit,
|
JourneyExit? exit,
|
||||||
bool terminal = false,
|
bool terminal = false,
|
||||||
@@ -271,6 +285,7 @@ class JourneyRecorder {
|
|||||||
sequence: ++journey.sequence,
|
sequence: ++journey.sequence,
|
||||||
elapsedMs: journey.time.elapsedMilliseconds,
|
elapsedMs: journey.time.elapsedMilliseconds,
|
||||||
step: operation?.step,
|
step: operation?.step,
|
||||||
|
resolution: resolution,
|
||||||
operation: operation?.number,
|
operation: operation?.number,
|
||||||
failure: failure,
|
failure: failure,
|
||||||
exit: exit,
|
exit: exit,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ class BackendDTO {
|
|||||||
(jsonDecode(body) as Map<String, dynamic>)['data'] as Map<String, dynamic>,
|
(jsonDecode(body) as Map<String, dynamic>)['data'] as Map<String, dynamic>,
|
||||||
);
|
);
|
||||||
final Map<String, dynamic> data;
|
final Map<String, dynamic> data;
|
||||||
|
bool get verified => data['verified'] == true;
|
||||||
|
String? get email => data['email'] as String?;
|
||||||
String get error => data['error'] as String? ?? 'unavailable';
|
String get error => data['error'] as String? ?? 'unavailable';
|
||||||
String get userId =>
|
String get userId =>
|
||||||
data['userId'] as String? ??
|
data['userId'] as String? ??
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class AuthDTO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class VerificationDTO {
|
class VerificationDTO {
|
||||||
const VerificationDTO({required this.simulated});
|
const VerificationDTO({required this.simulated, this.email});
|
||||||
final bool simulated;
|
final bool simulated;
|
||||||
|
final String? email;
|
||||||
}
|
}
|
||||||
@@ -5,9 +5,20 @@ import '../models/two_factor.dart';
|
|||||||
import '../models/verification.dart';
|
import '../models/verification.dart';
|
||||||
|
|
||||||
abstract final class BackendMapper {
|
abstract final class BackendMapper {
|
||||||
static AuthResult signup(BackendDTO dto) {
|
static VerificationResult activation(BackendDTO dto) {
|
||||||
|
if (!dto.verified) {
|
||||||
|
throw const VerificationException(VerificationError.internal);
|
||||||
|
}
|
||||||
|
return VerificationResult(isSimulated: false, verifiedEmail: dto.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
static AuthResult signup(BackendDTO dto, {required String email}) {
|
||||||
if (dto.userId.isEmpty) throw const FormatException('Missing user');
|
if (dto.userId.isEmpty) throw const FormatException('Missing user');
|
||||||
return AuthResult(userId: dto.userId, isSimulated: false);
|
return AuthResult(
|
||||||
|
userId: dto.userId,
|
||||||
|
isSimulated: false,
|
||||||
|
delivery: VerificationDelivery(email: email, isSimulated: false),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static FactorChallenge challenge(BackendDTO dto, String id) {
|
static FactorChallenge challenge(BackendDTO dto, String id) {
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ import '../models/verification.dart';
|
|||||||
abstract final class AuthMapper {
|
abstract final class AuthMapper {
|
||||||
static AuthResult fromDTO(AuthDTO dto) =>
|
static AuthResult fromDTO(AuthDTO dto) =>
|
||||||
AuthResult(userId: dto.userId, isSimulated: dto.simulated);
|
AuthResult(userId: dto.userId, isSimulated: dto.simulated);
|
||||||
|
|
||||||
|
static AuthResult signup(AuthDTO dto, {required String email}) => AuthResult(
|
||||||
|
userId: dto.userId,
|
||||||
|
isSimulated: dto.simulated,
|
||||||
|
delivery: VerificationDelivery(email: email, isSimulated: dto.simulated),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract final class VerificationMapper {
|
abstract final class VerificationMapper {
|
||||||
static VerificationResult fromDTO(VerificationDTO dto) =>
|
static VerificationResult fromDTO(VerificationDTO dto) =>
|
||||||
VerificationResult(isSimulated: dto.simulated);
|
VerificationResult(isSimulated: dto.simulated, verifiedEmail: dto.email);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'two_factor.dart';
|
import 'two_factor.dart';
|
||||||
|
import 'verification.dart';
|
||||||
|
|
||||||
/// Ephemeral input: never persist, log, or include passwords in provider state.
|
/// Ephemeral input: never persist, log, or include passwords in provider state.
|
||||||
class LoginRequest {
|
class LoginRequest {
|
||||||
@@ -31,9 +32,11 @@ class AuthResult {
|
|||||||
required this.userId,
|
required this.userId,
|
||||||
required this.isSimulated,
|
required this.isSimulated,
|
||||||
this.challenge,
|
this.challenge,
|
||||||
|
this.delivery,
|
||||||
});
|
});
|
||||||
final String userId;
|
final String userId;
|
||||||
final FactorChallenge? challenge;
|
final FactorChallenge? challenge;
|
||||||
|
final VerificationDelivery? delivery;
|
||||||
final bool isSimulated;
|
final bool isSimulated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,21 +11,35 @@ enum VerificationStatus {
|
|||||||
|
|
||||||
enum VerificationError { expired, invalid, internal, needsAssistance }
|
enum VerificationError { expired, invalid, internal, needsAssistance }
|
||||||
|
|
||||||
|
/// An accepted email-queue operation, not proof of inbox delivery or activation.
|
||||||
|
class VerificationDelivery {
|
||||||
|
const VerificationDelivery({required this.email, required this.isSimulated});
|
||||||
|
final String email;
|
||||||
|
final bool isSimulated;
|
||||||
|
}
|
||||||
|
|
||||||
class VerificationException implements Exception {
|
class VerificationException implements Exception {
|
||||||
const VerificationException(this.code);
|
const VerificationException(this.code);
|
||||||
final VerificationError code;
|
final VerificationError code;
|
||||||
}
|
}
|
||||||
|
|
||||||
class VerificationResult {
|
class VerificationResult {
|
||||||
const VerificationResult({required this.isSimulated});
|
const VerificationResult({required this.isSimulated, this.verifiedEmail});
|
||||||
final bool isSimulated;
|
final bool isSimulated;
|
||||||
|
final String? verifiedEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
class VerificationState {
|
class VerificationState {
|
||||||
const VerificationState(this.status, {this.isSimulated = false, this.error});
|
const VerificationState(
|
||||||
|
this.status, {
|
||||||
|
this.isSimulated = false,
|
||||||
|
this.error,
|
||||||
|
this.result,
|
||||||
|
});
|
||||||
final VerificationStatus status;
|
final VerificationStatus status;
|
||||||
final VerificationError? error;
|
final VerificationError? error;
|
||||||
final bool isSimulated;
|
final bool isSimulated;
|
||||||
|
final VerificationResult? result;
|
||||||
bool get isLoading =>
|
bool get isLoading =>
|
||||||
status == VerificationStatus.sending ||
|
status == VerificationStatus.sending ||
|
||||||
status == VerificationStatus.checking;
|
status == VerificationStatus.checking;
|
||||||
|
|||||||
@@ -4,13 +4,22 @@ import '../models/verification.dart';
|
|||||||
import '../services/verification.dart';
|
import '../services/verification.dart';
|
||||||
|
|
||||||
class VerificationProvider extends ChangeNotifier {
|
class VerificationProvider extends ChangeNotifier {
|
||||||
VerificationProvider(this.service);
|
VerificationProvider(this.service, {VerificationDelivery? delivery})
|
||||||
|
: _state = _initialState(delivery);
|
||||||
final VerificationService service;
|
final VerificationService service;
|
||||||
VerificationState _state = const VerificationState(VerificationStatus.idle);
|
VerificationState _state;
|
||||||
VerificationState get state => _state;
|
VerificationState get state => _state;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
int _generation = 0;
|
int _generation = 0;
|
||||||
|
|
||||||
|
static VerificationState _initialState(VerificationDelivery? delivery) =>
|
||||||
|
delivery == null
|
||||||
|
? const VerificationState(VerificationStatus.idle)
|
||||||
|
: VerificationState(
|
||||||
|
VerificationStatus.sent,
|
||||||
|
isSimulated: delivery.isSimulated,
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> send({required String email, required String locale}) => _run(
|
Future<void> send({required String email, required String locale}) => _run(
|
||||||
() => service.send(email: email.trim(), locale: locale),
|
() => service.send(email: email.trim(), locale: locale),
|
||||||
VerificationStatus.sending,
|
VerificationStatus.sending,
|
||||||
@@ -35,7 +44,11 @@ class VerificationProvider extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
final result = await operation();
|
final result = await operation();
|
||||||
if (_disposed || generation != _generation) return;
|
if (_disposed || generation != _generation) return;
|
||||||
_state = VerificationState(success, isSimulated: result.isSimulated);
|
_state = VerificationState(
|
||||||
|
success,
|
||||||
|
isSimulated: result.isSimulated,
|
||||||
|
result: success == VerificationStatus.verified ? result : null,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (_disposed || generation != _generation) return;
|
if (_disposed || generation != _generation) return;
|
||||||
final code = error is VerificationException
|
final code = error is VerificationException
|
||||||
@@ -57,10 +70,10 @@ class VerificationProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void reset() {
|
void reset({VerificationDelivery? delivery}) {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
++_generation;
|
++_generation;
|
||||||
_state = const VerificationState(VerificationStatus.idle);
|
_state = _initialState(delivery);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ class BackendAuthServices
|
|||||||
final _pending = <String, ({String token, String challengeId})>{};
|
final _pending = <String, ({String token, String challengeId})>{};
|
||||||
final _grants = <String, AuthSession>{};
|
final _grants = <String, AuthSession>{};
|
||||||
final _retryKeys = <String, String>{};
|
final _retryKeys = <String, String>{};
|
||||||
String? _queuedSignup;
|
|
||||||
AuthSession? _session;
|
AuthSession? _session;
|
||||||
AuthSession? get session => _session;
|
AuthSession? get session => _session;
|
||||||
@override
|
@override
|
||||||
@@ -113,9 +112,8 @@ class BackendAuthServices
|
|||||||
'locale': r.locale,
|
'locale': r.locale,
|
||||||
'idempotencyKey': key,
|
'idempotencyKey': key,
|
||||||
});
|
});
|
||||||
final result = BackendMapper.signup(dto);
|
final result = BackendMapper.signup(dto, email: email);
|
||||||
_retryKeys.remove('signup:$email');
|
_retryKeys.remove('signup:$email');
|
||||||
_queuedSignup = email;
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,14 +139,6 @@ class BackendAuthServices
|
|||||||
required String locale,
|
required String locale,
|
||||||
}) async {
|
}) async {
|
||||||
final normalized = email.trim().toLowerCase();
|
final normalized = email.trim().toLowerCase();
|
||||||
// Signup already queued this email. The existing confirmation page requests
|
|
||||||
// its initial delivery on mount; consume that receipt once.
|
|
||||||
if (_queuedSignup == normalized) {
|
|
||||||
_queuedSignup = null;
|
|
||||||
return VerificationMapper.fromDTO(
|
|
||||||
const VerificationDTO(simulated: false),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final key = _retryKeys.putIfAbsent('activation:$normalized', _key);
|
final key = _retryKeys.putIfAbsent('activation:$normalized', _key);
|
||||||
try {
|
try {
|
||||||
await _post('verification/send', {
|
await _post('verification/send', {
|
||||||
@@ -172,8 +162,10 @@ class BackendAuthServices
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<VerificationResult> confirm({required String token}) async {
|
Future<VerificationResult> confirm({required String token}) async {
|
||||||
await _post('verification/confirm', {'token': token}, verification: true);
|
final dto = await _post('verification/confirm', {
|
||||||
return VerificationMapper.fromDTO(const VerificationDTO(simulated: false));
|
'token': token,
|
||||||
|
}, verification: true);
|
||||||
|
return BackendMapper.activation(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ class DummySignupService implements SignupService, SimulationInfo {
|
|||||||
Future<AuthResult> signup(SignupRequest request) async {
|
Future<AuthResult> signup(SignupRequest request) async {
|
||||||
await Future<void>.delayed(delay);
|
await Future<void>.delayed(delay);
|
||||||
if (failure case final error?) throw AuthException(error);
|
if (failure case final error?) throw AuthException(error);
|
||||||
return AuthMapper.fromDTO(
|
return AuthMapper.signup(
|
||||||
const AuthDTO(userId: 'demo-user', simulated: true),
|
const AuthDTO(userId: 'demo-user', simulated: true),
|
||||||
|
email: request.email.trim(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,13 +111,27 @@ class InstrumentedAuthServices
|
|||||||
simulation: _simulation(loginService),
|
simulation: _simulation(loginService),
|
||||||
);
|
);
|
||||||
@override
|
@override
|
||||||
Future<AuthResult> signup(SignupRequest request) => _run(
|
Future<AuthResult> signup(SignupRequest request) async {
|
||||||
|
final result = await _run(
|
||||||
JourneyKind.signup,
|
JourneyKind.signup,
|
||||||
JourneyStep.signup,
|
JourneyStep.signup,
|
||||||
() => signupService.signup(request),
|
() => signupService.signup(request),
|
||||||
(r) => r.isSimulated,
|
(r) => r.isSimulated,
|
||||||
simulation: _simulation(signupService),
|
simulation: _simulation(signupService),
|
||||||
);
|
);
|
||||||
|
final delivery = result.delivery;
|
||||||
|
if (delivery != null) {
|
||||||
|
final operation = recorder.begin(
|
||||||
|
JourneyKind.verification,
|
||||||
|
JourneyStep.emailDelivery,
|
||||||
|
onboarding: true,
|
||||||
|
simulated: delivery.isSimulated,
|
||||||
|
);
|
||||||
|
recorder.finish(operation, simulated: delivery.isSimulated);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<VerificationResult> send({
|
Future<VerificationResult> send({
|
||||||
required String email,
|
required String email,
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ class DummyVerificationService implements VerificationService, SimulationInfo {
|
|||||||
const DummyVerificationService({
|
const DummyVerificationService({
|
||||||
this.delay = const Duration(milliseconds: 600),
|
this.delay = const Duration(milliseconds: 600),
|
||||||
this.sendFailure,
|
this.sendFailure,
|
||||||
|
this.verifiedEmail = 'alex@example.com',
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
bool get isSimulated => true;
|
bool get isSimulated => true;
|
||||||
final Duration delay;
|
final Duration delay;
|
||||||
final VerificationError? sendFailure;
|
final VerificationError? sendFailure;
|
||||||
|
final String verifiedEmail;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<VerificationResult> send({
|
Future<VerificationResult> send({
|
||||||
@@ -41,7 +43,7 @@ class DummyVerificationService implements VerificationService, SimulationInfo {
|
|||||||
switch (token) {
|
switch (token) {
|
||||||
case 'demo-valid':
|
case 'demo-valid':
|
||||||
return VerificationMapper.fromDTO(
|
return VerificationMapper.fromDTO(
|
||||||
const VerificationDTO(simulated: true),
|
VerificationDTO(simulated: true, email: verifiedEmail),
|
||||||
);
|
);
|
||||||
case 'demo-expired':
|
case 'demo-expired':
|
||||||
throw const VerificationException(VerificationError.expired);
|
throw const VerificationException(VerificationError.expired);
|
||||||
|
|||||||
@@ -142,11 +142,11 @@ void main() {
|
|||||||
.isSimulated,
|
.isSimulated,
|
||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
expect(
|
final accepted = await const DummySignupService(delay: Duration.zero)
|
||||||
(await const DummySignupService(delay: Duration.zero).signup(signup))
|
.signup(signup);
|
||||||
.isSimulated,
|
expect(accepted.isSimulated, isTrue);
|
||||||
isTrue,
|
expect(accepted.delivery?.email, 'a@example.com');
|
||||||
);
|
expect(accepted.delivery?.isSimulated, isTrue);
|
||||||
await expectLater(
|
await expectLater(
|
||||||
const DummyLoginService(
|
const DummyLoginService(
|
||||||
delay: Duration.zero,
|
delay: Duration.zero,
|
||||||
|
|||||||
@@ -10,6 +10,48 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
http.Response response(Map<String, Object?> data, [int status = 202]) =>
|
http.Response response(Map<String, Object?> data, [int status = 202]) =>
|
||||||
http.Response(jsonEncode({'data': data}), status);
|
http.Response(jsonEncode({'data': data}), status);
|
||||||
|
test(
|
||||||
|
'activation maps only a confirmed response to the verified email',
|
||||||
|
() async {
|
||||||
|
var confirmed = true;
|
||||||
|
final service = BackendAuthServices(
|
||||||
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||||
|
clientId: 'web',
|
||||||
|
client: MockClient((request) async {
|
||||||
|
expect(request.url.path, '/api/v1/accounts/verification/confirm');
|
||||||
|
expect(jsonDecode(request.body), {'token': 'opaque-link'});
|
||||||
|
return response({
|
||||||
|
'verified': confirmed,
|
||||||
|
'email': 'alias@example.com',
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
addTearDown(service.close);
|
||||||
|
final accepted = await service.confirm(token: 'opaque-link');
|
||||||
|
expect(accepted.verifiedEmail, 'alias@example.com');
|
||||||
|
expect(accepted.isSimulated, isFalse);
|
||||||
|
confirmed = false;
|
||||||
|
await expectLater(
|
||||||
|
service.confirm(token: 'opaque-link'),
|
||||||
|
throwsA(isA<VerificationException>()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'activation remains compatible with a verified response without email',
|
||||||
|
() async {
|
||||||
|
final service = BackendAuthServices(
|
||||||
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||||
|
clientId: 'web',
|
||||||
|
client: MockClient((_) async => response({'verified': true})),
|
||||||
|
);
|
||||||
|
addTearDown(service.close);
|
||||||
|
expect(
|
||||||
|
(await service.confirm(token: 'opaque-link')).verifiedEmail,
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
test(
|
test(
|
||||||
'signup distinguishes support recovery and keeps the retry key',
|
'signup distinguishes support recovery and keeps the retry key',
|
||||||
() async {
|
() async {
|
||||||
@@ -47,7 +89,9 @@ void main() {
|
|||||||
expect(keys[0], keys[1]);
|
expect(keys[0], keys[1]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
test('signup receipt avoids duplicate delivery and subsequent resend reaches API', () async {
|
test(
|
||||||
|
'signup returns its receipt and every explicit resend reaches API',
|
||||||
|
() async {
|
||||||
final paths = <String>[];
|
final paths = <String>[];
|
||||||
final service = BackendAuthServices(
|
final service = BackendAuthServices(
|
||||||
baseUri: Uri.parse('https://api.example/api/v1'),
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||||
@@ -55,7 +99,10 @@ void main() {
|
|||||||
client: MockClient((request) async {
|
client: MockClient((request) async {
|
||||||
paths.add(request.url.path);
|
paths.add(request.url.path);
|
||||||
if (request.url.path.endsWith('/signup')) {
|
if (request.url.path.endsWith('/signup')) {
|
||||||
expect(jsonDecode(request.body)['password'], ' password preserved ');
|
expect(
|
||||||
|
jsonDecode(request.body)['password'],
|
||||||
|
' password preserved ',
|
||||||
|
);
|
||||||
return response({'userId': 'user'});
|
return response({'userId': 'user'});
|
||||||
}
|
}
|
||||||
return response({'accepted': true});
|
return response({'accepted': true});
|
||||||
@@ -71,11 +118,19 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(result.isSimulated, isFalse);
|
expect(result.isSimulated, isFalse);
|
||||||
await service.send(email: 'user@example.com', locale: 'en');
|
expect(result.delivery?.email, 'user@example.com');
|
||||||
|
expect(result.delivery?.isSimulated, isFalse);
|
||||||
expect(paths, ['/api/v1/accounts/signup']);
|
expect(paths, ['/api/v1/accounts/signup']);
|
||||||
await service.send(email: 'user@example.com', locale: 'en');
|
await service.send(email: 'user@example.com', locale: 'en');
|
||||||
|
expect(paths, [
|
||||||
|
'/api/v1/accounts/signup',
|
||||||
|
'/api/v1/accounts/verification/send',
|
||||||
|
]);
|
||||||
|
await service.send(email: 'user@example.com', locale: 'en');
|
||||||
expect(paths.last, '/api/v1/accounts/verification/send');
|
expect(paths.last, '/api/v1/accounts/verification/send');
|
||||||
});
|
expect(paths, hasLength(3));
|
||||||
|
},
|
||||||
|
);
|
||||||
test('login confirms backend session only after factor and consumes local grant once', () async {
|
test('login confirms backend session only after factor and consumes local grant once', () async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
var verified = false;
|
var verified = false;
|
||||||
|
|||||||
@@ -28,6 +28,36 @@ class PendingService implements VerificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
test(
|
||||||
|
'accepted delivery initializes sent state without calling the service',
|
||||||
|
() async {
|
||||||
|
final service = PendingService();
|
||||||
|
final provider = VerificationProvider(
|
||||||
|
service,
|
||||||
|
delivery: const VerificationDelivery(
|
||||||
|
email: 'ada@example.com',
|
||||||
|
isSimulated: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(provider.dispose);
|
||||||
|
expect(provider.state.status, VerificationStatus.sent);
|
||||||
|
expect(provider.state.isSimulated, isFalse);
|
||||||
|
expect(provider.state.result, isNull);
|
||||||
|
expect(service.calls, 0);
|
||||||
|
final resend = provider.send(email: 'ada@example.com', locale: 'en');
|
||||||
|
expect(provider.state.status, VerificationStatus.sending);
|
||||||
|
expect(service.calls, 1);
|
||||||
|
service.pending.completeError(
|
||||||
|
const VerificationException(VerificationError.internal),
|
||||||
|
);
|
||||||
|
await resend;
|
||||||
|
expect(provider.state.status, VerificationStatus.error);
|
||||||
|
expect(provider.state.result, isNull);
|
||||||
|
provider.reset();
|
||||||
|
expect(provider.state.status, VerificationStatus.idle);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
for (final scenario in {
|
for (final scenario in {
|
||||||
'demo-valid': VerificationStatus.verified,
|
'demo-valid': VerificationStatus.verified,
|
||||||
'demo-expired': VerificationStatus.expired,
|
'demo-expired': VerificationStatus.expired,
|
||||||
@@ -42,6 +72,12 @@ void main() {
|
|||||||
addTearDown(provider.dispose);
|
addTearDown(provider.dispose);
|
||||||
await provider.confirm(scenario.key);
|
await provider.confirm(scenario.key);
|
||||||
expect(provider.state.status, scenario.value);
|
expect(provider.state.status, scenario.value);
|
||||||
|
expect(
|
||||||
|
provider.state.result?.verifiedEmail,
|
||||||
|
scenario.value == VerificationStatus.verified
|
||||||
|
? 'alex@example.com'
|
||||||
|
: null,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
provider.state.isSimulated,
|
provider.state.isSimulated,
|
||||||
scenario.value == VerificationStatus.verified,
|
scenario.value == VerificationStatus.verified,
|
||||||
@@ -75,7 +111,23 @@ void main() {
|
|||||||
service.pending.complete(const VerificationResult(isSimulated: true));
|
service.pending.complete(const VerificationResult(isSimulated: true));
|
||||||
await operation;
|
await operation;
|
||||||
expect(provider.state.status, VerificationStatus.idle);
|
expect(provider.state.status, VerificationStatus.idle);
|
||||||
|
expect(provider.state.result, isNull);
|
||||||
});
|
});
|
||||||
|
test(
|
||||||
|
'a later failed confirmation clears the previous verified identity',
|
||||||
|
() async {
|
||||||
|
final provider = VerificationProvider(
|
||||||
|
const DummyVerificationService(delay: Duration.zero),
|
||||||
|
);
|
||||||
|
addTearDown(provider.dispose);
|
||||||
|
await provider.confirm('demo-valid');
|
||||||
|
expect(provider.state.result?.verifiedEmail, 'alex@example.com');
|
||||||
|
final operation = provider.confirm('bad');
|
||||||
|
expect(provider.state.result, isNull);
|
||||||
|
await operation;
|
||||||
|
expect(provider.state.result, isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
test('dispose discards late exception', () async {
|
test('dispose discards late exception', () async {
|
||||||
final service = PendingService();
|
final service = PendingService();
|
||||||
final provider = VerificationProvider(service);
|
final provider = VerificationProvider(service);
|
||||||
|
|||||||
@@ -47,3 +47,17 @@ once on success/failure. Closing the app while pending does not count as draft
|
|||||||
abandonment. No amounts, account IDs or recipient data enter events. All demo
|
abandonment. No amounts, account IDs or recipient data enter events. All demo
|
||||||
signals are simulated. Cross-session outcomes require backend event delivery;
|
signals are simulated. Cross-session outcomes require backend event delivery;
|
||||||
client-only analytics are not production accounting or conversion evidence.
|
client-only analytics are not production accounting or conversion evidence.
|
||||||
|
|
||||||
|
## Saved destinations — #54
|
||||||
|
|
||||||
|
The injectable `DestinationService` adds canonical crypto, secure-card capture and
|
||||||
|
Russian bank models, saved/one-time input, independent metadata/execution revisions,
|
||||||
|
stable created/duplicate outcomes, contextual eligibility and management providers.
|
||||||
|
`DummyDestinationService` is customer-scoped and process-local. It has isolated
|
||||||
|
fixtures for directory/capture/recovery states, not live banking or card collection.
|
||||||
|
The demo wallet binds immutable destination snapshots to quotes and rechecks at its
|
||||||
|
synchronous acceptance boundary. Production must enforce those checks atomically
|
||||||
|
in the backend. Live mode currently advertises no new destination capabilities and
|
||||||
|
preserves explicit selection of the already provisioned wallet method. It never
|
||||||
|
uses a demo store. See [the UX/service contract](../../../docs/ux/payout-destinations.md)
|
||||||
|
for integration, security, retention, localization and acceptance requirements.
|
||||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user