Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe60b7ac1a | ||
|
|
b644a10663 | ||
|
|
c106706615 | ||
|
|
915b8e8123 | ||
|
|
ec4115d083 | ||
|
|
f3ff7fc016 | ||
|
|
658d715c8a | ||
|
|
288a2f0c02 | ||
|
|
4605a197ad | ||
|
|
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 | ||
|
|
a5689876ff | ||
|
|
34d8ef18d1 | ||
|
|
7a6510d1a8 | ||
|
|
5d67c25c8e | ||
|
|
b303285b8b | ||
|
|
1354551ae8 |
No files matched your search
@@ -54,6 +54,9 @@ package-notifications-interface: generate-notifications
|
|||||||
|
|
||||||
# Local product stack. Releases are resolved afresh, then pinned by digest.
|
# Local product stack. Releases are resolved afresh, then pinned by digest.
|
||||||
COMPOSE = docker compose --project-directory . -f infra/local/compose.yml --env-file .local/dev/images.env
|
COMPOSE = docker compose --project-directory . -f infra/local/compose.yml --env-file .local/dev/images.env
|
||||||
|
ifneq ($(wildcard .local/dev/sendico/settings.env),)
|
||||||
|
COMPOSE += -f infra/local/sendico/compose.yml
|
||||||
|
endif
|
||||||
.PHONY: build up down logs ps test-dev
|
.PHONY: build up down logs ps test-dev
|
||||||
build:
|
build:
|
||||||
sh infra/local/tools.sh build
|
sh infra/local/tools.sh build
|
||||||
@@ -92,3 +95,7 @@ email:
|
|||||||
|
|
||||||
test-dev-tooling:
|
test-dev-tooling:
|
||||||
sh infra/local/tools.sh test
|
sh infra/local/tools.sh test
|
||||||
|
|
||||||
|
.PHONY: sendico
|
||||||
|
sendico:
|
||||||
|
sh infra/local/tools.sh sendico
|
||||||
@@ -9,11 +9,11 @@ The consumer app includes responsive login/signup, English and Russian
|
|||||||
localization, email verification and recovery screens, and URL-based navigation
|
localization, email verification and recovery screens, and URL-based navigation
|
||||||
with `go_router`. Its interface uses the shared theme's colors and typography.
|
with `go_router`. Its interface uses the shared theme's colors and typography.
|
||||||
|
|
||||||
**Authentication, email verification and analytics use dummy services.** No real
|
The default preview uses dummy services. Configuring the existing backend URL
|
||||||
account or session is created, no email is delivered, and no analytics data is
|
and client ID selects authenticated BFF adapters for auth, identity, limits and
|
||||||
transmitted by those app services. The backend now contains locally owned copies
|
wallet quotations/payment execution. Optional Sendico configuration and dedicated
|
||||||
of Sendico’s BFF runtime, auth/session stack and account service. The app is not
|
customer financial resources are required for real operations; analytics still
|
||||||
connected to that backend or to Sendico yet.
|
defaults to an in-memory sink. See [payment setup and rollout](docs/payments.md).
|
||||||
|
|
||||||
## Repository structure
|
## Repository structure
|
||||||
|
|
||||||
@@ -256,7 +256,9 @@ gaps and delivery phases. Selected Sendico code has been adapted with recorded
|
|||||||
[provenance](backend/PROVENANCE.md); the projects share no runtime modules.
|
[provenance](backend/PROVENANCE.md); the projects share no runtime modules.
|
||||||
|
|
||||||
Run `make test-backend`, `make lint-backend` or `make run-backend` from the root.
|
Run `make test-backend`, `make lint-backend` or `make run-backend` from the root.
|
||||||
The copied runtime installs local authentication transport and initializes its
|
The runtime installs local authentication, verification delivery, identity and
|
||||||
account service. Signup/profile endpoints, verification delivery/completion,
|
wallet transports. Public Sendico adapters support independent customer facts,
|
||||||
Flutter HTTP adapters and live Sendico operations remain to be wired. See the
|
reviewed identity/limits and quotation/execution through the BFF. Deployment and
|
||||||
[backend run guide](backend/README.md) for local MongoDB configuration.
|
financial-resource provisioning are separate; backend account-language saving
|
||||||
|
and additional funding/recipient onboarding remain follow-up work. See the
|
||||||
|
[backend run guide](backend/README.md) and [payment integration](docs/payments.md).
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,3 +43,10 @@ contracts; local customers retain no platform tenancy. See
|
|||||||
`go test -race ./...` and `go vet ./...` validate this module. Tests require Docker
|
`go test -race ./...` and `go vet ./...` validate this module. Tests require Docker
|
||||||
for disposable Mongo replica sets. Root `make test` and `make lint-backend` also
|
for disposable Mongo replica sets. Root `make test` and `make lint-backend` also
|
||||||
validate `shared`. See [source provenance](../PROVENANCE.md).
|
validate `shared`. See [source provenance](../PROVENANCE.md).
|
||||||
|
|
||||||
|
## Customer quotations and payments
|
||||||
|
|
||||||
|
With `api.sendico`, runtime composes the customer-scoped payment service and
|
||||||
|
public HTTP provider adapter. Flutter backend mode selects this path automatically;
|
||||||
|
missing provider configuration never returns demo money. See
|
||||||
|
[contracts, provisioning, authorization and rollout](../../docs/payments.md).
|
||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/storage"
|
"git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||||
"git.sendico.io/tech/sxchnge/product/customers"
|
"git.sendico.io/tech/sxchnge/product/customers"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/payments"
|
||||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -17,6 +19,8 @@ type Dependencies struct {
|
|||||||
Customers accountservice.AccountService
|
Customers accountservice.AccountService
|
||||||
Auth *flow.Service
|
Auth *flow.Service
|
||||||
Factors *twofactor.Service
|
Factors *twofactor.Service
|
||||||
|
Identity *identity.Service
|
||||||
|
Payments *payments.Service
|
||||||
Token middleware.TokenConfig
|
Token middleware.TokenConfig
|
||||||
Signature middleware.Signature
|
Signature middleware.Signature
|
||||||
LoginLimits middleware.LoginRateLimits
|
LoginLimits middleware.LoginRateLimits
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||||
auth "git.sendico.io/tech/sxchnge/product/auth/http/v1"
|
auth "git.sendico.io/tech/sxchnge/product/auth/http/v1"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
|
"git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
|
||||||
|
identity "git.sendico.io/tech/sxchnge/product/identity/http/v1"
|
||||||
|
paymenthttp "git.sendico.io/tech/sxchnge/product/payments/http/v1"
|
||||||
factors "git.sendico.io/tech/sxchnge/product/twofactor/http/v1"
|
factors "git.sendico.io/tech/sxchnge/product/twofactor/http/v1"
|
||||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
)
|
)
|
||||||
@@ -22,5 +24,7 @@ func Install(scope api.Scope, deps *api.Dependencies) error {
|
|||||||
if deps.Factors != nil {
|
if deps.Factors != nil {
|
||||||
factors.Install(routes, deps.Factors, scope.Logger)
|
factors.Install(routes, deps.Factors, scope.Logger)
|
||||||
}
|
}
|
||||||
|
identity.Install(routes, deps.Identity, scope.Logger)
|
||||||
|
paymenthttp.Install(routes, deps.Payments, scope.Logger)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"git.sendico.io/tech/sxchnge/product/customers"
|
"git.sendico.io/tech/sxchnge/product/customers"
|
||||||
"git.sendico.io/tech/sxchnge/product/identity"
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/payments"
|
||||||
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
||||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||||
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
||||||
@@ -67,7 +68,13 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
deps.Auth.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("signup_facts")}
|
deps.Identity = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("identity")}
|
||||||
|
deps.Auth.Facts = deps.Identity
|
||||||
|
paymentStore, err := payments.OpenStore(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
|
||||||
logger.Info("Sendico sign-up facts integration enabled")
|
logger.Info("Sendico sign-up facts integration enabled")
|
||||||
}
|
}
|
||||||
records, err := factorstore.Open(logger, db)
|
records, err := factorstore.Open(logger, db)
|
||||||
@@ -79,6 +86,9 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if deps.Payments != nil {
|
||||||
|
deps.Payments.Factors = deps.Factors
|
||||||
|
}
|
||||||
if config.Sendico != nil && deps.Auth == nil {
|
if config.Sendico != nil && deps.Auth == nil {
|
||||||
return nil, nil, merrors.InvalidArgument("Sendico sign-up facts require notifications configuration")
|
return nil, nil, merrors.InvalidArgument("Sendico sign-up facts require notifications configuration")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,10 +179,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 +201,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))
|
||||||
|
|||||||
@@ -152,17 +152,33 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
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,48 @@
|
|||||||
|
package identityhttp
|
||||||
|
|
||||||
|
import "git.sendico.io/tech/sxchnge/product/identity"
|
||||||
|
|
||||||
|
type profileDTO struct {
|
||||||
|
FullName string `json:"fullName"`
|
||||||
|
DateOfBirth string `json:"dateOfBirth"`
|
||||||
|
ResidenceCountry string `json:"residenceCountry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p profileDTO) model() identity.Profile {
|
||||||
|
return identity.Profile{FullName: p.FullName, DateOfBirth: p.DateOfBirth, ResidenceCountry: p.ResidenceCountry}
|
||||||
|
}
|
||||||
|
|
||||||
|
type verificationDTO struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
UpgradePending bool `json:"upgradePending"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
SubmissionID string `json:"submissionId,omitempty"`
|
||||||
|
Profile profileDTO `json:"profile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapVerification(v identity.Verification) verificationDTO {
|
||||||
|
return verificationDTO{Status: v.Status, UpgradePending: v.UpgradePending, Reason: v.Reason, SubmissionID: v.SubmissionID, Profile: profileDTO{FullName: v.Profile.FullName, DateOfBirth: v.Profile.DateOfBirth, ResidenceCountry: v.Profile.ResidenceCountry}}
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitDTO struct {
|
||||||
|
Operation string `json:"operation"`
|
||||||
|
Period string `json:"period"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Cap string `json:"cap"`
|
||||||
|
Remaining string `json:"remaining"`
|
||||||
|
Used string `json:"used"`
|
||||||
|
Reserved string `json:"reserved"`
|
||||||
|
}
|
||||||
|
type limitsDTO struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
ProfileRef string `json:"profileRef"`
|
||||||
|
Entries []limitDTO `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapLimits(v identity.Limits) limitsDTO {
|
||||||
|
entries := make([]limitDTO, 0, len(v.Entries))
|
||||||
|
for _, r := range v.Entries {
|
||||||
|
entries = append(entries, limitDTO{Operation: r.Operation, Period: r.Period, Currency: r.Currency, Cap: r.Cap, Remaining: r.Remaining, Used: r.Used, Reserved: r.Reserved})
|
||||||
|
}
|
||||||
|
return limitsDTO{Tier: v.Tier, ProfileRef: v.ProfileRef, Entries: entries}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package identityhttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||||
|
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
|
mservice "git.sendico.io/tech/sxchnge/shared/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Registrar interface {
|
||||||
|
AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc)
|
||||||
|
}
|
||||||
|
type router struct {
|
||||||
|
service *identity.Service
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func Install(reg Registrar, service *identity.Service, logger mlogger.Logger) {
|
||||||
|
h := &router{service, logger.Named("identity_http")}
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/identity", api.Get, h.get)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/identity", api.Post, h.submit)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/limits", api.Get, h.limits)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *router) limits(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
v, err := h.service.Limits(r.Context(), a)
|
||||||
|
return h.result(mapLimits(v), err)
|
||||||
|
}
|
||||||
|
func (h *router) result(data any, err error) http.HandlerFunc {
|
||||||
|
if err == nil {
|
||||||
|
return response.Ok(h.logger, data)
|
||||||
|
}
|
||||||
|
if throttle, ok := merrors.ThrottleDetailsFrom(err); ok {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(throttle.RetryAfter.Seconds()))))
|
||||||
|
response.TooManyRequests(h.logger, mservice.Accounts, "Wait before retrying identity submission")(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, merrors.ErrInvalidArg):
|
||||||
|
return response.BadRequest(h.logger, mservice.Accounts, "invalid_request", "Check profile and use readable JPEG/PNG photos, 600×600 minimum, 10 MiB maximum each")
|
||||||
|
case errors.Is(err, merrors.ErrDataConflict):
|
||||||
|
return response.DataConflict(h.logger, mservice.Accounts, "Identity submission changed; refresh its status")
|
||||||
|
case errors.Is(err, merrors.ErrAccessDenied):
|
||||||
|
return response.AccessDenied(h.logger, mservice.Accounts, "Identity submission is unavailable")
|
||||||
|
default:
|
||||||
|
return response.Error(h.logger, mservice.Accounts, http.StatusServiceUnavailable, "unavailable", "Identity verification is temporarily unavailable; retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (h *router) get(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
v, err := h.service.Verification(r.Context(), a)
|
||||||
|
return h.result(mapVerification(v), err)
|
||||||
|
}
|
||||||
|
func (h *router) submit(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
// Read multipart as a bounded stream. Never spill identity documents to disk.
|
||||||
|
r.Body = http.MaxBytesReader(nil, r.Body, 2*identity.MaxPhotoBytes+16384)
|
||||||
|
reader, err := r.MultipartReader()
|
||||||
|
if err != nil {
|
||||||
|
return h.result(nil, merrors.InvalidArgument("multipart required"))
|
||||||
|
}
|
||||||
|
fields := map[string][]byte{}
|
||||||
|
for {
|
||||||
|
part, readErr := reader.NextPart()
|
||||||
|
if readErr == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return h.result(nil, merrors.InvalidArgument("invalid multipart"))
|
||||||
|
}
|
||||||
|
name := part.FormName()
|
||||||
|
limit := int64(8192)
|
||||||
|
switch name {
|
||||||
|
case "document", "selfie":
|
||||||
|
limit = identity.MaxPhotoBytes
|
||||||
|
case "profile", "idempotencyKey":
|
||||||
|
default:
|
||||||
|
_ = part.Close()
|
||||||
|
return h.result(nil, merrors.InvalidArgument("unexpected field"))
|
||||||
|
}
|
||||||
|
if _, exists := fields[name]; exists {
|
||||||
|
_ = part.Close()
|
||||||
|
return h.result(nil, merrors.InvalidArgument("duplicate field"))
|
||||||
|
}
|
||||||
|
data, readErr := io.ReadAll(io.LimitReader(part, limit+1))
|
||||||
|
_ = part.Close()
|
||||||
|
if readErr != nil || int64(len(data)) > limit {
|
||||||
|
return h.result(nil, merrors.InvalidArgument("field too large"))
|
||||||
|
}
|
||||||
|
fields[name] = data
|
||||||
|
}
|
||||||
|
var p profileDTO
|
||||||
|
if err = json.Unmarshal(fields["profile"], &p); err != nil {
|
||||||
|
return h.result(nil, merrors.InvalidArgument("profile required"))
|
||||||
|
}
|
||||||
|
v, err := h.service.SubmitVerification(r.Context(), a, string(fields["idempotencyKey"]), p.model(), fields["document"], fields["selfie"])
|
||||||
|
return h.result(mapVerification(v), err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package identityhttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestThrottleDoesNotBecomeGenericUnavailable(t *testing.T) {
|
||||||
|
h := router{logger: zap.NewNop()}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("POST", "/accounts/identity", nil)
|
||||||
|
h.result(nil, merrors.WithThrottle(merrors.ErrServiceUnavailable, "identity", 120*time.Second, "temporary limit"))(w, r)
|
||||||
|
require.Equal(t, 429, w.Code)
|
||||||
|
require.Equal(t, "120", w.Header().Get("Retry-After"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MaxPhotoBytes = 10 << 20
|
||||||
|
|
||||||
|
// ValidatePhoto repeats client checks at the trust boundary and bounds decoder
|
||||||
|
// memory before decoding pixels. File names and client MIME types are ignored.
|
||||||
|
func ValidatePhoto(data []byte) error {
|
||||||
|
if len(data) == 0 || len(data) > MaxPhotoBytes {
|
||||||
|
return merrors.InvalidArgument("photo must be between 1 byte and 10 MiB")
|
||||||
|
}
|
||||||
|
config, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||||
|
if err != nil || (format != "jpeg" && format != "png") {
|
||||||
|
return merrors.InvalidArgument("readable JPEG or PNG required")
|
||||||
|
}
|
||||||
|
if config.Width < 600 || config.Height < 600 || int64(config.Width)*int64(config.Height) > 40000000 {
|
||||||
|
return merrors.InvalidArgument("photo dimensions must be at least 600 by 600 and at most 40 megapixels")
|
||||||
|
}
|
||||||
|
if _, _, err = image.Decode(bytes.NewReader(data)); err != nil {
|
||||||
|
return merrors.InvalidArgument("readable JPEG or PNG required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
db "git.sendico.io/tech/sxchnge/shared/database"
|
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||||
deps "git.sendico.io/tech/sxchnge/shared/database/dependencies"
|
deps "git.sendico.io/tech/sxchnge/shared/database/dependencies"
|
||||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||||
|
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
||||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
@@ -26,7 +28,11 @@ func OpenStore(factory db.Factory) (Store, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &mongoStore{records: repository.CreateRepository(source.Majority(d.Database), "signup_facts")}, nil
|
records := repository.CreateRepository(source.Majority(d.Database), "signup_facts")
|
||||||
|
if err = records.CreateIndex(&ri.Definition{Name: "provider_subject_unique", Unique: true, Keys: []ri.Key{{Field: "namespace", Sort: ri.Asc}, {Field: "subjectRef", Sort: ri.Asc}}, PartialFilter: repository.Query().Comparison(repository.Field("subjectRef"), builder.Gt, "")}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &mongoStore{records: records}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *mongoStore) Get(ctx context.Context, id bson.ObjectID) (*Binding, error) {
|
func (s *mongoStore) Get(ctx context.Context, id bson.ObjectID) (*Binding, error) {
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ func TestDurableBindingClaimsAndIsolation(t *testing.T) {
|
|||||||
require.False(t, stored.RegisteringAt.IsZero())
|
require.False(t, stored.RegisteringAt.IsZero())
|
||||||
require.Error(t, store.Bind(ctx, b.ID, "other-source", "subject"))
|
require.Error(t, store.Bind(ctx, b.ID, "other-source", "subject"))
|
||||||
require.NoError(t, store.Bind(ctx, b.ID, b.Namespace, "subject"))
|
require.NoError(t, store.Bind(ctx, b.ID, b.Namespace, "subject"))
|
||||||
|
duplicate := &Binding{Namespace: b.Namespace}
|
||||||
|
duplicate.SetID(bson.NewObjectID())
|
||||||
|
require.NoError(t, store.Create(ctx, duplicate))
|
||||||
|
claimedDuplicate, err := store.Claim(ctx, duplicate.ID, duplicate.Namespace)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, claimedDuplicate)
|
||||||
|
require.ErrorIs(t, store.Bind(ctx, duplicate.ID, duplicate.Namespace, "subject"), merrors.ErrDataConflict)
|
||||||
require.Error(t, store.Bind(ctx, b.ID, b.Namespace, "different-subject"))
|
require.Error(t, store.Bind(ctx, b.ID, b.Namespace, "different-subject"))
|
||||||
receipt := Receipt{SubjectRef: "other-subject", SubmissionRef: "submission", AcceptedAt: time.Now().UTC().Truncate(time.Millisecond)}
|
receipt := Receipt{SubjectRef: "other-subject", SubmissionRef: "submission", AcceptedAt: time.Now().UTC().Truncate(time.Millisecond)}
|
||||||
require.Error(t, store.Accept(ctx, b.ID, b.Namespace, receipt))
|
require.Error(t, store.Accept(ctx, b.ID, b.Namespace, receipt))
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Profile holds unverified customer assertions. Email and customer reference are
|
||||||
|
// always supplied by the authenticated backend, never accepted from the browser.
|
||||||
|
type Profile struct {
|
||||||
|
FullName string
|
||||||
|
DateOfBirth string
|
||||||
|
ResidenceCountry string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Profile) Validate() error {
|
||||||
|
birth, err := time.Parse("2006-01-02", p.DateOfBirth)
|
||||||
|
if strings.TrimSpace(p.FullName) == "" || len(p.FullName) > 800 || err != nil || !birth.Before(time.Now()) || len(p.ResidenceCountry) != 2 || p.ResidenceCountry[0] < 'A' || p.ResidenceCountry[0] > 'Z' || p.ResidenceCountry[1] < 'A' || p.ResidenceCountry[1] > 'Z' {
|
||||||
|
return merrors.InvalidArgument("complete identity profile required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Verification struct {
|
||||||
|
Status string
|
||||||
|
UpgradePending bool
|
||||||
|
Reason string
|
||||||
|
SubmissionID string
|
||||||
|
Profile Profile
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerificationProvider consumes reviewed state through the platform public API.
|
||||||
|
// It cannot accept a browser-selected subject, policy profile or approval.
|
||||||
|
type VerificationProvider interface {
|
||||||
|
Verification(context.Context, string) (Verification, error)
|
||||||
|
SubmitVerification(context.Context, string, string, Profile, Facts, []byte, []byte) (Verification, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decimal amounts are transported losslessly in their currency's major unit.
|
||||||
|
// No fiat scale or illustrative client caps are applied to provider policy.
|
||||||
|
type Limit struct {
|
||||||
|
Operation string
|
||||||
|
Period string
|
||||||
|
Currency string
|
||||||
|
Cap string
|
||||||
|
Remaining string
|
||||||
|
Used string
|
||||||
|
Reserved string
|
||||||
|
}
|
||||||
|
type Limits struct {
|
||||||
|
Tier string
|
||||||
|
ProfileRef string
|
||||||
|
Entries []Limit
|
||||||
|
}
|
||||||
|
type LimitsProvider interface {
|
||||||
|
Limits(context.Context, string) (Limits, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Limits(ctx context.Context, a *customer.Account) (Limits, error) {
|
||||||
|
b, err := s.binding(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return Limits{}, err
|
||||||
|
}
|
||||||
|
p, ok := s.Provider.(LimitsProvider)
|
||||||
|
if !ok {
|
||||||
|
return Limits{}, unavailable()
|
||||||
|
}
|
||||||
|
return p.Limits(ctx, b.SubjectRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) binding(ctx context.Context, a *customer.Account) (*Binding, error) {
|
||||||
|
if s == nil || s.Provider == nil || s.Store == nil {
|
||||||
|
return nil, merrors.ServiceUnavailable("identity verification unavailable")
|
||||||
|
}
|
||||||
|
if a == nil || a.ID.IsZero() {
|
||||||
|
return nil, merrors.InvalidArgument("customer required")
|
||||||
|
}
|
||||||
|
b, err := s.Store.Get(ctx, a.ID)
|
||||||
|
if errors.Is(err, merrors.ErrNoData) || err == nil && b.Receipt == nil {
|
||||||
|
if err = s.Ensure(ctx, a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b, err = s.Store.Get(ctx, a.ID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if b.Namespace != s.Provider.Namespace() || b.SubjectRef == "" || b.Receipt == nil || b.Receipt.SubjectRef != b.SubjectRef {
|
||||||
|
return nil, merrors.FailedPrecondition("identity binding requires reconciliation")
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Verification(ctx context.Context, a *customer.Account) (Verification, error) {
|
||||||
|
b, err := s.binding(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return Verification{}, err
|
||||||
|
}
|
||||||
|
provider, ok := s.Provider.(VerificationProvider)
|
||||||
|
if !ok {
|
||||||
|
return Verification{}, unavailable()
|
||||||
|
}
|
||||||
|
v, err := provider.Verification(ctx, b.SubjectRef)
|
||||||
|
if err == nil && v.Profile.FullName == "" {
|
||||||
|
v.Profile.FullName = a.Name
|
||||||
|
}
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SubmitVerification(ctx context.Context, a *customer.Account, key string, p Profile, document, selfie []byte) (Verification, error) {
|
||||||
|
if len(key) < 16 || len(key) > 128 {
|
||||||
|
return Verification{}, merrors.InvalidArgument("valid idempotency key required")
|
||||||
|
}
|
||||||
|
if err := p.Validate(); err != nil {
|
||||||
|
return Verification{}, err
|
||||||
|
}
|
||||||
|
for _, photo := range [][]byte{document, selfie} {
|
||||||
|
if err := ValidatePhoto(photo); err != nil {
|
||||||
|
return Verification{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, err := s.binding(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return Verification{}, err
|
||||||
|
}
|
||||||
|
provider, ok := s.Provider.(VerificationProvider)
|
||||||
|
if !ok {
|
||||||
|
return Verification{}, unavailable()
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256([]byte(a.ID.Hex() + "\x00" + key))
|
||||||
|
facts := b.Facts
|
||||||
|
facts.Email = a.Login
|
||||||
|
facts.CustomerRef = a.ID.Hex()
|
||||||
|
v, err := provider.SubmitVerification(ctx, b.SubjectRef, "sxchnge:identity:"+hex.EncodeToString(digest[:]), p, facts, document, selfie)
|
||||||
|
if err != nil {
|
||||||
|
s.Logger.Warn("Identity proof submission incomplete", zap.String("account_ref", a.ID.Hex()))
|
||||||
|
return Verification{}, err
|
||||||
|
}
|
||||||
|
s.Logger.Info("Identity proofs accepted for review", zap.String("account_ref", a.ID.Hex()), zap.String("submission_ref", v.SubmissionID))
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subject resolves the authenticated customer's confirmed provider binding.
|
||||||
|
// Only server-side services may use this resource; clients cannot select it.
|
||||||
|
func (s *Service) Subject(ctx context.Context, a *customer.Account) (string, error) {
|
||||||
|
b, err := s.binding(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return b.SubjectRef, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type proofProvider struct {
|
||||||
|
*providerStub
|
||||||
|
subject, key string
|
||||||
|
profile Profile
|
||||||
|
facts Facts
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *proofProvider) Verification(_ context.Context, subject string) (Verification, error) {
|
||||||
|
p.subject = subject
|
||||||
|
return Verification{Status: "unverified"}, nil
|
||||||
|
}
|
||||||
|
func (p *proofProvider) SubmitVerification(_ context.Context, subject, key string, profile Profile, facts Facts, _, _ []byte) (Verification, error) {
|
||||||
|
p.subject = subject
|
||||||
|
p.key = key
|
||||||
|
p.profile = profile
|
||||||
|
p.facts = facts
|
||||||
|
p.calls++
|
||||||
|
return Verification{Status: "pending", SubmissionID: "receipt"}, nil
|
||||||
|
}
|
||||||
|
func proofImage(t *testing.T, width, height int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var body bytes.Buffer
|
||||||
|
require.NoError(t, png.Encode(&body, image.NewNRGBA(image.Rect(0, 0, width, height))))
|
||||||
|
return body.Bytes()
|
||||||
|
}
|
||||||
|
func TestVerificationUsesBoundSubjectAndStableCustomerKey(t *testing.T) {
|
||||||
|
s, _, base, a := fixture()
|
||||||
|
p := &proofProvider{providerStub: base}
|
||||||
|
s.Provider = p
|
||||||
|
ctx := t.Context()
|
||||||
|
result, err := s.Verification(ctx, a)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, a.Name, result.Profile.FullName)
|
||||||
|
require.Equal(t, "subject-1", p.subject)
|
||||||
|
photo := proofImage(t, 600, 600)
|
||||||
|
profile := Profile{FullName: "Legal Customer Name", DateOfBirth: "1990-04-23", ResidenceCountry: "PL"}
|
||||||
|
_, err = s.SubmitVerification(ctx, a, "0123456789012345", profile, photo, photo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
key := p.key
|
||||||
|
require.Equal(t, a.ID.Hex(), p.facts.CustomerRef)
|
||||||
|
require.Equal(t, a.Login, p.facts.Email)
|
||||||
|
require.Equal(t, profile, p.profile)
|
||||||
|
_, err = s.SubmitVerification(ctx, a, "0123456789012345", profile, photo, photo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, key, p.key)
|
||||||
|
require.Equal(t, 1, base.registrations)
|
||||||
|
other := a.Copy()
|
||||||
|
other.ID[0]++
|
||||||
|
_, err = s.SubmitVerification(ctx, other, "0123456789012345", profile, photo, photo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, key, p.key)
|
||||||
|
}
|
||||||
|
func TestVerificationValidationBeforeExternalHandoff(t *testing.T) {
|
||||||
|
s, _, base, a := fixture()
|
||||||
|
p := &proofProvider{providerStub: base}
|
||||||
|
s.Provider = p
|
||||||
|
photo := proofImage(t, 600, 600)
|
||||||
|
for _, profile := range []Profile{{}, {FullName: "Name", DateOfBirth: "1990-02-30", ResidenceCountry: "PL"}, {FullName: "Name", DateOfBirth: "2990-04-23", ResidenceCountry: "PL"}, {FullName: "Name", DateOfBirth: "1990-04-23", ResidenceCountry: "USA"}} {
|
||||||
|
_, err := s.SubmitVerification(t.Context(), a, "0123456789012345", profile, photo, photo)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
require.Zero(t, p.calls)
|
||||||
|
require.Zero(t, base.registrations)
|
||||||
|
require.Error(t, ValidatePhoto([]byte("not an image")))
|
||||||
|
require.Error(t, ValidatePhoto(proofImage(t, 599, 600)))
|
||||||
|
require.Error(t, ValidatePhoto(photo[:len(photo)-20]))
|
||||||
|
require.Error(t, ValidatePhoto(make([]byte, MaxPhotoBytes+1)))
|
||||||
|
var unavailableService *Service
|
||||||
|
_, err := unavailableService.Verification(t.Context(), a)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package paymenthttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sendico.io/tech/sxchnge/product/payments"
|
||||||
|
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type requestDTO struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Destination string `json:"destination"`
|
||||||
|
Recipient string `json:"recipient"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d requestDTO) model() payments.Request {
|
||||||
|
return payments.Request{Kind: d.Kind, Source: d.Source, Destination: d.Destination, Recipient: d.Recipient, Amount: d.Amount}
|
||||||
|
}
|
||||||
|
func fromRequest(r payments.Request) requestDTO {
|
||||||
|
return requestDTO{r.Kind, r.Source, r.Destination, r.Recipient, r.Amount}
|
||||||
|
}
|
||||||
|
|
||||||
|
type quoteDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Request requestDTO `json:"request"`
|
||||||
|
Fee int64 `json:"fee"`
|
||||||
|
Debit int64 `json:"debit"`
|
||||||
|
Credit int64 `json:"credit"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
Numerator int64 `json:"rateNumerator"`
|
||||||
|
Denominator int64 `json:"rateDenominator"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromQuote(q payments.Quote) quoteDTO {
|
||||||
|
return quoteDTO{q.ID, fromRequest(q.Request), q.Fee, q.Debit, q.Credit, q.ExpiresAt, q.RateNumerator, q.RateDenominator}
|
||||||
|
}
|
||||||
|
|
||||||
|
type operationDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Quote quoteDTO `json:"quote"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromOperation(o payments.Operation) operationDTO {
|
||||||
|
return operationDTO{o.ID, fromQuote(o.Quote), o.Status, o.CreatedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Precision int `json:"precision"`
|
||||||
|
Available int64 `json:"available"`
|
||||||
|
PayoutMethod string `json:"payoutMethod"`
|
||||||
|
PayoutLabel string `json:"payoutLabel"`
|
||||||
|
CanFund bool `json:"canFund"`
|
||||||
|
}
|
||||||
|
type snapshotDTO struct {
|
||||||
|
Accounts []accountDTO `json:"accounts"`
|
||||||
|
Operations []operationDTO `json:"operations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromSnapshot(s payments.Snapshot) snapshotDTO {
|
||||||
|
v := snapshotDTO{Accounts: []accountDTO{}, Operations: []operationDTO{}}
|
||||||
|
for _, a := range s.Accounts {
|
||||||
|
v.Accounts = append(v.Accounts, accountDTO{a.ID, a.Currency, a.Precision, a.Available, a.PayoutMethod, a.PayoutLabel, a.FundingMethod != ""})
|
||||||
|
}
|
||||||
|
for i := len(s.Operations) - 1; i >= 0; i-- {
|
||||||
|
v.Operations = append(v.Operations, fromOperation(s.Operations[i]))
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
type challengeDTO struct {
|
||||||
|
ID string `json:"challengeId"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
ResendAt time.Time `json:"resendAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromChallenge(c factor.Challenge) challengeDTO {
|
||||||
|
return challengeDTO{c.ID, c.Target, c.ExpiresAt, c.ResendAt}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package paymenthttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/payments"
|
||||||
|
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||||
|
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
|
mservice "git.sendico.io/tech/sxchnge/shared/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Registrar interface {
|
||||||
|
AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc)
|
||||||
|
}
|
||||||
|
type router struct {
|
||||||
|
service *payments.Service
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func Install(reg Registrar, s *payments.Service, logger mlogger.Logger) {
|
||||||
|
h := &router{s, logger.Named("payment_http")}
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/wallet/profile", api.Get, h.profile)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/wallet", api.Get, h.fetch)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/wallet/quote", api.Post, h.quote)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/wallet/submit", api.Post, h.submit)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/wallet/submission", api.Post, h.reconcile)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/wallet/challenge", api.Post, h.challenge)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/wallet/resend", api.Post, h.resend)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/wallet/verify", api.Post, h.verify)
|
||||||
|
}
|
||||||
|
func decode(r *http.Request, dst any) error {
|
||||||
|
d := json.NewDecoder(io.LimitReader(r.Body, 8193))
|
||||||
|
d.DisallowUnknownFields()
|
||||||
|
if d.Decode(dst) != nil {
|
||||||
|
return payments.InvalidAmount
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if d.Decode(&extra) != io.EOF {
|
||||||
|
return payments.InvalidAmount
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (h *router) result(data any, err error) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
if err == nil {
|
||||||
|
response.Ok(h.logger, data)(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := "unavailable"
|
||||||
|
status := http.StatusServiceUnavailable
|
||||||
|
var f payments.Failure
|
||||||
|
if errors.As(err, &f) {
|
||||||
|
code = string(f)
|
||||||
|
if f != payments.Unavailable && f != payments.Uncertain {
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, verification.ErrTokenExpired):
|
||||||
|
code = "factorExpired"
|
||||||
|
status = 400
|
||||||
|
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
|
||||||
|
code = "factorLocked"
|
||||||
|
status = 400
|
||||||
|
case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed), errors.Is(err, merrors.ErrUnauthorized):
|
||||||
|
code = "factorInvalid"
|
||||||
|
status = 400
|
||||||
|
}
|
||||||
|
if throttle, ok := verification.Throttle(err); ok {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(throttle.RetryAfter.Seconds()))))
|
||||||
|
code = "factorCooldown"
|
||||||
|
status = 429
|
||||||
|
}
|
||||||
|
response.Error(h.logger, mservice.Accounts, status, code, "Unable to complete wallet operation")(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (h *router) fetch(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
v, e := h.service.Fetch(r.Context(), a)
|
||||||
|
return h.result(fromSnapshot(v), e)
|
||||||
|
}
|
||||||
|
func (h *router) quote(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
var d requestDTO
|
||||||
|
if e := decode(r, &d); e != nil {
|
||||||
|
return h.result(nil, e)
|
||||||
|
}
|
||||||
|
v, e := h.service.Quote(r.Context(), a, d.model())
|
||||||
|
return h.result(fromQuote(v), e)
|
||||||
|
}
|
||||||
|
|
||||||
|
type actionDTO struct {
|
||||||
|
Quote string `json:"quoteId"`
|
||||||
|
Key string `json:"idempotencyKey"`
|
||||||
|
Challenge string `json:"challengeId"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *router) submit(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
var d actionDTO
|
||||||
|
if e := decode(r, &d); e != nil {
|
||||||
|
return h.result(nil, e)
|
||||||
|
}
|
||||||
|
v, e := h.service.Submit(r.Context(), a, d.Quote, d.Key)
|
||||||
|
return h.result(fromOperation(v), e)
|
||||||
|
}
|
||||||
|
func (h *router) reconcile(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
var d actionDTO
|
||||||
|
if e := decode(r, &d); e != nil {
|
||||||
|
return h.result(nil, e)
|
||||||
|
}
|
||||||
|
v, e := h.service.Reconcile(r.Context(), a, d.Key)
|
||||||
|
if v == nil {
|
||||||
|
return h.result(nil, e)
|
||||||
|
}
|
||||||
|
return h.result(fromOperation(*v), e)
|
||||||
|
}
|
||||||
|
func requestIP(r *http.Request) string {
|
||||||
|
if ip := ipguard.ClientIP(r); ip != nil {
|
||||||
|
return ip.String()
|
||||||
|
}
|
||||||
|
return "unresolved"
|
||||||
|
}
|
||||||
|
func (h *router) challenge(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
var d actionDTO
|
||||||
|
if e := decode(r, &d); e != nil {
|
||||||
|
return h.result(nil, e)
|
||||||
|
}
|
||||||
|
v, e := h.service.Challenge(r.Context(), a, d.Quote, requestIP(r))
|
||||||
|
return h.result(fromChallenge(v), e)
|
||||||
|
}
|
||||||
|
func (h *router) resend(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
var d actionDTO
|
||||||
|
if e := decode(r, &d); e != nil {
|
||||||
|
return h.result(nil, e)
|
||||||
|
}
|
||||||
|
v, e := h.service.Resend(r.Context(), a, d.Quote, d.Challenge, d.Key, requestIP(r))
|
||||||
|
return h.result(fromChallenge(v), e)
|
||||||
|
}
|
||||||
|
func (h *router) verify(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
var d actionDTO
|
||||||
|
if e := decode(r, &d); e != nil {
|
||||||
|
return h.result(nil, e)
|
||||||
|
}
|
||||||
|
e := h.service.Authorize(r.Context(), a, d.Quote, d.Challenge, d.Code, d.Key)
|
||||||
|
return h.result(struct {
|
||||||
|
Key string `json:"idempotencyKey"`
|
||||||
|
}{d.Key}, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *router) profile(_ *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
return h.result(struct {
|
||||||
|
Locale string `json:"locale"`
|
||||||
|
}{a.Locale}, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package paymenthttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUnconfiguredWalletCannotReturnDemoFunds(t *testing.T) {
|
||||||
|
h := &router{logger: zap.NewNop()}
|
||||||
|
a := &customer.Account{}
|
||||||
|
a.ID = bson.NewObjectID()
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/accounts/wallet", nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
h.fetch(request, a, nil)(recorder, request)
|
||||||
|
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
|
||||||
|
require.Equal(t, "no-store", recorder.Header().Get("Cache-Control"))
|
||||||
|
require.NotContains(t, recorder.Body.String(), "7500")
|
||||||
|
}
|
||||||
|
func TestQuoteRejectsUnknownFieldsAndTrailingRequests(t *testing.T) {
|
||||||
|
h := &router{logger: zap.NewNop()}
|
||||||
|
a := &customer.Account{}
|
||||||
|
a.ID = bson.NewObjectID()
|
||||||
|
for _, body := range []string{`{"kind":"payout","subject":"foreign"}`, `{"amount":10} {"amount":20}`, `{"amount":1.2}`} {
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/accounts/wallet/quote", strings.NewReader(body))
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
h.quote(request, a, nil)(recorder, request)
|
||||||
|
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Package payments owns customer quotation ownership and durable execution intent.
|
||||||
|
// Money and settlement remain authoritative in the provider.
|
||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/database/record"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Account struct {
|
||||||
|
ID, Currency string
|
||||||
|
Precision int
|
||||||
|
Available int64
|
||||||
|
PayoutMethod, PayoutLabel, FundingMethod string
|
||||||
|
}
|
||||||
|
type Request struct {
|
||||||
|
Kind, Source, Destination, Recipient string
|
||||||
|
Amount int64
|
||||||
|
}
|
||||||
|
type Quote struct {
|
||||||
|
ID string
|
||||||
|
Request Request
|
||||||
|
Fee, Debit, Credit int64
|
||||||
|
ExpiresAt time.Time
|
||||||
|
RateNumerator, RateDenominator int64
|
||||||
|
}
|
||||||
|
type Operation struct {
|
||||||
|
ID string
|
||||||
|
Quote Quote
|
||||||
|
Status string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
type Snapshot struct {
|
||||||
|
Accounts []Account
|
||||||
|
Operations []Operation
|
||||||
|
}
|
||||||
|
type Record struct {
|
||||||
|
storable.Base `bson:",inline"`
|
||||||
|
Customer bson.ObjectID `bson:"customer"`
|
||||||
|
Namespace string `bson:"namespace"`
|
||||||
|
Subject string `bson:"subject"`
|
||||||
|
Quote Quote `bson:"quote"`
|
||||||
|
ProviderQuote string `bson:"providerQuote"`
|
||||||
|
Key string `bson:"key"`
|
||||||
|
Payment string `bson:"payment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Record) Collection() string { return "payment_quotes" }
|
||||||
|
|
||||||
|
type Provider interface {
|
||||||
|
Namespace() string
|
||||||
|
Accounts(context.Context, string) ([]Account, error)
|
||||||
|
Quote(context.Context, string, Request, []Account) (Quote, error)
|
||||||
|
Execute(context.Context, string, string) (RemoteOperation, error)
|
||||||
|
Payment(context.Context, string) (RemoteOperation, error)
|
||||||
|
}
|
||||||
|
type RemoteOperation struct {
|
||||||
|
ID, QuoteRef, Status string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
type Identity interface {
|
||||||
|
Subject(context.Context, *customer.Account) (string, error)
|
||||||
|
}
|
||||||
|
type Failure string
|
||||||
|
|
||||||
|
func (e Failure) Error() string { return string(e) }
|
||||||
|
|
||||||
|
const (
|
||||||
|
Unavailable Failure = "unavailable"
|
||||||
|
InvalidAmount Failure = "invalidAmount"
|
||||||
|
InvalidRecipient Failure = "invalidRecipient"
|
||||||
|
InsufficientFunds Failure = "insufficientFunds"
|
||||||
|
LimitExceeded Failure = "limitExceeded"
|
||||||
|
ExpiredQuote Failure = "expiredQuote"
|
||||||
|
Unsupported Failure = "unsupported"
|
||||||
|
NotFound Failure = "notFound"
|
||||||
|
Uncertain Failure = "uncertain"
|
||||||
|
FactorRequired Failure = "factorRequired"
|
||||||
|
)
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||||
|
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||||
|
verification "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
Store Store
|
||||||
|
Provider Provider
|
||||||
|
Identity Identity
|
||||||
|
Factors twofactor.API
|
||||||
|
Logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ready() error {
|
||||||
|
if s == nil || s.Store == nil || s.Provider == nil || s.Identity == nil || s.Factors == nil {
|
||||||
|
return Unavailable
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *Service) record(ctx context.Context, a *customer.Account, id string) (*Record, error) {
|
||||||
|
if err := s.ready(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if a == nil || a.ID.IsZero() {
|
||||||
|
return nil, NotFound
|
||||||
|
}
|
||||||
|
ref, err := bson.ObjectIDFromHex(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, NotFound
|
||||||
|
}
|
||||||
|
r, err := s.Store.Get(ctx, ref, a.ID, s.Provider.Namespace())
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return nil, NotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
subject, err := s.Identity.Subject(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if subject != r.Subject {
|
||||||
|
return nil, Unavailable
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
func (s *Service) Quote(ctx context.Context, a *customer.Account, request Request) (Quote, error) {
|
||||||
|
if err := s.ready(); err != nil {
|
||||||
|
return Quote{}, err
|
||||||
|
}
|
||||||
|
subject, err := s.Identity.Subject(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return Quote{}, err
|
||||||
|
}
|
||||||
|
accounts, err := s.Provider.Accounts(ctx, subject)
|
||||||
|
if err != nil {
|
||||||
|
return Quote{}, err
|
||||||
|
}
|
||||||
|
q, err := s.Provider.Quote(ctx, subject, request, accounts)
|
||||||
|
if err != nil {
|
||||||
|
return Quote{}, err
|
||||||
|
}
|
||||||
|
providerRef := q.ID
|
||||||
|
q.ID = bson.NewObjectID().Hex()
|
||||||
|
r := &Record{Customer: a.ID, Namespace: s.Provider.Namespace(), Subject: subject, Quote: q, ProviderQuote: providerRef}
|
||||||
|
r.SetID(mustID(q.ID))
|
||||||
|
if err = s.Store.Create(ctx, r); err != nil {
|
||||||
|
return Quote{}, err
|
||||||
|
}
|
||||||
|
s.Logger.Info("Customer quotation stored", zap.String("quote_ref", q.ID), zap.String("kind", request.Kind))
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
func mustID(id string) bson.ObjectID { value, _ := bson.ObjectIDFromHex(id); return value }
|
||||||
|
func operationScope(r *Record) factor.Scope {
|
||||||
|
return factor.Scope{Purpose: verification.PurposePayment, ContextRef: r.Quote.ID}
|
||||||
|
}
|
||||||
|
func (s *Service) Challenge(ctx context.Context, a *customer.Account, id, ip string) (factor.Challenge, error) {
|
||||||
|
r, err := s.record(ctx, a, id)
|
||||||
|
if err != nil {
|
||||||
|
return factor.Challenge{}, err
|
||||||
|
}
|
||||||
|
if !time.Now().Before(r.Quote.ExpiresAt) || r.Key != "" {
|
||||||
|
return factor.Challenge{}, ExpiredQuote
|
||||||
|
}
|
||||||
|
return s.Factors.Begin(ctx, factor.Request{Subject: a.ID, Scope: operationScope(r), Deadline: r.Quote.ExpiresAt, IdempotencyKey: "payment:" + r.Quote.ID, IP: ip})
|
||||||
|
}
|
||||||
|
func (s *Service) Resend(ctx context.Context, a *customer.Account, id, challenge, key, ip string) (factor.Challenge, error) {
|
||||||
|
r, err := s.record(ctx, a, id)
|
||||||
|
if err != nil {
|
||||||
|
return factor.Challenge{}, err
|
||||||
|
}
|
||||||
|
if !time.Now().Before(r.Quote.ExpiresAt) || r.Key != "" {
|
||||||
|
return factor.Challenge{}, ExpiredQuote
|
||||||
|
}
|
||||||
|
if !validKey(key) {
|
||||||
|
return factor.Challenge{}, InvalidAmount
|
||||||
|
}
|
||||||
|
return s.Factors.Resend(ctx, factor.Request{Subject: a.ID, Scope: operationScope(r), Deadline: r.Quote.ExpiresAt, IdempotencyKey: key, IP: ip}, challenge)
|
||||||
|
}
|
||||||
|
func validKey(key string) bool {
|
||||||
|
if len(key) < 16 || len(key) > 128 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range key {
|
||||||
|
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize consumes the challenge atomically with a durable execution intent.
|
||||||
|
// No upstream I/O occurs inside the transaction. Replaying the same authorization
|
||||||
|
// recovers a lost response without reusing the one-use verification code.
|
||||||
|
func (s *Service) Authorize(ctx context.Context, a *customer.Account, id, challenge, code, key string) error {
|
||||||
|
r, err := s.record(ctx, a, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !validKey(key) {
|
||||||
|
return InvalidAmount
|
||||||
|
}
|
||||||
|
if r.Key != "" {
|
||||||
|
if r.Key == key {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ExpiredQuote
|
||||||
|
}
|
||||||
|
return s.Factors.Complete(ctx, a.ID, operationScope(r), challenge, code, func(tx context.Context) error {
|
||||||
|
if !time.Now().Before(r.Quote.ExpiresAt) {
|
||||||
|
return ExpiredQuote
|
||||||
|
}
|
||||||
|
return s.Store.Authorize(tx, r, key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
func (s *Service) execute(ctx context.Context, r *Record) (Operation, error) {
|
||||||
|
var remote RemoteOperation
|
||||||
|
var err error
|
||||||
|
if r.Payment != "" {
|
||||||
|
remote, err = s.Provider.Payment(ctx, r.Payment)
|
||||||
|
} else {
|
||||||
|
remote, err = s.Provider.Execute(ctx, r.ProviderQuote, "sxchnge:payment:"+r.ID.Hex())
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.Logger.Warn("Payment execution needs reconciliation", zap.String("quote_ref", r.Quote.ID))
|
||||||
|
return Operation{}, merrors.OpaqueWrap(Uncertain, err, "provider payment outcome unknown")
|
||||||
|
}
|
||||||
|
if remote.QuoteRef != r.ProviderQuote || remote.ID == "" || (r.Payment != "" && r.Payment != remote.ID) {
|
||||||
|
return Operation{}, Uncertain
|
||||||
|
}
|
||||||
|
if r.Payment == "" {
|
||||||
|
if err = s.Store.Attach(ctx, r, remote.ID); err != nil {
|
||||||
|
return Operation{}, merrors.OpaqueWrap(Uncertain, err, "persist provider payment reference")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Logger.Info("Payment execution reconciled", zap.String("quote_ref", r.Quote.ID), zap.String("payment_ref", remote.ID), zap.String("state", remote.Status))
|
||||||
|
return Operation{ID: r.Quote.ID, Quote: r.Quote, Status: remote.Status, CreatedAt: remote.CreatedAt}, nil
|
||||||
|
}
|
||||||
|
func (s *Service) Submit(ctx context.Context, a *customer.Account, id, key string) (Operation, error) {
|
||||||
|
r, err := s.record(ctx, a, id)
|
||||||
|
if err != nil {
|
||||||
|
return Operation{}, err
|
||||||
|
}
|
||||||
|
if !validKey(key) || r.Key == "" || r.Key != key {
|
||||||
|
return Operation{}, FactorRequired
|
||||||
|
}
|
||||||
|
return s.execute(ctx, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile replays the already authorized durable intent with the same provider
|
||||||
|
// key. A timeout or an empty upstream list is never treated as proof of absence.
|
||||||
|
func (s *Service) Reconcile(ctx context.Context, a *customer.Account, key string) (*Operation, error) {
|
||||||
|
if err := s.ready(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !validKey(key) {
|
||||||
|
return nil, NotFound
|
||||||
|
}
|
||||||
|
r, err := s.Store.ByKey(ctx, a.ID, s.Provider.Namespace(), key)
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r, err = s.record(ctx, a, r.ID.Hex())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
op, err := s.execute(ctx, r)
|
||||||
|
return &op, err
|
||||||
|
}
|
||||||
|
func (s *Service) Fetch(ctx context.Context, a *customer.Account) (Snapshot, error) {
|
||||||
|
if err := s.ready(); err != nil {
|
||||||
|
return Snapshot{}, err
|
||||||
|
}
|
||||||
|
subject, err := s.Identity.Subject(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return Snapshot{}, err
|
||||||
|
}
|
||||||
|
rows, err := s.Store.List(ctx, a.ID, s.Provider.Namespace())
|
||||||
|
if err != nil {
|
||||||
|
return Snapshot{}, err
|
||||||
|
}
|
||||||
|
result := Snapshot{Operations: []Operation{}}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Subject != subject {
|
||||||
|
return Snapshot{}, Unavailable
|
||||||
|
}
|
||||||
|
op, err := s.execute(ctx, &r)
|
||||||
|
if errors.Is(err, Uncertain) {
|
||||||
|
// A durable authorization with an unknown provider outcome remains
|
||||||
|
// pending. It must not hide unrelated balances or report failure.
|
||||||
|
op = Operation{ID: r.Quote.ID, Quote: r.Quote, Status: "pending", CreatedAt: r.CreatedAt}
|
||||||
|
} else if err != nil {
|
||||||
|
return Snapshot{}, err
|
||||||
|
}
|
||||||
|
result.Operations = append(result.Operations, op)
|
||||||
|
}
|
||||||
|
result.Accounts, err = s.Provider.Accounts(ctx, subject)
|
||||||
|
if err != nil {
|
||||||
|
return Snapshot{}, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type memoryStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
records map[bson.ObjectID]Record
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memoryStore) Create(_ context.Context, r *Record) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.records[r.ID] = *r
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *memoryStore) Get(_ context.Context, id, customer bson.ObjectID, namespace string) (*Record, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
r, ok := s.records[id]
|
||||||
|
if !ok || r.Customer != customer || r.Namespace != namespace {
|
||||||
|
return nil, merrors.ErrNoData
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
func (s *memoryStore) ByKey(_ context.Context, customer bson.ObjectID, namespace, key string) (*Record, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for _, r := range s.records {
|
||||||
|
if r.Customer == customer && r.Namespace == namespace && r.Key == key {
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, merrors.ErrNoData
|
||||||
|
}
|
||||||
|
func (s *memoryStore) List(_ context.Context, customer bson.ObjectID, namespace string) ([]Record, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
rows := []Record{}
|
||||||
|
for _, r := range s.records {
|
||||||
|
if r.Customer == customer && r.Namespace == namespace && r.Key != "" {
|
||||||
|
rows = append(rows, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
func (s *memoryStore) Authorize(_ context.Context, r *Record, key string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
current := s.records[r.ID]
|
||||||
|
if current.Key != "" {
|
||||||
|
return Uncertain
|
||||||
|
}
|
||||||
|
current.Key = key
|
||||||
|
s.records[r.ID] = current
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *memoryStore) Attach(_ context.Context, r *Record, id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
current := s.records[r.ID]
|
||||||
|
current.Payment = id
|
||||||
|
s.records[r.ID] = current
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type provider struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
accepted int
|
||||||
|
lost bool
|
||||||
|
status string
|
||||||
|
key string
|
||||||
|
namespace string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *provider) Namespace() string { return p.namespace }
|
||||||
|
func (p *provider) Accounts(context.Context, string) ([]Account, error) {
|
||||||
|
return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil
|
||||||
|
}
|
||||||
|
func (p *provider) Quote(_ context.Context, _ string, r Request, _ []Account) (Quote, error) {
|
||||||
|
return Quote{ID: "provider-quote", Request: r, ExpiresAt: time.Now().Add(time.Minute)}, nil
|
||||||
|
}
|
||||||
|
func (p *provider) Execute(_ context.Context, quote, key string) (RemoteOperation, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.key == "" {
|
||||||
|
p.accepted++
|
||||||
|
p.key = key
|
||||||
|
}
|
||||||
|
if p.key != key {
|
||||||
|
return RemoteOperation{}, Unavailable
|
||||||
|
}
|
||||||
|
if p.lost {
|
||||||
|
p.lost = false
|
||||||
|
return RemoteOperation{}, Unavailable
|
||||||
|
}
|
||||||
|
return RemoteOperation{ID: "payment", QuoteRef: quote, Status: p.status, CreatedAt: time.Now()}, nil
|
||||||
|
}
|
||||||
|
func (p *provider) Payment(context.Context, string) (RemoteOperation, error) {
|
||||||
|
return RemoteOperation{ID: "payment", QuoteRef: "provider-quote", Status: p.status, CreatedAt: time.Now()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type identity struct{}
|
||||||
|
|
||||||
|
func (identity) Subject(context.Context, *customer.Account) (string, error) { return "subject", nil }
|
||||||
|
|
||||||
|
type factors struct {
|
||||||
|
calls int
|
||||||
|
subject bson.ObjectID
|
||||||
|
scope factor.Scope
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *factors) Begin(_ context.Context, r factor.Request) (factor.Challenge, error) {
|
||||||
|
f.subject = r.Subject
|
||||||
|
f.scope = r.Scope
|
||||||
|
return factor.Challenge{ID: "challenge"}, nil
|
||||||
|
}
|
||||||
|
func (f *factors) Resend(ctx context.Context, r factor.Request, _ string) (factor.Challenge, error) {
|
||||||
|
return f.Begin(ctx, r)
|
||||||
|
}
|
||||||
|
func (f *factors) Complete(ctx context.Context, subject bson.ObjectID, scope factor.Scope, id, code string, action func(context.Context) error) error {
|
||||||
|
if subject != f.subject || scope != f.scope || id != "challenge" || code != "123456" {
|
||||||
|
return FactorRequired
|
||||||
|
}
|
||||||
|
f.calls++
|
||||||
|
return action(ctx)
|
||||||
|
}
|
||||||
|
func TestCustomerExecutionOwnershipAndLostResponse(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
a := &customer.Account{}
|
||||||
|
a.ID = bson.NewObjectID()
|
||||||
|
other := &customer.Account{}
|
||||||
|
other.ID = bson.NewObjectID()
|
||||||
|
store := &memoryStore{records: map[bson.ObjectID]Record{}}
|
||||||
|
p := &provider{lost: true, status: "pending", namespace: "provider"}
|
||||||
|
f := &factors{}
|
||||||
|
s := &Service{Store: store, Provider: p, Identity: identity{}, Factors: f, Logger: zap.NewNop()}
|
||||||
|
q, err := s.Quote(ctx, a, Request{Kind: "payout", Source: "ledger", Amount: 1000})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = s.Submit(ctx, a, q.ID, "unapproved-key-123")
|
||||||
|
require.ErrorIs(t, err, FactorRequired)
|
||||||
|
require.Zero(t, p.accepted)
|
||||||
|
_, err = s.Challenge(ctx, other, q.ID, "")
|
||||||
|
require.ErrorIs(t, err, NotFound)
|
||||||
|
_, err = s.Challenge(ctx, a, q.ID, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.ErrorIs(t, s.Authorize(ctx, a, q.ID, "challenge", "bad-code", "authorized-key-123"), FactorRequired)
|
||||||
|
require.NoError(t, s.Authorize(ctx, a, q.ID, "challenge", "123456", "authorized-key-123"))
|
||||||
|
require.NoError(t, s.Authorize(ctx, a, q.ID, "challenge", "123456", "authorized-key-123"))
|
||||||
|
require.Equal(t, 1, f.calls)
|
||||||
|
_, err = s.Submit(ctx, a, q.ID, "authorized-key-123")
|
||||||
|
require.ErrorIs(t, err, Uncertain)
|
||||||
|
op, err := s.Reconcile(ctx, other, "authorized-key-123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, op)
|
||||||
|
op, err = s.Reconcile(ctx, a, "authorized-key-123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "pending", op.Status)
|
||||||
|
require.Equal(t, 1, p.accepted)
|
||||||
|
p.status = "completed"
|
||||||
|
snapshot, err := s.Fetch(ctx, a)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "completed", snapshot.Operations[0].Status)
|
||||||
|
require.ErrorIs(t, s.Authorize(ctx, a, q.ID, "challenge", "123456", "different-key-1234"), ExpiredQuote)
|
||||||
|
// A changed provider namespace cannot execute the old quote.
|
||||||
|
p.namespace = "rotated-provider"
|
||||||
|
_, err = s.Submit(ctx, a, q.ID, "authorized-key-123")
|
||||||
|
require.ErrorIs(t, err, NotFound)
|
||||||
|
}
|
||||||
|
func TestExpiredQuoteCannotAuthorize(t *testing.T) {
|
||||||
|
a := &customer.Account{}
|
||||||
|
a.ID = bson.NewObjectID()
|
||||||
|
r := Record{Customer: a.ID, Namespace: "p", Subject: "subject", Quote: Quote{ID: bson.NewObjectID().Hex(), ExpiresAt: time.Now().Add(-time.Second)}}
|
||||||
|
r.SetID(mustID(r.Quote.ID))
|
||||||
|
s := &Service{Store: &memoryStore{records: map[bson.ObjectID]Record{r.ID: r}}, Provider: &provider{namespace: "p"}, Identity: identity{}, Factors: &factors{}, Logger: zap.NewNop()}
|
||||||
|
_, err := s.Challenge(context.Background(), a, r.Quote.ID, "")
|
||||||
|
require.ErrorIs(t, err, ExpiredQuote)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||||
|
deps "git.sendico.io/tech/sxchnge/shared/database/dependencies"
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||||
|
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
Create(context.Context, *Record) error
|
||||||
|
Get(context.Context, bson.ObjectID, bson.ObjectID, string) (*Record, error)
|
||||||
|
ByKey(context.Context, bson.ObjectID, string, string) (*Record, error)
|
||||||
|
List(context.Context, bson.ObjectID, string) ([]Record, error)
|
||||||
|
Authorize(context.Context, *Record, string) error
|
||||||
|
Attach(context.Context, *Record, string) error
|
||||||
|
}
|
||||||
|
type mongoStore struct{ records repository.Repository }
|
||||||
|
|
||||||
|
func OpenStore(factory db.Factory) (Store, error) {
|
||||||
|
d, err := deps.ResolveDependencies(factory)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r := repository.CreateRepository(source.Majority(d.Database), "payment_quotes")
|
||||||
|
err = r.CreateIndex(&ri.Definition{Name: "customer_execution_key", Unique: true, Keys: []ri.Key{{Field: "customer", Sort: ri.Asc}, {Field: "namespace", Sort: ri.Asc}, {Field: "key", Sort: ri.Asc}}, PartialFilter: repository.Query().Comparison(repository.Field("key"), builder.Gt, "")})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = r.CreateIndex(&ri.Definition{Name: "provider_quote_unique", Unique: true, Keys: []ri.Key{{Field: "namespace", Sort: ri.Asc}, {Field: "providerQuote", Sort: ri.Asc}}, PartialFilter: repository.Query().Comparison(repository.Field("providerQuote"), builder.Gt, "")}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &mongoStore{r}, nil
|
||||||
|
}
|
||||||
|
func scope(customer bson.ObjectID, namespace string) repository.FilterQuery {
|
||||||
|
return repository.Query().Filter(repository.Field("customer"), customer).Filter(repository.Field("namespace"), namespace)
|
||||||
|
}
|
||||||
|
func (s *mongoStore) Create(ctx context.Context, r *Record) error {
|
||||||
|
return s.records.Insert(ctx, r, nil)
|
||||||
|
}
|
||||||
|
func (s *mongoStore) Get(ctx context.Context, id, customer bson.ObjectID, namespace string) (*Record, error) {
|
||||||
|
r := &Record{}
|
||||||
|
err := s.records.FindOneByFilter(ctx, scope(customer, namespace).Filter(repository.IDField(), id), r)
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
func (s *mongoStore) ByKey(ctx context.Context, customer bson.ObjectID, namespace, key string) (*Record, error) {
|
||||||
|
r := &Record{}
|
||||||
|
err := s.records.FindOneByFilter(ctx, scope(customer, namespace).Filter(repository.Field("key"), key), r)
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
func (s *mongoStore) List(ctx context.Context, customer bson.ObjectID, namespace string) ([]Record, error) {
|
||||||
|
var rows []Record
|
||||||
|
err := s.records.FindAllByFilter(ctx, scope(customer, namespace).Comparison(repository.Field("key"), builder.Gt, "").Sort(repository.Field("createdAt"), true), &rows)
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
func (s *mongoStore) Authorize(ctx context.Context, r *Record, key string) error {
|
||||||
|
matched, err := s.records.PatchOneByFilter(ctx, scope(r.Customer, r.Namespace).Filter(repository.IDField(), r.ID).Filter(repository.Field("key"), ""), repository.Patch().Set(repository.Field("key"), key), &Record{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
return Uncertain
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mongoStore) Attach(ctx context.Context, r *Record, id string) error {
|
||||||
|
matched, err := s.records.PatchOneByFilter(ctx, scope(r.Customer, r.Namespace).Filter(repository.IDField(), r.ID).Filter(repository.Field("payment"), ""), repository.Patch().Set(repository.Field("payment"), id), &Record{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
current, err := s.Get(ctx, r.ID, r.Customer, r.Namespace)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if current.Payment != id {
|
||||||
|
return Uncertain
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDurableAuthorizationTransactionAndCustomerIsolation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
|
||||||
|
uri, err := container.ConnectionString(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
u, err := url.Parse(uri)
|
||||||
|
require.NoError(t, err)
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("directConnection", "true")
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "payments"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(factory.CloseConnection)
|
||||||
|
store, err := OpenStore(factory)
|
||||||
|
require.NoError(t, err)
|
||||||
|
customer := bson.NewObjectID()
|
||||||
|
r := &Record{Customer: customer, Namespace: "provider", Subject: "subject"}
|
||||||
|
r.SetID(bson.NewObjectID())
|
||||||
|
require.NoError(t, store.Create(ctx, r))
|
||||||
|
_, err = store.Get(ctx, r.ID, bson.NewObjectID(), r.Namespace)
|
||||||
|
require.ErrorIs(t, err, merrors.ErrNoData)
|
||||||
|
_, err = factory.TransactionFactory().CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
||||||
|
require.NoError(t, store.Authorize(tx, r, "authorized-key-123"))
|
||||||
|
return nil, Uncertain
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, Uncertain)
|
||||||
|
stored, err := store.Get(ctx, r.ID, customer, r.Namespace)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, stored.Key)
|
||||||
|
require.NoError(t, store.Authorize(ctx, r, "authorized-key-123"))
|
||||||
|
require.ErrorIs(t, store.Authorize(ctx, r, "other-key-123456"), Uncertain)
|
||||||
|
another := &Record{Customer: customer, Namespace: r.Namespace}
|
||||||
|
another.SetID(bson.NewObjectID())
|
||||||
|
require.NoError(t, store.Create(ctx, another))
|
||||||
|
require.ErrorIs(t, store.Authorize(ctx, another, "authorized-key-123"), merrors.ErrDataConflict)
|
||||||
|
require.NoError(t, store.Attach(ctx, r, "payment-1"))
|
||||||
|
require.NoError(t, store.Attach(ctx, r, "payment-1"))
|
||||||
|
require.ErrorIs(t, store.Attach(ctx, r, "different-payment"), Uncertain)
|
||||||
|
reopened, err := OpenStore(factory)
|
||||||
|
require.NoError(t, err)
|
||||||
|
stored, err = reopened.ByKey(ctx, customer, r.Namespace, "authorized-key-123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "payment-1", stored.Payment)
|
||||||
|
_, err = reopened.ByKey(ctx, customer, "other-provider", "authorized-key-123")
|
||||||
|
require.ErrorIs(t, err, merrors.ErrNoData)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/payments"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Accounts exposes only dedicated, organization-owned customer ledgers tagged by
|
||||||
|
// trusted platform provisioning. The integration login's personal/pooled ledgers
|
||||||
|
// never become customer balances. Method bindings are provisioned on that ledger.
|
||||||
|
func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Account, error) {
|
||||||
|
if !validRef(subject) {
|
||||||
|
return nil, payments.Unavailable
|
||||||
|
}
|
||||||
|
var currencies struct {
|
||||||
|
Rows []currencyDTO `json:"currencies"`
|
||||||
|
}
|
||||||
|
if err := c.paymentRequest(ctx, http.MethodGet, "/payments/fx/currencies/"+c.config.OrganizationID, nil, ¤cies); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
scales := map[string]int{}
|
||||||
|
for _, row := range currencies.Rows {
|
||||||
|
if row.Scale != nil && *row.Scale >= 0 && *row.Scale <= 8 {
|
||||||
|
scales[row.Code] = *row.Scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var data struct {
|
||||||
|
Accounts []accountDTO `json:"accounts"`
|
||||||
|
}
|
||||||
|
root := "/ledger_accounts/" + c.config.OrganizationID
|
||||||
|
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
accounts := []payments.Account{}
|
||||||
|
for _, row := range data.Accounts {
|
||||||
|
if row.Metadata["sxchnge_subject_ref"] != subject {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !validRef(row.ID) || row.ScopeRef != c.config.OrganizationID || row.Owner != "" || row.Status != "ACCOUNT_STATUS_ACTIVE" || row.Type != "ACCOUNT_TYPE_ASSET" || row.LedgerRole != "ACCOUNT_ROLE_OPERATING" {
|
||||||
|
return nil, payments.Unavailable
|
||||||
|
}
|
||||||
|
scale, ok := scales[row.Currency]
|
||||||
|
if !ok {
|
||||||
|
return nil, payments.Unsupported
|
||||||
|
}
|
||||||
|
var balance struct {
|
||||||
|
Value balanceDTO `json:"balance"`
|
||||||
|
}
|
||||||
|
if err := c.paymentRequest(ctx, http.MethodGet, root+"/"+row.ID+"/balance", nil, &balance); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
account, err := mapAccount(row, balance.Value, scale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
accounts = append(accounts, account)
|
||||||
|
}
|
||||||
|
return accounts, nil
|
||||||
|
}
|
||||||
|
func (c *Client) Quote(ctx context.Context, subject string, r payments.Request, accounts []payments.Account) (payments.Quote, error) {
|
||||||
|
mapped, err := mapIntent(subject, r, accounts)
|
||||||
|
if err != nil {
|
||||||
|
return payments.Quote{}, err
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Quote paymentQuoteDTO `json:"quote"`
|
||||||
|
Subject string `json:"externalSubjectRef"`
|
||||||
|
}
|
||||||
|
err = c.paymentRequest(ctx, http.MethodPost, "/payments/quote/"+c.config.OrganizationID, quoteRequestDTO{Key: "sxchnge:quote:" + bson.NewObjectID().Hex(), Intent: mapped.wire}, &response)
|
||||||
|
if err != nil {
|
||||||
|
return payments.Quote{}, err
|
||||||
|
}
|
||||||
|
if response.Subject != subject {
|
||||||
|
return payments.Quote{}, payments.Unavailable
|
||||||
|
}
|
||||||
|
return mapPaymentQuote(response.Quote, mapped.request, mapped.source, mapped.destination)
|
||||||
|
}
|
||||||
|
func (c *Client) Execute(ctx context.Context, quote, key string) (payments.RemoteOperation, error) {
|
||||||
|
if !safePaymentRef(quote) {
|
||||||
|
return payments.RemoteOperation{}, payments.Unavailable
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Payment paymentDTO `json:"payment"`
|
||||||
|
}
|
||||||
|
payload := struct {
|
||||||
|
Quote string `json:"quoteRef"`
|
||||||
|
Key string `json:"idempotencyKey"`
|
||||||
|
}{quote, key}
|
||||||
|
err := c.paymentRequest(ctx, http.MethodPost, "/payments/by-quote/"+c.config.OrganizationID, payload, &result)
|
||||||
|
if err != nil {
|
||||||
|
return payments.RemoteOperation{}, err
|
||||||
|
}
|
||||||
|
return mapPayment(result.Payment)
|
||||||
|
}
|
||||||
|
func (c *Client) Payment(ctx context.Context, id string) (payments.RemoteOperation, error) {
|
||||||
|
if !safePaymentRef(id) {
|
||||||
|
return payments.RemoteOperation{}, payments.Unavailable
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Payment paymentDTO `json:"payment"`
|
||||||
|
}
|
||||||
|
err := c.paymentRequest(ctx, http.MethodGet, "/payments/by-ref/"+id, nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return payments.RemoteOperation{}, err
|
||||||
|
}
|
||||||
|
return mapPayment(result.Payment)
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/big"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/payments"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxMinor int64 = 9007199254740991
|
||||||
|
|
||||||
|
func safePaymentRef(value string) bool {
|
||||||
|
if len(value) == 0 || len(value) > 200 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range value {
|
||||||
|
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == ':') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decimal parsing never rounds provider values or converts money through floats.
|
||||||
|
func minorMoney(m moneyDTO, currency string, scale int) (int64, error) {
|
||||||
|
if m.Currency != currency || scale < 0 || scale > 8 {
|
||||||
|
return 0, payments.Unavailable
|
||||||
|
}
|
||||||
|
parts := strings.Split(m.Amount, ".")
|
||||||
|
if len(parts) > 2 || parts[0] == "" {
|
||||||
|
return 0, payments.Unavailable
|
||||||
|
}
|
||||||
|
digits := parts[0]
|
||||||
|
if len(parts) == 2 {
|
||||||
|
if parts[1] == "" || len(parts[1]) > scale {
|
||||||
|
return 0, payments.Unavailable
|
||||||
|
}
|
||||||
|
digits += parts[1] + strings.Repeat("0", scale-len(parts[1]))
|
||||||
|
} else {
|
||||||
|
digits += strings.Repeat("0", scale)
|
||||||
|
}
|
||||||
|
for _, r := range digits {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return 0, payments.Unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseInt(digits, 10, 64)
|
||||||
|
if err != nil || value > maxMinor {
|
||||||
|
return 0, payments.Unavailable
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
func majorMoney(minor int64, scale int) string {
|
||||||
|
digits := strconv.FormatInt(minor, 10)
|
||||||
|
if scale == 0 {
|
||||||
|
return digits
|
||||||
|
}
|
||||||
|
if len(digits) <= scale {
|
||||||
|
digits = strings.Repeat("0", scale+1-len(digits)) + digits
|
||||||
|
}
|
||||||
|
return digits[:len(digits)-scale] + "." + digits[len(digits)-scale:]
|
||||||
|
}
|
||||||
|
func mapPaymentQuote(dto paymentQuoteDTO, r payments.Request, source, destination payments.Account) (payments.Quote, error) {
|
||||||
|
if !dto.Executable || dto.State != "executable" {
|
||||||
|
switch dto.BlockReason {
|
||||||
|
case "limit_blocked":
|
||||||
|
return payments.Quote{}, payments.LimitExceeded
|
||||||
|
case "amount_too_small", "amount_too_large":
|
||||||
|
return payments.Quote{}, payments.InvalidAmount
|
||||||
|
default:
|
||||||
|
return payments.Quote{}, payments.Unsupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !safePaymentRef(dto.ID) || !dto.Expires.After(time.Now()) {
|
||||||
|
return payments.Quote{}, payments.ExpiredQuote
|
||||||
|
}
|
||||||
|
debit, err := minorMoney(dto.Amounts.Debit, source.Currency, source.Precision)
|
||||||
|
if err != nil {
|
||||||
|
return payments.Quote{}, err
|
||||||
|
}
|
||||||
|
principal, err := minorMoney(dto.Amounts.Principal, source.Currency, source.Precision)
|
||||||
|
if err != nil {
|
||||||
|
return payments.Quote{}, err
|
||||||
|
}
|
||||||
|
credit, err := minorMoney(dto.Amounts.Credit, destination.Currency, destination.Precision)
|
||||||
|
if err != nil {
|
||||||
|
return payments.Quote{}, err
|
||||||
|
}
|
||||||
|
if principal != r.Amount || debit < principal || credit <= 0 {
|
||||||
|
return payments.Quote{}, payments.Unavailable
|
||||||
|
}
|
||||||
|
if r.Kind != "topUp" && debit > source.Available {
|
||||||
|
return payments.Quote{}, payments.InsufficientFunds
|
||||||
|
}
|
||||||
|
num, den := big.NewInt(credit), big.NewInt(principal)
|
||||||
|
num.Mul(num, new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(source.Precision)), nil))
|
||||||
|
den.Mul(den, new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(destination.Precision)), nil))
|
||||||
|
gcd := new(big.Int).GCD(nil, nil, num, den)
|
||||||
|
num.Quo(num, gcd)
|
||||||
|
den.Quo(den, gcd)
|
||||||
|
if !num.IsInt64() || !den.IsInt64() || num.Int64() > maxMinor || den.Int64() > maxMinor {
|
||||||
|
return payments.Quote{}, payments.Unsupported
|
||||||
|
}
|
||||||
|
return payments.Quote{ID: dto.ID, Request: r, Fee: debit - principal, Debit: debit, Credit: credit, ExpiresAt: dto.Expires, RateNumerator: num.Int64(), RateDenominator: den.Int64()}, nil
|
||||||
|
}
|
||||||
|
func mapPayment(dto paymentDTO) (payments.RemoteOperation, error) {
|
||||||
|
if !safePaymentRef(dto.ID) || !safePaymentRef(dto.Quote.ID) || dto.Created.IsZero() {
|
||||||
|
return payments.RemoteOperation{}, payments.Unavailable
|
||||||
|
}
|
||||||
|
status := "pending"
|
||||||
|
switch dto.State {
|
||||||
|
case "orchestration_state_settled":
|
||||||
|
status = "completed"
|
||||||
|
case "orchestration_state_failed":
|
||||||
|
status = "failed"
|
||||||
|
case "orchestration_state_created", "orchestration_state_executing", "orchestration_state_needs_attention":
|
||||||
|
default:
|
||||||
|
return payments.RemoteOperation{}, payments.Unavailable
|
||||||
|
}
|
||||||
|
return payments.RemoteOperation{ID: dto.ID, QuoteRef: dto.Quote.ID, Status: status, CreatedAt: dto.Created}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountByID(accounts []payments.Account, id string) (payments.Account, error) {
|
||||||
|
for _, a := range accounts {
|
||||||
|
if a.ID == id {
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payments.Account{}, payments.NotFound
|
||||||
|
}
|
||||||
|
func ledgerEndpoint(id string) endpointDTO {
|
||||||
|
return endpointDTO{Type: "ledger", Data: map[string]string{"account_ref": id}}
|
||||||
|
}
|
||||||
|
func methodEndpoint(id string) endpointDTO {
|
||||||
|
return endpointDTO{Type: "paymentMethod", Data: map[string]string{"ref": id}}
|
||||||
|
}
|
||||||
|
|
||||||
|
type mappedIntent struct {
|
||||||
|
wire paymentIntentDTO
|
||||||
|
request payments.Request
|
||||||
|
source, destination payments.Account
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapIntent(subject string, r payments.Request, accounts []payments.Account) (mappedIntent, error) {
|
||||||
|
source, err := accountByID(accounts, r.Source)
|
||||||
|
if err != nil {
|
||||||
|
return mappedIntent{}, err
|
||||||
|
}
|
||||||
|
if r.Amount <= 0 || r.Amount > maxMinor {
|
||||||
|
return mappedIntent{}, payments.InvalidAmount
|
||||||
|
}
|
||||||
|
intent := paymentIntentDTO{Subject: subject, Source: ledgerEndpoint(source.ID), Amount: moneyDTO{Amount: majorMoney(r.Amount, source.Precision), Currency: source.Currency}, FeeTreatment: "add_to_source"}
|
||||||
|
destination := source
|
||||||
|
switch r.Kind {
|
||||||
|
case "payout":
|
||||||
|
r.Destination = ""
|
||||||
|
if source.PayoutMethod == "" || r.Recipient != source.PayoutMethod {
|
||||||
|
return mappedIntent{}, payments.InvalidRecipient
|
||||||
|
}
|
||||||
|
intent.Kind = "payout"
|
||||||
|
intent.Destination = methodEndpoint(source.PayoutMethod)
|
||||||
|
// Store the safe provisioned display label, never present an opaque method ID.
|
||||||
|
r.Recipient = source.PayoutLabel
|
||||||
|
case "exchange":
|
||||||
|
r.Recipient = ""
|
||||||
|
destination, err = accountByID(accounts, r.Destination)
|
||||||
|
if err != nil {
|
||||||
|
return mappedIntent{}, err
|
||||||
|
}
|
||||||
|
if source.ID == destination.ID || source.Currency == destination.Currency {
|
||||||
|
return mappedIntent{}, payments.Unsupported
|
||||||
|
}
|
||||||
|
intent.Kind = "fx_conversion"
|
||||||
|
intent.Destination = ledgerEndpoint(destination.ID)
|
||||||
|
case "topUp":
|
||||||
|
r.Destination = ""
|
||||||
|
r.Recipient = ""
|
||||||
|
if source.FundingMethod == "" {
|
||||||
|
return mappedIntent{}, payments.Unsupported
|
||||||
|
}
|
||||||
|
intent.Kind = "internal_transfer"
|
||||||
|
intent.Source = methodEndpoint(source.FundingMethod)
|
||||||
|
intent.Destination = ledgerEndpoint(source.ID)
|
||||||
|
default:
|
||||||
|
return mappedIntent{}, payments.Unsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
return mappedIntent{intent, r, source, destination}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapAccount(row accountDTO, balance balanceDTO, scale int) (payments.Account, error) {
|
||||||
|
if balance.ID != row.ID {
|
||||||
|
return payments.Account{}, payments.Unavailable
|
||||||
|
}
|
||||||
|
minor, err := minorMoney(balance.Money, row.Currency, scale)
|
||||||
|
if err != nil {
|
||||||
|
return payments.Account{}, err
|
||||||
|
}
|
||||||
|
account := payments.Account{ID: row.ID, Currency: row.Currency, Precision: scale, Available: minor, PayoutMethod: row.Metadata["sxchnge_payout_method_ref"], PayoutLabel: row.Metadata["sxchnge_payout_label"], FundingMethod: row.Metadata["sxchnge_funding_method_ref"]}
|
||||||
|
for _, ref := range []string{account.PayoutMethod, account.FundingMethod} {
|
||||||
|
if ref != "" && !validRef(ref) {
|
||||||
|
return payments.Account{}, payments.Unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if account.PayoutMethod != "" && (strings.TrimSpace(account.PayoutLabel) == "" || len(account.PayoutLabel) > 140) {
|
||||||
|
return payments.Account{}, payments.Unavailable
|
||||||
|
}
|
||||||
|
return account, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/payments"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPaymentPublicWireAndExactMoney(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
ledger := "507f1f77bcf86cd799439015"
|
||||||
|
method := "507f1f77bcf86cd799439016"
|
||||||
|
calls := 0
|
||||||
|
acknowledge := true
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.NotEmpty(t, r.Header.Get("Authorization"))
|
||||||
|
var result any
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/payments/fx/currencies/" + org:
|
||||||
|
result = map[string]any{"currencies": []any{map[string]any{"code": "EUR", "accountingScale": 2}}}
|
||||||
|
case "/api/v1/ledger_accounts/" + org:
|
||||||
|
result = map[string]any{"accounts": []any{
|
||||||
|
map[string]any{"ledgerAccountRef": source, "organizationRef": org, "ownerRef": source, "currency": "EUR", "accountType": "ACCOUNT_TYPE_ASSET", "role": "ACCOUNT_ROLE_OPERATING", "status": "ACCOUNT_STATUS_ACTIVE"},
|
||||||
|
map[string]any{"ledgerAccountRef": ledger, "organizationRef": org, "currency": "EUR", "accountType": "ACCOUNT_TYPE_ASSET", "role": "ACCOUNT_ROLE_OPERATING", "status": "ACCOUNT_STATUS_ACTIVE", "metadata": map[string]string{"sxchnge_subject_ref": subject, "sxchnge_payout_method_ref": method, "sxchnge_payout_label": "Saved bank ending 1234"}},
|
||||||
|
}}
|
||||||
|
case "/api/v1/ledger_accounts/" + org + "/" + ledger + "/balance":
|
||||||
|
result = map[string]any{"balance": map[string]any{"ledgerAccountRef": ledger, "balance": moneyDTO{"75.00", "EUR"}}}
|
||||||
|
case "/api/v1/payments/quote/" + org:
|
||||||
|
var request quoteRequestDTO
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
|
||||||
|
require.Equal(t, subject, request.Intent.Subject)
|
||||||
|
require.Equal(t, "20.01", request.Intent.Amount.Amount)
|
||||||
|
require.Equal(t, ledger, request.Intent.Source.Data["account_ref"])
|
||||||
|
require.Equal(t, method, request.Intent.Destination.Data["ref"])
|
||||||
|
confirmedSubject := subject
|
||||||
|
if !acknowledge {
|
||||||
|
confirmedSubject = ""
|
||||||
|
}
|
||||||
|
result = map[string]any{"externalSubjectRef": confirmedSubject, "quote": paymentQuoteDTO{ID: "quote:1", State: "executable", Executable: true, Expires: time.Now().Add(time.Minute), Amounts: quoteAmountsDTO{Principal: moneyDTO{"20.01", "EUR"}, Debit: moneyDTO{"21.01", "EUR"}, Credit: moneyDTO{"20.01", "EUR"}}}}
|
||||||
|
case "/api/v1/payments/by-quote/" + org:
|
||||||
|
calls++
|
||||||
|
var payload map[string]string
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
|
||||||
|
require.Equal(t, "stable-execution", payload["idempotencyKey"])
|
||||||
|
require.Equal(t, "quote:1", payload["quoteRef"])
|
||||||
|
result = map[string]any{"payment": paymentDTO{ID: "payment:1", State: "orchestration_state_needs_attention", Quote: paymentQuoteDTO{ID: "quote:1"}, Created: time.Now()}}
|
||||||
|
case "/api/v1/payments/by-ref/payment:1":
|
||||||
|
result = map[string]any{"payment": paymentDTO{ID: "payment:1", State: "orchestration_state_settled", Quote: paymentQuoteDTO{ID: "quote:1"}, Created: time.Now()}}
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
w.WriteHeader(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"status": "success", "data": result}))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
accounts, err := client.Accounts(ctx, subject)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, accounts, 1)
|
||||||
|
require.EqualValues(t, 7500, accounts[0].Available)
|
||||||
|
q, err := client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: ledger, Amount: 2001, Recipient: method}, accounts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 100, q.Fee)
|
||||||
|
require.EqualValues(t, 2101, q.Debit)
|
||||||
|
require.Equal(t, "Saved bank ending 1234", q.Request.Recipient)
|
||||||
|
_, err = client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: source, Amount: 2001, Recipient: method}, accounts)
|
||||||
|
require.ErrorIs(t, err, payments.NotFound)
|
||||||
|
_, err = client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: ledger, Amount: 2001, Recipient: "foreign-method"}, accounts)
|
||||||
|
require.ErrorIs(t, err, payments.InvalidRecipient)
|
||||||
|
acknowledge = false
|
||||||
|
_, err = client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: ledger, Amount: 2001, Recipient: method}, accounts)
|
||||||
|
require.ErrorIs(t, err, payments.Unavailable)
|
||||||
|
acknowledge = true
|
||||||
|
op, err := client.Execute(ctx, q.ID, "stable-execution")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "pending", op.Status)
|
||||||
|
require.Equal(t, 1, calls)
|
||||||
|
op, err = client.Payment(ctx, op.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "completed", op.Status)
|
||||||
|
}
|
||||||
|
func TestPaymentMappingRejectsUnsafeOrUnexecutableValues(t *testing.T) {
|
||||||
|
for _, amount := range []string{"1e3", "0.001", "-2", "NaN", "90071992547409.92", ""} {
|
||||||
|
_, err := minorMoney(moneyDTO{amount, "EUR"}, "EUR", 2)
|
||||||
|
require.Error(t, err, amount)
|
||||||
|
}
|
||||||
|
_, err := minorMoney(moneyDTO{"10.00", "USD"}, "EUR", 2)
|
||||||
|
require.Error(t, err)
|
||||||
|
_, err = mapPayment(paymentDTO{ID: "p", State: "new_unknown_state", Quote: paymentQuoteDTO{ID: "q"}, Created: time.Now()})
|
||||||
|
require.Error(t, err)
|
||||||
|
_, err = mapPaymentQuote(paymentQuoteDTO{State: "blocked", BlockReason: "limit_blocked"}, payments.Request{}, payments.Account{}, payments.Account{})
|
||||||
|
require.ErrorIs(t, err, payments.LimitExceeded)
|
||||||
|
_, err = mapPaymentQuote(paymentQuoteDTO{State: "executable", Executable: false}, payments.Request{}, payments.Account{}, payments.Account{})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/payments"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type moneyDTO struct {
|
||||||
|
Amount string `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
}
|
||||||
|
type endpointDTO struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Data map[string]string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit wire fields avoid coupling local domain models to provider JSON.
|
||||||
|
type quoteRequestDTO struct {
|
||||||
|
Key string `json:"idempotencyKey"`
|
||||||
|
Intent paymentIntentDTO `json:"intent"`
|
||||||
|
}
|
||||||
|
type paymentIntentDTO struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Source endpointDTO `json:"source"`
|
||||||
|
Destination endpointDTO `json:"destination"`
|
||||||
|
Amount moneyDTO `json:"amount"`
|
||||||
|
Subject string `json:"external_subject_ref"`
|
||||||
|
FeeTreatment string `json:"fee_treatment"`
|
||||||
|
}
|
||||||
|
type quoteAmountsDTO struct {
|
||||||
|
Principal moneyDTO `json:"sourcePrincipal"`
|
||||||
|
Debit moneyDTO `json:"sourceDebitTotal"`
|
||||||
|
Credit moneyDTO `json:"destinationSettlement"`
|
||||||
|
}
|
||||||
|
type paymentQuoteDTO struct {
|
||||||
|
ID string `json:"quoteRef"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Executable bool `json:"executable"`
|
||||||
|
BlockReason string `json:"blockReason"`
|
||||||
|
Expires time.Time `json:"expiresAt"`
|
||||||
|
Amounts quoteAmountsDTO `json:"amounts"`
|
||||||
|
}
|
||||||
|
type paymentDTO struct {
|
||||||
|
ID string `json:"paymentRef"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Quote paymentQuoteDTO `json:"lastQuote"`
|
||||||
|
Created time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
type accountDTO struct {
|
||||||
|
ID string `json:"ledgerAccountRef"`
|
||||||
|
ScopeRef string `json:"organizationRef"`
|
||||||
|
Owner string `json:"ownerRef"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Type string `json:"accountType"`
|
||||||
|
LedgerRole string `json:"role"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
}
|
||||||
|
type currencyDTO struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Scale *int `json:"accountingScale"`
|
||||||
|
}
|
||||||
|
type balanceDTO struct {
|
||||||
|
ID string `json:"ledgerAccountRef"`
|
||||||
|
Money moneyDTO `json:"balance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any) error {
|
||||||
|
token, err := c.token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var body []byte
|
||||||
|
if payload != nil {
|
||||||
|
body, err = json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(payments.Unavailable, err, "encode Sendico payment request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.base+path, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(payments.Unavailable, err, "create Sendico payment request")
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
client := *c.http
|
||||||
|
client.Timeout = 30 * time.Second
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(payments.Unavailable, err, "Sendico payment request failed")
|
||||||
|
}
|
||||||
|
defer func() { _ = res.Body.Close() }()
|
||||||
|
// Never forward upstream errors, credentials, or arbitrary error bodies.
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
var envelope struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
d := json.NewDecoder(io.LimitReader(res.Body, 4*1024*1024+1))
|
||||||
|
if err = d.Decode(&envelope); err != nil {
|
||||||
|
return merrors.OpaqueWrap(payments.Unavailable, err, "decode Sendico payment envelope")
|
||||||
|
}
|
||||||
|
if envelope.Status != "success" {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if d.Decode(&extra) != io.EOF {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(envelope.Data, dst); err != nil {
|
||||||
|
return merrors.OpaqueWrap(payments.Unavailable, err, "decode Sendico payment payload")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type profileDTO struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
DateOfBirth string `json:"date_of_birth"`
|
||||||
|
ResidenceCountry string `json:"residence_country"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
CustomerRef string `json:"external_customer_ref"`
|
||||||
|
}
|
||||||
|
type verificationDTO struct {
|
||||||
|
SubjectRef string `json:"subject_ref"`
|
||||||
|
SubmissionRef string `json:"submission_ref"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Profile profileDTO `json:"profile"`
|
||||||
|
UpgradePending bool `json:"upgrade_pending"`
|
||||||
|
}
|
||||||
|
type policyDTO struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
ProfileRef string `json:"profile_ref"`
|
||||||
|
Entries []limitDTO `json:"entries"`
|
||||||
|
}
|
||||||
|
type limitDTO struct {
|
||||||
|
Operation string `json:"operation"`
|
||||||
|
Period string `json:"period"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Cap string `json:"cap"`
|
||||||
|
Remaining string `json:"remaining"`
|
||||||
|
Used string `json:"used"`
|
||||||
|
Reserved string `json:"reserved"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) identityRequest(ctx context.Context, subject, suffix, method, contentType string, body []byte, dst any) error {
|
||||||
|
if !validRef(subject) {
|
||||||
|
return merrors.InvalidArgument("invalid bound subject")
|
||||||
|
}
|
||||||
|
token, err := c.token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.base+"/identity_facts/"+c.config.OrganizationID+"/proofs/"+subject+suffix, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "create identity request")
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
if contentType != "" {
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
// Uploads need a longer bound than small sign-up receipts. The base client is
|
||||||
|
// copied so concurrent sign-up operations keep their own four-second timeout.
|
||||||
|
client := *c.http
|
||||||
|
client.Timeout = 60 * time.Second
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "identity provider request failed")
|
||||||
|
}
|
||||||
|
defer func() { _ = res.Body.Close() }()
|
||||||
|
switch res.StatusCode {
|
||||||
|
case http.StatusOK:
|
||||||
|
case http.StatusBadRequest, http.StatusRequestEntityTooLarge:
|
||||||
|
return merrors.InvalidArgument("identity profile or photos were not accepted")
|
||||||
|
case http.StatusConflict:
|
||||||
|
return merrors.DataConflict("identity submission changed; refresh")
|
||||||
|
case http.StatusForbidden:
|
||||||
|
return merrors.PermissionDenied("identity resource unavailable")
|
||||||
|
case http.StatusTooManyRequests:
|
||||||
|
retry := 60
|
||||||
|
if seconds, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil && seconds > 0 && seconds <= 86400 {
|
||||||
|
retry = seconds
|
||||||
|
}
|
||||||
|
return merrors.WithThrottle(merrors.ErrServiceUnavailable, "identity", time.Duration(retry)*time.Second, "identity submission temporarily limited")
|
||||||
|
default:
|
||||||
|
return merrors.ServiceUnavailable("identity provider unavailable")
|
||||||
|
}
|
||||||
|
var envelope struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(io.LimitReader(res.Body, 256*1024+1))
|
||||||
|
if err := decoder.Decode(&envelope); err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode identity response")
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if decoder.Decode(&extra) != io.EOF || envelope.Status != "success" {
|
||||||
|
return merrors.ServiceUnavailable("invalid identity response")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(envelope.Data, dst); err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode identity payload")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func mapVerification(dto verificationDTO, subject string) (identity.Verification, error) {
|
||||||
|
if dto.SubjectRef != subject {
|
||||||
|
return identity.Verification{}, merrors.ServiceUnavailable("identity response scope mismatch")
|
||||||
|
}
|
||||||
|
switch dto.Status {
|
||||||
|
case "unverified", "pending", "approved", "changesRequested", "denied":
|
||||||
|
default:
|
||||||
|
return identity.Verification{}, merrors.ServiceUnavailable("invalid identity status")
|
||||||
|
}
|
||||||
|
if dto.Status == "approved" && dto.UpgradePending {
|
||||||
|
dto.Status = "pending"
|
||||||
|
}
|
||||||
|
if dto.Status != "unverified" && !validRef(dto.SubmissionRef) {
|
||||||
|
return identity.Verification{}, merrors.ServiceUnavailable("invalid identity receipt")
|
||||||
|
}
|
||||||
|
profile := identity.Profile{FullName: dto.Profile.FullName, DateOfBirth: dto.Profile.DateOfBirth, ResidenceCountry: dto.Profile.ResidenceCountry}
|
||||||
|
if dto.Status != "unverified" {
|
||||||
|
if err := profile.Validate(); err != nil {
|
||||||
|
return identity.Verification{}, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "invalid identity profile response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return identity.Verification{Status: dto.Status, UpgradePending: dto.UpgradePending, Reason: dto.Reason, SubmissionID: dto.SubmissionRef, Profile: profile}, nil
|
||||||
|
}
|
||||||
|
func (c *Client) Verification(ctx context.Context, subject string) (identity.Verification, error) {
|
||||||
|
var dto verificationDTO
|
||||||
|
if err := c.identityRequest(ctx, subject, "", http.MethodGet, "", nil, &dto); err != nil {
|
||||||
|
return identity.Verification{}, err
|
||||||
|
}
|
||||||
|
return mapVerification(dto, subject)
|
||||||
|
}
|
||||||
|
func (c *Client) SubmitVerification(ctx context.Context, subject, key string, p identity.Profile, f identity.Facts, document, selfie []byte) (identity.Verification, error) {
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
profile, err := json.Marshal(profileDTO{FullName: p.FullName, DateOfBirth: p.DateOfBirth, ResidenceCountry: p.ResidenceCountry, Email: f.Email, CustomerRef: f.CustomerRef})
|
||||||
|
if err != nil {
|
||||||
|
return identity.Verification{}, merrors.OpaqueWrap(merrors.ErrInternal, err, "encode identity profile")
|
||||||
|
}
|
||||||
|
if err = writer.WriteField("profile", string(profile)); err != nil {
|
||||||
|
return identity.Verification{}, err
|
||||||
|
}
|
||||||
|
if err = writer.WriteField("source_submission_ref", key); err != nil {
|
||||||
|
return identity.Verification{}, err
|
||||||
|
}
|
||||||
|
for i, data := range [][]byte{document, selfie} {
|
||||||
|
name := "document"
|
||||||
|
if i == 1 {
|
||||||
|
name = "selfie"
|
||||||
|
}
|
||||||
|
part, writeErr := writer.CreateFormFile(name, name)
|
||||||
|
if writeErr != nil {
|
||||||
|
return identity.Verification{}, writeErr
|
||||||
|
}
|
||||||
|
if _, writeErr = part.Write(data); writeErr != nil {
|
||||||
|
return identity.Verification{}, writeErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = writer.Close(); err != nil {
|
||||||
|
return identity.Verification{}, err
|
||||||
|
}
|
||||||
|
var dto verificationDTO
|
||||||
|
if err = c.identityRequest(ctx, subject, "", http.MethodPost, writer.FormDataContentType(), body.Bytes(), &dto); err != nil {
|
||||||
|
return identity.Verification{}, err
|
||||||
|
}
|
||||||
|
return mapVerification(dto, subject)
|
||||||
|
}
|
||||||
|
func (c *Client) Limits(ctx context.Context, subject string) (identity.Limits, error) {
|
||||||
|
var dto policyDTO
|
||||||
|
if err := c.identityRequest(ctx, subject, "/limits", http.MethodGet, "", nil, &dto); err != nil {
|
||||||
|
return identity.Limits{}, err
|
||||||
|
}
|
||||||
|
if (dto.Tier != "basic" && dto.Tier != "verified") || dto.ProfileRef == "" || dto.Entries == nil {
|
||||||
|
return identity.Limits{}, merrors.ServiceUnavailable("invalid limits response")
|
||||||
|
}
|
||||||
|
entries := make([]identity.Limit, 0, len(dto.Entries))
|
||||||
|
for _, v := range dto.Entries {
|
||||||
|
entries = append(entries, identity.Limit{Operation: v.Operation, Period: v.Period, Currency: v.Currency, Cap: v.Cap, Remaining: v.Remaining, Used: v.Used, Reserved: v.Reserved})
|
||||||
|
}
|
||||||
|
return identity.Limits{Tier: dto.Tier, ProfileRef: dto.ProfileRef, Entries: entries}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProofWireContractAndLimitsProjection(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Contains(t, r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
expected := "/api/v1/identity_facts/" + org + "/proofs/" + subject
|
||||||
|
if r.URL.Path == expected+"/limits" {
|
||||||
|
require.Equal(t, http.MethodGet, r.Method)
|
||||||
|
_, _ = io.WriteString(w, `{"status":"success","data":{"tier":"verified","profile_ref":"profile_personal_verified","entries":[{"operation":"Transfer","period":"day","currency":"BTC","cap":"0.12345678","remaining":"0.10000001","used":"0.02","reserved":"0.00345677"}]}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.Equal(t, expected, r.URL.Path)
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
reader, err := r.MultipartReader()
|
||||||
|
require.NoError(t, err)
|
||||||
|
fields := map[string]string{}
|
||||||
|
for {
|
||||||
|
part, err := reader.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
data, err := io.ReadAll(part)
|
||||||
|
require.NoError(t, err)
|
||||||
|
fields[part.FormName()] = string(data)
|
||||||
|
}
|
||||||
|
require.Equal(t, "document bytes", fields["document"])
|
||||||
|
require.Equal(t, "selfie bytes", fields["selfie"])
|
||||||
|
require.Equal(t, "stable-source", fields["source_submission_ref"])
|
||||||
|
var profile map[string]string
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(fields["profile"]), &profile))
|
||||||
|
require.Equal(t, "Legal Name", profile["full_name"])
|
||||||
|
require.Equal(t, "local-customer", profile["external_customer_ref"])
|
||||||
|
require.Equal(t, "verified@example.test", profile["email"])
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `{"status":"success","data":{"subject_ref":"`+subject+`","submission_ref":"`+submission+`","status":"approved","upgrade_pending":true,"profile":{"full_name":"Legal Name","date_of_birth":"1990-04-23","residence_country":"PL"}}}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
result, err := client.SubmitVerification(t.Context(), subject, "stable-source", identity.Profile{FullName: "Legal Name", DateOfBirth: "1990-04-23", ResidenceCountry: "PL"}, identity.Facts{Email: "verified@example.test", CustomerRef: "local-customer"}, []byte("document bytes"), []byte("selfie bytes"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "pending", result.Status)
|
||||||
|
require.True(t, result.UpgradePending)
|
||||||
|
policy, err := client.Limits(t.Context(), subject)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "0.12345678", policy.Entries[0].Cap)
|
||||||
|
require.Equal(t, "verified", policy.Tier)
|
||||||
|
require.Equal(t, 4*time.Second, client.http.Timeout)
|
||||||
|
}
|
||||||
|
func TestProofRateLimitPreservesTypedRecoveryWithoutUpstreamBody(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Retry-After", "120")
|
||||||
|
w.WriteHeader(429)
|
||||||
|
_, _ = io.WriteString(w, "private identity assertions")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = client.Verification(t.Context(), subject)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.NotContains(t, err.Error(), "private identity assertions")
|
||||||
|
throttle, ok := merrors.ThrottleDetailsFrom(err)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, 120*time.Second, throttle.RetryAfter)
|
||||||
|
}
|
||||||
@@ -87,7 +87,7 @@ func validScope(scope model.Scope) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
switch scope.Purpose {
|
switch scope.Purpose {
|
||||||
case vmodel.PurposeLogin, vmodel.PurposePayout, vmodel.PurposeFactorPreference:
|
case vmodel.PurposeLogin, vmodel.PurposePayment, vmodel.PurposePayout, vmodel.PurposeFactorPreference:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const (
|
|||||||
PurposeFactorPreference VerificationPurpose = "factor_preference"
|
PurposeFactorPreference VerificationPurpose = "factor_preference"
|
||||||
PurposeFactorProof VerificationPurpose = "factor_proof"
|
PurposeFactorProof VerificationPurpose = "factor_proof"
|
||||||
PurposePayout VerificationPurpose = "payout"
|
PurposePayout VerificationPurpose = "payout"
|
||||||
|
PurposePayment VerificationPurpose = "payment"
|
||||||
)
|
)
|
||||||
|
|
||||||
func VPToString(code VerificationPurpose) string {
|
func VPToString(code VerificationPurpose) string {
|
||||||
@@ -45,6 +46,8 @@ func VPFromString(s string) (VerificationPurpose, error) {
|
|||||||
return PurposeFactorPreference, nil
|
return PurposeFactorPreference, nil
|
||||||
case string(PurposeFactorProof):
|
case string(PurposeFactorProof):
|
||||||
return PurposeFactorProof, nil
|
return PurposeFactorProof, nil
|
||||||
|
case string(PurposePayment):
|
||||||
|
return PurposePayment, nil
|
||||||
case string(PurposePayout):
|
case string(PurposePayout):
|
||||||
return PurposePayout, nil
|
return PurposePayout, nil
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ func (r *MongoRepository) PatchOneByFilter(ctx context.Context, query builder.Qu
|
|||||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
if mongo.IsDuplicateKeyError(err) {
|
||||||
|
return false, merrors.OpaqueWrap(merrors.ErrDataConflict, err, "duplicate_key")
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Customer identity verification
|
||||||
|
|
||||||
|
Task [SXCHNGE #48](https://git.sendico.io/tech/sxchnge/issues/48), following
|
||||||
|
[sign-up Facts intake](signup-facts.md). Platform implementation:
|
||||||
|
[Sendico #2885](https://git.sendico.io/tech/sendico/issues/2885).
|
||||||
|
|
||||||
|
## Customer flow
|
||||||
|
|
||||||
|
Sign-up still creates a basic account and submits its initial name/email/customer
|
||||||
|
reference as immutable facts. Email verification and login remain independent of
|
||||||
|
identity proof collection. After the first authenticated login in an app session,
|
||||||
|
an unverified customer is offered verification and can choose **Keep basic
|
||||||
|
limits**. An unavailable optional identity dependency does not prevent login.
|
||||||
|
**Accounts → Identity verification**, **Limits**, and the existing identity
|
||||||
|
navigation let the customer return later.
|
||||||
|
|
||||||
|
The customer supplies their full legal name, date of birth (YYYY-MM-DD), country
|
||||||
|
of residence, an ID document photo and a selfie holding that same document. The
|
||||||
|
backend supplies the authenticated customer's email and reference. The client
|
||||||
|
cannot choose a platform subject, organization, source principal, review status
|
||||||
|
or limits profile. JPEG/PNG photos must decode, be at least 600×600, at most
|
||||||
|
40 megapixels and no larger than 10 MiB each. Sendico also enforces its existing
|
||||||
|
configured evidence cap, MIME/extension rules and submission rate limits.
|
||||||
|
|
||||||
|
Unsent fields and photos stay in memory, survive retryable errors and are cleared
|
||||||
|
on successful submission, discard or page disposal. Previews leave Flutter's
|
||||||
|
image cache on exit/replacement. Profile edits or photo replacements create a
|
||||||
|
new request key; unchanged retries retain their key. Draft loss requires an
|
||||||
|
explicit discard choice. No sensitive extras enter browser history or analytics.
|
||||||
|
|
||||||
|
A successful submission means **pending staff review**. Corrections permit a new
|
||||||
|
submission; rejection retains basic access and the existing support route. A
|
||||||
|
recorded approval with an unfinished limits assignment is shown as **Limits
|
||||||
|
upgrade pending**, rather than verified. Refresh fetches the real reviewed state
|
||||||
|
and the authoritative limits. Production uses no simulated KYC review controls
|
||||||
|
or illustrative limit amounts. Demo mode remains separately simulated.
|
||||||
|
|
||||||
|
## Boundaries and contracts
|
||||||
|
|
||||||
|
The authenticated local routes are `GET/POST /accounts/identity` and
|
||||||
|
`GET /accounts/limits`, under the configured API mount. Multipart POST fields are
|
||||||
|
`profile` (JSON), `document`, `selfie`, and `idempotencyKey`. A customer ID is
|
||||||
|
always resolved from the full authenticated session. Pending-login tokens do not
|
||||||
|
provide account access. Browser and native requests both carry the access token;
|
||||||
|
browser refresh cookies remain an auth concern.
|
||||||
|
|
||||||
|
The existing `signup_facts` binding resolves each local customer to one independent
|
||||||
|
Sendico subject. A conditional unique `(namespace, subjectRef)` index prevents
|
||||||
|
binding the same provider subject to two customers. Empty registration claims
|
||||||
|
are excluded. Existing duplicates cause bootstrap to fail and require controlled
|
||||||
|
reconciliation; the application never guesses a replacement subject. Credential
|
||||||
|
rotation must retain the configured source account.
|
||||||
|
|
||||||
|
The Sendico adapter exclusively uses public HTTP:
|
||||||
|
|
||||||
|
- `GET/POST /identity_facts/{organization}/proofs/{subject}`;
|
||||||
|
- `GET /identity_facts/{organization}/proofs/{subject}/limits`.
|
||||||
|
|
||||||
|
Adapter DTOs and mappers remain separate from local models and local HTTP DTOs.
|
||||||
|
There are no Sendico database, gRPC, bus or sibling-module imports in SXCHNGE.
|
||||||
|
Provider organization/source settings remain in the adapter configuration.
|
||||||
|
Uploads have a bounded 60-second upstream timeout and redirects are rejected.
|
||||||
|
Upstream error bodies and photo bytes are never logged. Rate-limit responses
|
||||||
|
retain typed recovery. Registration's existing non-idempotent reconciliation
|
||||||
|
rules are unchanged.
|
||||||
|
|
||||||
|
Visible policy entries retain exact decimal strings, currency, group labels and
|
||||||
|
per-operation minimum/maximum, day and month slots. Usage is not invented for
|
||||||
|
per-operation slots. The backend owns the effective policy and remaining
|
||||||
|
capacity. The existing wallet remains a simulated service; this contribution
|
||||||
|
connects identity and visible limits, not real money movement. Future money
|
||||||
|
adapters must use the platform-owned customer account resource and authoritative
|
||||||
|
Limits reservation path, not a browser-selected policy or displayed balance.
|
||||||
|
|
||||||
|
## Review and rollout
|
||||||
|
|
||||||
|
Staff work in the existing **Sendico backoffice → KYC → Identity proof review**.
|
||||||
|
The platform stores private proofs, immutable profile assertions, review history
|
||||||
|
and the reviewed limits assignment. See the platform's
|
||||||
|
`docs/customer-identity-proofs.md` for authorization, retry and retention details.
|
||||||
|
|
||||||
|
Deploy Sendico Product/provider, Backoffice, Limits and backoffice frontend
|
||||||
|
changes together before enabling the new SXCHNGE frontend/backend adapters.
|
||||||
|
Existing `api.sendico` settings are reused. The integration account needs
|
||||||
|
organization read/update access. The platform's existing evidence/storage,
|
||||||
|
cleanup, discovery and limits configuration must be provisioned. Its ACCOUNT
|
||||||
|
default for SXCHNGE must be the existing unverified profile, and the existing
|
||||||
|
verified profile and personal-account visible view must be active. No new runtime
|
||||||
|
configuration parameters or live submissions are introduced by this work.
|
||||||
|
|
||||||
|
Merge [SXCHNGE sign-up Facts PR #47](https://git.sendico.io/tech/sxchnge/pulls/47)
|
||||||
|
before [this flow's PR #49](https://git.sendico.io/tech/sxchnge/pulls/49).
|
||||||
|
For the combined existing backoffice, apply
|
||||||
|
[Sendico identity-proofs PR #2886](https://git.sendico.io/tech/sendico/pulls/2886)
|
||||||
|
before [Facts review PR #2887](https://git.sendico.io/tech/sendico/pulls/2887).
|
||||||
|
They share one Limits assignment contract and retain both review destinations.
|
||||||
|
Proof cases go to Identity proof review; generic Facts review cannot link them
|
||||||
|
to a login account or assign limits through that path. Deploy the reconciled
|
||||||
|
Sendico services and frontend before using this flow. Deployment is separate.
|
||||||
|
|
||||||
|
## UX, measurement and validation
|
||||||
|
|
||||||
|
Existing KYC journey events measure preparation, selection, accepted submission,
|
||||||
|
status reads, retries, abandonment and latency. Profile contents, filenames,
|
||||||
|
photo bytes and credentials are excluded. Submission success is an intake
|
||||||
|
outcome, not proof of approval. Pending asynchronous staff review is not counted
|
||||||
|
as customer abandonment. A resumed route starts a new client attempt; immutable
|
||||||
|
submission IDs deduplicate backend business outcomes.
|
||||||
|
|
||||||
|
Authoritative completion is the staff decision plus the Limits assignment
|
||||||
|
receipt for that submission. Backend IDs correlate these records without becoming
|
||||||
|
metric labels. Existing identity request counters/histograms distinguish accepted,
|
||||||
|
invalid, denied, conflict, rate-limited and unavailable operations and include
|
||||||
|
actual authorization/I/O duration. Client analytics still defaults to the
|
||||||
|
existing in-memory sink; retained cross-device analytics depends on deployment.
|
||||||
|
|
||||||
|
Review covers optional onboarding, later account navigation, editable profile,
|
||||||
|
readable ID/selfie guidance, disabled actions, error/session recovery, input
|
||||||
|
preservation, correction/rejection/pending/approval, exact monetary presentation,
|
||||||
|
EN/RU at narrow widths and large text, keyboard access and private-image cache
|
||||||
|
eviction. Tests use synthetic data, local HTTP servers and disposable MongoDB.
|
||||||
|
No live ID documents, live email, production review or deployment were exercised.
|
||||||
|
Full validation commands and final PR links are recorded in `implementation.md`
|
||||||
|
and the task.
|
||||||
@@ -883,3 +883,120 @@ parallelism resolved that infrastructure contention. The next full run exposed
|
|||||||
the new locale fixture disposal error noted above; the corrected final run
|
the new locale fixture disposal error noted above; the corrected final run
|
||||||
passed, including EN/RU support states and verification recovery. No checks were
|
passed, including EN/RU support states and verification recovery. No checks were
|
||||||
weakened or unrelated work changed.
|
weakened or unrelated work changed.
|
||||||
|
|
||||||
|
## Customer identity verification — task #48
|
||||||
|
|
||||||
|
[Implementation and rollout](identity-verification.md) adds optional verification
|
||||||
|
on authenticated onboarding and later from the account: legal profile, ID photo
|
||||||
|
and selfie holding the same ID. Real adapters replace demo KYC/limits in backend
|
||||||
|
mode. Sendico's existing backoffice owns decisions and the reviewed Limits profile;
|
||||||
|
SXCHNGE has no local tenancy, roles or policy engine. Provider-subject uniqueness,
|
||||||
|
private bounded uploads, stable retries, exact decimal policy projections and
|
||||||
|
explicit upgrade-pending feedback are included. The unrelated notifications
|
||||||
|
runtime configuration is excluded.
|
||||||
|
|
||||||
|
Delivered in [PR #49](https://git.sendico.io/tech/sxchnge/pulls/49), branch
|
||||||
|
`identity-verification-48`, implementation commit `1354551`. It is stacked on
|
||||||
|
[sign-up Facts PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), which must
|
||||||
|
merge first, and requires
|
||||||
|
[Sendico PR #2886](https://git.sendico.io/tech/sendico/pulls/2886) to be deployed
|
||||||
|
before enabling the new flow. Tracking tasks:
|
||||||
|
[SXCHNGE #48](https://git.sendico.io/tech/sxchnge/issues/48) and
|
||||||
|
[Sendico #2885](https://git.sendico.io/tech/sendico/issues/2885).
|
||||||
|
|
||||||
|
Completion review covered the full backend and frontend contribution, including
|
||||||
|
subject ownership, private uploads, idempotent retries, session changes, exact
|
||||||
|
limits, DTO/mapper/service/model/provider boundaries, and the UX/measurement
|
||||||
|
checklists. Review fixes included duplicate-binding error classification,
|
||||||
|
upgrade-pending feedback, bounded response timeouts and stale-session protection.
|
||||||
|
|
||||||
|
Final validation passed: `GOFLAGS=-p=1 make test` (all backend race tests and all
|
||||||
|
frontend suites; 79 app tests), `make lint-backend`, changed-Dart formatting,
|
||||||
|
and `flutter analyze` in the app and shared KYC package. Focused KYC adapter and
|
||||||
|
onboarding tests passed (20 tests), as did focused Go identity/provider/HTTP
|
||||||
|
checks. An initial parallel run hit the existing Testcontainers reaper deadline;
|
||||||
|
serial Go package execution resolved local Docker contention without weakening
|
||||||
|
checks. No optional security scan, live customer proof, email, production
|
||||||
|
approval, deployment or merge was performed. Generated outputs remain ignored.
|
||||||
|
|
||||||
|
Reconciliation follow-up: the shared Limits assignment implementation and its
|
||||||
|
persisted-state validation are owned by Sendico PR #2886. The subsequent
|
||||||
|
[Sendico Facts review PR #2887](https://git.sendico.io/tech/sendico/pulls/2887)
|
||||||
|
retains both Product providers, Backoffice services and KYC destinations, and
|
||||||
|
routes proof cases to Identity proof review while blocking generic login linking
|
||||||
|
and assignment. Apply #2886 before #2887 and deploy the combined Sendico result
|
||||||
|
before this SXCHNGE flow. The public proof/limits contract is unchanged; this
|
||||||
|
SXCHNGE follow-up changes documentation only and requires consistency/diff checks.
|
||||||
|
|
||||||
|
## Sendico quotations and payment execution — task #50
|
||||||
|
|
||||||
|
[Task #50](https://git.sendico.io/tech/sxchnge/issues/50), branch `payments-50`,
|
||||||
|
connects backend wallet mode through the authenticated customer BFF to Sendico's
|
||||||
|
public account, quotation, by-quote execution and payment-status APIs. It is
|
||||||
|
stacked on `identity-verification-48` / PR #49; merge #47 → #49 first.
|
||||||
|
|
||||||
|
The separate public-provider prerequisite is
|
||||||
|
[Sendico PR #2897](https://git.sendico.io/tech/sendico/pulls/2897), task #2896.
|
||||||
|
It validates independent customer context and selects the reviewed ACCOUNT limits
|
||||||
|
resource rather than the integration login or an unassigned CUSTOMER policy.
|
||||||
|
Deploy its Product Edge and Quotation/Orchestrator consumers together before
|
||||||
|
SXCHNGE. The BFF requires an exact customer-context acknowledgement from quotes.
|
||||||
|
|
||||||
|
The contribution adds durable customer/namespace/subject-bound quotation ownership,
|
||||||
|
transactional payment-factor authorization, unique quote/execution keys, stable
|
||||||
|
provider replay, payment-reference reconciliation and authoritative status polling.
|
||||||
|
Live UI uses provisioned destinations, accurate quote expiry, explicit missing
|
||||||
|
method/loading/error/pending states and long labels at narrow widths. It clears
|
||||||
|
account state on customer changes. DTO/mapper/service/model/provider boundaries,
|
||||||
|
shared database wrappers, existing credentials and explicit demo mode are retained.
|
||||||
|
|
||||||
|
Validation passed: `make test`, `make lint-backend`, `make test-frontend` after the
|
||||||
|
final UI review, changed-Dart format verification, and app/wallet `flutter analyze`.
|
||||||
|
The final frontend run passed 21 wallet and 81 app tests, plus the unchanged shared
|
||||||
|
package suites. Tests include synthetic public HTTP, Mongo transaction rollback,
|
||||||
|
unique-key/customer isolation, lost-response reconciliation, factor retry and
|
||||||
|
session replacement. Sendico's full backend test/lint targets also passed.
|
||||||
|
|
||||||
|
[Payment contracts, provisioning, UX measurement and limitations](payments.md)
|
||||||
|
document rollout. Dedicated customer ledger/method bindings and platform access
|
||||||
|
must be provisioned through existing operator APIs; this work does not run live
|
||||||
|
payments or deploy either repository. Generic ambiguous rejections remain pending
|
||||||
|
for operational reconciliation. Cross-session analytics retention, additional
|
||||||
|
funding/recipient onboarding, backend locale saving and external history ingestion
|
||||||
|
remain separate capabilities. Unrelated notifications runtime changes are excluded.
|
||||||
|
|
||||||
|
Delivered in [PR #51](https://git.sendico.io/tech/sxchnge/pulls/51), targeting
|
||||||
|
`identity-verification-48`. Merge order is #47 → #49 → #51; deploy Sendico #2897
|
||||||
|
before SXCHNGE #51. Both implementation branches are pushed and remain unmerged.
|
||||||
|
|
||||||
|
## 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.
|
||||||
+35
-17
@@ -1,5 +1,7 @@
|
|||||||
# Local development
|
# Local development
|
||||||
|
|
||||||
|
For the connected Sendico stand, see [local Sendico integration](local-sendico.md).
|
||||||
|
|
||||||
Task [#42](https://git.sendico.io/tech/sxchnge/issues/42),
|
Task [#42](https://git.sendico.io/tech/sxchnge/issues/42),
|
||||||
[PR #43](https://git.sendico.io/tech/sxchnge/pulls/43), branch `dev-env-42`.
|
[PR #43](https://git.sendico.io/tech/sxchnge/pulls/43), branch `dev-env-42`.
|
||||||
|
|
||||||
@@ -25,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,
|
||||||
@@ -54,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
|
||||||
@@ -72,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:
|
||||||
@@ -104,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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Local Sendico integration
|
||||||
|
|
||||||
|
Task [#52](https://git.sendico.io/tech/sxchnge/issues/52), branch `local-stand-52`,
|
||||||
|
based on `payments-50` / [PR #51](https://git.sendico.io/tech/sxchnge/pulls/51).
|
||||||
|
The local Sendico checkout uses `payments-2896` /
|
||||||
|
[PR #2897](https://git.sendico.io/tech/sendico/pulls/2897). Merge the prerequisites
|
||||||
|
before this infrastructure PR. No PR is merged by local startup.
|
||||||
|
|
||||||
|
## Running the configured stands
|
||||||
|
|
||||||
|
Start Sendico first from its checkout using its documented Make workflow. Its
|
||||||
|
Product API must respond at `http://localhost:8080/api/v1/health`. Then, here:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make build
|
||||||
|
make up
|
||||||
|
make test-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [SXCHNGE](https://localhost:5126),
|
||||||
|
[Sendico Product](http://localhost:3000), and
|
||||||
|
[Sendico Backoffice](http://localhost:4000).
|
||||||
|
SXCHNGE's Caddy CA is exported to `.local/dev/ca.pem`; trust that public CA in
|
||||||
|
the browser/OS if necessary. Bootstrap does not modify the system trust store.
|
||||||
|
|
||||||
|
The optional integration is selected by ignored `.local/dev/sendico/settings.env`.
|
||||||
|
Make and the containerized bootstrap both include
|
||||||
|
`infra/local/sendico/compose.yml` whenever that file exists. Standalone SXCHNGE
|
||||||
|
still uses six services. The connected stack adds one `sendico` session worker.
|
||||||
|
`make ps`, `make logs`, and `make down` include it automatically.
|
||||||
|
|
||||||
|
## Identity and configuration
|
||||||
|
|
||||||
|
The configured local stand has a dedicated `SXCHNGE Local` organization, active
|
||||||
|
integration account and account-bound confidential API client. The account is
|
||||||
|
an organization member with organization read/update, ledger read, payment
|
||||||
|
create/read, and recipient create/read/update permissions. Customer subjects
|
||||||
|
remain independent from that account. No pooled balances, customer funds or
|
||||||
|
payout destinations are seeded by this setup. Existing stand data is retained.
|
||||||
|
This identity was created through a one-time local administrative bootstrap;
|
||||||
|
runtime traffic and session creation use only the public HTTP API.
|
||||||
|
|
||||||
|
For another stand, an operator must first provision the organization, account,
|
||||||
|
client and grants in Sendico. Resolve actual IDs from that stand; do not copy
|
||||||
|
another environment's references. Initialize SXCHNGE's Vault using `make up`
|
||||||
|
before configuring the integration. Supply this JSON privately on stdin:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"organization_ref": "<organization object ID>",
|
||||||
|
"source_account_ref": "<integration account object ID>",
|
||||||
|
"client_id": "<confidential client ID>",
|
||||||
|
"device_id": "sxchnge-local-bff",
|
||||||
|
"login": "<integration login>",
|
||||||
|
"password": "<integration password>",
|
||||||
|
"client_secret": "<confidential client secret>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Save operator input outside tracked files, with mode 0600. Never echo it.
|
||||||
|
make sendico < /private/path/sendico-input.json
|
||||||
|
make up
|
||||||
|
make test-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
`make sendico` validates references and identifiers, stores only login/password/
|
||||||
|
client_secret at Vault `kv/services/bff/sendico`, and writes the non-secret client,
|
||||||
|
device, organization and source references into ignored local settings. The BFF
|
||||||
|
runtime is generated from `infra/local/bff/runtime.yml` with the existing
|
||||||
|
`api.sendico` configuration. Re-run configuration after changing that base YAML.
|
||||||
|
Remove the temporary input file once credentials have been stored in Vault.
|
||||||
|
No Sendico configuration parameters or application transport rules change.
|
||||||
|
|
||||||
|
## Connection and renewal
|
||||||
|
|
||||||
|
The session worker shares BFF's network namespace and binds a private loopback
|
||||||
|
TCP bridge at `127.0.0.1:18081`, forwarding to
|
||||||
|
`host.docker.internal:8080`. This uses the local stand's existing public HTTP API
|
||||||
|
port; it requires a local Docker engine with `host.docker.internal` support, as
|
||||||
|
provided by Docker Desktop. The bridge publishes no new host port. The adapter's
|
||||||
|
existing HTTPS requirement and loopback-only HTTP exception remain intact.
|
||||||
|
|
||||||
|
The worker authenticates to Vault with its own AppRole, reads exactly
|
||||||
|
`services/bff/sendico`, and revokes its five-minute Vault token after reading.
|
||||||
|
It cannot read Mongo credentials, JWT signing keys, NATS auth or administrator
|
||||||
|
material. Its initial login uses Sendico's public `/accounts/login/api` grant,
|
||||||
|
validates the access token's source account, full-session status and expiry, and
|
||||||
|
atomically publishes the token in a volume mounted read-only by BFF. Credentials
|
||||||
|
and response bodies stay in private tmpfs files and never appear in logs or
|
||||||
|
command arguments. Flutter receives no platform credentials.
|
||||||
|
|
||||||
|
Successful login repeats every 20 minutes before the one-hour access token
|
||||||
|
expires. A failure retains the prior token, logs one generic actionable message,
|
||||||
|
and retries after five minutes. The worker becomes unhealthy after 25 minutes
|
||||||
|
without renewal or when the upstream health endpoint fails; the adapter
|
||||||
|
separately rejects expired or mismatched tokens.
|
||||||
|
BFF waits for a recent token at startup. Following credential changes, restart
|
||||||
|
the worker with `make up` followed by the Compose restart command below, or wait
|
||||||
|
for its next renewal. Never change the integration account on an existing
|
||||||
|
financial environment without reviewing resource and idempotency bindings.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose --project-directory . -f infra/local/compose.yml \
|
||||||
|
-f infra/local/sendico/compose.yml --env-file .local/dev/images.env restart sendico
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
layout and least-privilege identities, Mongo transactions/lease rotation, and
|
||||||
|
authenticated Sendico currency and ledger reads through the same loopback
|
||||||
|
connection and token used by BFF. It sends no email and executes no payment.
|
||||||
|
An empty wallet is expected until dedicated subject-bound currency ledgers and
|
||||||
|
saved methods are provisioned as described in [payment provisioning](payments.md).
|
||||||
|
|
||||||
|
## Recovery
|
||||||
|
|
||||||
|
The existing stand had application processes stopped inside still-running Air
|
||||||
|
containers after a NATS disconnect and Mongo replica-state interruption. Restore
|
||||||
|
discovery first, then Treasury and the other service announcers. Wait until each
|
||||||
|
stage is registered before restarting dependent services: billing fees and
|
||||||
|
Ledger, then Limits/gateways/quotation/receiving routes, then payment orchestrator,
|
||||||
|
compliance assessment and Product BFF. The Sendico Makefile provides its ordered
|
||||||
|
backend startup workflow. Inspect current logs and health instead of assuming a
|
||||||
|
running development container has a live application process. Do not delete
|
||||||
|
volumes or reset financial data to recover service registration.
|
||||||
|
|
||||||
|
If an old Caddy leaf certificate expires while the stack is stopped, restart
|
||||||
|
frontend after startup so Caddy issues a fresh certificate. Preserve `caddy-data`
|
||||||
|
and the established CA. A subsequent verified TLS check must succeed.
|
||||||
|
|
||||||
|
## Acceptance and operator UX review
|
||||||
|
|
||||||
|
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`,
|
||||||
|
`make test-dev-infra`, `make test-dev`, and `git diff --check` passed. Both product
|
||||||
|
sites, Sendico Backoffice and both API health endpoints returned HTTP 200;
|
||||||
|
SXCHNGE's browser endpoint was checked with its exported CA. All Sendico services
|
||||||
|
with health checks were healthy. Forced worker restart issued a different valid
|
||||||
|
same-account token and preserved successful authenticated currency/ledger reads.
|
||||||
|
The existing notification sender edit remains outside this task's commit.
|
||||||
|
|
||||||
|
The setup journey starts with the operator's Make invocation and completes when
|
||||||
|
Compose confirms service readiness; `make test-dev` additionally confirms the
|
||||||
|
authenticated integration. Existing command exit codes and Compose progress /
|
||||||
|
health states distinguish configuration, dependency, authentication and readiness
|
||||||
|
failures. A renewed-session log records credential recovery, while stale renewal
|
||||||
|
or upstream outage changes container health. Each new operator invocation is a
|
||||||
|
new attempt; worker retries remain within that process's lifetime. Command wall
|
||||||
|
time measures setup duration. Interrupted commands are incomplete attempts, not
|
||||||
|
successful launches. These are local operator signals, with no retained analytics
|
||||||
|
or cross-invocation conversion/abandonment reporting added.
|
||||||
|
|
||||||
|
The UX review covered missing bootstrap material, malformed operator input,
|
||||||
|
authentication failure, retained retry context, stale TLS recovery, successful
|
||||||
|
startup and confirmed API access. Commands reuse existing Make/Compose patterns,
|
||||||
|
errors give a recovery action, and success follows readiness. No secret values
|
||||||
|
appear in terminal feedback. Product layouts, focus and screen-reader behavior
|
||||||
|
are unchanged; browser interaction and funded payment settlement are not claimed
|
||||||
|
by HTTP readiness checks. No email or payment was sent during validation.
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# Customer quotations and payment execution
|
||||||
|
|
||||||
|
Task [SXCHNGE #50](https://git.sendico.io/tech/sxchnge/issues/50), branch
|
||||||
|
`payments-50`, based on `identity-verification-48` / PR #49. Provider prerequisite:
|
||||||
|
[Sendico #2896](https://git.sendico.io/tech/sendico/issues/2896).
|
||||||
|
|
||||||
|
## Boundary and behavior
|
||||||
|
|
||||||
|
The authenticated app uses `BackendWalletService` whenever backend authentication
|
||||||
|
is configured. Explicit service injection remains available for tests; demo mode
|
||||||
|
continues to use the isolated in-memory wallet. Missing provider configuration
|
||||||
|
returns an unavailable wallet, never demonstration balances or fake settlements.
|
||||||
|
|
||||||
|
The path is Flutter DTO → mapper → service → domain model → provider → UI, then
|
||||||
|
SXCHNGE's authenticated customer BFF → Sendico public HTTP. The existing confirmed
|
||||||
|
identity binding resolves each signed-in customer to an independent subject.
|
||||||
|
Neither a browser-selected organization nor an integration-login-owned balance
|
||||||
|
is used as that customer's financial resource.
|
||||||
|
|
||||||
|
Consumed public endpoints, below the existing configured API prefix:
|
||||||
|
|
||||||
|
- `GET /payments/fx/currencies/{organization}` for accounting scales;
|
||||||
|
- `GET /ledger_accounts/{organization}` and
|
||||||
|
`GET /ledger_accounts/{organization}/{ledger}/balance`;
|
||||||
|
- `POST /payments/quote/{organization}` with `external_subject_ref`;
|
||||||
|
- `POST /payments/by-quote/{organization}`;
|
||||||
|
- `GET /payments/by-ref/{payment}` for authoritative status.
|
||||||
|
|
||||||
|
The adapter requires the exact `externalSubjectRef` acknowledgement on the quote
|
||||||
|
response. An old deployment that ignores the new request field cannot silently
|
||||||
|
produce an acceptable integration-principal quote. Public provider DTOs and
|
||||||
|
mappers stay inside the adapter. No Sendico modules, databases, gRPC clients or
|
||||||
|
message bus are imported by SXCHNGE.
|
||||||
|
|
||||||
|
Amounts are parsed and formatted as exact decimal strings at the provider
|
||||||
|
boundary and represented as integer minor units locally. Unknown scales, values
|
||||||
|
beyond the browser's exact integer range, excess fractional precision, unknown
|
||||||
|
payment states, unexecutable quotes and mismatched payment/quote references fail
|
||||||
|
closed. Source principal, total debit and net destination credit come from the
|
||||||
|
quote; rates account for different currency precisions. Only same-currency saved
|
||||||
|
payout/funding methods are currently represented by the wallet review model.
|
||||||
|
Exchange connects two dedicated currency ledgers belonging to the same customer.
|
||||||
|
|
||||||
|
## Provisioning and rollout
|
||||||
|
|
||||||
|
Merge/deploy the identity prerequisites first: SXCHNGE #47 → #49 and the Sendico
|
||||||
|
identity-proof/limits-assignment work documented in `identity-verification.md`.
|
||||||
|
Deploy the Sendico #2896 Product Edge and the Quotation/Orchestrator builds that
|
||||||
|
consume the changed shared payment-storage Limits adapter together. Then deploy
|
||||||
|
SXCHNGE #50. The Edge acknowledgement is a compatibility guard; it does not replace
|
||||||
|
coordinated deployment of the platform's financial services.
|
||||||
|
|
||||||
|
Reuse `api.sendico` and its existing Vault-rendered token file. Do not put provider
|
||||||
|
credentials or organization/source settings in Flutter. Provision the integration
|
||||||
|
account's organization membership, payment-create/read, ledger-account/balance
|
||||||
|
read and the permissions needed to resolve its stored payment methods. Verify
|
||||||
|
those permissions in the intended environment before enabling real operations.
|
||||||
|
|
||||||
|
Through Sendico's existing operator APIs, provision one dedicated unowned
|
||||||
|
organization ledger per customer currency, with active `asset` / `operating`
|
||||||
|
semantics, no negative balance and metadata:
|
||||||
|
|
||||||
|
| Ledger metadata | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `sxchnge_subject_ref` | Confirmed independent subject reference from sign-up |
|
||||||
|
| `sxchnge_payout_method_ref` | Optional prevalidated same-currency saved payout method |
|
||||||
|
| `sxchnge_payout_label` | Safe customer-visible destination label, e.g. bank plus masked suffix |
|
||||||
|
| `sxchnge_funding_method_ref` | Optional same-currency saved funding source supported by the platform route |
|
||||||
|
|
||||||
|
These are financial-resource bindings maintained by trusted provisioning, not
|
||||||
|
client choices or new runtime configuration. Do not tag a pooled/integration-login
|
||||||
|
ledger as a customer ledger. Do not place full bank/card details or secret values
|
||||||
|
in labels or metadata. The BFF checks subject, organization, account type, role,
|
||||||
|
status and owner on each account projection/quotation. It accepts only the bound
|
||||||
|
saved payout method and replaces its opaque reference with the safe label in the
|
||||||
|
reviewed quote. No arbitrary recipient identifier or free-text bank input can
|
||||||
|
be executed from the live UI. Missing bindings have an explanatory disabled
|
||||||
|
review action. Funding uses a quote for the provisioned stored source; it does
|
||||||
|
not mint money or treat demo top-ups as real deposits. Additional destinations,
|
||||||
|
recipient onboarding and other funding instructions remain separate features.
|
||||||
|
|
||||||
|
An empty set of provisioned ledgers is an empty wallet. Existing organization
|
||||||
|
payments and pooled balances are never imported into customer history. This
|
||||||
|
integration exposes the durable operations initiated through this BFF, with live
|
||||||
|
provider reconciliation; independent inbound deposits/history ingestion and
|
||||||
|
pagination are not added by this contribution. Ledger balances are posted ledger
|
||||||
|
projections; execution-time solvency and holds remain the platform's authority.
|
||||||
|
|
||||||
|
## Authorization, idempotency and recovery
|
||||||
|
|
||||||
|
Local routes are under `/accounts/wallet`: snapshot GET, `/profile` GET,
|
||||||
|
`/quote`, `/challenge`, `/resend`, `/verify`, `/submit` and `/submission` POST.
|
||||||
|
All use the existing full authenticated-account wrapper, access token and browser
|
||||||
|
CSRF guard. Pending-login credentials cannot access them. Local quote references
|
||||||
|
are random BFF IDs; provider quote/payment references are not accepted as user
|
||||||
|
ownership evidence.
|
||||||
|
|
||||||
|
`payment_quotes` stores customer, adapter namespace, confirmed subject, immutable
|
||||||
|
quote, upstream quote reference, authorized execution key and eventual payment
|
||||||
|
reference. A unique partial index enforces customer/namespace/key uniqueness.
|
||||||
|
The existing second-factor service uses the dedicated `payment` purpose and
|
||||||
|
exact local quote context. Successful code verification consumes its one-use
|
||||||
|
proof in the same local Mongo transaction that records execution authorization.
|
||||||
|
No external I/O runs in that transaction and raw factor proofs never leave the
|
||||||
|
server. Verification response retries retain their key and recover committed
|
||||||
|
intent. Resend uses a separate stable-per-retry issuance key.
|
||||||
|
|
||||||
|
Submit/reconciliation replays `sxchnge:payment:<local-quote-id>` at Sendico.
|
||||||
|
A quote cannot be authorized under a second execution key. A network failure or
|
||||||
|
lost provider response remains uncertain; it never permits execution with a new
|
||||||
|
provider key. Once the provider payment reference is attached, refresh uses GET.
|
||||||
|
Authorization is durable: reopening the wallet reconciles authorized intents even
|
||||||
|
if the original client never received verification/submission success. Unknown
|
||||||
|
outcomes remain pending and do not hide unrelated balances. `needs_attention`
|
||||||
|
also stays pending; only `settled`/`failed` become terminal UI outcomes.
|
||||||
|
|
||||||
|
Generic upstream errors cannot prove that no payment exists. An unresolved
|
||||||
|
pre-execution rejection (including an expired quote after durable authorization)
|
||||||
|
therefore requires operational reconciliation rather than automatic release or a
|
||||||
|
new payment. Inspect the local quote/provider reference and platform audit trail;
|
||||||
|
never delete/rekey a record to bypass uncertainty. The UI's live copy does not
|
||||||
|
promise a debit, refund or completion from mere acceptance. Only local drafts,
|
||||||
|
not durable authorized intents, count as abandoned when a user leaves.
|
||||||
|
|
||||||
|
The adapter rejects changed-session HTTP responses, and the app clears wallet
|
||||||
|
snapshots/journeys when the authenticated customer changes. Account-language
|
||||||
|
reads use the authenticated customer. Backend language saving is deliberately
|
||||||
|
unsupported and reports failure; the previous demo setting is not presented as
|
||||||
|
persisted backend state.
|
||||||
|
|
||||||
|
## UX, observation and validation
|
||||||
|
|
||||||
|
The existing typed transfer journey recorder measures draft → quotation →
|
||||||
|
second-factor challenge/verification → submission → authoritative settlement.
|
||||||
|
Attempts/retries share their client journey; accepted operations finish once
|
||||||
|
when the wallet observes a terminal platform status. An uncertain result is not
|
||||||
|
success. Leaving an unresolved asynchronous operation is not draft abandonment.
|
||||||
|
Use the existing per-attempt timestamps for durations and a 30-minute inactivity
|
||||||
|
window for unfinished local drafts; server-authorized pending operations remain
|
||||||
|
outside that abandonment denominator. Compare draft-to-quote and quote-to-submit
|
||||||
|
drop-off, intent-to-terminal completion/failure and time to terminal outcome.
|
||||||
|
The existing recorder provides in-memory client evidence only: deployment of a
|
||||||
|
retained analytics sink and backend event delivery is needed for cross-session
|
||||||
|
conversion/settlement measurement. No amounts, recipients, bank labels, codes or
|
||||||
|
credentials enter analytics. Structured server logs use quote/payment references
|
||||||
|
and bounded states; operational reconciliation uses the durable database record.
|
||||||
|
|
||||||
|
Tests cover public wire requests, exact money, source/destination isolation,
|
||||||
|
blocked/unknown states, factor errors and retry keys, changed-session responses,
|
||||||
|
durable authorization rollback/uniqueness, lost-response reconciliation and
|
||||||
|
customer/provider-namespace isolation. Widget checks exercise saved destinations
|
||||||
|
and the missing-destination state at 320px with 1.6× text, alongside the existing
|
||||||
|
EN/RU workspace flows. Default Flutter keyboard/focus semantics are retained;
|
||||||
|
full browser/screen-reader acceptance and live funded settlement are deployment
|
||||||
|
checks, not claimed by synthetic tests. No live payments or deployment are run.
|
||||||
|
|
||||||
|
Validation passed after review: `make test` (backend race tests and all frontend
|
||||||
|
suites), `make lint-backend`, a subsequent `make test-frontend` after the final
|
||||||
|
long-label UI adjustment, changed-Dart format verification, and `flutter analyze`
|
||||||
|
in `frontend/app` and `frontend/packages/sxchnge_wallet`. The final frontend run
|
||||||
|
passed 21 wallet, 20 KYC, 7 analytics, 40 auth, 3 theme, 3 theme-example and 81 app
|
||||||
|
tests. The provider prerequisite passed Sendico's full backend test/lint targets.
|
||||||
|
|
||||||
|
Delivery: [SXCHNGE PR #51](https://git.sendico.io/tech/sxchnge/pulls/51), stacked
|
||||||
|
on PR #49. Merge #47 → #49 → #51. The separate
|
||||||
|
[Sendico provider PR #2897](https://git.sendico.io/tech/sendico/pulls/2897) must be
|
||||||
|
deployed before enabling this consumer. Both implementation branches are pushed;
|
||||||
|
merge, deployment and real financial-resource provisioning remain separate.
|
||||||
+221
-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
|
||||||
@@ -175,3 +387,8 @@ The EN/RU error state is exercised at a 390px viewport with 1.5x text scaling;
|
|||||||
existing keyboard regression tests and a code review of the live-region semantics
|
existing keyboard regression tests and a code review of the live-region semantics
|
||||||
cover the unchanged controls. No manual browser or screen-reader walkthrough
|
cover the unchanged controls. No manual browser or screen-reader walkthrough
|
||||||
against a live Sendico deployment was performed.
|
against a live Sendico deployment was performed.
|
||||||
|
|
||||||
|
The initial sign-up assertion remains a separate immutable submission. Optional
|
||||||
|
profile/ID/selfie collection, later account uploads and reviewed limits are now
|
||||||
|
covered by [Customer identity verification](identity-verification.md), task #48.
|
||||||
|
Proofs are not required to activate or log into a basic account.
|
||||||
@@ -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,33 @@ 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.
|
||||||
|
|
||||||
|
## Empty currency accounts (task #66)
|
||||||
|
|
||||||
|
The shared empty-account message used on Accounts and transfer screens explains
|
||||||
|
that no currency accounts have been set up and directs the customer to support
|
||||||
|
to request setup. English and Russian no longer suggest that refreshing creates
|
||||||
|
accounts or imply that automatic provisioning is underway. This follows the
|
||||||
|
existing support guidance for missing funding and payout setup. The application
|
||||||
|
has no configured support contact channel; the message therefore provides text
|
||||||
|
guidance without a nonfunctional contact button or invented contact details.
|
||||||
|
|
||||||
|
UX review: the copy names the missing resource and the next step, retains the
|
||||||
|
existing wrapping text and accessible reading order, and does not alter loading,
|
||||||
|
error or populated-state behavior. Existing manual refresh remains available for
|
||||||
|
accounts provisioned externally. This copy-only change adds no controls or
|
||||||
|
journey outcomes and requires no new analytics events. Existing transaction
|
||||||
|
journey signals and their production measurement limitations below still apply;
|
||||||
|
support resolution and external account provisioning are not measured in-app.
|
||||||
|
|
||||||
|
Validation: `flutter gen-l10n`, `flutter analyze` in `frontend/app`,
|
||||||
|
`make test-frontend` (217 tests), and `git diff --check` passed. No Dart source
|
||||||
|
changed, so Dart formatting was not applicable. Existing responsive workspace
|
||||||
|
tests cover Russian at 320px with enlarged text; this copy-only change does not
|
||||||
|
add a dedicated empty-state rendering or native screen-reader test. No security
|
||||||
|
scan was requested. Delivery: [task #66](https://git.sendico.io/tech/sxchnge/issues/66),
|
||||||
|
branch `wallet-66`, [PR #68](https://git.sendico.io/tech/sxchnge/pulls/68).
|
||||||
|
Merge and deployment remain separate.
|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
Task [#20](https://git.sendico.io/tech/sxchnge/issues/20). Plan recorded before implementation.
|
Task [#20](https://git.sendico.io/tech/sxchnge/issues/20). Plan recorded before implementation.
|
||||||
|
|
||||||
|
> This original demo plan is retained as design history. Production identity
|
||||||
|
> uploads, staff review and authoritative limits are implemented by task #48;
|
||||||
|
> see [Customer identity verification](../identity-verification.md). Demo behavior
|
||||||
|
> below applies only when the backend adapters are not selected.
|
||||||
|
|
||||||
## User outcome and scope
|
## User outcome and scope
|
||||||
|
|
||||||
Understand current access, decide whether higher limits are useful, and submit two
|
Understand current access, decide whether higher limits are useful, and submit two
|
||||||
@@ -162,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.
|
||||||
@@ -15,7 +15,15 @@ flutter run -d chrome
|
|||||||
|
|
||||||
A responsive login/sign-up preview with a themed brand panel, email and password validation, password visibility controls, keyboard submission, and inline feedback. Sign-up additionally asks for a name and matching password confirmation. The 12-character sign-up minimum is a UI preview policy and must be aligned with the backend when authentication is integrated.
|
A responsive login/sign-up preview with a themed brand panel, email and password validation, password visibility controls, keyboard submission, and inline feedback. Sign-up additionally asks for a name and matching password confirmation. The 12-character sign-up minimum is a UI preview policy and must be aligned with the backend when authentication is integrated.
|
||||||
|
|
||||||
No authentication endpoint is configured. Submission validates locally and calls the shared dummy authentication services; it never creates an account, starts a session, sends credentials, or persists them. Passwords are cleared when switching modes. Password managers/autofill integration is deferred until real authentication exists. LoginProvider and SignupProvider own loading/results/errors; SxchngeApp accepts injected loginService and signupService implementations. Dummy completion is explicitly labeled and does not establish a real session.
|
Without backend configuration the app uses dummy services. The existing
|
||||||
|
`SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID` select authenticated backend auth,
|
||||||
|
identity, limits and wallet adapters. Wallet operations use Sendico through the
|
||||||
|
BFF after the provider prerequisites and dedicated customer resource bindings
|
||||||
|
are provisioned; they do not use demo balances in backend mode. LoginProvider,
|
||||||
|
SignupProvider and shared wallet providers own loading/results/errors.
|
||||||
|
SxchngeApp retains explicit service injection for tests and previews. See
|
||||||
|
[quotation/payment integration](../../docs/payments.md) for setup, supported
|
||||||
|
funding/destinations, authorization, reconciliation and rollout.
|
||||||
|
|
||||||
The interface uses `ColorScheme` and `TextTheme` roles from `sxchnge_theme`; the desktop brand panel installs the package's dark theme locally. Entrance animation respects reduced-motion preferences. The public website remains separate in `website/`.
|
The interface uses `ColorScheme` and `TextTheme` roles from `sxchnge_theme`; the desktop brand panel installs the package's dark theme locally. Entrance animation respects reduced-motion preferences. The public website remains separate in `website/`.
|
||||||
|
|
||||||
|
|||||||
+164
-119
@@ -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';
|
||||||
|
|
||||||
@@ -8,6 +9,8 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
|||||||
|
|
||||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
import '../routing/routes.dart';
|
import '../routing/routes.dart';
|
||||||
import '../security/challenge.dart';
|
import '../security/challenge.dart';
|
||||||
import 'feedback.dart';
|
import 'feedback.dart';
|
||||||
@@ -16,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();
|
||||||
@@ -30,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 =>
|
||||||
@@ -61,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();
|
||||||
@@ -93,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(
|
||||||
@@ -105,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,
|
||||||
@@ -121,14 +129,29 @@ 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;
|
||||||
}
|
}
|
||||||
await login.complete(challenge, grant);
|
await login.complete(challenge, grant);
|
||||||
if (mounted && login.state.status == AuthStatus.success) {
|
if (mounted && login.state.status == AuthStatus.success) {
|
||||||
context.go('/accounts');
|
final accepted = login.state.result!;
|
||||||
|
final offer = await context.read<IdentityOnboardingProvider>().offer(
|
||||||
|
accepted.userId,
|
||||||
|
);
|
||||||
|
if (mounted &&
|
||||||
|
!_signUp &&
|
||||||
|
identical(login.state.result, accepted) &&
|
||||||
|
login.state.status == AuthStatus.success) {
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
|
_password.clear();
|
||||||
|
context.go(offer ? '/identity?onboarding=1' : '/accounts');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else if (login.state.status == AuthStatus.success) {
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,148 +207,170 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
expandedInsets: EdgeInsets.zero,
|
expandedInsets: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Form(
|
AutofillGroup(
|
||||||
key: _form,
|
// Leaving an unfinished form must not offer to save its credentials.
|
||||||
autovalidateMode: _submitted
|
onDisposeAction: AutofillContextAction.cancel,
|
||||||
? AutovalidateMode.onUserInteraction
|
child: Form(
|
||||||
: AutovalidateMode.disabled,
|
key: _form,
|
||||||
onChanged: () {
|
autovalidateMode: _submitted
|
||||||
if (_provider.state.status == AuthStatus.success ||
|
? AutovalidateMode.onUserInteraction
|
||||||
_provider.state.status == AuthStatus.error) {
|
: AutovalidateMode.disabled,
|
||||||
_provider.reset();
|
onChanged: () {
|
||||||
}
|
if (_provider.state.status == AuthStatus.success ||
|
||||||
},
|
_provider.state.status == AuthStatus.error) {
|
||||||
child: Column(
|
_provider.reset();
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
}
|
||||||
children: [
|
},
|
||||||
if (_signUp) ...[
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (_signUp) ...[
|
||||||
|
TextFormField(
|
||||||
|
key: const ValueKey('name'),
|
||||||
|
controller: _name,
|
||||||
|
enabled: !state.isLoading,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.name,
|
||||||
|
prefixIcon: Icon(Icons.person_outline),
|
||||||
|
errorMaxLines: 3,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
keyboardType: TextInputType.name,
|
||||||
|
autofillHints: const [AutofillHints.name],
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
validator: (value) => AuthValidation.name(value, l),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
],
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: const ValueKey('name'),
|
key: const ValueKey('email'),
|
||||||
controller: _name,
|
controller: _email,
|
||||||
enabled: !state.isLoading,
|
enabled: !state.isLoading,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: l.name,
|
labelText: l.email,
|
||||||
prefixIcon: Icon(Icons.person_outline),
|
hintText: l.emailHint,
|
||||||
|
prefixIcon: Icon(Icons.alternate_email),
|
||||||
errorMaxLines: 3,
|
errorMaxLines: 3,
|
||||||
),
|
),
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
autofillHints: const [
|
||||||
|
AutofillHints.username,
|
||||||
|
AutofillHints.email,
|
||||||
|
],
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
textCapitalization: TextCapitalization.words,
|
autocorrect: false,
|
||||||
validator: (value) => AuthValidation.name(value, l),
|
validator: (value) => AuthValidation.email(value, l),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
],
|
|
||||||
TextFormField(
|
|
||||||
key: const ValueKey('email'),
|
|
||||||
controller: _email,
|
|
||||||
enabled: !state.isLoading,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: l.email,
|
|
||||||
hintText: l.emailHint,
|
|
||||||
prefixIcon: Icon(Icons.alternate_email),
|
|
||||||
errorMaxLines: 3,
|
|
||||||
),
|
|
||||||
keyboardType: TextInputType.emailAddress,
|
|
||||||
textInputAction: TextInputAction.next,
|
|
||||||
autocorrect: false,
|
|
||||||
validator: (value) => AuthValidation.email(value, l),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
TextFormField(
|
|
||||||
key: const ValueKey('password'),
|
|
||||||
enabled: !state.isLoading,
|
|
||||||
controller: _password,
|
|
||||||
obscureText: !_showPassword,
|
|
||||||
autocorrect: false,
|
|
||||||
enableSuggestions: false,
|
|
||||||
textInputAction: _signUp
|
|
||||||
? TextInputAction.next
|
|
||||||
: TextInputAction.done,
|
|
||||||
onFieldSubmitted: (_) {
|
|
||||||
if (!_signUp) _submit();
|
|
||||||
},
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: l.password,
|
|
||||||
prefixIcon: const Icon(Icons.lock_outline),
|
|
||||||
errorMaxLines: 3,
|
|
||||||
helperText: _signUp ? l.passwordHint : null,
|
|
||||||
helperMaxLines: 3,
|
|
||||||
suffixIcon: IconButton(
|
|
||||||
tooltip: _showPassword ? l.hidePassword : l.showPassword,
|
|
||||||
onPressed: () =>
|
|
||||||
setState(() => _showPassword = !_showPassword),
|
|
||||||
icon: Icon(
|
|
||||||
_showPassword
|
|
||||||
? Icons.visibility_off_outlined
|
|
||||||
: Icons.visibility_outlined,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
validator: (value) =>
|
|
||||||
AuthValidation.password(value, l, signUp: _signUp),
|
|
||||||
),
|
|
||||||
if (_signUp) ...[
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: const ValueKey('confirmation'),
|
key: const ValueKey('password'),
|
||||||
enabled: !state.isLoading,
|
enabled: !state.isLoading,
|
||||||
controller: _confirmation,
|
controller: _password,
|
||||||
obscureText: !_showConfirmation,
|
autofocus: widget.email?.isNotEmpty ?? false,
|
||||||
|
obscureText: !_showPassword,
|
||||||
|
autofillHints: [
|
||||||
|
_signUp
|
||||||
|
? AutofillHints.newPassword
|
||||||
|
: AutofillHints.password,
|
||||||
|
],
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
enableSuggestions: false,
|
enableSuggestions: false,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: _signUp
|
||||||
onFieldSubmitted: (_) => _submit(),
|
? TextInputAction.next
|
||||||
|
: TextInputAction.done,
|
||||||
|
onFieldSubmitted: (_) {
|
||||||
|
if (!_signUp) _submit();
|
||||||
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: l.confirmPassword,
|
labelText: l.password,
|
||||||
prefixIcon: const Icon(Icons.lock_outline),
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
errorMaxLines: 3,
|
errorMaxLines: 3,
|
||||||
|
helperText: _signUp ? l.passwordHint : null,
|
||||||
|
helperMaxLines: 3,
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
tooltip: _showConfirmation
|
tooltip: _showPassword ? l.hidePassword : l.showPassword,
|
||||||
? l.hideConfirmation
|
onPressed: () =>
|
||||||
: l.showConfirmation,
|
setState(() => _showPassword = !_showPassword),
|
||||||
onPressed: () => setState(
|
|
||||||
() => _showConfirmation = !_showConfirmation,
|
|
||||||
),
|
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_showConfirmation
|
_showPassword
|
||||||
? Icons.visibility_off_outlined
|
? Icons.visibility_off_outlined
|
||||||
: Icons.visibility_outlined,
|
: Icons.visibility_outlined,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
validator: (value) =>
|
validator: (value) =>
|
||||||
AuthValidation.confirmation(value, _password.text, l),
|
AuthValidation.password(value, l, signUp: _signUp),
|
||||||
),
|
),
|
||||||
],
|
if (_signUp) ...[
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 20),
|
||||||
FilledButton(
|
TextFormField(
|
||||||
key: const ValueKey('submit'),
|
key: const ValueKey('confirmation'),
|
||||||
onPressed: state.isLoading ? null : _submit,
|
enabled: !state.isLoading,
|
||||||
child: Padding(
|
controller: _confirmation,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
obscureText: !_showConfirmation,
|
||||||
child: Row(
|
autofillHints: const [AutofillHints.newPassword],
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
autocorrect: false,
|
||||||
children: [
|
enableSuggestions: false,
|
||||||
Flexible(
|
textInputAction: TextInputAction.done,
|
||||||
child: Text(
|
onFieldSubmitted: (_) => _submit(),
|
||||||
state.isLoading
|
decoration: InputDecoration(
|
||||||
? l.authWorking
|
labelText: l.confirmPassword,
|
||||||
: (_signUp ? l.createAccount : l.letsGo),
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
textAlign: TextAlign.center,
|
errorMaxLines: 3,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
tooltip: _showConfirmation
|
||||||
|
? l.hideConfirmation
|
||||||
|
: l.showConfirmation,
|
||||||
|
onPressed: () => setState(
|
||||||
|
() => _showConfirmation = !_showConfirmation,
|
||||||
|
),
|
||||||
|
icon: Icon(
|
||||||
|
_showConfirmation
|
||||||
|
? Icons.visibility_off_outlined
|
||||||
|
: Icons.visibility_outlined,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
),
|
||||||
state.isLoading
|
validator: (value) =>
|
||||||
? const SizedBox(
|
AuthValidation.confirmation(value, _password.text, l),
|
||||||
width: 20,
|
),
|
||||||
height: 20,
|
],
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
const SizedBox(height: 28),
|
||||||
)
|
FilledButton(
|
||||||
: const Icon(Icons.arrow_forward, size: 20),
|
key: const ValueKey('submit'),
|
||||||
],
|
onPressed:
|
||||||
|
state.isLoading || state.status == AuthStatus.success
|
||||||
|
? null
|
||||||
|
: _submit,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
state.isLoading
|
||||||
|
? l.authWorking
|
||||||
|
: (_signUp ? l.createAccount : l.letsGo),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
state.isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.arrow_forward, size: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (state.status == AuthStatus.success ||
|
if (state.status == AuthStatus.success ||
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,16 +22,17 @@ String reasonText(AppLocalizations l, ReviewReason r) => switch (r) {
|
|||||||
ReviewReason.passportExpired => l.kycExpiredReason,
|
ReviewReason.passportExpired => l.kycExpiredReason,
|
||||||
ReviewReason.unableToVerify => l.kycDeniedReason,
|
ReviewReason.unableToVerify => l.kycDeniedReason,
|
||||||
};
|
};
|
||||||
String errorText(AppLocalizations l, KycError e) => switch (e) {
|
String errorText(AppLocalizations l, KycError e, {bool photo = false}) =>
|
||||||
KycError.unavailable => l.kycErrorUnavailable,
|
switch (e) {
|
||||||
KycError.internal => l.kycErrorInternal,
|
KycError.unavailable => l.kycErrorUnavailable,
|
||||||
KycError.permission => l.kycErrorPermission,
|
KycError.internal => l.kycErrorInternal,
|
||||||
KycError.format => l.kycErrorFormat,
|
KycError.permission => photo ? l.kycErrorPermission : l.kycErrorAccess,
|
||||||
KycError.tooLarge => l.kycErrorLarge,
|
KycError.format => l.kycErrorFormat,
|
||||||
KycError.unreadable => l.kycErrorUnreadable,
|
KycError.tooLarge => l.kycErrorLarge,
|
||||||
KycError.tooSmall => l.kycErrorSmall,
|
KycError.unreadable => l.kycErrorUnreadable,
|
||||||
KycError.incomplete => l.kycErrorIncomplete,
|
KycError.tooSmall => l.kycErrorSmall,
|
||||||
KycError.sessionExpired => l.kycErrorSession,
|
KycError.incomplete => l.kycErrorIncomplete,
|
||||||
KycError.rateLimited => l.kycErrorRate,
|
KycError.sessionExpired => l.kycErrorSession,
|
||||||
KycError.conflict => l.kycErrorConflict,
|
KycError.rateLimited => l.kycErrorRate,
|
||||||
};
|
KycError.conflict => l.kycErrorConflict,
|
||||||
|
};
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
import 'photo.dart';
|
import 'photo.dart';
|
||||||
|
import 'profile.dart';
|
||||||
|
|
||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -26,6 +27,10 @@ class IdentityForm extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
if (p.step == 0) ...[
|
||||||
|
ProfileForm(provider: p),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
value: (p.step + 1) / 3,
|
value: (p.step + 1) / 3,
|
||||||
semanticsLabel: titles[p.step],
|
semanticsLabel: titles[p.step],
|
||||||
@@ -47,6 +52,10 @@ class IdentityForm extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (p.step == 2) ...[
|
if (p.step == 2) ...[
|
||||||
|
Text(
|
||||||
|
'${p.profile.fullName}\n${p.profile.dateOfBirth} · ${p.profile.residenceCountry}',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
Text(l.kycReviewHelp),
|
Text(l.kycReviewHelp),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
PhotoPanel(kind: PhotoKind.passport, photo: p.passport, review: true),
|
PhotoPanel(kind: PhotoKind.passport, photo: p.passport, review: true),
|
||||||
@@ -68,7 +77,10 @@ class IdentityForm extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
key: const ValueKey('kyc-next'),
|
key: const ValueKey('kyc-next'),
|
||||||
onPressed: p.busy || (p.step == 0 ? p.passport : p.selfie) == null
|
onPressed:
|
||||||
|
p.busy ||
|
||||||
|
(p.step == 0 && !p.profile.isComplete) ||
|
||||||
|
(p.step == 0 ? p.passport : p.selfie) == null
|
||||||
? null
|
? null
|
||||||
: p.next,
|
: p.next,
|
||||||
child: Text(l.kycNext),
|
child: Text(l.kycNext),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
import 'policy.dart';
|
||||||
|
|
||||||
class LimitsPanel extends StatelessWidget {
|
class LimitsPanel extends StatelessWidget {
|
||||||
const LimitsPanel({super.key});
|
const LimitsPanel({super.key});
|
||||||
@@ -48,7 +49,9 @@ class LimitsPanel extends StatelessWidget {
|
|||||||
style: t.textTheme.titleLarge,
|
style: t.textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (data.rows.isEmpty) Text(l.kycLimitsEmpty),
|
if (data.rows.isEmpty && data.entries.isEmpty)
|
||||||
|
Text(l.kycLimitsEmpty),
|
||||||
|
for (final row in data.entries) PolicyRow(row: row),
|
||||||
for (final row in data.rows) ...[
|
for (final row in data.rows) ...[
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import 'status.dart';
|
|||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
class IdentityPage extends StatefulWidget {
|
class IdentityPage extends StatefulWidget {
|
||||||
const IdentityPage({super.key});
|
const IdentityPage({super.key, this.onboarding = false});
|
||||||
|
final bool onboarding;
|
||||||
@override
|
@override
|
||||||
State<IdentityPage> createState() => IdentityPageState();
|
State<IdentityPage> createState() => IdentityPageState();
|
||||||
}
|
}
|
||||||
@@ -98,6 +99,10 @@ class IdentityPageState extends State<IdentityPage> {
|
|||||||
if (!await allowExit() || !mounted) return;
|
if (!await allowExit() || !mounted) return;
|
||||||
_identity.exit();
|
_identity.exit();
|
||||||
if (account) {
|
if (account) {
|
||||||
|
if (widget.onboarding) {
|
||||||
|
context.go('/accounts');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (context.canPop()) {
|
if (context.canPop()) {
|
||||||
context.pop();
|
context.pop();
|
||||||
} else {
|
} else {
|
||||||
@@ -166,7 +171,11 @@ class IdentityPageState extends State<IdentityPage> {
|
|||||||
key: _feedbackKey,
|
key: _feedbackKey,
|
||||||
liveRegion: true,
|
liveRegion: true,
|
||||||
child: Text(
|
child: Text(
|
||||||
errorText(l, p.error!),
|
errorText(
|
||||||
|
l,
|
||||||
|
p.error!,
|
||||||
|
photo: p.photoError != null,
|
||||||
|
),
|
||||||
key: const ValueKey('kyc-error'),
|
key: const ValueKey('kyc-error'),
|
||||||
style: TextStyle(color: t.colorScheme.error),
|
style: TextStyle(color: t.colorScheme.error),
|
||||||
),
|
),
|
||||||
@@ -178,6 +187,16 @@ class IdentityPageState extends State<IdentityPage> {
|
|||||||
l.kycStatusStale,
|
l.kycStatusStale,
|
||||||
style: TextStyle(color: t.colorScheme.error),
|
style: TextStyle(color: t.colorScheme.error),
|
||||||
),
|
),
|
||||||
|
if (p.error == KycError.sessionExpired)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (await allowExit() && mounted) {
|
||||||
|
_identity.exit();
|
||||||
|
if (context.mounted) context.go('/');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(l.login),
|
||||||
|
),
|
||||||
Focus(
|
Focus(
|
||||||
includeSemantics: false,
|
includeSemantics: false,
|
||||||
key: _contentKey,
|
key: _contentKey,
|
||||||
@@ -198,7 +217,7 @@ class IdentityPageState extends State<IdentityPage> {
|
|||||||
const LimitsPanel(),
|
const LimitsPanel(),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (p.editing)
|
if (p.editing && !widget.onboarding)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: p.busy ? null : _leave,
|
onPressed: p.busy ? null : _leave,
|
||||||
child: Text(l.kycSkip),
|
child: Text(l.kycSkip),
|
||||||
@@ -207,7 +226,15 @@ class IdentityPageState extends State<IdentityPage> {
|
|||||||
onPressed: p.busy
|
onPressed: p.busy
|
||||||
? null
|
? null
|
||||||
: () => _leave(account: true),
|
: () => _leave(account: true),
|
||||||
child: Text(l.wLimits),
|
child: Text(
|
||||||
|
widget.onboarding
|
||||||
|
? (p.record?.status ==
|
||||||
|
IdentityStatus.unverified ||
|
||||||
|
p.editing
|
||||||
|
? l.kycSkip
|
||||||
|
: l.kycContinue)
|
||||||
|
: l.wLimits,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Display exact provider decimals; never infer currency scale or active caps.
|
||||||
|
class PolicyRow extends StatelessWidget {
|
||||||
|
const PolicyRow({super.key, required this.row});
|
||||||
|
final PolicyLimit row;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Divider(height: 32),
|
||||||
|
Text(
|
||||||
|
'${row.operation} · ${switch (row.period) {
|
||||||
|
'day' => l.kycDay,
|
||||||
|
'month' => l.kycMonth,
|
||||||
|
'operationMin' => l.kycOperationMinimum,
|
||||||
|
'operationMax' => l.kycOperationMaximum,
|
||||||
|
_ => row.period,
|
||||||
|
}}',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 28,
|
||||||
|
runSpacing: 16,
|
||||||
|
children: [
|
||||||
|
_amount(context, l.kycCurrent, row.cap),
|
||||||
|
if (row.remaining.isNotEmpty)
|
||||||
|
_amount(context, l.kycRemaining, row.remaining),
|
||||||
|
if (row.used.isNotEmpty) _amount(context, l.wUsage, row.used),
|
||||||
|
if (row.reserved.isNotEmpty)
|
||||||
|
_amount(context, l.kycReserved, row.reserved),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _amount(BuildContext context, String label, String amount) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label),
|
||||||
|
Text(
|
||||||
|
'$amount ${row.currency}',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'birth.dart';
|
||||||
|
import 'country.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class ProfileForm extends StatelessWidget {
|
||||||
|
const ProfileForm({super.key, required this.provider});
|
||||||
|
final IdentityProvider provider;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = provider;
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
void update({String? name, String? birth, String? country}) =>
|
||||||
|
p.updateProfile(
|
||||||
|
IdentityProfile(
|
||||||
|
fullName: name ?? p.profile.fullName,
|
||||||
|
dateOfBirth: birth ?? p.profile.dateOfBirth,
|
||||||
|
residenceCountry: country ?? p.profile.residenceCountry,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(l.kycProfileHelp),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
key: const ValueKey('kyc-name'),
|
||||||
|
initialValue: p.profile.fullName,
|
||||||
|
enabled: !p.busy,
|
||||||
|
maxLength: 200,
|
||||||
|
autofillHints: const [AutofillHints.name],
|
||||||
|
decoration: InputDecoration(labelText: l.kycFullName),
|
||||||
|
onChanged: (value) => update(name: value),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
BirthField(
|
||||||
|
key: const ValueKey('kyc-birth'),
|
||||||
|
value: p.profile.dateOfBirth,
|
||||||
|
enabled: !p.busy,
|
||||||
|
onChanged: (value) => update(birth: value),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
CountryField(
|
||||||
|
key: const ValueKey('kyc-country'),
|
||||||
|
value: p.profile.residenceCountry,
|
||||||
|
enabled: !p.busy,
|
||||||
|
onChanged: (value) => update(country: value),
|
||||||
|
),
|
||||||
|
if (!p.profile.isComplete) Text(l.kycProfileRequired),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,9 @@ class StatusPanel extends StatelessWidget {
|
|||||||
header: true,
|
header: true,
|
||||||
liveRegion: true,
|
liveRegion: true,
|
||||||
child: Text(
|
child: Text(
|
||||||
statusTitle(l, status),
|
record.upgradePending
|
||||||
|
? l.kycUpgradePendingTitle
|
||||||
|
: statusTitle(l, status),
|
||||||
key: ValueKey('kyc-${status.name}'),
|
key: ValueKey('kyc-${status.name}'),
|
||||||
style: t.textTheme.headlineMedium?.copyWith(
|
style: t.textTheme.headlineMedium?.copyWith(
|
||||||
color: t.colorScheme.onPrimaryContainer,
|
color: t.colorScheme.onPrimaryContainer,
|
||||||
@@ -53,7 +55,9 @@ class StatusPanel extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
statusBody(l, status),
|
record.upgradePending
|
||||||
|
? l.kycUpgradePendingBody
|
||||||
|
: statusBody(l, status),
|
||||||
style: TextStyle(color: t.colorScheme.onPrimaryContainer),
|
style: TextStyle(color: t.colorScheme.onPrimaryContainer),
|
||||||
),
|
),
|
||||||
if (record.reason != null) ...[
|
if (record.reason != null) ...[
|
||||||
|
|||||||
+142
-18
@@ -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",
|
||||||
@@ -73,19 +74,19 @@
|
|||||||
"kycUnverified": "Not verified",
|
"kycUnverified": "Not verified",
|
||||||
"kycPending": "In review",
|
"kycPending": "In review",
|
||||||
"kycApproved": "Identity verified",
|
"kycApproved": "Identity verified",
|
||||||
"kycChanges": "New photos needed",
|
"kycChanges": "Changes requested",
|
||||||
"kycDenied": "Verification declined",
|
"kycDenied": "Verification declined",
|
||||||
"kycUnverifiedBody": "You can keep using your account with basic limits. Submit a passport photo and a selfie holding it to request an upgrade.",
|
"kycUnverifiedBody": "You can keep using your account with basic limits. Submit an ID document photo and a selfie holding it to request an upgrade.",
|
||||||
"kycPendingBody": "Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.",
|
"kycPendingBody": "Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.",
|
||||||
"kycApprovedBody": "Your review is complete. Check your current limits below.",
|
"kycApprovedBody": "Your review is complete. Check your current limits below.",
|
||||||
"kycChangesBody": "Your account remains available with basic limits. Replace the photos and submit a new review request.",
|
"kycChangesBody": "Basic limits remain available. Check your details, replace the photos and submit a new review request.",
|
||||||
"kycDeniedBody": "Basic limits remain available. If you believe this decision is incorrect, contact support for a review.",
|
"kycDeniedBody": "Basic limits remain available. If you believe this decision is incorrect, contact support for a review.",
|
||||||
"kycPassportReason": "The passport details could not be read. Retake the photo in good light, with every edge visible.",
|
"kycPassportReason": "The ID document details could not be read. Retake the photo in good light, with every edge visible.",
|
||||||
"kycSelfieReason": "Your face or passport was not clear in the selfie. Keep both visible and in focus.",
|
"kycSelfieReason": "Your face or ID document was not clear in the selfie. Keep both visible and in focus.",
|
||||||
"kycExpiredReason": "The passport has expired. Use a valid passport and take both photos again.",
|
"kycExpiredReason": "The ID document has expired. Use a valid ID document and take both photos again.",
|
||||||
"kycDeniedReason": "We could not verify your identity from this submission.",
|
"kycDeniedReason": "We could not verify your identity from this submission.",
|
||||||
"kycStart": "Verify identity",
|
"kycStart": "Verify identity",
|
||||||
"kycResubmit": "Replace photos",
|
"kycResubmit": "Update details and photos",
|
||||||
"kycRefresh": "Refresh status & limits",
|
"kycRefresh": "Refresh status & limits",
|
||||||
"kycSupport": "Contact support",
|
"kycSupport": "Contact support",
|
||||||
"kycSupportBody": "Support is currently unavailable. Please try again later.",
|
"kycSupportBody": "Support is currently unavailable. Please try again later.",
|
||||||
@@ -100,11 +101,11 @@
|
|||||||
"kycLimitsError": "Limits could not be refreshed. Any values below are out of date. Retry to see your current access.",
|
"kycLimitsError": "Limits could not be refreshed. Any values below are out of date. Retry to see your current access.",
|
||||||
"kycLimitsEmpty": "No limits were returned. Refresh or check with support before making a transaction.",
|
"kycLimitsEmpty": "No limits were returned. Refresh or check with support before making a transaction.",
|
||||||
"kycLoading": "Loading your status…",
|
"kycLoading": "Loading your status…",
|
||||||
"kycPassport": "Passport photo",
|
"kycPassport": "ID document photo",
|
||||||
"kycSelfie": "Selfie with passport",
|
"kycSelfie": "Selfie with ID document",
|
||||||
"kycReview": "Review your photos",
|
"kycReview": "Review details and photos",
|
||||||
"kycPassportHelp": "Open your valid passport to the photo page. Show all four edges and keep text sharp. Avoid glare, blur and covered details.",
|
"kycPassportHelp": "Photograph the photo page or front of your valid ID document. Show all four edges and keep every detail readable. Avoid glare, blur and covered details.",
|
||||||
"kycSelfieHelp": "Hold the open passport beside your face. Show your whole face and the photo page in the same image. Keep both sharp and uncovered.",
|
"kycSelfieHelp": "Hold the same ID document beside your face. Show your whole face and the document details in the same image, sharp and uncovered.",
|
||||||
"kycFileRules": "JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.",
|
"kycFileRules": "JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.",
|
||||||
"kycChoose": "Choose photo",
|
"kycChoose": "Choose photo",
|
||||||
"kycReplace": "Choose a different photo",
|
"kycReplace": "Choose a different photo",
|
||||||
@@ -113,12 +114,12 @@
|
|||||||
"kycBack": "Back",
|
"kycBack": "Back",
|
||||||
"kycSkip": "Keep basic limits",
|
"kycSkip": "Keep basic limits",
|
||||||
"kycClose": "Back to account",
|
"kycClose": "Back to account",
|
||||||
"kycReviewHelp": "Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.",
|
"kycReviewHelp": "Check your details and make sure both photos are readable and show the same ID document. When you submit, they are stored privately for staff review. Submission does not immediately raise your limits.",
|
||||||
"kycConfirm": "These photos show me and my own valid passport.",
|
"kycConfirm": "These photos show me and my own valid ID document.",
|
||||||
"kycSubmit": "Submit for review",
|
"kycSubmit": "Submit for review",
|
||||||
"kycWorking": "Processing… Please keep this page open.",
|
"kycWorking": "Processing… Please keep this page open.",
|
||||||
"kycDiscardTitle": "Discard these photos?",
|
"kycDiscardTitle": "Discard this verification draft?",
|
||||||
"kycDiscardBody": "Your draft is only in memory. Leaving will remove the selected photos; you can start again later.",
|
"kycDiscardBody": "Leaving removes unsent profile changes and selected photos from this session. You can start again later.",
|
||||||
"kycStay": "Keep editing",
|
"kycStay": "Keep editing",
|
||||||
"kycDiscard": "Discard & leave",
|
"kycDiscard": "Discard & leave",
|
||||||
"kycErrorUnavailable": "The service is unavailable. Your selected photos are still here. Try again.",
|
"kycErrorUnavailable": "The service is unavailable. Your selected photos are still here. Try again.",
|
||||||
@@ -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,7 @@
|
|||||||
"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": "No currency accounts have been set up for you yet. Contact support to request account setup.",
|
||||||
"wRefresh": "Refresh",
|
"wRefresh": "Refresh",
|
||||||
"wSource": "From account",
|
"wSource": "From account",
|
||||||
"wDestination": "To account",
|
"wDestination": "To account",
|
||||||
@@ -264,5 +266,127 @@
|
|||||||
},
|
},
|
||||||
"factorEmailMethod": "Email code",
|
"factorEmailMethod": "Email code",
|
||||||
"factorAppMethod": "Authenticator app",
|
"factorAppMethod": "Authenticator app",
|
||||||
"factorAppExpired": "This verification has expired. Return and start again."
|
"factorAppExpired": "This verification has expired. Return and start again.",
|
||||||
|
"kycProfileHelp": "Enter your details exactly as shown on your identity document.",
|
||||||
|
"kycFullName": "Full legal name",
|
||||||
|
"kycBirth": "Date of birth",
|
||||||
|
"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",
|
||||||
|
"kycCountrySelect": "Select a country",
|
||||||
|
"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",
|
||||||
|
"kycOperationMinimum": "Per operation minimum",
|
||||||
|
"kycOperationMaximum": "Per operation maximum",
|
||||||
|
"kycErrorAccess": "Verification is unavailable for this account. Contact support if this continues.",
|
||||||
|
"kycUpgradePendingTitle": "Limits upgrade pending",
|
||||||
|
"kycUpgradePendingBody": "Your identity review is approved. We are waiting for confirmation of the limits upgrade. You do not need to submit your photos again.",
|
||||||
|
"wNoPayoutMethod": "No payout destination is set up for this account. Contact support to add one.",
|
||||||
|
"wNoFundingMethod": "No funding method is set up for this account. Contact support for funding instructions.",
|
||||||
|
"wLivePending": "Your payment is awaiting a final outcome. Refresh to see its latest status and balance.",
|
||||||
|
"wLiveFailed": "Your payment did not complete. Refresh your accounts to see the current available balance.",
|
||||||
|
"wLiveQuoteNotice": "Quote expires in {seconds} seconds.",
|
||||||
|
"@wLiveQuoteNotice": {
|
||||||
|
"placeholders": {
|
||||||
|
"seconds": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
+143
-19
@@ -63,6 +63,7 @@
|
|||||||
"verificationRequest": "Отправить новое письмо",
|
"verificationRequest": "Отправить новое письмо",
|
||||||
"verificationRetry": "Повторить подтверждение",
|
"verificationRetry": "Повторить подтверждение",
|
||||||
"verificationBack": "Вернуться ко входу",
|
"verificationBack": "Вернуться ко входу",
|
||||||
|
"verificationContinue": "Перейти ко входу",
|
||||||
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
|
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
|
||||||
"verificationDemoLink": "Перейти к подтверждению",
|
"verificationDemoLink": "Перейти к подтверждению",
|
||||||
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
|
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
|
||||||
@@ -73,19 +74,19 @@
|
|||||||
"kycUnverified": "Личность не подтверждена",
|
"kycUnverified": "Личность не подтверждена",
|
||||||
"kycPending": "На проверке",
|
"kycPending": "На проверке",
|
||||||
"kycApproved": "Личность подтверждена",
|
"kycApproved": "Личность подтверждена",
|
||||||
"kycChanges": "Нужны новые фото",
|
"kycChanges": "Нужны исправления",
|
||||||
"kycDenied": "Верификация отклонена",
|
"kycDenied": "Верификация отклонена",
|
||||||
"kycUnverifiedBody": "Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото паспорта и селфи с ним.",
|
"kycUnverifiedBody": "Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото документа и селфи с ним.",
|
||||||
"kycPendingBody": "Заявка ожидает проверки сотрудником. До одобрения действуют базовые лимиты. Повторная отправка не нужна.",
|
"kycPendingBody": "Заявка ожидает проверки сотрудником. До одобрения действуют базовые лимиты. Повторная отправка не нужна.",
|
||||||
"kycApprovedBody": "Проверка завершена. Актуальные лимиты указаны ниже.",
|
"kycApprovedBody": "Проверка завершена. Актуальные лимиты указаны ниже.",
|
||||||
"kycChangesBody": "Аккаунт доступен с базовыми лимитами. Замените фото и отправьте новую заявку.",
|
"kycChangesBody": "Базовые лимиты доступны. Проверьте данные, замените фото и отправьте новый запрос на проверку.",
|
||||||
"kycDeniedBody": "Базовые лимиты остаются доступными. Если вы не согласны с решением, обратитесь в поддержку для пересмотра.",
|
"kycDeniedBody": "Базовые лимиты остаются доступными. Если вы не согласны с решением, обратитесь в поддержку для пересмотра.",
|
||||||
"kycPassportReason": "Данные паспорта не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.",
|
"kycPassportReason": "Данные документа не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.",
|
||||||
"kycSelfieReason": "На селфи плохо видно лицо или паспорт. Оба должны быть в кадре и в фокусе.",
|
"kycSelfieReason": "На селфи плохо видно лицо или документ. Оба должны быть в кадре и в фокусе.",
|
||||||
"kycExpiredReason": "Срок действия паспорта истёк. Используйте действующий паспорт и сделайте оба фото заново.",
|
"kycExpiredReason": "Срок действия документа истёк. Используйте действующий документ и сделайте оба фото заново.",
|
||||||
"kycDeniedReason": "По этой заявке не удалось подтвердить вашу личность.",
|
"kycDeniedReason": "По этой заявке не удалось подтвердить вашу личность.",
|
||||||
"kycStart": "Подтвердить личность",
|
"kycStart": "Подтвердить личность",
|
||||||
"kycResubmit": "Заменить фото",
|
"kycResubmit": "Исправить данные и фото",
|
||||||
"kycRefresh": "Обновить статус и лимиты",
|
"kycRefresh": "Обновить статус и лимиты",
|
||||||
"kycSupport": "Обратиться в поддержку",
|
"kycSupport": "Обратиться в поддержку",
|
||||||
"kycSupportBody": "Поддержка сейчас недоступна. Попробуйте позже.",
|
"kycSupportBody": "Поддержка сейчас недоступна. Попробуйте позже.",
|
||||||
@@ -100,11 +101,11 @@
|
|||||||
"kycLimitsError": "Не удалось обновить лимиты. Значения ниже могут быть устаревшими. Повторите запрос.",
|
"kycLimitsError": "Не удалось обновить лимиты. Значения ниже могут быть устаревшими. Повторите запрос.",
|
||||||
"kycLimitsEmpty": "Сервис не вернул лимиты. Обновите данные или уточните их в поддержке перед операцией.",
|
"kycLimitsEmpty": "Сервис не вернул лимиты. Обновите данные или уточните их в поддержке перед операцией.",
|
||||||
"kycLoading": "Загружаем ваш статус…",
|
"kycLoading": "Загружаем ваш статус…",
|
||||||
"kycPassport": "Фото паспорта",
|
"kycPassport": "Фото документа",
|
||||||
"kycSelfie": "Селфи с паспортом",
|
"kycSelfie": "Селфи с документом",
|
||||||
"kycReview": "Проверьте фото",
|
"kycReview": "Проверьте данные и фото",
|
||||||
"kycPassportHelp": "Откройте действующий паспорт на странице с фото. Все четыре края и текст должны быть чётко видны. Избегайте бликов и размытия, не закрывайте данные.",
|
"kycPassportHelp": "Снимите страницу с фото или лицевую сторону действующего документа. Все четыре края и данные должны быть чётко видны. Избегайте бликов и размытия.",
|
||||||
"kycSelfieHelp": "Держите открытый паспорт рядом с лицом. Лицо целиком и страница с фото должны быть в одном кадре, чёткими и ничем не закрытыми.",
|
"kycSelfieHelp": "Держите тот же документ рядом с лицом. Лицо целиком и данные документа должны быть в одном кадре, чёткими и ничем не закрытыми.",
|
||||||
"kycFileRules": "JPEG или PNG · до 10 МБ · не менее 600 × 600 пикселей. Сделайте фото камерой, затем выберите его здесь.",
|
"kycFileRules": "JPEG или PNG · до 10 МБ · не менее 600 × 600 пикселей. Сделайте фото камерой, затем выберите его здесь.",
|
||||||
"kycChoose": "Выбрать фото",
|
"kycChoose": "Выбрать фото",
|
||||||
"kycReplace": "Выбрать другое фото",
|
"kycReplace": "Выбрать другое фото",
|
||||||
@@ -113,12 +114,12 @@
|
|||||||
"kycBack": "Назад",
|
"kycBack": "Назад",
|
||||||
"kycSkip": "Оставить базовые лимиты",
|
"kycSkip": "Оставить базовые лимиты",
|
||||||
"kycClose": "К аккаунту",
|
"kycClose": "К аккаунту",
|
||||||
"kycReviewHelp": "Убедитесь, что оба фото читаемы и на них один паспорт. Их проверит сотрудник. Отправка не повышает лимиты сразу.",
|
"kycReviewHelp": "Проверьте данные: оба фото должны быть читаемы и показывать один документ. После отправки они хранятся конфиденциально для проверки сотрудником. Отправка не повышает лимиты сразу.",
|
||||||
"kycConfirm": "На фото я и мой действующий паспорт.",
|
"kycConfirm": "На фото я и мой действующий документ.",
|
||||||
"kycSubmit": "Отправить на проверку",
|
"kycSubmit": "Отправить на проверку",
|
||||||
"kycWorking": "Обработка… Не закрывайте страницу.",
|
"kycWorking": "Обработка… Не закрывайте страницу.",
|
||||||
"kycDiscardTitle": "Удалить выбранные фото?",
|
"kycDiscardTitle": "Удалить черновик проверки?",
|
||||||
"kycDiscardBody": "Черновик хранится только в памяти. При выходе выбранные фото будут удалены. Позже можно начать заново.",
|
"kycDiscardBody": "Несохранённые данные и выбранные фото будут удалены из этой сессии. Вы сможете начать заново позже.",
|
||||||
"kycStay": "Продолжить",
|
"kycStay": "Продолжить",
|
||||||
"kycDiscard": "Удалить и выйти",
|
"kycDiscard": "Удалить и выйти",
|
||||||
"kycErrorUnavailable": "Сервис недоступен. Выбранные фото сохранены в памяти. Повторите попытку.",
|
"kycErrorUnavailable": "Сервис недоступен. Выбранные фото сохранены в памяти. Повторите попытку.",
|
||||||
@@ -127,8 +128,9 @@
|
|||||||
"kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.",
|
"kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.",
|
||||||
"kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.",
|
"kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.",
|
||||||
"kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.",
|
"kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.",
|
||||||
|
"kycErrorPreview": "Не удалось показать фото. Попробуйте выбрать его ещё раз.",
|
||||||
"kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.",
|
"kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.",
|
||||||
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш паспорт.",
|
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш документ.",
|
||||||
"kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.",
|
"kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.",
|
||||||
"kycErrorRate": "Слишком много запросов. Немного подождите и повторите попытку.",
|
"kycErrorRate": "Слишком много запросов. Немного подождите и повторите попытку.",
|
||||||
"kycErrorConflict": "Статус проверки изменился. Обновите его, прежде чем продолжить.",
|
"kycErrorConflict": "Статус проверки изменился. Обновите его, прежде чем продолжить.",
|
||||||
@@ -159,7 +161,7 @@
|
|||||||
"wRecent": "Последние операции",
|
"wRecent": "Последние операции",
|
||||||
"wSeeAll": "Смотреть все",
|
"wSeeAll": "Смотреть все",
|
||||||
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
|
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
|
||||||
"wEmptyAccounts": "Ваши счета ещё не готовы. Попробуйте обновить страницу.",
|
"wEmptyAccounts": "У вас ещё нет валютных счетов. Обратитесь в поддержку за помощью с их открытием.",
|
||||||
"wRefresh": "Обновить",
|
"wRefresh": "Обновить",
|
||||||
"wSource": "Со счёта",
|
"wSource": "Со счёта",
|
||||||
"wDestination": "На счёт",
|
"wDestination": "На счёт",
|
||||||
@@ -264,5 +266,127 @@
|
|||||||
},
|
},
|
||||||
"factorEmailMethod": "Код из письма",
|
"factorEmailMethod": "Код из письма",
|
||||||
"factorAppMethod": "Приложение-аутентификатор",
|
"factorAppMethod": "Приложение-аутентификатор",
|
||||||
"factorAppExpired": "Срок проверки истёк. Вернитесь и начните снова."
|
"factorAppExpired": "Срок проверки истёк. Вернитесь и начните снова.",
|
||||||
|
"kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.",
|
||||||
|
"kycFullName": "Полное имя по документу",
|
||||||
|
"kycBirth": "Дата рождения",
|
||||||
|
"kycBirthDay": "День",
|
||||||
|
"kycBirthMonth": "Месяц",
|
||||||
|
"kycBirthYear": "Год",
|
||||||
|
"kycBirthInvalid": "Укажите корректную дату рождения.",
|
||||||
|
"kycBirthFuture": "Дата рождения не может быть в будущем.",
|
||||||
|
"kycCountry": "Страна проживания",
|
||||||
|
"kycCountrySelect": "Выберите страну",
|
||||||
|
"kycCountrySearch": "Поиск страны",
|
||||||
|
"kycCountryEmpty": "Страны не найдены. Попробуйте другое название или код страны.",
|
||||||
|
"kycCountryClearSearch": "Очистить поиск",
|
||||||
|
"kycProfileRequired": "Укажите полное имя, корректную дату рождения и выберите страну проживания.",
|
||||||
|
"kycContinue": "Перейти в личный кабинет",
|
||||||
|
"kycOperationMinimum": "Минимум на операцию",
|
||||||
|
"kycOperationMaximum": "Максимум на операцию",
|
||||||
|
"kycErrorAccess": "Проверка недоступна для этого аккаунта. Если это повторяется, обратитесь в поддержку.",
|
||||||
|
"kycUpgradePendingTitle": "Ожидается повышение лимитов",
|
||||||
|
"kycUpgradePendingBody": "Проверка личности одобрена. Ожидается подтверждение повышения лимитов. Отправлять фото повторно не нужно.",
|
||||||
|
"wNoPayoutMethod": "Для этого счёта не настроен способ вывода. Обратитесь в поддержку.",
|
||||||
|
"wNoFundingMethod": "Для этого счёта не настроен способ пополнения. Обратитесь в поддержку за инструкциями.",
|
||||||
|
"wLivePending": "Ожидается окончательный результат платежа. Обновите страницу, чтобы проверить статус и баланс.",
|
||||||
|
"wLiveFailed": "Платёж не завершён. Обновите счета, чтобы проверить доступный баланс.",
|
||||||
|
"wLiveQuoteNotice": "Предложение истекает через {seconds} сек.",
|
||||||
|
"@wLiveQuoteNotice": {
|
||||||
|
"placeholders": {
|
||||||
|
"seconds": {
|
||||||
|
"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;
|
||||||
@@ -63,25 +70,49 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
late final _locale = widget.localeProvider ?? LocaleProvider();
|
late final _locale = widget.localeProvider ?? LocaleProvider();
|
||||||
|
|
||||||
late final _analytics = widget.analyticsService ?? DummyAnalyticsService();
|
late final _analytics = widget.analyticsService ?? DummyAnalyticsService();
|
||||||
late final _factors =
|
late final _authFactors =
|
||||||
widget.backend ?? widget.twoFactorService ?? DummyTwoFactorService();
|
widget.backend ?? widget.twoFactorService ?? DummyTwoFactorService();
|
||||||
|
late final _factors = _walletService is BackendWalletService
|
||||||
|
? WalletFactorService(_walletService, _authFactors)
|
||||||
|
: _authFactors;
|
||||||
late final _security = SecurityProvider(_factors, _analytics);
|
late final _security = SecurityProvider(_factors, _analytics);
|
||||||
late final _identity = widget.identityService ?? DummyIdentityService();
|
late final _identity =
|
||||||
|
widget.identityService ??
|
||||||
|
(widget.backend == null
|
||||||
|
? DummyIdentityService()
|
||||||
|
: BackendIdentityService(
|
||||||
|
baseUri: widget.backend!.baseUri,
|
||||||
|
accessToken: () => widget.backend!.session?.accessToken,
|
||||||
|
));
|
||||||
late final _walletService =
|
late final _walletService =
|
||||||
widget.walletService ??
|
widget.walletService ??
|
||||||
DummyWalletService(
|
(widget.backend != null
|
||||||
_identity,
|
? BackendWalletService(
|
||||||
initialLocale: _locale.locale.languageCode,
|
baseUri: widget.backend!.baseUri,
|
||||||
factors: _factors,
|
accessToken: () => widget.backend!.session?.accessToken,
|
||||||
);
|
)
|
||||||
|
: DummyWalletService(
|
||||||
|
_identity,
|
||||||
|
initialLocale: _locale.locale.languageCode,
|
||||||
|
factors: _authFactors,
|
||||||
|
));
|
||||||
|
late final _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 ??
|
||||||
(_walletService is DummyWalletService
|
(_identity is BackendIdentityService
|
||||||
|
? BackendLimitsService(_identity)
|
||||||
|
: _walletService is DummyWalletService
|
||||||
? WalletLimitsService(_walletService)
|
? WalletLimitsService(_walletService)
|
||||||
: DummyLimitsService(_identity));
|
: DummyLimitsService(_identity));
|
||||||
late final _journeys = JourneyRecorder(_analytics);
|
late final _journeys = JourneyRecorder(_analytics);
|
||||||
@@ -100,14 +131,22 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
: () => widget.backend!.session != null,
|
: () => widget.backend!.session != null,
|
||||||
)..routeInformationProvider.addListener(_routeChanged);
|
)..routeInformationProvider.addListener(_routeChanged);
|
||||||
String _previousPath = '/';
|
String _previousPath = '/';
|
||||||
|
String? _walletCustomer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_previousPath = _router.routeInformationProvider.value.uri.path;
|
_previousPath = _router.routeInformationProvider.value.uri.path;
|
||||||
|
_walletCustomer = widget.backend?.session?.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _routeChanged() {
|
void _routeChanged() {
|
||||||
|
final customer = widget.backend?.session?.userId;
|
||||||
|
if (customer != _walletCustomer) {
|
||||||
|
_walletCustomer = customer;
|
||||||
|
_wallet.reset();
|
||||||
|
_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') {
|
||||||
_journeys.abandon(JourneyExit.navigation);
|
_journeys.abandon(JourneyExit.navigation);
|
||||||
@@ -121,7 +160,14 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
_router.dispose();
|
_router.dispose();
|
||||||
_journeys.dispose();
|
_journeys.dispose();
|
||||||
_wallet.dispose();
|
_wallet.dispose();
|
||||||
|
_destinations.dispose();
|
||||||
|
if (_walletService is BackendWalletService) {
|
||||||
|
_walletService.close();
|
||||||
|
}
|
||||||
_security.dispose();
|
_security.dispose();
|
||||||
|
if (_identity is BackendIdentityService) {
|
||||||
|
_identity.close();
|
||||||
|
}
|
||||||
_locale.dispose();
|
_locale.dispose();
|
||||||
widget.backend?.close();
|
widget.backend?.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -131,11 +177,13 @@ 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),
|
||||||
Provider<AnalyticsService>.value(value: _analytics),
|
Provider<AnalyticsService>.value(value: _analytics),
|
||||||
Provider<IdentityService>.value(value: _identity),
|
Provider<IdentityService>.value(value: _identity),
|
||||||
|
Provider(create: (_) => IdentityOnboardingProvider(_identity)),
|
||||||
Provider<LimitsService>.value(value: _limits),
|
Provider<LimitsService>.value(value: _limits),
|
||||||
Provider<PhotoService>.value(value: widget.photoService),
|
Provider<PhotoService>.value(value: widget.photoService),
|
||||||
ChangeNotifierProvider.value(value: _locale),
|
ChangeNotifierProvider.value(value: _locale),
|
||||||
@@ -149,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',
|
||||||
@@ -116,7 +125,10 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/identity',
|
path: '/identity',
|
||||||
name: AppRoutes.identity,
|
name: AppRoutes.identity,
|
||||||
builder: (context, state) => IdentityPage(key: identityKey),
|
builder: (context, state) => IdentityPage(
|
||||||
|
key: identityKey,
|
||||||
|
onboarding: state.uri.queryParameters['onboarding'] == '1',
|
||||||
|
),
|
||||||
onExit: (context, state) async =>
|
onExit: (context, state) async =>
|
||||||
await identityKey.currentState?.allowExit() ?? true,
|
await identityKey.currentState?.allowExit() ?? true,
|
||||||
),
|
),
|
||||||
@@ -125,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 &&
|
||||||
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
oldWidget.email == widget.email &&
|
||||||
_provider.reset();
|
identical(oldWidget.delivery, widget.delivery)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (oldWidget.token != widget.token) {
|
||||||
|
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
||||||
|
}
|
||||||
|
_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,15 +334,21 @@ 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),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.goNamed(AppRoutes.login),
|
||||||
|
child: Text(l.verificationBack),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => context.goNamed(AppRoutes.login),
|
|
||||||
child: Text(l.verificationBack),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ class AccountsPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(l.wSubheading, style: t.textTheme.bodyLarge),
|
Text(l.wSubheading, style: t.textTheme.bodyLarge),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(l.kycIntro),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.push('/identity'),
|
||||||
|
child: Text(l.wIdentity),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
if (p.loading) const LinearProgressIndicator(),
|
if (p.loading) const LinearProgressIndicator(),
|
||||||
if (p.failed)
|
if (p.failed)
|
||||||
|
|||||||
@@ -28,18 +28,27 @@ String statusText(AppLocalizations l, OperationStatus s) => switch (s) {
|
|||||||
OperationStatus.completed => l.wCompleted,
|
OperationStatus.completed => l.wCompleted,
|
||||||
OperationStatus.failed => l.wFailed,
|
OperationStatus.failed => l.wFailed,
|
||||||
};
|
};
|
||||||
String fundsText(AppLocalizations l, WalletOperation o) =>
|
String fundsText(
|
||||||
switch ((o.status, o.quote.request.kind)) {
|
AppLocalizations l,
|
||||||
(OperationStatus.pending, TransferKind.topUp) => l.wPendingTopUp,
|
WalletOperation o, {
|
||||||
(OperationStatus.pending, TransferKind.exchange) => l.wPendingExchange,
|
bool simulated = true,
|
||||||
(OperationStatus.pending, TransferKind.payout) => l.wPendingPayout,
|
}) => !simulated && o.status == OperationStatus.pending
|
||||||
(OperationStatus.completed, TransferKind.topUp) => l.wCompleteTopUp,
|
? l.wLivePending
|
||||||
(OperationStatus.completed, TransferKind.exchange) => l.wCompleteExchange,
|
: !simulated && o.status == OperationStatus.failed
|
||||||
(OperationStatus.completed, TransferKind.payout) => l.wCompletePayout,
|
? l.wLiveFailed
|
||||||
(OperationStatus.failed, TransferKind.topUp) => l.wFailedTopUp,
|
: switch ((o.status, o.quote.request.kind)) {
|
||||||
_ => l.wFailedOutgoing,
|
(OperationStatus.pending, TransferKind.topUp) => l.wPendingTopUp,
|
||||||
};
|
(OperationStatus.pending, TransferKind.exchange) => l.wPendingExchange,
|
||||||
|
(OperationStatus.pending, TransferKind.payout) => l.wPendingPayout,
|
||||||
|
(OperationStatus.completed, TransferKind.topUp) => l.wCompleteTopUp,
|
||||||
|
(OperationStatus.completed, TransferKind.exchange) =>
|
||||||
|
l.wCompleteExchange,
|
||||||
|
(OperationStatus.completed, TransferKind.payout) => l.wCompletePayout,
|
||||||
|
(OperationStatus.failed, TransferKind.topUp) => l.wFailedTopUp,
|
||||||
|
_ => l.wFailedOutgoing,
|
||||||
|
};
|
||||||
String walletError(AppLocalizations l, WalletError e) => switch (e) {
|
String walletError(AppLocalizations l, WalletError e) => switch (e) {
|
||||||
|
WalletError.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,
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ class _OperationPageState extends State<OperationPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
fundsText(l, o),
|
fundsText(l, o, simulated: p.service.isSimulated),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: o.status == OperationStatus.failed
|
color: o.status == OperationStatus.failed
|
||||||
? c.onErrorContainer
|
? c.onErrorContainer
|
||||||
|
|||||||
@@ -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,13 +53,23 @@ 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),
|
||||||
SelectableText(q.request.recipient),
|
if (q.payoutSnapshot != null)
|
||||||
|
DestinationDetailsView(details: q.payoutSnapshot!)
|
||||||
|
else
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
@@ -133,6 +140,17 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
final t = Theme.of(context);
|
final t = Theme.of(context);
|
||||||
final accounts = wallet.data?.accounts ?? [];
|
final accounts = wallet.data?.accounts ?? [];
|
||||||
final source = wallet.account(p.source);
|
final source = wallet.account(p.source);
|
||||||
|
final live = !wallet.service.isSimulated;
|
||||||
|
final destinations = context.watch<DestinationsProvider>();
|
||||||
|
final legacyPayout =
|
||||||
|
live &&
|
||||||
|
!(destinations.capabilities?.types.isNotEmpty ?? false) &&
|
||||||
|
destinations.methods.isEmpty;
|
||||||
|
final expiresIn =
|
||||||
|
p.quote?.expiresAt.difference(DateTime.now()).inSeconds ?? 0;
|
||||||
|
final unavailableMethod =
|
||||||
|
live &&
|
||||||
|
((widget.kind == TransferKind.topUp && !(source?.canFund ?? false)));
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -207,7 +225,15 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
] else if (p.quote != null) ...[
|
] else if (p.quote != null) ...[
|
||||||
TransferSummary(quote: p.quote!),
|
TransferSummary(quote: p.quote!),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(p.expired ? l.wExpired : l.wQuoteNotice),
|
Text(
|
||||||
|
p.expired
|
||||||
|
? l.wExpired
|
||||||
|
: live
|
||||||
|
? l.wLiveQuoteNotice(
|
||||||
|
expiresIn < 0 ? 0 : expiresIn,
|
||||||
|
)
|
||||||
|
: l.wQuoteNotice,
|
||||||
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
if (p.expired)
|
if (p.expired)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
@@ -218,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,
|
||||||
@@ -299,21 +329,88 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
),
|
),
|
||||||
if (widget.kind == TransferKind.payout) ...[
|
if (widget.kind == TransferKind.payout) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
TextField(
|
if (legacyPayout) ...[
|
||||||
key: const ValueKey('transfer-recipient'),
|
if (source?.payoutMethod.isNotEmpty == true)
|
||||||
controller: _recipient,
|
OutlinedButton.icon(
|
||||||
enabled: !p.busy,
|
key: const ValueKey(
|
||||||
decoration: InputDecoration(
|
'choose-configured-destination',
|
||||||
labelText: l.wRecipient,
|
),
|
||||||
hintText: l.wRecipientHint,
|
icon: const Icon(
|
||||||
|
Icons.account_balance_outlined,
|
||||||
|
),
|
||||||
|
label: Text(source!.payoutLabel),
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () => p.change(
|
||||||
|
recipient: source.payoutMethod,
|
||||||
|
payoutLabel: source.payoutLabel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
onChanged: (v) => p.change(recipient: v),
|
],
|
||||||
|
if (unavailableMethod) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
widget.kind == TransferKind.payout
|
||||||
|
? l.wNoPayoutMethod
|
||||||
|
: l.wNoFundingMethod,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
key: const ValueKey('transfer-review'),
|
key: const ValueKey('transfer-review'),
|
||||||
onPressed: p.busy ? null : p.review,
|
onPressed:
|
||||||
|
p.busy ||
|
||||||
|
unavailableMethod ||
|
||||||
|
(widget.kind == TransferKind.payout &&
|
||||||
|
(legacyPayout
|
||||||
|
? p.recipient.isEmpty
|
||||||
|
: p.payoutDestination == null))
|
||||||
|
? null
|
||||||
|
: p.review,
|
||||||
child: Text(l.wReview),
|
child: Text(l.wReview),
|
||||||
),
|
),
|
||||||
if (p.error == WalletError.limitExceeded)
|
if (p.error == WalletError.limitExceeded)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user