Author SHA1 Message Date
Stephan D 5a0b211d5f docs: generate code from externally published schemas 2026-11-02 16:09:42 +01:00
tech cd0981cea1 Merge pull request 'docs: require schema code generation at build time' (#77) from codex/docs-build-time-generation into main 2026-11-02 15:07:54 +00:00
Stephan D efeac88a49 docs: require schema code generation at build time 2026-11-02 16:07:39 +01:00
tech 3b551f1565 Merge pull request 'Set up RUB, USDT and USDC accounts automatically' (#73) from wallet-72 into main 2026-10-23 11:10:45 +00:00
Stephan D 645181c65c Record automatic currency account validation and delivery (#72) 2026-10-23 12:58:28 +02:00
tech de449d53bf Merge pull request 'Restore identity photo previews in Flutter Web' (#71) from kyc-69 into main 2026-10-23 10:51:08 +00:00
Stephan D fe60b7ac1a Record final photo preview contribution review (#69) 2026-10-23 12:50:43 +02:00
Stephan D b644a10663 Merge remote-tracking branch 'origin/main' into kyc-69 2026-10-23 12:46:56 +02:00
Stephan D 9370eacd9c Merge remote-tracking branch 'origin/main' into wallet-72 2026-10-23 12:43:55 +02:00
Stephan D 5c255296d3 Set up default currency accounts automatically (#72) 2026-10-23 12:43:55 +02:00
Stephan D c106706615 Record photo preview delivery PR (#69) 2026-10-23 12:33:52 +02:00
Stephan D 915b8e8123 Restore web identity photo previews (#69) 2026-10-23 12:33:28 +02:00
tech ec4115d083 Merge pull request 'Use dedicated day, month and year controls for birthdays' (#70) from kyc-67 into main 2026-10-23 10:32:05 +00:00
tech f3ff7fc016 Merge pull request 'Explain missing currency account setup' (#68) from wallet-66 into main 2026-10-23 10:31:58 +00:00
tech 658d715c8a Merge pull request 'Show accepted signup delivery without a second send step' (#63) from email-verification-62 into main 2026-10-23 10:31:43 +00:00
Stephan D 288a2f0c02 Record birthday control delivery PR (#67) 2026-10-23 12:20:01 +02:00
Stephan D 4605a197ad Use dedicated birthday entry controls (#67) 2026-10-23 12:19:24 +02:00
Stephan D 62df9eac5f Merge remote-tracking branch 'origin/main' into wallet-66 2026-10-23 12:17:10 +02:00
Stephan D 4b22f6ce12 Record empty-account message delivery PR (#66) 2026-10-23 12:13:40 +02:00
Stephan D b5c4772168 Explain missing currency account setup (#66) 2026-10-23 12:13:10 +02:00
tech 464bd2d565 Merge pull request 'Select residence country by searchable name' (#65) from kyc-64 into main 2026-10-23 10:12:13 +00:00
Stephan D f1ff40d072 Record country selector delivery PR (#64) 2026-10-23 11:48:34 +02:00
Stephan D 417e6d96b6 Select residence country by searchable name (#64) 2026-10-23 11:47:59 +02:00
Stephan D c8ef9c3d14 Record delivery PR for accepted signup state (#62) 2026-10-23 09:35:28 +02:00
Stephan D 3af60f38b1 Show accepted signup delivery without a second send step (#62) 2026-10-23 09:34:30 +02:00
tech e84d2d1ad3 Merge pull request 'Restore signup, email delivery and local recovery' (#59) from identity-facts-57 into main
Reviewed-on: #59
2026-10-20 18:00:23 +00:00
tech fa7b75d1b8 Merge pull request 'Complete verification links and prefill login with the verified email' (#61) from email-verification-60 into main
Reviewed-on: #61
2026-10-20 18:00:05 +00:00
Stephan D a587cbbc24 Record verification validation and local deployment (#60) 2026-10-19 14:54:53 +02:00
Stephan D 5f3b443e7b Complete verification links and preserve verified login email (#60) 2026-10-19 14:34:00 +02:00
tech 30f06c2f8f Merge pull request 'Enable Safari credential autofill for signup and login' (#58) from auth-56 into main 2026-10-19 11:02:42 +00:00
Stephan D 4c4b639321 Record private local signup request outcomes (#57) 2026-10-19 12:41:26 +02:00
Stephan D 13cc9c302e Recover local Sendico session after BFF network restarts (#57) 2026-10-19 12:34:37 +02:00
Stephan D 021bd04324 Reap orphaned frontend HTTPS probe processes (#57) 2026-10-18 13:54:30 +02:00
Stephan D 4844fb5bc9 Record verified local API recovery after Docker restart (#57) 2026-10-17 23:41:41 +02:00
Stephan D 3aeea9aa77 Record healthy SMTP deployment and passing validation (#57) 2026-10-17 23:38:10 +02:00
Stephan D 69fba8df70 Record SMTP deployment blocker and remaining restart (#57) 2026-10-17 21:17:39 +02:00
Stephan D b36eb72c23 Use Sendico SMTP mailbox for local notifications (#57) 2026-10-17 20:51:28 +02:00
Stephan D 3e4e496037 Log redacted email provider errors and diagnose sender configuration (#57) 2026-10-17 11:39:05 +02:00
Stephan D c1d28e705e Record signup fix delivery and recovery limitation 2026-10-17 07:30:50 +02:00
Stephan D abca15f047 Fix signup registration receipts and activation worker lifetime (#57) 2026-10-17 07:30:33 +02:00
Stephan D c6ab4680fd Record autofill validation and delivery link 2026-10-17 07:24:55 +02:00
Stephan D 86b6aafe7d Enable credential autofill for signup and login (#56) 2026-10-17 07:24:27 +02:00
Stephan D bd675357bb Record payout destination delivery and validation links 2026-10-16 12:02:01 +02:00
Stephan D 24af794112 Add saved payout destination UX and service contracts (#54) 2026-10-16 12:01:33 +02:00
Stephan D a5689876ff Connect local SXCHNGE to the Sendico stand 2026-10-16 10:19:59 +02:00
Stephan D 34d8ef18d1 Record quotation and payment integration delivery links 2026-10-16 09:58:12 +02:00
Stephan D 7a6510d1a8 Connect customer quotations and payment execution to Sendico (#50) 2026-10-16 09:57:28 +02:00
Stephan D 5d67c25c8e Document reconciled Sendico review dependencies 2026-10-15 09:43:26 +02:00
Stephan D b303285b8b Record identity verification delivery links 2026-10-15 02:23:24 +02:00
Stephan D 1354551ae8 Submit customer identity proofs and show reviewed limits 2026-10-15 02:22:28 +02:00
Stephan D 7bf8b8087a Record Facts intake delivery and validation (#46) 2026-10-15 00:55:33 +02:00
Stephan D a584c8189a Connect sign-up to Sendico Facts intake (#46) 2026-10-15 00:55:19 +02:00
tech 30f0455122 Merge pull request 'Run local infrastructure and product images with Vault-managed secrets' (#43) from dev-env-42 into main
Reviewed-on: #43
2026-09-13 08:35:01 +00:00
tech 9f9525df56 Merge pull request 'Install product API versions independently with shared services' (#45) from product-api-44 into dev-env-42
Reviewed-on: #45
2026-09-13 08:34:33 +00:00
218 changed files with 15516 additions and 472 deletions

No files matched your search

+9 -6
View File
@@ -13,14 +13,17 @@
- Never commit autogenerated code to the repository. This applies to backend,
frontend, shared packages, and every other module, including generated
protobuf bindings, API clients, serializers, and framework-generated code.
- Commit the source definitions, generator configuration, and reproducible
generation commands instead. Generate code locally or during build and CI
workflows before any dependent validation or compilation steps.
- Use externally published schemas as generation inputs. Do not embed or commit
schema snapshots in the repository. Commit external schema references,
generator configuration, and reproducible generation commands instead.
- Fetch the externally published schemas and generate code at build time, before
any dependent validation or compilation steps, including in CI.
- Keep generated outputs ignored by Git. Do not force-add ignored generated
files or copy generated code into tracked source files.
- Change the source definitions or generator inputs rather than editing
generated outputs. Document the generation workflow so a clean checkout can
build without committed generated code.
- Change schema sources in their owning project or update external schema
references and generator inputs rather than editing generated outputs.
Document the generation workflow so a clean checkout can build without
committed generated code or embedded schema snapshots.
## Local development tooling
+7
View File
@@ -54,6 +54,9 @@ package-notifications-interface: generate-notifications
# 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
ifneq ($(wildcard .local/dev/sendico/settings.env),)
COMPOSE += -f infra/local/sendico/compose.yml
endif
.PHONY: build up down logs ps test-dev
build:
sh infra/local/tools.sh build
@@ -92,3 +95,7 @@ email:
test-dev-tooling:
sh infra/local/tools.sh test
.PHONY: sendico
sendico:
sh infra/local/tools.sh sendico
+11 -9
View File
@@ -9,11 +9,11 @@ The consumer app includes responsive login/signup, English and Russian
localization, email verification and recovery screens, and URL-based navigation
with `go_router`. Its interface uses the shared theme's colors and typography.
**Authentication, email verification and analytics use dummy services.** No real
account or session is created, no email is delivered, and no analytics data is
transmitted by those app services. The backend now contains locally owned copies
of Sendico’s BFF runtime, auth/session stack and account service. The app is not
connected to that backend or to Sendico yet.
The default preview uses dummy services. Configuring the existing backend URL
and client ID selects authenticated BFF adapters for auth, identity, limits and
wallet quotations/payment execution. Optional Sendico configuration and dedicated
customer financial resources are required for real operations; analytics still
defaults to an in-memory sink. See [payment setup and rollout](docs/payments.md).
## 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.
Run `make test-backend`, `make lint-backend` or `make run-backend` from the root.
The copied runtime installs local authentication transport and initializes its
account service. Signup/profile endpoints, verification delivery/completion,
Flutter HTTP adapters and live Sendico operations remain to be wired. See the
[backend run guide](backend/README.md) for local MongoDB configuration.
The runtime installs local authentication, verification delivery, identity and
wallet transports. Public Sendico adapters support independent customer facts,
reviewed identity/limits and quotation/execution through the BFF. Deployment and
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).
+6
View File
@@ -11,6 +11,12 @@ classifies delivery outcomes and never logs provider response bodies. Sendico's
builders, dynamic-template dependency, notification events and unused producer
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
`api/pkg/messaging`: `config.go`, `capability/errors.go`, `reliable/transport.go`,
`reliable/durable_settings.go`, `reliable/durable_settings_behavior.go`,
+54 -3
View File
@@ -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
transport, adapted from Sendico. A durable NATS worker claims the notification
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
uses adapted Sendico SDK construction; Resend uses its email HTTP API. Both support
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
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
is 24 hours. SendGrid has no equivalent guarantee; exactly-once external email
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
`driver_changed`. Drain queued jobs before changing sender credentials/config.
- Callers authorize recipients and preferences. No direct customer-database access,
bulk campaigns, provider failover, SMS, push or Telegram delivery is included.
Structured logs report notification IDs, driver, state and attempt count; message
content, template values, addresses and provider response bodies are excluded.
Structured logs report notification IDs, driver, state and attempt count. Provider
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.
## Validate and distribute
+1 -3
View File
@@ -45,8 +45,6 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
defer cancel()
_ = client.Disconnect(cleanup)
}()
startup, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
repo, err := store.New(logger, client.Database())
if err != nil {
return err
@@ -57,7 +55,7 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
}
defer rt.Close()
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 {
return err
}
+5 -1
View File
@@ -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}
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")
}
// 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)
if err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "subscribe notification worker")
@@ -10,6 +10,7 @@ import (
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type outbox struct{ jobs []model.Notification }
@@ -23,6 +24,50 @@ type publisher struct {
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 {
if p.fail {
return merrors.ServiceUnavailable("offline")
+1 -1
View File
@@ -13,7 +13,7 @@ messaging:
reconnect_wait: 2
templates: "templates.example.json"
email:
driver: resend # sendgrid or resend
driver: resend # sendgrid or resend; see README for authenticated SMTP settings
settings:
api_key_env: "RESEND_API_KEY" # SENDGRID_API_KEY when driver is sendgrid
sender_address: "notifications@example.com"
+9
View File
@@ -19,6 +19,7 @@ type Driver string
const (
SendGrid Driver = "sendgrid"
Resend Driver = "resend"
SMTP Driver = "smtp"
)
type Config = sharedmodel.DriverConfig[Driver]
@@ -33,6 +34,11 @@ type Outcome struct {
State model.State
Code string
RetryAfter time.Duration
// Provider diagnostics are sanitized operational data, not public failures.
HTTPStatus int
SMTPStatus int
ProviderError string
ProviderMessage string
}
type Sender interface {
Send(context.Context, string, model.Email) Outcome
@@ -46,6 +52,9 @@ type client struct {
}
func New(config Config) (Sender, error) {
if config.Driver == SMTP {
return newSMTP(config)
}
var s Settings
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
if err != nil {
+97
View File
@@ -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)
}
}
+17 -8
View File
@@ -28,14 +28,15 @@ func (c *client) post(ctx context.Context, key string, payload any) Outcome {
}
resp, err := c.http.Do(req)
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()
// Discard a bounded response without logging provider-controlled content.
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
// Never pass raw provider bodies through the outcome or log them directly.
details, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody+1))
outcome := Outcome{HTTPStatus: resp.StatusCode}
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
return Outcome{State: model.Accepted}
outcome.State = model.Accepted
case resp.StatusCode == 429:
delay := time.Minute
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 {
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:
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:
return Outcome{State: model.Failed, Code: "provider_authentication_failed"}
outcome.State, outcome.Code = model.Failed, "provider_authentication_failed"
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
}
+49
View File
@@ -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
}
+122
View File
@@ -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
}
+223
View File
@@ -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)
}
}
+19 -4
View File
@@ -5,6 +5,7 @@ import (
"errors"
"time"
"git.sendico.io/tech/sxchnge/notifications/email"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.uber.org/zap"
@@ -23,6 +24,7 @@ func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
if err != nil {
return false, err
}
var outcome email.Outcome
switch {
case !n.ExpiresAt.After(now):
n.State = model.Expired
@@ -36,7 +38,7 @@ func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
deadline = n.ExpiresAt
}
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()
n.State = outcome.State
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.
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
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)}
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 {
s.logger.Info("Email accepted by provider", fields...)
} else {
s.logger.Warn("Email delivery attempt finished", fields...)
}
if err = s.repo.Finish(finishCtx, n); err != nil {
return true, err
}
return true, nil
}
+44 -3
View File
@@ -2,18 +2,23 @@ package service
import (
"context"
"errors"
"fmt"
"testing"
"time"
"git.sendico.io/tech/sxchnge/notifications/email"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
type workerRepo struct {
Repository
job model.Notification
saved *model.Notification
job model.Notification
saved *model.Notification
finishErr error
}
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
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 {
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)
}
}
})
}
}
+11 -3
View File
@@ -33,12 +33,20 @@ API versions are installed explicitly by the product composition. See
mounts, shared lifecycle and adding a future version. Customer profile endpoint
wiring remains follow-up work.
Sendico integration is not wired. Its organization context belongs only to the
future public HTTP adapter. There is no unused public API snapshot in this module;
copy only the wire definitions consumed by an implemented adapter.
Optional `api.sendico` connects sign-up to the public individual registry and
immutable Facts intake. The adapter owns organization context and consumed wire
contracts; local customers retain no platform tenancy. See
[configuration, recovery and measurement](../../docs/signup-facts.md).
## Validate
`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
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).
+4
View File
@@ -6,6 +6,8 @@ import (
"git.sendico.io/tech/sxchnge/product/auth/flow"
"git.sendico.io/tech/sxchnge/product/auth/storage"
"git.sendico.io/tech/sxchnge/product/customers"
"git.sendico.io/tech/sxchnge/product/identity"
"git.sendico.io/tech/sxchnge/product/payments"
"git.sendico.io/tech/sxchnge/product/twofactor"
"git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5"
@@ -17,6 +19,8 @@ type Dependencies struct {
Customers accountservice.AccountService
Auth *flow.Service
Factors *twofactor.Service
Identity *identity.Service
Payments *payments.Service
Token middleware.TokenConfig
Signature middleware.Signature
LoginLimits middleware.LoginRateLimits
+2
View File
@@ -3,10 +3,12 @@ package api
import (
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/notifications"
"git.sendico.io/tech/sxchnge/product/providers/sendico"
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
)
type Config struct {
Sendico *sendico.Config `yaml:"sendico"`
// nil preserves the legacy v1 mount; an explicit empty list installs no API.
Versions []Mount `yaml:"versions"`
Notifications *notifications.Config `yaml:"notifications"`
+4
View File
@@ -5,6 +5,8 @@ import (
"git.sendico.io/tech/sxchnge/product/app/api"
auth "git.sendico.io/tech/sxchnge/product/auth/http/v1"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
identity "git.sendico.io/tech/sxchnge/product/identity/http/v1"
paymenthttp "git.sendico.io/tech/sxchnge/product/payments/http/v1"
factors "git.sendico.io/tech/sxchnge/product/twofactor/http/v1"
"git.sendico.io/tech/sxchnge/shared/errors"
)
@@ -22,5 +24,7 @@ func Install(scope api.Scope, deps *api.Dependencies) error {
if deps.Factors != nil {
factors.Install(routes, deps.Factors, scope.Logger)
}
identity.Install(routes, deps.Identity, scope.Logger)
paymenthttp.Install(routes, deps.Payments, scope.Logger)
return nil
}
@@ -2,16 +2,20 @@ package runtime_test
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"time"
"git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/app/profile"
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
"git.sendico.io/tech/sxchnge/product/providers/sendico"
"git.sendico.io/tech/sxchnge/shared/database"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/go-chi/chi/v5"
@@ -61,4 +65,22 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
result = httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
require.Equal(t, http.StatusBadRequest, result.Code)
// Enabling the adapter validates credentials, composes once, and does not
// register any provider resource until a customer starts sign-up.
const source = "507f1f77bcf86cd799439011"
claims, err := json.Marshal(map[string]any{"id": source, "exp": time.Now().Add(time.Hour).Unix()})
require.NoError(t, err)
file := filepath.Join(t.TempDir(), "credential")
require.NoError(t, os.WriteFile(file, []byte("header."+base64.RawURLEncoding.EncodeToString(claims)+".signature"), 0600))
example.API.Sendico = &sendico.Config{APIURL: "https://api.example/api/v1", OrganizationID: source, SourceID: source, TokenFile: file}
dependencies, finish, err := sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
require.NoError(t, err)
require.NotNil(t, dependencies.Auth.Facts)
require.NoError(t, finish(ctx))
example.API.Sendico.TokenFile = ""
_, _, err = sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
require.Error(t, err)
example.API.Notifications = nil
_, _, err = sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
require.Error(t, err)
}
+29
View File
@@ -8,11 +8,15 @@ import (
"git.sendico.io/tech/sxchnge/product/auth/flow"
"git.sendico.io/tech/sxchnge/product/auth/storage"
"git.sendico.io/tech/sxchnge/product/customers"
"git.sendico.io/tech/sxchnge/product/identity"
"git.sendico.io/tech/sxchnge/product/notifications"
"git.sendico.io/tech/sxchnge/product/payments"
"git.sendico.io/tech/sxchnge/product/providers/sendico"
"git.sendico.io/tech/sxchnge/product/twofactor"
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
"git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging"
"go.uber.org/zap"
)
@@ -55,6 +59,25 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
return nil, nil, err
}
deps.Auth.Sender = client
if config.Sendico != nil {
provider, err := sendico.Open(*config.Sendico)
if err != nil {
return nil, nil, err
}
bindings, err := identity.OpenStore(db)
if err != nil {
return nil, nil, err
}
deps.Identity = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("identity")}
deps.Auth.Facts = deps.Identity
paymentStore, err := payments.OpenStore(db)
if err != nil {
return nil, nil, err
}
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
deps.Auth.Wallet = deps.Payments
logger.Info("Sendico sign-up facts integration enabled")
}
records, err := factorstore.Open(logger, db)
if err != nil {
return nil, nil, err
@@ -64,6 +87,12 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
return nil, nil, err
}
}
if deps.Payments != nil {
deps.Payments.Factors = deps.Factors
}
if config.Sendico != nil && deps.Auth == nil {
return nil, nil, merrors.InvalidArgument("Sendico sign-up facts require notifications configuration")
}
logger.Info("Product dependencies initialized")
return deps, cleanup, nil
}
+54 -7
View File
@@ -26,7 +26,18 @@ import (
const Cooldown = time.Minute
type SignupFacts interface {
Ensure(context.Context, *customer.Account) error
Ready(context.Context, bson.ObjectID) error
}
type CurrencyAccounts interface {
Ensure(context.Context, *customer.Account) error
}
type Service struct {
Facts SignupFacts
Wallet CurrencyAccounts
Accounts account.DB
Customers accountservice.AccountService
Tokens verification.DB
@@ -85,20 +96,30 @@ func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key strin
if err = s.Limit(ctx, ip, email); err != nil {
return nil, err
}
raw, err := s.Customers.CreateAccount(ctx, a)
raw, err := s.Customers.CreateAccountWithActivationKey(ctx, a, s.activationKey(a.Login, key))
if errors.Is(err, merrors.ErrDataConflict) {
existing, lookup := s.Accounts.GetByEmail(ctx, email)
if lookup != nil {
return nil, lookup
}
// A retry after a queue failure may resume only with the original password.
if existing.Status != customer.AccountPendingVerification || !existing.MatchPassword(a.Password) {
if existing.Status != customer.AccountPendingVerification || existing.IsArchived() || !existing.MatchPassword(a.Password) {
return nil, merrors.DataConflict("email already registered")
}
a = existing
} else if err != nil {
return nil, err
}
if s.Facts != nil {
if err := s.Facts.Ensure(ctx, a); err != nil {
return nil, err
}
}
if s.Wallet != nil {
if err := s.Wallet.Ensure(ctx, a); err != nil {
return nil, err
}
}
if raw != "" {
u, _ := url.Parse(s.ActivationURL)
q := u.Query()
@@ -132,14 +153,27 @@ func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) e
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
return nil
}
if s.Facts != nil {
if err := s.Facts.Ensure(ctx, a); err != nil {
return err
}
}
if s.Wallet != nil {
if err := s.Wallet.Ensure(ctx, a); err != nil {
return err
}
}
_, err = s.sendActivation(ctx, a, key)
return err
}
func (s *Service) activationKey(email, key string) string {
return s.key(email, string(verificationmodel.PurposeAccountActivation), "", key)
}
func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key string) (time.Time, error) {
if strings.TrimSpace(key) == "" || len(key) > 128 {
return time.Time{}, merrors.InvalidArgument("idempotency key required")
}
secretKey := s.key(a.ID.Hex(), string(verificationmodel.PurposeAccountActivation), "", key)
secretKey := s.activationKey(a.Login, key)
req := verification.NewLinkRequest(a.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24 * time.Hour).WithCooldown(Cooldown).WithIdempotencyKey(secretKey)
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC()))
result, err := s.Tokens.CreateWithResult(ctx, req)
@@ -160,10 +194,11 @@ func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key s
err = s.Sender.Send(ctx, notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"url": u.String()}})
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 {
return verification.ErorrTokenNotFound()
return "", verification.ErorrTokenNotFound()
}
var email string
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
if err != nil {
@@ -176,7 +211,19 @@ func (s *Service) Activate(ctx context.Context, raw string) error {
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
return nil, merrors.Unauthorized("account cannot be activated")
}
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
if s.Facts != nil {
if err := s.Facts.Ready(tx, a.ID); err != nil {
return nil, err
}
}
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
}
+1 -1
View File
@@ -55,7 +55,7 @@ func TestIssueRetriesSamePayloadAndBindsServerSecret(t *testing.T) {
require.Equal(t, sender.messages[0], sender.messages[1])
require.Equal(t, "ru", sender.messages[0].Locale)
service.Secret = []byte(strings.Repeat("b", 32))
require.NotEqual(t, key, service.key(a.ID.Hex(), "account_activation", "client-key"))
require.NotEqual(t, key, service.activationKey(a.Login, "client-key"))
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
_, err = service.sendActivation(context.Background(), a, "expired")
require.ErrorIs(t, err, verification.ErrTokenExpired)
@@ -0,0 +1,29 @@
package public
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestFactsAssistanceHasTypedPrivacySafeResponse(t *testing.T) {
router := &PublicRouter{logger: zap.NewNop(), service: "accounts"}
request := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/signup", nil)
response := httptest.NewRecorder()
router.flowError(merrors.FailedPrecondition("private upstream detail"))(response, request)
require.Equal(t, http.StatusServiceUnavailable, response.Code)
require.Equal(t, "no-store", response.Header().Get("Cache-Control"))
var envelope struct {
Data struct {
Error string `json:"error"`
}
}
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &envelope))
require.Equal(t, "signup_attention_required", envelope.Data.Error)
require.NotContains(t, response.Body.String(), "private upstream detail")
}
@@ -72,6 +72,8 @@ func (pr *PublicRouter) flowError(err error) http.HandlerFunc {
return pr.loginRateLimited(details.RetryAfter)
}
switch {
case errors.Is(err, merrors.ErrFailedPrecondition):
return response.Error(pr.logger, pr.service, http.StatusServiceUnavailable, "signup_attention_required", "We could not finish setting up your account. Please contact support.")
case errors.Is(err, verification.ErrTokenExpired):
return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email")
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
@@ -137,11 +139,15 @@ func (pr *PublicRouter) activate(r *http.Request) http.HandlerFunc {
if err := decodeFlow(r, &req); err != nil {
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)
}
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 {
h := sha256.Sum256([]byte(raw))
@@ -15,6 +15,7 @@ import (
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
accountservice "git.sendico.io/tech/sxchnge/product/customers"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/identity"
"git.sendico.io/tech/sxchnge/product/notifications"
session "git.sendico.io/tech/sxchnge/product/sessions/model"
"git.sendico.io/tech/sxchnge/product/twofactor"
@@ -26,6 +27,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
@@ -34,6 +36,38 @@ type capturedMail struct {
fail bool
}
type signupProvider struct {
registrations, submissions int
fail bool
last identity.Facts
}
func (*signupProvider) Namespace() string { return "test-provider" }
func (p *signupProvider) Register(context.Context, string) (string, error) {
p.registrations++
return "subject", nil
}
func (p *signupProvider) Submit(_ context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) {
p.submissions++
p.last = facts
if p.fail {
return identity.Receipt{}, merrors.ServiceUnavailable("provider unavailable")
}
return identity.Receipt{SubjectRef: subject, SubmissionRef: "submission", AcceptedAt: time.Now().UTC()}, nil
}
type activationGuard struct {
flow.SignupFacts
block bool
}
func (g *activationGuard) Ready(ctx context.Context, id bson.ObjectID) error {
if g.block {
return merrors.ServiceUnavailable("intake receipt unavailable")
}
return g.SignupFacts.Ready(ctx, id)
}
func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
m.messages = append(m.messages, r)
if m.fail {
@@ -41,6 +75,17 @@ func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
}
return nil
}
type signupWallet struct {
customers []bson.ObjectID
err error
}
func (s *signupWallet) Ensure(_ context.Context, a *customer.Account) error {
s.customers = append(s.customers, a.ID)
return s.err
}
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
@@ -64,6 +109,12 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
mail := &capturedMail{}
service := &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: factory.TransactionFactory(), Attempts: stores.LoginAttempts, Sender: mail, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
require.NoError(t, service.Validate())
bindings, err := identity.OpenStore(factory)
require.NoError(t, err)
provider := &signupProvider{fail: true}
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()}
wallet := &signupWallet{err: merrors.ServiceUnavailable("account setup temporarily unavailable")}
service.Wallet = wallet
// Keep the real verification/customer stores and capture only session writes.
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
records, err := factorstore.Open(zap.NewNop(), factory)
@@ -83,7 +134,30 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
}
signup := map[string]any{"name": "Customer", "email": "USER@example.com", "password": " correct password ", "locale": "ru", "idempotencyKey": "signup-one"}
w := post("signup", signup)
require.Equal(t, 503, w.Code, w.Body.String())
require.Empty(t, mail.messages)
require.Equal(t, 1, provider.registrations)
require.Equal(t, 503, post("verification/send", map[string]string{"email": "user@example.com", "idempotencyKey": "failed-resend"}).Code)
require.Empty(t, mail.messages)
// Only the original password can resume a pending local customer.
signup["password"] = "different password"
require.Equal(t, 400, post("signup", signup).Code)
signup["password"] = " correct password "
signup["name"] = "Changed retry name"
provider.fail = false
w = post("signup", signup)
require.Equal(t, 503, w.Code, w.Body.String())
require.Empty(t, mail.messages)
require.Len(t, wallet.customers, 1)
w = post("verification/send", map[string]string{"email": "user@example.com", "idempotencyKey": "wallet-retry"})
require.Equal(t, 503, w.Code, w.Body.String())
require.Empty(t, mail.messages)
wallet.err = nil
w = post("signup", signup)
require.Equal(t, 202, w.Code, w.Body.String())
require.Equal(t, 1, provider.registrations)
require.Equal(t, "Customer", provider.last.FullName)
require.Equal(t, "user@example.com", provider.last.Email)
require.Len(t, mail.messages, 1)
require.Equal(t, "auth.activation", mail.messages[0].Template)
require.Equal(t, "user@example.com", mail.messages[0].Email)
@@ -95,12 +169,38 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
a, err := stores.Accounts.GetByEmail(ctx, "user@example.com")
require.NoError(t, err)
require.False(t, a.IsActive())
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
require.Equal(t, []bson.ObjectID{a.ID, a.ID, a.ID}, wallet.customers)
require.NoError(t, service.Facts.Ready(ctx, a.ID))
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, 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}
service.Facts = guard
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)
require.NoError(t, err)
require.False(t, a.IsActive())
guard.block = false
w = post("verification/confirm", map[string]string{"token": activation})
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)
require.NoError(t, err)
require.Equal(t, customer.AccountActive, a.Status)
+7
View File
@@ -9,6 +9,13 @@ database:
uri_env: SXCHNGE_MONGO_URI
database: sxchnge
api:
# Optional public Sendico facts integration. Enable with provisioned values;
# omit this block to retain local-only sign-up. See docs/signup-facts.md.
# sendico:
# api_url: https://sendico.example/api/v1
# organization_ref: <sxchnge-organization-object-id>
# source_account_ref: <integration-account-object-id>
# token_file: /run/sxchnge/sendico-token
# Optional: omit for v1 at SXCHNGE_API_ENDPOINT; [] disables application APIs.
# versions:
# - version: v1
+12 -1
View File
@@ -119,6 +119,17 @@ func (s *service) CreateAccount(
ctx context.Context,
acct *customermodel.Account,
) (string, error) {
return s.createAccount(ctx, acct, "")
}
func (s *service) CreateAccountWithActivationKey(ctx context.Context, acct *customermodel.Account, key string) (string, error) {
if len(key) < 32 || len(key) > 128 {
return "", merrors.InvalidArgument("server activation key required")
}
return s.createAccount(ctx, acct, key)
}
func (s *service) createAccount(ctx context.Context, acct *customermodel.Account, key string) (string, error) {
if acct == nil {
return "", merrors.InvalidArgument("account is required")
}
@@ -137,7 +148,7 @@ func (s *service) CreateAccount(
if err := s.accountDB.Create(tx, saved); err != nil {
return nil, err
}
return s.VerifyAccount(tx, saved)
return s.vdb.Create(tx, verification.NewLinkRequest(saved.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24*time.Hour).WithIdempotencyKey(key))
})
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
+3
View File
@@ -36,6 +36,9 @@ type AccountService interface {
ctx context.Context,
acct *customermodel.Account,
) (verificationToken string, err error)
// CreateAccountWithActivationKey preserves atomic creation and lets the auth
// service replay an unsent initial token. The key must be server-secret-derived.
CreateAccountWithActivationKey(context.Context, *customermodel.Account, string) (string, error)
VerifyAccount(
ctx context.Context,
+48
View File
@@ -0,0 +1,48 @@
package identityhttp
import "git.sendico.io/tech/sxchnge/product/identity"
type profileDTO struct {
FullName string `json:"fullName"`
DateOfBirth string `json:"dateOfBirth"`
ResidenceCountry string `json:"residenceCountry"`
}
func (p profileDTO) model() identity.Profile {
return identity.Profile{FullName: p.FullName, DateOfBirth: p.DateOfBirth, ResidenceCountry: p.ResidenceCountry}
}
type verificationDTO struct {
Status string `json:"status"`
UpgradePending bool `json:"upgradePending"`
Reason string `json:"reason,omitempty"`
SubmissionID string `json:"submissionId,omitempty"`
Profile profileDTO `json:"profile"`
}
func mapVerification(v identity.Verification) verificationDTO {
return verificationDTO{Status: v.Status, UpgradePending: v.UpgradePending, Reason: v.Reason, SubmissionID: v.SubmissionID, Profile: profileDTO{FullName: v.Profile.FullName, DateOfBirth: v.Profile.DateOfBirth, ResidenceCountry: v.Profile.ResidenceCountry}}
}
type limitDTO struct {
Operation string `json:"operation"`
Period string `json:"period"`
Currency string `json:"currency"`
Cap string `json:"cap"`
Remaining string `json:"remaining"`
Used string `json:"used"`
Reserved string `json:"reserved"`
}
type limitsDTO struct {
Tier string `json:"tier"`
ProfileRef string `json:"profileRef"`
Entries []limitDTO `json:"entries"`
}
func mapLimits(v identity.Limits) limitsDTO {
entries := make([]limitDTO, 0, len(v.Entries))
for _, r := range v.Entries {
entries = append(entries, limitDTO{Operation: r.Operation, Period: r.Period, Currency: r.Currency, Cap: r.Cap, Remaining: r.Remaining, Used: r.Used, Reserved: r.Reserved})
}
return limitsDTO{Tier: v.Tier, ProfileRef: v.ProfileRef, Entries: entries}
}
+107
View File
@@ -0,0 +1,107 @@
package identityhttp
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/identity"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/http/response"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
mservice "git.sendico.io/tech/sxchnge/shared/service"
)
type Registrar interface {
AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc)
}
type router struct {
service *identity.Service
logger mlogger.Logger
}
func Install(reg Registrar, service *identity.Service, logger mlogger.Logger) {
h := &router{service, logger.Named("identity_http")}
reg.AccountHandler(mservice.Accounts, "/identity", api.Get, h.get)
reg.AccountHandler(mservice.Accounts, "/identity", api.Post, h.submit)
reg.AccountHandler(mservice.Accounts, "/limits", api.Get, h.limits)
}
func (h *router) limits(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
v, err := h.service.Limits(r.Context(), a)
return h.result(mapLimits(v), err)
}
func (h *router) result(data any, err error) http.HandlerFunc {
if err == nil {
return response.Ok(h.logger, data)
}
if throttle, ok := merrors.ThrottleDetailsFrom(err); ok {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(throttle.RetryAfter.Seconds()))))
response.TooManyRequests(h.logger, mservice.Accounts, "Wait before retrying identity submission")(w, r)
}
}
switch {
case errors.Is(err, merrors.ErrInvalidArg):
return response.BadRequest(h.logger, mservice.Accounts, "invalid_request", "Check profile and use readable JPEG/PNG photos, 600×600 minimum, 10 MiB maximum each")
case errors.Is(err, merrors.ErrDataConflict):
return response.DataConflict(h.logger, mservice.Accounts, "Identity submission changed; refresh its status")
case errors.Is(err, merrors.ErrAccessDenied):
return response.AccessDenied(h.logger, mservice.Accounts, "Identity submission is unavailable")
default:
return response.Error(h.logger, mservice.Accounts, http.StatusServiceUnavailable, "unavailable", "Identity verification is temporarily unavailable; retry")
}
}
func (h *router) get(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
v, err := h.service.Verification(r.Context(), a)
return h.result(mapVerification(v), err)
}
func (h *router) submit(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
// Read multipart as a bounded stream. Never spill identity documents to disk.
r.Body = http.MaxBytesReader(nil, r.Body, 2*identity.MaxPhotoBytes+16384)
reader, err := r.MultipartReader()
if err != nil {
return h.result(nil, merrors.InvalidArgument("multipart required"))
}
fields := map[string][]byte{}
for {
part, readErr := reader.NextPart()
if readErr == io.EOF {
break
}
if readErr != nil {
return h.result(nil, merrors.InvalidArgument("invalid multipart"))
}
name := part.FormName()
limit := int64(8192)
switch name {
case "document", "selfie":
limit = identity.MaxPhotoBytes
case "profile", "idempotencyKey":
default:
_ = part.Close()
return h.result(nil, merrors.InvalidArgument("unexpected field"))
}
if _, exists := fields[name]; exists {
_ = part.Close()
return h.result(nil, merrors.InvalidArgument("duplicate field"))
}
data, readErr := io.ReadAll(io.LimitReader(part, limit+1))
_ = part.Close()
if readErr != nil || int64(len(data)) > limit {
return h.result(nil, merrors.InvalidArgument("field too large"))
}
fields[name] = data
}
var p profileDTO
if err = json.Unmarshal(fields["profile"], &p); err != nil {
return h.result(nil, merrors.InvalidArgument("profile required"))
}
v, err := h.service.SubmitVerification(r.Context(), a, string(fields["idempotencyKey"]), p.model(), fields["document"], fields["selfie"])
return h.result(mapVerification(v), err)
}
@@ -0,0 +1,20 @@
package identityhttp
import (
"net/http/httptest"
"testing"
"time"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestThrottleDoesNotBecomeGenericUnavailable(t *testing.T) {
h := router{logger: zap.NewNop()}
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/accounts/identity", nil)
h.result(nil, merrors.WithThrottle(merrors.ErrServiceUnavailable, "identity", 120*time.Second, "temporary limit"))(w, r)
require.Equal(t, 429, w.Code)
require.Equal(t, "120", w.Header().Get("Retry-After"))
}
+9
View File
@@ -0,0 +1,9 @@
package identity
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var requests = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_signup_facts_total", Help: "Sign-up facts handoff attempts; acceptance is not verification."}, []string{"outcome"})
var duration = promauto.NewHistogram(prometheus.HistogramOpts{Name: "sxchnge_signup_facts_duration_seconds", Help: "Sign-up facts handoff latency including retries."})
+39
View File
@@ -0,0 +1,39 @@
// Package identity owns the durable sign-up facts handoff, not KYC decisions.
package identity
import (
"time"
"git.sendico.io/tech/sxchnge/shared/database/record"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Facts is a snapshot of local sign-up assertions. It never contains credentials.
type Facts struct {
FullName string `bson:"fullName"`
Email string `bson:"email"`
CustomerRef string `bson:"customerRef"`
}
type Receipt struct {
SubmissionRef string `bson:"submissionRef"`
SubjectRef string `bson:"subjectRef"`
AcceptedAt time.Time `bson:"acceptedAt"`
}
// Binding uses the local customer ID as its primary key. Namespace is an opaque
// adapter configuration fingerprint; no platform tenancy enters the customer model.
type Binding struct {
storable.Base `bson:",inline"`
Namespace string `bson:"namespace"`
RegistrationRequest string `bson:"registrationRequest"`
Facts Facts `bson:"facts"`
Registering bool `bson:"registering"`
RegisteringAt time.Time `bson:"registeringAt"`
SubjectRef string `bson:"subjectRef"`
Receipt *Receipt `bson:"receipt,omitempty"`
}
func (*Binding) Collection() string { return "signup_facts" }
func sourceRef(id bson.ObjectID) string { return "sxchnge:signup:" + id.Hex() }
+31
View File
@@ -0,0 +1,31 @@
package identity
import (
"bytes"
"image"
_ "image/jpeg"
_ "image/png"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
)
const MaxPhotoBytes = 10 << 20
// ValidatePhoto repeats client checks at the trust boundary and bounds decoder
// memory before decoding pixels. File names and client MIME types are ignored.
func ValidatePhoto(data []byte) error {
if len(data) == 0 || len(data) > MaxPhotoBytes {
return merrors.InvalidArgument("photo must be between 1 byte and 10 MiB")
}
config, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || (format != "jpeg" && format != "png") {
return merrors.InvalidArgument("readable JPEG or PNG required")
}
if config.Width < 600 || config.Height < 600 || int64(config.Width)*int64(config.Height) > 40000000 {
return merrors.InvalidArgument("photo dimensions must be at least 600 by 600 and at most 40 megapixels")
}
if _, _, err = image.Decode(bytes.NewReader(data)); err != nil {
return merrors.InvalidArgument("readable JPEG or PNG required")
}
return nil
}
+132
View File
@@ -0,0 +1,132 @@
package identity
import (
"context"
"errors"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type Provider interface {
Namespace() string
Register(context.Context, string) (string, error)
Submit(context.Context, string, string, Facts) (Receipt, error)
}
type Service struct {
Store Store
Provider Provider
Logger mlogger.Logger
}
func unavailable() error { return merrors.ServiceUnavailable("sign-up facts unavailable") }
// Ensure completes only after a provider receipt has been persisted. Registration
// has no idempotency contract, so its durable claim is never automatically expired.
func (s *Service) Ensure(ctx context.Context, a *customer.Account) (result error) {
if a == nil || a.ID.IsZero() {
return merrors.InvalidArgument("customer required")
}
started := time.Now()
requestRef := ""
outcome := "unavailable"
defer func() {
requests.WithLabelValues(outcome).Inc()
duration.Observe(time.Since(started).Seconds())
fields := []zap.Field{zap.String("account_ref", a.ID.Hex()), zap.String("request_ref", requestRef), zap.String("outcome", outcome)}
if result != nil {
s.Logger.Warn("Sign-up facts handoff incomplete", fields...)
} else {
s.Logger.Info("Sign-up facts handoff completed", fields...)
}
}()
namespace := s.Provider.Namespace()
b, err := s.Store.Get(ctx, a.ID)
if errors.Is(err, merrors.ErrNoData) {
b = &Binding{Namespace: namespace, RegistrationRequest: bson.NewObjectID().Hex(), Facts: Facts{FullName: a.Name, Email: a.Login, CustomerRef: a.ID.Hex()}}
b.SetID(a.ID)
if err = s.Store.Create(ctx, b); err != nil {
if !errors.Is(err, merrors.ErrDataConflict) {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "persist sign-up facts")
}
b, err = s.Store.Get(ctx, a.ID)
}
}
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "load sign-up facts")
}
requestRef = b.RegistrationRequest
if b.Namespace != namespace {
outcome = "attention_required"
return merrors.FailedPrecondition("sign-up provider binding changed")
}
if b.Receipt != nil {
outcome = "replayed"
return nil
}
if b.SubjectRef == "" {
claimed, err := s.Store.Claim(ctx, a.ID, namespace)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "claim individual registration")
}
if !claimed {
// Another request may have completed while this request was claiming.
b, err = s.Store.Get(ctx, a.ID)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "reload sign-up facts")
}
if b.SubjectRef == "" {
if b.RegisteringAt.Add(30 * time.Second).After(time.Now()) {
return unavailable()
}
outcome = "attention_required"
return merrors.FailedPrecondition("individual registration requires reconciliation")
}
} else {
subject, err := s.Provider.Register(ctx, b.RegistrationRequest)
if err != nil {
outcome = "attention_required"
return merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "individual registration requires reconciliation")
}
// Persist even if the browser left while Sendico accepted registration.
saveCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
err = s.Store.Bind(saveCtx, a.ID, namespace, subject)
cancel()
if err != nil {
outcome = "attention_required"
s.Logger.Warn("Registered individual binding needs reconciliation", zap.String("account_ref", a.ID.Hex()), zap.String("subject_ref", subject))
return merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "persist individual binding")
}
b.SubjectRef = subject
}
}
receipt, err := s.Provider.Submit(ctx, b.SubjectRef, sourceRef(a.ID), b.Facts)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "submit sign-up facts")
}
if receipt.SubjectRef != b.SubjectRef || receipt.SubmissionRef == "" || receipt.AcceptedAt.IsZero() {
return unavailable()
}
if err = s.Store.Accept(ctx, a.ID, namespace, receipt); err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "persist facts receipt")
}
outcome = "accepted"
return nil
}
// Ready checks confirmed intake without external I/O inside activation transactions.
func (s *Service) Ready(ctx context.Context, id bson.ObjectID) error {
b, err := s.Store.Get(ctx, id)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "load facts receipt")
}
if b.Namespace != s.Provider.Namespace() || b.Receipt == nil {
return unavailable()
}
return nil
}
+213
View File
@@ -0,0 +1,213 @@
package identity
import (
"context"
"sync"
"testing"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
type memoryStore struct {
mu sync.Mutex
rows map[bson.ObjectID]Binding
bindErr, acceptErr error
}
func (s *memoryStore) Get(_ context.Context, id bson.ObjectID) (*Binding, error) {
s.mu.Lock()
defer s.mu.Unlock()
b, ok := s.rows[id]
if !ok {
return nil, merrors.NoData("missing")
}
return &b, nil
}
func (s *memoryStore) Create(_ context.Context, b *Binding) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.rows[b.ID]; ok {
return merrors.DataConflict("duplicate")
}
s.rows[b.ID] = *b
return nil
}
func (s *memoryStore) Claim(_ context.Context, id bson.ObjectID, namespace string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
b := s.rows[id]
if b.Namespace != namespace || b.Registering || b.SubjectRef != "" {
return false, nil
}
b.Registering = true
b.RegisteringAt = time.Now()
s.rows[id] = b
return true, nil
}
func (s *memoryStore) Bind(_ context.Context, id bson.ObjectID, namespace, subject string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.bindErr != nil {
return s.bindErr
}
b := s.rows[id]
b.SubjectRef = subject
b.Registering = false
s.rows[id] = b
return nil
}
func (s *memoryStore) Accept(_ context.Context, id bson.ObjectID, namespace string, r Receipt) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.acceptErr != nil {
return s.acceptErr
}
b := s.rows[id]
b.Receipt = &r
s.rows[id] = b
return nil
}
type providerStub struct {
registrations, submissions int
registerErr, submitErr error
snapshots []Facts
sources []string
entered, release chan struct{}
}
func (*providerStub) Namespace() string { return "provider-1" }
func (p *providerStub) Register(context.Context, string) (string, error) {
p.registrations++
if p.entered != nil {
close(p.entered)
<-p.release
}
return "subject-1", p.registerErr
}
func (p *providerStub) Submit(_ context.Context, subject, source string, f Facts) (Receipt, error) {
p.submissions++
p.snapshots = append(p.snapshots, f)
p.sources = append(p.sources, source)
return Receipt{SubjectRef: subject, SubmissionRef: "submission-1", AcceptedAt: time.Now()}, p.submitErr
}
func fixture() (*Service, *memoryStore, *providerStub, *customer.Account) {
store := &memoryStore{rows: map[bson.ObjectID]Binding{}}
provider := &providerStub{}
a := &customer.Account{}
a.ID = bson.NewObjectID()
a.Name = "Ada Lovelace"
a.Login = "ada@example.com"
a.Password = "never transmit"
return &Service{Store: store, Provider: provider, Logger: zap.NewNop()}, store, provider, a
}
func TestIntakeRetryUsesPersistedSubjectSnapshotAndStableSource(t *testing.T) {
s, store, p, a := fixture()
p.submitErr = merrors.ServiceUnavailable("upstream")
require.Error(t, s.Ensure(context.Background(), a))
require.Error(t, s.Ready(context.Background(), a.ID))
require.Equal(t, 1, p.registrations)
b, err := store.Get(context.Background(), a.ID)
require.NoError(t, err)
require.Equal(t, "subject-1", b.SubjectRef)
require.Nil(t, b.Receipt)
a.Name = "changed retry"
p.submitErr = nil
require.NoError(t, s.Ensure(context.Background(), a))
require.NoError(t, s.Ready(context.Background(), a.ID))
require.Equal(t, 1, p.registrations)
require.Equal(t, 2, p.submissions)
require.Equal(t, p.snapshots[0], p.snapshots[1])
require.Equal(t, "Ada Lovelace", p.snapshots[1].FullName)
require.Equal(t, p.sources[0], p.sources[1])
require.NoError(t, s.Ensure(context.Background(), a))
require.Equal(t, 2, p.submissions)
other := a.Copy()
other.ID = bson.NewObjectID()
require.Error(t, s.Ready(context.Background(), other.ID))
}
func TestLostAcceptanceReceiptIsSafelyReplayed(t *testing.T) {
s, store, p, a := fixture()
store.acceptErr = merrors.ServiceUnavailable("storage")
require.Error(t, s.Ensure(context.Background(), a))
store.acceptErr = nil
require.NoError(t, s.Ensure(context.Background(), a))
require.Equal(t, 1, p.registrations)
require.Equal(t, 2, p.submissions)
require.Equal(t, p.sources[0], p.sources[1])
}
func TestAmbiguousRegistrationNeverAutomaticallyRegistersAgain(t *testing.T) {
for _, bindFailure := range []bool{false, true} {
t.Run(map[bool]string{false: "request", true: "binding"}[bindFailure], func(t *testing.T) {
s, store, p, a := fixture()
if bindFailure {
store.bindErr = merrors.ServiceUnavailable("storage")
} else {
p.registerErr = merrors.ServiceUnavailable("lost response")
}
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
b := store.rows[a.ID]
b.RegisteringAt = time.Now().Add(-time.Minute)
store.rows[a.ID] = b
store.bindErr = nil
p.registerErr = nil
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
require.Equal(t, 1, p.registrations)
require.Zero(t, p.submissions)
})
}
}
func TestConcurrentRequestCannotRegisterSameCustomerTwice(t *testing.T) {
s, _, p, a := fixture()
p.entered = make(chan struct{})
p.release = make(chan struct{})
done := make(chan error, 1)
go func() { done <- s.Ensure(context.Background(), a) }()
<-p.entered
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrServiceUnavailable)
close(p.release)
require.NoError(t, <-done)
require.Equal(t, 1, p.registrations)
require.NoError(t, s.Ensure(context.Background(), a))
require.Equal(t, 1, p.submissions)
}
func TestNamespaceChangeFailsClosed(t *testing.T) {
s, store, p, a := fixture()
require.NoError(t, s.Ensure(context.Background(), a))
b := store.rows[a.ID]
b.Namespace = "other-account-or-organization"
store.rows[a.ID] = b
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
require.Error(t, s.Ready(context.Background(), a.ID))
require.Equal(t, 1, p.submissions)
}
func TestHandoffLogsExcludeAssertionsAndCredentials(t *testing.T) {
s, _, p, a := fixture()
core, logs := observer.New(zap.InfoLevel)
s.Logger = zap.New(core)
p.submitErr = merrors.ServiceUnavailable(a.Login + " " + a.Password)
require.Error(t, s.Ensure(context.Background(), a))
p.submitErr = nil
require.NoError(t, s.Ensure(context.Background(), a))
require.Len(t, logs.All(), 2)
for _, entry := range logs.All() {
require.NotContains(t, entry.Message, a.Login)
require.NotContains(t, entry.Message, a.Name)
fields := entry.ContextMap()
require.Equal(t, a.ID.Hex(), fields["account_ref"])
require.NotEmpty(t, fields["request_ref"])
for _, value := range fields {
require.NotEqual(t, a.Login, value)
require.NotEqual(t, a.Name, value)
require.NotEqual(t, a.Password, value)
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package identity
import (
"context"
"time"
db "git.sendico.io/tech/sxchnge/shared/database"
deps "git.sendico.io/tech/sxchnge/shared/database/dependencies"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/source"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Store interface {
Get(context.Context, bson.ObjectID) (*Binding, error)
Create(context.Context, *Binding) error
Claim(context.Context, bson.ObjectID, string) (bool, error)
Bind(context.Context, bson.ObjectID, string, string) error
Accept(context.Context, bson.ObjectID, string, Receipt) error
}
type mongoStore struct{ records repository.Repository }
func OpenStore(factory db.Factory) (Store, error) {
d, err := deps.ResolveDependencies(factory)
if err != nil {
return nil, err
}
records := repository.CreateRepository(source.Majority(d.Database), "signup_facts")
if err = records.CreateIndex(&ri.Definition{Name: "provider_subject_unique", Unique: true, Keys: []ri.Key{{Field: "namespace", Sort: ri.Asc}, {Field: "subjectRef", Sort: ri.Asc}}, PartialFilter: repository.Query().Comparison(repository.Field("subjectRef"), builder.Gt, "")}); err != nil {
return nil, err
}
return &mongoStore{records: records}, nil
}
func (s *mongoStore) Get(ctx context.Context, id bson.ObjectID) (*Binding, error) {
b := &Binding{}
err := s.records.Get(ctx, id, b)
return b, err
}
func (s *mongoStore) Create(ctx context.Context, b *Binding) error {
return s.records.Insert(ctx, b, nil)
}
func scoped(id bson.ObjectID, namespace string) repository.FilterQuery {
return repository.IDFilter(id).Filter(repository.Field("namespace"), namespace)
}
func (s *mongoStore) Claim(ctx context.Context, id bson.ObjectID, namespace string) (bool, error) {
query := scoped(id, namespace).Filter(repository.Field("registering"), false).Filter(repository.Field("subjectRef"), "")
return s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("registering"), true).Set(repository.Field("registeringAt"), time.Now().UTC()), &Binding{})
}
func (s *mongoStore) Bind(ctx context.Context, id bson.ObjectID, namespace, subject string) error {
query := scoped(id, namespace).Filter(repository.Field("registering"), true).Filter(repository.Field("subjectRef"), "")
matched, err := s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("subjectRef"), subject).Set(repository.Field("registering"), false), &Binding{})
if err != nil {
return err
}
if !matched {
return unavailable()
}
return nil
}
func (s *mongoStore) Accept(ctx context.Context, id bson.ObjectID, namespace string, receipt Receipt) error {
query := scoped(id, namespace).Filter(repository.Field("subjectRef"), receipt.SubjectRef)
matched, err := s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("receipt"), receipt), &Binding{})
if err != nil {
return err
}
if !matched {
return unavailable()
}
return nil
}
+88
View File
@@ -0,0 +1,88 @@
package identity
import (
"context"
"net/url"
"sync"
"sync/atomic"
"testing"
"time"
db "git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func TestDurableBindingClaimsAndIsolation(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx)
require.NoError(t, err)
u, err := url.Parse(uri)
require.NoError(t, err)
q := u.Query()
q.Set("directConnection", "true")
// The handoff store must override unsafe caller defaults before claiming
// ownership of a non-idempotent external operation.
q.Set("w", "0")
q.Set("readPreference", "secondary")
u.RawQuery = q.Encode()
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "facts"}})
require.NoError(t, err)
t.Cleanup(factory.CloseConnection)
store, err := OpenStore(factory)
require.NoError(t, err)
b := &Binding{Namespace: "configured-source", Facts: Facts{FullName: "Ada", Email: "ada@example.com"}}
b.SetID(bson.NewObjectID())
require.NoError(t, store.Create(ctx, b))
require.ErrorIs(t, store.Create(ctx, b), merrors.ErrDataConflict)
var wg sync.WaitGroup
var wins atomic.Int32
for range 12 {
wg.Go(func() {
claimed, err := store.Claim(ctx, b.ID, b.Namespace)
require.NoError(t, err)
if claimed {
wins.Add(1)
}
})
}
wg.Wait()
require.EqualValues(t, 1, wins.Load())
stored, err := store.Get(ctx, b.ID)
require.NoError(t, err)
require.True(t, stored.Registering)
require.False(t, stored.RegisteringAt.IsZero())
require.Error(t, store.Bind(ctx, b.ID, "other-source", "subject"))
require.NoError(t, store.Bind(ctx, b.ID, b.Namespace, "subject"))
duplicate := &Binding{Namespace: b.Namespace}
duplicate.SetID(bson.NewObjectID())
require.NoError(t, store.Create(ctx, duplicate))
claimedDuplicate, err := store.Claim(ctx, duplicate.ID, duplicate.Namespace)
require.NoError(t, err)
require.True(t, claimedDuplicate)
require.ErrorIs(t, store.Bind(ctx, duplicate.ID, duplicate.Namespace, "subject"), merrors.ErrDataConflict)
require.Error(t, store.Bind(ctx, b.ID, b.Namespace, "different-subject"))
receipt := Receipt{SubjectRef: "other-subject", SubmissionRef: "submission", AcceptedAt: time.Now().UTC().Truncate(time.Millisecond)}
require.Error(t, store.Accept(ctx, b.ID, b.Namespace, receipt))
receipt.SubjectRef = "subject"
require.NoError(t, store.Accept(ctx, b.ID, b.Namespace, receipt))
// A separately opened store sees the durable receipt and cannot reclaim registration.
reopened, err := OpenStore(factory)
require.NoError(t, err)
stored, err = reopened.Get(ctx, b.ID)
require.NoError(t, err)
require.Equal(t, receipt, *stored.Receipt)
claimed, err := reopened.Claim(ctx, b.ID, b.Namespace)
require.NoError(t, err)
require.False(t, claimed)
_, err = reopened.Get(ctx, bson.NewObjectID())
require.ErrorIs(t, err, merrors.ErrNoData)
}
+159
View File
@@ -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)
}
+50
View File
@@ -0,0 +1,50 @@
package payments
import (
"context"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"go.uber.org/zap"
)
var accountSetup = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_currency_accounts_setup_total", Help: "Default currency account setup attempts, including checks of existing accounts."}, []string{"outcome"})
var accountSetupDuration = promauto.NewHistogram(prometheus.HistogramOpts{Name: "sxchnge_currency_accounts_setup_duration_seconds", Help: "Time to confirm all default currency accounts exist."})
func (s *Service) Ensure(ctx context.Context, a *customer.Account) error {
_, err := s.ensureAccounts(ctx, a)
if err != nil {
return merrors.ServiceUnavailableWrap(err, "currency account setup unavailable")
}
return nil
}
func (s *Service) ensureAccounts(ctx context.Context, a *customer.Account) (subject string, result error) {
started := time.Now()
defer func() {
outcome := "ready"
if result != nil {
outcome = "failed"
}
accountSetup.WithLabelValues(outcome).Inc()
accountSetupDuration.Observe(time.Since(started).Seconds())
}()
if s == nil || s.Identity == nil || s.Provider == nil {
return "", Unavailable
}
if a == nil || a.ID.IsZero() {
return "", NotFound
}
subject, result = s.Identity.Subject(ctx, a)
if result != nil {
return "", result
}
if result = s.Provider.EnsureAccounts(ctx, subject); result != nil {
s.Logger.Warn("Currency account setup incomplete", zap.String("account_ref", a.ID.Hex()), zap.Error(result))
return "", result
}
return subject, nil
}
+28
View File
@@ -0,0 +1,28 @@
package payments
import (
"testing"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func TestSignupAndWalletReadEnsureCustomerCurrencyAccounts(t *testing.T) {
a := &customer.Account{}
a.ID = bson.NewObjectID()
p := &provider{namespace: "provider"}
s := &Service{Provider: p, Identity: identity{}, Logger: zap.NewNop(), Factors: &factors{}, Store: &memoryStore{records: map[bson.ObjectID]Record{}}}
require.NoError(t, s.Ensure(t.Context(), a))
require.Equal(t, "subject", p.setupSubject)
snapshot, err := s.Fetch(t.Context(), a)
require.NoError(t, err)
require.Len(t, snapshot.Accounts, 1)
require.Equal(t, 2, p.setupCalls)
p.setupErr = Unavailable
_, err = s.Fetch(t.Context(), a)
require.ErrorIs(t, err, Unavailable)
require.Error(t, s.Ensure(t.Context(), a))
require.Error(t, s.Ensure(t.Context(), nil))
}
+84
View File
@@ -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}
}
+164
View File
@@ -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)
}
}
+84
View File
@@ -0,0 +1,84 @@
// 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
EnsureAccounts(context.Context, string) error
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"
)
+237
View File
@@ -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.ensureAccounts(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
}
+199
View File
@@ -0,0 +1,199 @@
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
setupCalls int
setupSubject string
setupErr error
}
func (p *provider) Namespace() string { return p.namespace }
func (p *provider) EnsureAccounts(_ context.Context, subject string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.setupCalls++
p.setupSubject = subject
return p.setupErr
}
func (p *provider) Accounts(context.Context, string) ([]Account, error) {
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)
}
+88
View File
@@ -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
}
+66
View File
@@ -0,0 +1,66 @@
package payments
import (
"context"
"net/url"
"testing"
"time"
db "git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func TestDurableAuthorizationTransactionAndCustomerIsolation(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx)
require.NoError(t, err)
u, err := url.Parse(uri)
require.NoError(t, err)
q := u.Query()
q.Set("directConnection", "true")
u.RawQuery = q.Encode()
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "payments"}})
require.NoError(t, err)
t.Cleanup(factory.CloseConnection)
store, err := OpenStore(factory)
require.NoError(t, err)
customer := bson.NewObjectID()
r := &Record{Customer: customer, Namespace: "provider", Subject: "subject"}
r.SetID(bson.NewObjectID())
require.NoError(t, store.Create(ctx, r))
_, err = store.Get(ctx, r.ID, bson.NewObjectID(), r.Namespace)
require.ErrorIs(t, err, merrors.ErrNoData)
_, err = factory.TransactionFactory().CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
require.NoError(t, store.Authorize(tx, r, "authorized-key-123"))
return nil, Uncertain
})
require.ErrorIs(t, err, Uncertain)
stored, err := store.Get(ctx, r.ID, customer, r.Namespace)
require.NoError(t, err)
require.Empty(t, stored.Key)
require.NoError(t, store.Authorize(ctx, r, "authorized-key-123"))
require.ErrorIs(t, store.Authorize(ctx, r, "other-key-123456"), Uncertain)
another := &Record{Customer: customer, Namespace: r.Namespace}
another.SetID(bson.NewObjectID())
require.NoError(t, store.Create(ctx, another))
require.ErrorIs(t, store.Authorize(ctx, another, "authorized-key-123"), merrors.ErrDataConflict)
require.NoError(t, store.Attach(ctx, r, "payment-1"))
require.NoError(t, store.Attach(ctx, r, "payment-1"))
require.ErrorIs(t, store.Attach(ctx, r, "different-payment"), Uncertain)
reopened, err := OpenStore(factory)
require.NoError(t, err)
stored, err = reopened.ByKey(ctx, customer, r.Namespace, "authorized-key-123")
require.NoError(t, err)
require.Equal(t, "payment-1", stored.Payment)
_, err = reopened.ByKey(ctx, customer, "other-provider", "authorized-key-123")
require.ErrorIs(t, err, merrors.ErrNoData)
}
@@ -0,0 +1,87 @@
package sendico
import (
"context"
"net/http"
"git.sendico.io/tech/sxchnge/product/payments"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
)
func (c *Client) validCustomerAccount(a accountDTO, subject string) bool {
return validRef(a.ID) && a.ScopeRef == c.config.OrganizationID && a.Owner == "" &&
a.Type == "ACCOUNT_TYPE_ASSET" && a.LedgerRole == "ACCOUNT_ROLE_OPERATING" &&
a.Status == "ACCOUNT_STATUS_ACTIVE" && !a.AllowNegative && a.Metadata["sxchnge_subject_ref"] == subject &&
(a.Metadata["external_subject_ref"] == "" || a.Metadata["external_subject_ref"] == subject)
}
// EnsureAccounts also repairs older customers, using provider-enforced
// organization/subject/currency uniqueness for safe retries after lost responses.
func (c *Client) EnsureAccounts(ctx context.Context, subject string) error {
if !validRef(subject) {
return payments.Unavailable
}
root := "/ledger_accounts/" + c.config.OrganizationID
var data accountListDTO
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
return err
}
existing := map[string]bool{}
reconcile := map[string]bool{}
for _, a := range data.Accounts {
if a.Metadata["sxchnge_subject_ref"] != subject && a.Metadata["external_subject_ref"] != subject {
continue
}
if !c.validCustomerAccount(a, subject) || existing[a.Currency] {
return payments.Unavailable
}
existing[a.Currency] = true
reconcile[a.Currency] = a.Metadata["external_subject_ref"] == subject
}
missing := []string{}
for _, code := range []string{"RUB", "USDT", "USDC"} {
if !existing[code] || reconcile[code] {
missing = append(missing, code)
}
}
if len(missing) == 0 {
return nil
}
// Older providers can accept the metadata but cannot prevent duplicates.
// Require the explicit capability before issuing the first create.
if !data.ExternalSubjectAccounts {
return payments.Unavailable
}
var catalog struct {
Rows []currencyDTO `json:"currencies"`
}
if err := c.paymentRequest(ctx, http.MethodGet, "/payments/fx/currencies/"+c.config.OrganizationID, nil, &catalog); err != nil {
return err
}
for _, code := range missing {
found := false
for _, currency := range catalog.Rows {
if currency.Code == code && currency.Scale != nil && *currency.Scale >= 0 && *currency.Scale <= 8 {
found = true
}
}
if !found {
return merrors.OpaqueWrap(payments.Unavailable, payments.Unsupported, "default currency missing from provider catalog")
}
}
// Replaying a bound create also resumes Sendico's durable wallet/route setup
// if a previous response failed after the ledger itself had been committed.
for _, code := range missing {
var response struct {
Account accountDTO `json:"account"`
}
request := createAccountDTO{Currency: code, Type: "asset", LedgerRole: "operating", Metadata: map[string]string{"external_subject_ref": subject, "sxchnge_subject_ref": subject}}
if err := c.paymentRequest(ctx, http.MethodPost, root, request, &response, http.StatusCreated); err != nil {
return err
}
if !c.validCustomerAccount(response.Account, subject) || response.Account.Currency != code || response.Account.Metadata["external_subject_ref"] != subject {
return payments.Unavailable
}
}
return nil
}
@@ -0,0 +1,200 @@
package sendico
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
)
type accountServer struct {
mu sync.Mutex
rows []accountDTO
posts []string
capable bool
missingCurrency bool
failCurrency string
loseResponse bool
wrongResponse bool
}
func customerLedger(code string) accountDTO {
return accountDTO{ID: bson.NewObjectID().Hex(), ScopeRef: org, Currency: code, Type: "ACCOUNT_TYPE_ASSET", LedgerRole: "ACCOUNT_ROLE_OPERATING", Status: "ACCOUNT_STATUS_ACTIVE", Metadata: map[string]string{"sxchnge_subject_ref": subject, "external_subject_ref": subject}}
}
func (s *accountServer) serve(t *testing.T) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
require.NotEmpty(t, r.Header.Get("Authorization"))
var data any
switch {
case strings.Contains(r.URL.Path, "/currencies/"):
rows := []map[string]any{{"code": "RUB", "accountingScale": 2}, {"code": "USDT", "accountingScale": 6}, {"code": "USDC", "accountingScale": 6}}
if s.missingCurrency {
rows = rows[:2]
}
data = map[string]any{"currencies": rows}
case r.Method == http.MethodPost:
var request createAccountDTO
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
require.Equal(t, "asset", request.Type)
require.Equal(t, "operating", request.LedgerRole)
require.False(t, request.AllowNegative)
require.Equal(t, subject, request.Metadata["external_subject_ref"])
require.Equal(t, subject, request.Metadata["sxchnge_subject_ref"])
s.posts = append(s.posts, request.Currency)
if s.failCurrency == request.Currency {
s.failCurrency = ""
w.WriteHeader(503)
return
}
var row accountDTO
for _, a := range s.rows {
if a.Currency == request.Currency && a.Metadata["external_subject_ref"] == subject {
row = a
}
}
if row.ID == "" {
row = customerLedger(request.Currency)
s.rows = append(s.rows, row)
}
if s.loseResponse {
s.loseResponse = false
w.WriteHeader(503)
return
}
if s.wrongResponse {
row.Currency = "EUR"
}
w.WriteHeader(http.StatusCreated)
data = map[string]any{"account": row}
case strings.HasSuffix(r.URL.Path, "/balance"):
parts := strings.Split(r.URL.Path, "/")
data = map[string]any{"balance": balanceDTO{ID: parts[len(parts)-2], Money: moneyDTO{Amount: "45.00", Currency: "RUB"}}}
default:
data = map[string]any{"accounts": s.rows, "externalSubjectAccounts": s.capable}
}
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"status": "success", "data": data}))
}
}
func TestEnsureCurrencyAccountsPreservesExistingAndRecoversPartialFailure(t *testing.T) {
for _, lost := range []bool{false, true} {
t.Run(map[bool]string{false: "partial failure", true: "lost response"}[lost], func(t *testing.T) {
s := &accountServer{capable: true, failCurrency: "USDT", loseResponse: lost}
if lost {
s.failCurrency = ""
}
server := httptest.NewServer(s.serve(t))
defer server.Close()
c, err := Open(config(t, server.URL))
require.NoError(t, err)
require.Error(t, c.EnsureAccounts(t.Context(), subject))
require.Len(t, s.rows, 1)
firstID := s.rows[0].ID
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
require.Len(t, s.rows, 3)
require.Equal(t, firstID, s.rows[0].ID)
// Bound creates are replayed to finish interrupted wallet/route setup;
// the provider must return the original ledger each time.
require.Equal(t, 3, countCurrency(s.posts, "RUB"))
})
}
}
func countCurrency(rows []string, code string) int {
n := 0
for _, r := range rows {
if r == code {
n++
}
}
return n
}
func TestEnsureAccountsConcurrentRequestsUseStableSubjectBinding(t *testing.T) {
s := &accountServer{capable: true}
server := httptest.NewServer(s.serve(t))
defer server.Close()
c, err := Open(config(t, server.URL))
require.NoError(t, err)
errors := make(chan error, 12)
for range 12 {
go func() { errors <- c.EnsureAccounts(t.Context(), subject) }()
}
for range 12 {
require.NoError(t, <-errors)
}
require.Len(t, s.rows, 3)
}
func TestEnsureAccountsRejectsUnsafeProviderStateBeforeCreating(t *testing.T) {
for _, kind := range []string{"old provider", "missing currency", "frozen", "owned", "negative", "duplicate", "wrong subject"} {
t.Run(kind, func(t *testing.T) {
s := &accountServer{capable: true}
a := customerLedger("RUB")
switch kind {
case "old provider":
s.capable = false
case "missing currency":
s.missingCurrency = true
case "frozen":
a.Status = "ACCOUNT_STATUS_FROZEN"
s.rows = []accountDTO{a}
case "owned":
a.Owner = source
s.rows = []accountDTO{a}
case "negative":
a.AllowNegative = true
s.rows = []accountDTO{a}
case "duplicate":
s.rows = []accountDTO{a, customerLedger("RUB")}
case "wrong subject":
a.Metadata["sxchnge_subject_ref"] = source
s.rows = []accountDTO{a}
}
server := httptest.NewServer(s.serve(t))
defer server.Close()
c, err := Open(config(t, server.URL))
require.NoError(t, err)
require.Error(t, c.EnsureAccounts(t.Context(), subject))
require.Empty(t, s.posts)
})
}
}
func TestEnsureAccountsValidatesCreateReceipt(t *testing.T) {
s := &accountServer{capable: true, wrongResponse: true}
server := httptest.NewServer(s.serve(t))
defer server.Close()
c, err := Open(config(t, server.URL))
require.NoError(t, err)
require.Error(t, c.EnsureAccounts(t.Context(), subject))
require.Len(t, s.posts, 1)
}
func TestEnsureAccountsKeepsExistingBindingsAndBalances(t *testing.T) {
a := customerLedger("RUB")
delete(a.Metadata, "external_subject_ref")
s := &accountServer{capable: true, rows: []accountDTO{a}}
server := httptest.NewServer(s.serve(t))
defer server.Close()
c, err := Open(config(t, server.URL))
require.NoError(t, err)
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
require.Equal(t, []string{"USDT", "USDC"}, s.posts)
// A subsequent read uses the original RUB ledger and its authoritative funds.
s.rows = s.rows[:1]
accounts, err := c.Accounts(t.Context(), subject)
require.NoError(t, err)
require.Len(t, accounts, 1)
require.Equal(t, a.ID, accounts[0].ID)
require.EqualValues(t, 4500, accounts[0].Available)
}
@@ -0,0 +1,14 @@
package sendico
type accountListDTO struct {
Accounts []accountDTO `json:"accounts"`
ExternalSubjectAccounts bool `json:"externalSubjectAccounts"`
}
type createAccountDTO struct {
Currency string `json:"currency"`
Type string `json:"accountType"`
LedgerRole string `json:"role"`
AllowNegative bool `json:"allowNegative"`
Metadata map[string]string `json:"metadata"`
}
@@ -0,0 +1,69 @@
package sendico
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"git.sendico.io/tech/sxchnge/product/identity"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson"
)
func (c *Client) post(ctx context.Context, resource, requestRef string, payload any, expectedStatus int) (receiptDTO, error) {
var receipt receiptDTO
token, err := c.token()
if err != nil {
return receipt, err
}
var body []byte
if payload != nil {
body, err = json.Marshal(payload)
if err != nil {
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "encode Sendico request")
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/"+resource+"/"+c.config.OrganizationID, bytes.NewReader(body))
if err != nil {
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "create Sendico request")
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Request-ID", requestRef)
response, err := c.http.Do(req)
if err != nil {
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "Sendico request failed")
}
defer func() { _ = response.Body.Close() }()
// Never decode or expose upstream error bodies, which can contain assertions.
if response.StatusCode != expectedStatus {
return receipt, merrors.ServiceUnavailable("Sendico request was not accepted")
}
decoder := json.NewDecoder(io.LimitReader(response.Body, 16385))
var envelope envelopeDTO
if err := decoder.Decode(&envelope); err != nil {
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode Sendico receipt")
}
var trailing any
if decoder.Decode(&trailing) != io.EOF || envelope.Status != "success" {
return receipt, merrors.ServiceUnavailable("invalid Sendico response")
}
return envelope.Data, nil
}
func (c *Client) Register(ctx context.Context, requestRef string) (string, error) {
dto, err := c.post(ctx, "individuals", requestRef, nil, http.StatusCreated)
if err != nil {
return "", err
}
return mapSubject(dto)
}
func (c *Client) Submit(ctx context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) {
dto, err := c.post(ctx, "identity_facts", bson.NewObjectID().Hex(), mapFacts(subject, source, facts), http.StatusOK)
if err != nil {
return identity.Receipt{}, err
}
return mapReceipt(dto, subject)
}
@@ -0,0 +1,190 @@
package sendico
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.sendico.io/tech/sxchnge/product/identity"
"github.com/stretchr/testify/require"
)
const org = "507f1f77bcf86cd799439011"
const source = "507f1f77bcf86cd799439012"
const subject = "507f1f77bcf86cd799439013"
const submission = "507f1f77bcf86cd799439014"
func credential(t *testing.T, id string, expiry int64) string {
t.Helper()
raw, err := json.Marshal(map[string]any{"id": id, "exp": expiry})
require.NoError(t, err)
return "header." + base64.RawURLEncoding.EncodeToString(raw) + ".signature"
}
func config(t *testing.T, endpoint string) Config {
t.Helper()
file := filepath.Join(t.TempDir(), "token")
require.NoError(t, os.WriteFile(file, []byte(credential(t, source, time.Now().Add(time.Hour).Unix())), 0600))
return Config{APIURL: endpoint + "/api/v1", OrganizationID: org, SourceID: source, TokenFile: file}
}
func TestSignupWireContractAndCredentialRotation(t *testing.T) {
paths := []string{}
var token string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "Bearer "+token, r.Header.Get("Authorization"))
paths = append(paths, r.URL.Path)
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
if strings.Contains(r.URL.Path, "/individuals/") {
require.Equal(t, "registration-request", r.Header.Get("X-Request-ID"))
require.Empty(t, body)
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"registered_at":"2026-10-07T10:30:00.123Z"}}`, subject)
} else {
require.JSONEq(t, `{"subject_ref":"`+subject+`","source_submission_ref":"signup-1","facts":{"full_name":"Ada Lovelace","email":"ada@example.com","external_customer_ref":"local-user"}}`, string(body))
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"submission_ref":%q,"accepted_at":"2026-10-07T10:30:00.123Z"}}`, subject, submission)
}
}))
defer server.Close()
cfg := config(t, server.URL)
token = credential(t, source, time.Now().Add(time.Hour).Unix())
client, err := Open(cfg)
require.NoError(t, err)
ref, err := client.Register(context.Background(), "registration-request")
require.NoError(t, err)
require.Equal(t, subject, ref)
// File rotation takes effect without restarting; account identity stays fixed.
token = credential(t, source, time.Now().Add(2*time.Hour).Unix())
require.NoError(t, os.WriteFile(cfg.TokenFile, []byte(token), 0600))
receipt, err := client.Submit(context.Background(), subject, "signup-1", identity.Facts{FullName: "Ada Lovelace", Email: "ada@example.com", CustomerRef: "local-user"})
require.NoError(t, err)
require.Equal(t, submission, receipt.SubmissionRef)
require.Equal(t, subject, receipt.SubjectRef)
require.Equal(t, []string{"/api/v1/individuals/" + org, "/api/v1/identity_facts/" + org}, paths)
require.NoError(t, os.WriteFile(cfg.TokenFile, []byte(credential(t, org, time.Now().Add(time.Hour).Unix())), 0600))
_, err = client.Register(context.Background(), "registration-request")
require.Error(t, err)
require.Len(t, paths, 2)
}
func TestRejectInvalidConfiguration(t *testing.T) {
for _, endpoint := range []string{"http://remote.example", "https://user:pass@api.example", "https://api.example?secret=x", "https://api.example/#fragment", "//api.example", "ftp://localhost"} {
_, err := Open(config(t, endpoint))
require.Error(t, err, endpoint)
}
cfg := config(t, "https://api.example")
cfg.OrganizationID = "../other"
_, err := Open(cfg)
require.Error(t, err)
cfg = config(t, "https://api.example")
cfg.SourceID = ""
_, err = Open(cfg)
require.Error(t, err)
cfg = config(t, "https://api.example")
require.NoError(t, os.WriteFile(cfg.TokenFile, []byte(credential(t, source, 1)), 0600))
_, err = Open(cfg)
require.Error(t, err)
}
func 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) {
for _, tc := range []struct {
name string
status int
body string
}{
{"denied", 403, `{"secret":"private-assertion"}`},
{"conflict", 409, `{"secret":"private-assertion"}`},
{"unavailable", 503, `{"secret":"private-assertion"}`},
{"malformed", 200, `{"private-assertion":`},
{"empty", 200, `{"status":"success","data":{}}`},
{"wrong-status", 200, `{"status":"error","data":{}}`},
{"oversized", 200, `{"padding":"` + strings.Repeat("x", 17000) + `"}`},
{"wrong-subject", 200, `{"status":"success","data":{"subject_ref":"` + org + `","submission_ref":"` + submission + `","accepted_at":"2026-10-07T10:30:00Z"}}`},
{"trailing", 200, `{"status":"success","data":{}} {}`},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
status := tc.status
if status == http.StatusOK && strings.Contains(r.URL.Path, "/individuals/") {
status = http.StatusCreated
}
w.WriteHeader(status)
_, _ = io.WriteString(w, tc.body)
}))
defer server.Close()
client, err := Open(config(t, server.URL))
require.NoError(t, err)
_, err = client.Submit(context.Background(), subject, "key", identity.Facts{})
require.Error(t, err)
require.NotContains(t, err.Error(), "private-assertion")
_, err = client.Register(context.Background(), "registration-request")
require.Error(t, err)
})
}
}
func TestRedirectAndTimeoutDoNotRepeatRegistration(t *testing.T) {
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
http.Redirect(w, r, "/redirected", http.StatusTemporaryRedirect)
}))
defer server.Close()
client, err := Open(config(t, server.URL))
require.NoError(t, err)
_, err = client.Register(context.Background(), "registration-request")
require.Error(t, err)
require.Equal(t, 1, calls)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = client.Register(ctx, "registration-request")
require.Error(t, err)
require.Equal(t, 1, calls)
timeoutServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }))
defer timeoutServer.Close()
client, err = Open(config(t, timeoutServer.URL))
require.NoError(t, err)
client.http.Timeout = 20 * time.Millisecond
_, err = client.Register(context.Background(), "timeout-request")
require.Error(t, err)
}
@@ -0,0 +1,87 @@
// Package sendico consumes only Sendico's public HTTP contracts.
package sendico
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"net/url"
"os"
"strings"
"time"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Config struct {
APIURL string `yaml:"api_url"`
OrganizationID string `yaml:"organization_ref"`
SourceID string `yaml:"source_account_ref"`
TokenFile string `yaml:"token_file"`
}
type Client struct {
config Config
base string
namespace string
http *http.Client
}
func Open(c Config) (*Client, error) {
u, err := url.Parse(c.APIURL)
if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) {
return nil, merrors.InvalidArgument("Sendico API URL must be absolute HTTPS (HTTP allowed on localhost)")
}
for _, ref := range []string{c.OrganizationID, c.SourceID} {
id, err := bson.ObjectIDFromHex(ref)
if err != nil || id.IsZero() || ref != id.Hex() {
return nil, merrors.InvalidArgument("Sendico organization and source references must be lowercase object IDs")
}
}
if strings.TrimSpace(c.TokenFile) == "" {
return nil, merrors.InvalidArgument("Sendico token file required")
}
base := strings.TrimRight(c.APIURL, "/")
hash := sha256.Sum256([]byte(base + "\x00" + c.OrganizationID + "\x00" + c.SourceID))
client := &Client{config: c, base: base, namespace: hex.EncodeToString(hash[:]), http: &http.Client{Timeout: 4 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
if _, err := client.token(); err != nil {
return nil, err
}
return client, nil
}
func (c *Client) Namespace() string { return c.namespace }
// Rotation may replace this file, but must retain the authenticated account.
// Claim inspection only protects the replay namespace from operator mistakes;
// Sendico remains responsible for signature validation and authorization.
func (c *Client) token() (string, error) {
raw, err := os.ReadFile(c.config.TokenFile)
if err != nil {
return "", merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "read Sendico credential")
}
token := strings.TrimSpace(string(raw))
parts := strings.Split(token, ".")
if len(token) > 16384 || len(parts) != 3 {
return "", merrors.FailedPrecondition("invalid Sendico credential")
}
claims, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "invalid Sendico credential claims")
}
var payload struct {
ID string `json:"id"`
Pending bool `json:"pending"`
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(claims, &payload); err != nil {
return "", merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "invalid Sendico credential claims")
}
if payload.ID != c.config.SourceID || payload.Pending || payload.Exp <= time.Now().Unix() {
return "", merrors.FailedPrecondition("Sendico credential must be unexpired and match configured source account")
}
return token, nil
}
+51
View File
@@ -0,0 +1,51 @@
package sendico
import (
"time"
"git.sendico.io/tech/sxchnge/product/identity"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Only consumed wire fields are owned here; never import platform modules.
type factsDTO struct {
FullName string `json:"full_name"`
Email string `json:"email"`
CustomerRef string `json:"external_customer_ref"`
}
type submissionDTO struct {
SubjectRef string `json:"subject_ref"`
SourceRef string `json:"source_submission_ref"`
Facts factsDTO `json:"facts"`
}
type receiptDTO struct {
SubjectRef string `json:"subject_ref"`
RegisteredAt time.Time `json:"registered_at"`
SubmissionRef string `json:"submission_ref"`
AcceptedAt time.Time `json:"accepted_at"`
}
type envelopeDTO struct {
Status string `json:"status"`
Data receiptDTO `json:"data"`
}
func validRef(ref string) bool {
id, err := bson.ObjectIDFromHex(ref)
return err == nil && !id.IsZero() && id.Hex() == ref
}
func mapFacts(subject, source string, facts identity.Facts) submissionDTO {
return submissionDTO{SubjectRef: subject, SourceRef: source, Facts: factsDTO{FullName: facts.FullName, Email: facts.Email, CustomerRef: facts.CustomerRef}}
}
func mapSubject(dto receiptDTO) (string, error) {
if !validRef(dto.SubjectRef) || dto.RegisteredAt.IsZero() {
return "", merrors.ServiceUnavailable("invalid Sendico registration receipt")
}
return dto.SubjectRef, nil
}
func mapReceipt(dto receiptDTO, subject string) (identity.Receipt, error) {
if !validRef(dto.SubmissionRef) || dto.SubjectRef != subject || dto.AcceptedAt.IsZero() {
return identity.Receipt{}, merrors.ServiceUnavailable("invalid Sendico intake receipt")
}
return identity.Receipt{SubmissionRef: dto.SubmissionRef, SubjectRef: dto.SubjectRef, AcceptedAt: dto.AcceptedAt}, nil
}
@@ -0,0 +1,108 @@
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, &currencies); 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 accountListDTO
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 !c.validCustomerAccount(row, subject) {
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,128 @@
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"`
AllowNegative bool `json:"allowNegative"`
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, accepted ...int) 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.
expected := http.StatusOK
if len(accepted) != 0 {
expected = accepted[0]
}
if res.StatusCode != expected {
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)
}
+1 -1
View File
@@ -87,7 +87,7 @@ func validScope(scope model.Scope) bool {
return false
}
switch scope.Purpose {
case vmodel.PurposeLogin, vmodel.PurposePayout, vmodel.PurposeFactorPreference:
case vmodel.PurposeLogin, vmodel.PurposePayment, vmodel.PurposePayout, vmodel.PurposeFactorPreference:
return true
}
return false
@@ -21,6 +21,7 @@ const (
PurposeFactorPreference VerificationPurpose = "factor_preference"
PurposeFactorProof VerificationPurpose = "factor_proof"
PurposePayout VerificationPurpose = "payout"
PurposePayment VerificationPurpose = "payment"
)
func VPToString(code VerificationPurpose) string {
@@ -45,6 +46,8 @@ func VPFromString(s string) (VerificationPurpose, error) {
return PurposeFactorPreference, nil
case string(PurposeFactorProof):
return PurposeFactorProof, nil
case string(PurposePayment):
return PurposePayment, nil
case string(PurposePayout):
return PurposePayout, nil
default:
@@ -70,6 +70,9 @@ func (r *MongoRepository) PatchOneByFilter(ctx context.Context, query builder.Qu
if errors.Is(err, mongo.ErrNoDocuments) {
return false, nil
}
if mongo.IsDuplicateKeyError(err) {
return false, merrors.OpaqueWrap(merrors.ErrDataConflict, err, "duplicate_key")
}
if err != nil {
return false, err
}
@@ -0,0 +1,25 @@
package source
import (
"context"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.mongodb.org/mongo-driver/v2/mongo/readconcern"
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
"go.mongodb.org/mongo-driver/v2/mongo/writeconcern"
)
type majority struct{ Source }
// Majority retains the source's lease and transaction lifecycle while requiring
// durable writes and primary majority reads for externally visible handoffs.
func Majority(database Source) Source { return &majority{Source: database} }
func (s *majority) Acquire(ctx context.Context) (*mongo.Database, context.Context, func(), error) {
database, scoped, release, err := s.Source.Acquire(ctx)
if err != nil {
return nil, scoped, release, err
}
return database.Client().Database(database.Name(), options.Database().SetWriteConcern(writeconcern.Majority()).SetReadConcern(readconcern.Majority()).SetReadPreference(readpref.Primary())), scoped, release, nil
}
@@ -0,0 +1,40 @@
package source
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
"go.mongodb.org/mongo-driver/v2/mongo/writeconcern"
)
type trackedSource struct {
database *mongo.Database
released bool
}
func (s *trackedSource) Acquire(ctx context.Context) (*mongo.Database, context.Context, func(), error) {
return s.database, ctx, func() { s.released = true }, nil
}
func TestMajorityPreservesSourceLifetime(t *testing.T) {
client, err := mongo.Connect(options.Client().ApplyURI("mongodb://localhost:27017").SetWriteConcern(&writeconcern.WriteConcern{W: 1}).SetReadPreference(readpref.Secondary()))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, client.Disconnect(context.Background())) })
underlying := &trackedSource{database: client.Database("handoff")}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
database, scoped, release, err := Majority(underlying).Acquire(ctx)
require.NoError(t, err)
require.Same(t, client, database.Client())
require.Equal(t, "handoff", database.Name())
require.Equal(t, ctx, scoped)
require.False(t, underlying.released)
release()
require.True(t, underlying.released)
_, _, _, err = Majority(Fixed(nil)).Acquire(ctx)
require.Error(t, err)
}
+9 -1
View File
@@ -61,7 +61,7 @@ envelope. Credentials, codes and activation tokens are sent in POST bodies.
| Route | Request | Successful result |
| --- | --- | --- |
| POST `/signup` | `name`, `email`, `password`, `locale`, `idempotencyKey` | 202 with `userId`, after activation email queue acceptance |
| POST `/signup` | `name`, `email`, `password`, `locale`, `idempotencyKey` | 202 with `userId`, after configured Facts intake and activation email queue acceptance |
| POST `/verification/send` | `email`, `idempotencyKey` | 202; generic acceptance also applies to absent/already-active accounts |
| POST `/verification/confirm` | `token` | 202 with `verified: true` after activation commits |
| POST `/login` | `login: {login, password}`, `clientId`, `deviceId` | 202 with `userId`, `pendingToken: {token, expiration}`, `challengeId`, `channel`, masked `target`, `expiresAt`, `resendAt` |
@@ -101,6 +101,14 @@ retries can create replacement messages; they are not distributed exactly-once
operations. Tokens are stored before the RPC, so a process crash between those
steps requires user recovery; there is no product-side delivery outbox.
When `api.sendico` is enabled, sign-up and activation resend first require a
durable Facts intake receipt. Activation checks that receipt in its transaction.
Initial account/token creation uses the same server-secret-derived activation
key as a same-key retry, so an intake outage does not force an extra cooldown for
an unsent initial token. Existing pending customers created before this change
retain the prior cooldown behavior. See [Facts integration](signup-facts.md) for
uncertain registration recovery and the `signup_attention_required` error.
Code verification, one-use proof consumption and session creation now commit in
one transaction. A session-write failure leaves the code retryable; a lost HTTP
response after commit requires restarting password login. The adapter
+124
View File
@@ -0,0 +1,124 @@
# Customer identity verification
Task [SXCHNGE #48](https://git.sendico.io/tech/sxchnge/issues/48), following
[sign-up Facts intake](signup-facts.md). Platform implementation:
[Sendico #2885](https://git.sendico.io/tech/sendico/issues/2885).
## Customer flow
Sign-up still creates a basic account and submits its initial name/email/customer
reference as immutable facts. Email verification and login remain independent of
identity proof collection. After the first authenticated login in an app session,
an unverified customer is offered verification and can choose **Keep basic
limits**. An unavailable optional identity dependency does not prevent login.
**Accounts → Identity verification**, **Limits**, and the existing identity
navigation let the customer return later.
The customer supplies their full legal name, date of birth (YYYY-MM-DD), country
of residence, an ID document photo and a selfie holding that same document. The
backend supplies the authenticated customer's email and reference. The client
cannot choose a platform subject, organization, source principal, review status
or limits profile. JPEG/PNG photos must decode, be at least 600×600, at most
40 megapixels and no larger than 10 MiB each. Sendico also enforces its existing
configured evidence cap, MIME/extension rules and submission rate limits.
Unsent fields and photos stay in memory, survive retryable errors and are cleared
on successful submission, discard or page disposal. Previews leave Flutter's
image cache on exit/replacement. Profile edits or photo replacements create a
new request key; unchanged retries retain their key. Draft loss requires an
explicit discard choice. No sensitive extras enter browser history or analytics.
A successful submission means **pending staff review**. Corrections permit a new
submission; rejection retains basic access and the existing support route. A
recorded approval with an unfinished limits assignment is shown as **Limits
upgrade pending**, rather than verified. Refresh fetches the real reviewed state
and the authoritative limits. Production uses no simulated KYC review controls
or illustrative limit amounts. Demo mode remains separately simulated.
## Boundaries and contracts
The authenticated local routes are `GET/POST /accounts/identity` and
`GET /accounts/limits`, under the configured API mount. Multipart POST fields are
`profile` (JSON), `document`, `selfie`, and `idempotencyKey`. A customer ID is
always resolved from the full authenticated session. Pending-login tokens do not
provide account access. Browser and native requests both carry the access token;
browser refresh cookies remain an auth concern.
The existing `signup_facts` binding resolves each local customer to one independent
Sendico subject. A conditional unique `(namespace, subjectRef)` index prevents
binding the same provider subject to two customers. Empty registration claims
are excluded. Existing duplicates cause bootstrap to fail and require controlled
reconciliation; the application never guesses a replacement subject. Credential
rotation must retain the configured source account.
The Sendico adapter exclusively uses public HTTP:
- `GET/POST /identity_facts/{organization}/proofs/{subject}`;
- `GET /identity_facts/{organization}/proofs/{subject}/limits`.
Adapter DTOs and mappers remain separate from local models and local HTTP DTOs.
There are no Sendico database, gRPC, bus or sibling-module imports in SXCHNGE.
Provider organization/source settings remain in the adapter configuration.
Uploads have a bounded 60-second upstream timeout and redirects are rejected.
Upstream error bodies and photo bytes are never logged. Rate-limit responses
retain typed recovery. Registration's existing non-idempotent reconciliation
rules are unchanged.
Visible policy entries retain exact decimal strings, currency, group labels and
per-operation minimum/maximum, day and month slots. Usage is not invented for
per-operation slots. The backend owns the effective policy and remaining
capacity. The existing wallet remains a simulated service; this contribution
connects identity and visible limits, not real money movement. Future money
adapters must use the platform-owned customer account resource and authoritative
Limits reservation path, not a browser-selected policy or displayed balance.
## Review and rollout
Staff work in the existing **Sendico backoffice → KYC → Identity proof review**.
The platform stores private proofs, immutable profile assertions, review history
and the reviewed limits assignment. See the platform's
`docs/customer-identity-proofs.md` for authorization, retry and retention details.
Deploy Sendico Product/provider, Backoffice, Limits and backoffice frontend
changes together before enabling the new SXCHNGE frontend/backend adapters.
Existing `api.sendico` settings are reused. The integration account needs
organization read/update access. The platform's existing evidence/storage,
cleanup, discovery and limits configuration must be provisioned. Its ACCOUNT
default for SXCHNGE must be the existing unverified profile, and the existing
verified profile and personal-account visible view must be active. No new runtime
configuration parameters or live submissions are introduced by this work.
Merge [SXCHNGE sign-up Facts PR #47](https://git.sendico.io/tech/sxchnge/pulls/47)
before [this flow's PR #49](https://git.sendico.io/tech/sxchnge/pulls/49).
For the combined existing backoffice, apply
[Sendico identity-proofs PR #2886](https://git.sendico.io/tech/sendico/pulls/2886)
before [Facts review PR #2887](https://git.sendico.io/tech/sendico/pulls/2887).
They share one Limits assignment contract and retain both review destinations.
Proof cases go to Identity proof review; generic Facts review cannot link them
to a login account or assign limits through that path. Deploy the reconciled
Sendico services and frontend before using this flow. Deployment is separate.
## UX, measurement and validation
Existing KYC journey events measure preparation, selection, accepted submission,
status reads, retries, abandonment and latency. Profile contents, filenames,
photo bytes and credentials are excluded. Submission success is an intake
outcome, not proof of approval. Pending asynchronous staff review is not counted
as customer abandonment. A resumed route starts a new client attempt; immutable
submission IDs deduplicate backend business outcomes.
Authoritative completion is the staff decision plus the Limits assignment
receipt for that submission. Backend IDs correlate these records without becoming
metric labels. Existing identity request counters/histograms distinguish accepted,
invalid, denied, conflict, rate-limited and unavailable operations and include
actual authorization/I/O duration. Client analytics still defaults to the
existing in-memory sink; retained cross-device analytics depends on deployment.
Review covers optional onboarding, later account navigation, editable profile,
readable ID/selfie guidance, disabled actions, error/session recovery, input
preservation, correction/rejection/pending/approval, exact monetary presentation,
EN/RU at narrow widths and large text, keyboard access and private-image cache
eviction. Tests use synthetic data, local HTTP servers and disposable MongoDB.
No live ID documents, live email, production review or deployment were exercised.
Full validation commands and final PR links are recorded in `implementation.md`
and the task.
+172
View File
@@ -828,3 +828,175 @@ existing mount requires reauthentication at its new cookie path. Notification
activation URLs remain configured frontend URLs. No live deployment, optional
security scans or merge were performed. The unrelated local notifications sender
configuration remains uncommitted. See [API version installation](api-versions.md).
## Sign-up Facts intake — task #46
[Task #46](https://git.sendico.io/tech/sxchnge/issues/46), branch
`identity-facts-46`, connects the product sign-up service to Sendico's public
individual registry and immutable Facts intake through optional `api.sendico`
configuration. Owner: `tech`; project: SXCHNGE; labels: backend and frontend.
Delivered in [PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), targeting
`main`. Implementation commit: `a584c81`.
The adapter sends full name, normalized email and the local customer reference.
The BFF persists a customer-owned subject binding, immutable facts snapshot and
confirmed receipt before activation email or sign-up acceptance. Activation and
resend enforce the handoff. The shared database source can require majority
writes and primary majority reads without bypassing its connection/transaction
lifecycle. Initial account/token creation remains atomic and uses a secret-derived
activation key, allowing same-key recovery after an intake outage without an
extra cooldown for an unsent token. Archived customers cannot resume sign-up.
Backend and frontend completion reviews covered the full task contribution,
including the public contracts, customer ownership, stable replay namespace,
concurrent claims, error classification, credential handling, logging, metrics and
DTO/mapper/service/model/provider separation. UX review preserved input, loading
suppression and confirmation-based success. Typed support guidance reaches both
sign-up and verification resend/confirmation through the shared auth package;
EN/RU feedback preserves live-region semantics and existing responsive controls.
The new localized widget fixture initially double-disposed the app-owned locale
provider; the fixture ownership was corrected. Focused integration tests also
exposed the initial-token cooldown issue, which was fixed in the creation flow.
Individual registration is not idempotent upstream. A durable claim prevents
speculative repeat registration after an ambiguous result; reconciliation uses
the saved request reference and authoritative Sendico records. Intake replay is
safe after receipt loss and retains original assertions. Actual provider IDs,
the endpoint and a same-account Vault-rendered access token must be provisioned
before enabling this integration. Tests use simulated HTTP and disposable MongoDB;
no live provider resources, live email, deployment, merge or optional security
scan is part of this delivery. The unrelated notifications runtime configuration
is excluded. Configuration, rollout, telemetry and recovery details are in
[Sign-up Facts intake](signup-facts.md).
Final validation passed: `GOFLAGS=-p=2 make test` (all backend race tests and
`make test-frontend` suites), `make lint-backend`,
`dart format --output=none --set-exit-if-changed` for all 11 changed Dart files,
and `flutter analyze` in `frontend/app` and `frontend/packages/sxchnge_auth`.
Focused Go race suites and Flutter backend/provider/widget tests also passed;
the diagnostic public API run
`go -C backend/product test -race -v ./auth/http/v1/public -count=1 -timeout=90s`
passed. The initial unrestricted `make test` failed when Testcontainers could
not start its reaper before the Mongo fixture deadline. Lower Go package
parallelism resolved that infrastructure contention. The next full run exposed
the new locale fixture disposal error noted above; the corrected final run
passed, including EN/RU support states and verification recovery. No checks were
weakened or unrelated work changed.
## Customer identity verification — task #48
[Implementation and rollout](identity-verification.md) adds optional verification
on authenticated onboarding and later from the account: legal profile, ID photo
and selfie holding the same ID. Real adapters replace demo KYC/limits in backend
mode. Sendico's existing backoffice owns decisions and the reviewed Limits profile;
SXCHNGE has no local tenancy, roles or policy engine. Provider-subject uniqueness,
private bounded uploads, stable retries, exact decimal policy projections and
explicit upgrade-pending feedback are included. The unrelated notifications
runtime configuration is excluded.
Delivered in [PR #49](https://git.sendico.io/tech/sxchnge/pulls/49), branch
`identity-verification-48`, implementation commit `1354551`. It is stacked on
[sign-up Facts PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), which must
merge first, and requires
[Sendico PR #2886](https://git.sendico.io/tech/sendico/pulls/2886) to be deployed
before enabling the new flow. Tracking tasks:
[SXCHNGE #48](https://git.sendico.io/tech/sxchnge/issues/48) and
[Sendico #2885](https://git.sendico.io/tech/sendico/issues/2885).
Completion review covered the full backend and frontend contribution, including
subject ownership, private uploads, idempotent retries, session changes, exact
limits, DTO/mapper/service/model/provider boundaries, and the UX/measurement
checklists. Review fixes included duplicate-binding error classification,
upgrade-pending feedback, bounded response timeouts and stale-session protection.
Final validation passed: `GOFLAGS=-p=1 make test` (all backend race tests and all
frontend suites; 79 app tests), `make lint-backend`, changed-Dart formatting,
and `flutter analyze` in the app and shared KYC package. Focused KYC adapter and
onboarding tests passed (20 tests), as did focused Go identity/provider/HTTP
checks. An initial parallel run hit the existing Testcontainers reaper deadline;
serial Go package execution resolved local Docker contention without weakening
checks. No optional security scan, live customer proof, email, production
approval, deployment or merge was performed. Generated outputs remain ignored.
Reconciliation follow-up: the shared Limits assignment implementation and its
persisted-state validation are owned by Sendico PR #2886. The subsequent
[Sendico Facts review PR #2887](https://git.sendico.io/tech/sendico/pulls/2887)
retains both Product providers, Backoffice services and KYC destinations, and
routes proof cases to Identity proof review while blocking generic login linking
and assignment. Apply #2886 before #2887 and deploy the combined Sendico result
before this SXCHNGE flow. The public proof/limits contract is unchanged; this
SXCHNGE follow-up changes documentation only and requires consistency/diff checks.
## Sendico quotations and payment execution — task #50
[Task #50](https://git.sendico.io/tech/sxchnge/issues/50), branch `payments-50`,
connects backend wallet mode through the authenticated customer BFF to Sendico's
public account, quotation, by-quote execution and payment-status APIs. It is
stacked on `identity-verification-48` / PR #49; merge #47 → #49 first.
The separate public-provider prerequisite is
[Sendico PR #2897](https://git.sendico.io/tech/sendico/pulls/2897), task #2896.
It validates independent customer context and selects the reviewed ACCOUNT limits
resource rather than the integration login or an unassigned CUSTOMER policy.
Deploy its Product Edge and Quotation/Orchestrator consumers together before
SXCHNGE. The BFF requires an exact customer-context acknowledgement from quotes.
The contribution adds durable customer/namespace/subject-bound quotation ownership,
transactional payment-factor authorization, unique quote/execution keys, stable
provider replay, payment-reference reconciliation and authoritative status polling.
Live UI uses provisioned destinations, accurate quote expiry, explicit missing
method/loading/error/pending states and long labels at narrow widths. It clears
account state on customer changes. DTO/mapper/service/model/provider boundaries,
shared database wrappers, existing credentials and explicit demo mode are retained.
Validation passed: `make test`, `make lint-backend`, `make test-frontend` after the
final UI review, changed-Dart format verification, and app/wallet `flutter analyze`.
The final frontend run passed 21 wallet and 81 app tests, plus the unchanged shared
package suites. Tests include synthetic public HTTP, Mongo transaction rollback,
unique-key/customer isolation, lost-response reconciliation, factor retry and
session replacement. Sendico's full backend test/lint targets also passed.
[Payment contracts, provisioning, UX measurement and limitations](payments.md)
document rollout. Dedicated customer ledger/method bindings and platform access
must be provisioned through existing operator APIs; this work does not run live
payments or deploy either repository. Generic ambiguous rejections remain pending
for operational reconciliation. Cross-session analytics retention, additional
funding/recipient onboarding, backend locale saving and external history ingestion
remain separate capabilities. Unrelated notifications runtime changes are excluded.
Delivered in [PR #51](https://git.sendico.io/tech/sxchnge/pulls/51), targeting
`identity-verification-48`. Merge order is #47 → #49 → #51; deploy Sendico #2897
before SXCHNGE #51. Both implementation branches are pushed and remain unmerged.
## 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
View File
@@ -1,5 +1,7 @@
# Local development
For the connected Sendico stand, see [local Sendico integration](local-sendico.md).
Task [#42](https://git.sendico.io/tech/sxchnge/issues/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,
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
AppRoles and secrets, initializes Mongo replica set `rs0`, configures Vault's Mongo
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
exported, `make up` prints a Sendico-style completion message with the frontend,
@@ -54,7 +63,7 @@ kv/
├── services/
│ ├── bff/jwt # jwt
│ └── notifications/
│ └── email/resend # api_key only
│ └── email/smtp # password only
└── infrastructure/
├── nats/auth # token; read by NATS and notifications
└── 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
on the trusted private Docker network without caller authentication.
## Resend
## Local SMTP mail
Open [Vault](http://127.0.0.1:18200) and edit
`kv/services/notifications/email/resend`:
`kv/services/notifications/email/smtp`:
| 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`:
```yaml
email:
driver: resend
driver: smtp
settings:
api_key_env: EMAIL_API_KEY
sender_address: "your-verified-sender@example.com"
host: mail.sendico.io
port: 465
username: no-reply@sendico.io
password_env: SMTP_PASSWORD
network_timeout: 10
sender_address: no-reply@sendico.io
sender_name: "SXCHNGE"
```
Set your verified address in that file and rebuild. It is intentionally empty by
default, so notifications cannot start with an unconfigured sender. Vault Agent
injects only the API key; it does not generate email configuration.
The local stack uses Sendico's existing `no-reply@sendico.io` mailbox with
authenticated implicit TLS on port 465 and certificate verification enabled.
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`.
Product containers cannot access it. Alternatively use the hidden terminal prompt:
@@ -104,12 +119,15 @@ make email
make build && make up
```
No placeholder key or delivery fallback is installed. Startup validates presence;
it does not send an email or validate domain ownership with Resend. Updating the
API key causes Agent to restart notifications. Driver/sender changes require an
image rebuild. Existing API keys survive bootstrap. Migration removes legacy
driver/sender fields from the current Vault secret with a version-checked write;
the preceding version is preserved in Vault history and the private archive.
No placeholder password or delivery fallback is installed. Startup validates
configuration and secret presence; the SMTP connection is opened on demand.
Updating the password causes Agent to restart notifications. Driver/sender
changes require an image rebuild. Old Resend secrets remain in Vault history
but are not used or readable by the notifications AppRole. HTTP providers remain
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
+196
View File
@@ -0,0 +1,196 @@
# 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.
Signup and wallet access automatically ensure subject-bound RUB, USDT and USDC
ledgers as described in [account setup](payments.md#automatic-currency-accounts-72).
Deploy the provider prerequisite and grant ledger-create permission first.
Saved funding and payout methods remain separate from currency-account setup.
## Recovery
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.
+197
View File
@@ -0,0 +1,197 @@
# 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 create/read,
balance read and the permissions needed to resolve its stored payment methods. Verify
those permissions in the intended environment before enabling real operations.
### Automatic currency accounts (#72)
The backend ensures **RUB, USDT and USDC** accounts after confirmed identity intake
during signup, before sending the activation email. Resending activation retries
the same setup. Every authenticated wallet load also ensures the defaults, so
existing customers receive missing accounts automatically when they next open
their wallet. This is lazy backfill, not an eager scan of dormant customers.
Setup creates dedicated unowned organization ledgers through the public
`POST /ledger_accounts/{organization}` API, with active `asset` / `operating`
semantics, zero initial funds, no negative balance and metadata:
| Ledger metadata | Meaning |
| --- | --- |
| `sxchnge_subject_ref` | Confirmed independent subject reference from sign-up |
| `external_subject_ref` | Same confirmed subject; provider-enforced organization/subject/currency uniqueness |
| `sxchnge_payout_method_ref` | Optional prevalidated same-currency saved payout method |
| `sxchnge_payout_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 |
Existing customer ledgers and their balances are preserved, including manually
provisioned ledgers with the original `sxchnge_subject_ref` tag. Only missing
defaults are created; existing additional currencies remain visible. Repeated
bound creates reuse the ledger and resume Sendico's durable wallet/route setup
after an interrupted response. Frozen, duplicate or incorrectly owned bindings
fail instead of creating a replacement. All required currency precision entries
are checked against the provider catalog before issuing creates. Saved funding
and payout bindings remain separate from creation of the currency accounts.
Deploy [Sendico #2968](https://git.sendico.io/tech/sendico/issues/2968) first:
Ledger (including its unique index), then Product Edge, then SXCHNGE backend and
frontend from [PR #73](https://git.sendico.io/tech/sxchnge/pulls/73). The public
ledger list must advertise `externalSubjectAccounts: true` before SXCHNGE sends
any create requests. Older providers fail safely without creating accounts.
The acknowledgement assumes Ledger and Edge are deployed in that order; it
does not discover the version of a separately running Ledger. No new runtime
configuration or credentials are introduced. Existing deployments must give the
integration principal the ledger-create permission in addition to its reads.
These are financial-resource bindings maintained by server-side setup, not
client choices or new runtime configuration. Do not tag a pooled/integration-login
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.
If setup reports a failure, the API returns an unavailable wallet rather than an
empty snapshot or demo balances. Retrying the app load retries setup. Existing organization
payments and pooled balances are never imported into customer history. This
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.
+394
View File
@@ -0,0 +1,394 @@
# Sign-up Facts intake
Task [#46](https://git.sendico.io/tech/sxchnge/issues/46), branch
`identity-facts-46`. Primary module: identity-facts; secondary modules: auth,
customer creation, runtime, shared database source and Flutter error feedback.
Delivery: [PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), targeting `main`.
## 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
### 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
and activation token atomically, then registers an independent individual through
Sendico's public HTTP API and submits the sign-up assertions. It persists the
intake receipt before sending activation email or returning sign-up success.
Activation resend runs the same handoff; activation checks the receipt in its
local transaction, without external I/O. A failed receipt check rolls back token
consumption and leaves the account pending. Existing active customers are not
backfilled or blocked at login.
The adapter consumes these existing Sendico contracts:
- `POST /individuals/{organization_ref}`, empty body, HTTP 201 registration
receipt containing `subject_ref` and `registered_at`.
- `POST /identity_facts/{organization_ref}`, HTTP 200 immutable intake receipt
containing `submission_ref`, `subject_ref` and `accepted_at`.
The configured API prefix precedes both paths. See Sendico
[individual subjects](https://git.sendico.io/tech/sendico/src/branch/main/docs/api/individual-subjects.md)
and [Facts intake](https://git.sendico.io/tech/sendico/src/branch/main/docs/api/identity-facts-intake.md).
The only assertions sent are `full_name`, normalized `email` and
`external_customer_ref` (the local customer ID). Passwords, password hashes,
activation tokens, session credentials and device data never enter the adapter.
The input name is retained as a full name, without guessing name parts. No facts
are marked verified; acceptance does not establish identity, KYC completeness,
verification, limits or payout eligibility. The existing KYC preview is unchanged.
Only the BFF owns Sendico credentials and organization scope. Local customer
models have no organization, membership or platform permissions. HTTP DTOs and
mappers stay in `providers/sendico`; the handoff operates on domain facts and
receipts in `identity`. Flutter continues through its DTO, mapper, service,
model and provider layers. It never calls Sendico or supplies subject references.
## Configuration and rollout
Add this block under `api` in the product service configuration, retaining
`api.notifications`:
```yaml
sendico:
api_url: https://your-sendico-host/api/v1
organization_ref: <provisioned-SXCHNGE-organization-object-id>
source_account_ref: <provisioned-integration-account-object-id>
token_file: /run/sxchnge/sendico-token
```
Resolve the real IDs from the target Sendico deployment. The integration account
must be a member of the SXCHNGE organization with the existing organizations
`update` permission; reconciliation GET requests also require `read`. The token
must be a full, unexpired Sendico access JWT whose `id` claim is that account.
The adapter inspects claims only to protect the replay namespace; Sendico checks
the signature and authorizes every call. No login, refresh or privileged account
provisioning is performed by this adapter.
Keep the token in Vault at `services/bff/sendico`, field `access_token`. Render it
with the deployment's Vault Agent into the configured private runtime file; give
only the BFF policy read access to that KV path. Endpoint, organization and source
IDs remain in service YAML. For the local stack, configure
`infra/local/bff/runtime.yml`, render to the BFF's existing `/run/sxchnge` tmpfs,
and extend its Vault Agent policy/template for this provisioned secret. These
deployment values and secret provisioning are deliberately not guessed or
enabled by the example. Do not place tokens in Git, browser builds or shell
history. Refresh the file atomically before expiry using the same Sendico account;
the adapter rereads it on every request. Startup rejects missing, expired or
mismatched credentials and rejects Sendico configuration without notifications.
HTTPS is required except for loopback HTTP test/development endpoints. Redirects
are rejected and each HTTP call has a four-second timeout. Unexpected response
statuses (registration requires 201; Facts intake requires 200), malformed/truncated
receipts, invalid identifiers, wrong subjects and unsuccessful envelopes never
count as acceptance. Upstream error bodies are not surfaced.
Omitting `api.sendico` keeps existing local-only behavior. Enabling it also gates
activation for previously pending customers; use activation resend to submit
their saved name/email before confirming their old link. There is no automatic
bulk migration. Changing endpoint, organization or source account changes the
adapter fingerprint and blocks existing bindings for explicit reconciliation.
Token rotation for the same account retains that fingerprint. Plan such changes
as migrations; never reset a binding just to bypass the check.
## 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
The local `signup_facts` collection uses the customer ID as `_id`, so its unique
primary key coordinates concurrent requests and separate BFF instances. It stores
an immutable sign-up facts snapshot, adapter fingerprint, random registration
request correlation, registration claim, subject and confirmed receipt. It uses
the shared database repository and connection lifecycle with majority writes and
primary majority reads, overriding weaker connection defaults. No external call
runs in a database transaction. Snapshots and bindings contain personal data and
must follow the customer data retention policy; no TTL silently removes replay
state, and customer deletion does not delete the immutable Sendico submission.
Once a subject is stored, intake retries use
`sxchnge:signup:<local-customer-id>` as `source_submission_ref`. Sendico's
organization/authenticated-account/source-reference uniqueness returns the
original receipt for that subject. Retries send the saved snapshot even if a
later request changes name or email. Losing the HTTP receipt or a local receipt
write is recoverable by replay. A saved receipt skips subsequent external calls.
Retrying sign-up still requires the original password; active, archived or
otherwise conflicting accounts cannot be resumed as a new customer.
**Individual registration is not idempotent.** The BFF commits a claim before
dispatch and never expires or automatically retries that registration. It cannot
prove that a timed-out request, malformed response or failed local binding write
did not create a subject. Another request sees an in-progress claim as temporarily
unavailable for 30 seconds; an older unresolved claim requires support. The
registration caller immediately reports assistance on an uncertain failure. An
accepted registration is saved with a short independent context even if the
browser disconnects. A crash before dispatch can also require reconciliation.
This trades automatic recovery for avoiding duplicate subjects under the current
upstream contract; there is no distributed exactly-once guarantee.
For an unresolved registration, operators must stop requests for the affected
customer and inspect the saved `registrationRequest` plus Sendico identity logs
(`X-Request-ID`, operation `subject_register`) under the configured account and
organization. A successful registration's server-issued `record_ref` identifies
the candidate subject. Confirm it using the organization-scoped individual GET
API before atomically setting the matching local binding's `subjectRef` and
clearing `registering`; retain its snapshot, fingerprint and request reference.
If a local binding write failed after receipt, BFF diagnostics also contain the
subject reference. If authoritative records prove no subject was created, an
operator may clear the claim after fixing the dependency. If neither result can
be established, leave it blocked; never register a speculative replacement.
Resume sign-up with the original credentials or activation resend after this
repair. These are controlled operational repairs, not customer API endpoints.
HTTP 503 `signup_attention_required` maps to typed shared auth and verification
state and localized EN/RU support guidance, including activation resend recovery.
Other intake/receipt failures remain temporary service
failures. Form input is retained, duplicate submissions stay disabled during
loading, and neither failure navigates to an email-sent screen or creates a
session. The existing support channel must be available to handle reconciliation.
## Measurement and UX review
The existing [auth journey](ux/auth-journeys.md) keeps its attempt, retry,
abandonment and 30-minute settled inactivity rules. `auth_signup_succeeded` now
requires server confirmation of configured intake plus activation queue acceptance;
it remains an intermediate step and email confirmation remains the terminal
onboarding outcome. Sign-up dependency failures and assistance outcomes use the
existing allowlisted `unavailable` analytics category; verification assistance
uses it too. Retries retain the journey
and operation semantics. No names, emails or provider response bodies are added.
`sxchnge_signup_facts_total{outcome}` records `accepted`, `replayed`,
`unavailable` and `attention_required` handoff attempts;
`sxchnge_signup_facts_duration_seconds` measures their latency. Counts are
attempts, not distinct users. Use unavailable/attention counts divided by total
handoff attempts for operational failure rates; use the existing correlated auth
journey for conversion, duration and abandonment. Structured handoff logs include
local customer and random registration request references for support correlation,
without asserted values, credentials or raw errors. IDs are never metric labels.
The product team uses the distinction to separate retryable intake outages from
registration reconciliation. The default client analytics sink remains in memory;
production retention and cross-device conversion correlation remain deployment
gaps, as documented by the existing journey contract.
Review covers loading suppression, input preservation, success only on confirmed
handoff, temporary retry and assistance states, existing navigation, mobile layout,
text scaling and live-region error feedback. Focused tests cover these states,
the mapped backend error, non-inflated client success, immutable retry payloads,
credential rotation, timeout/cancellation/redirect behavior, receipt validation,
privacy-safe errors, concurrent durable claims and activation rollback. The tests
use disposable local MongoDB and a simulated Sendico HTTP server; they do not
create live subjects or send email. Live integration and support operations must
be verified against provisioned deployment credentials before enabling the block.
The EN/RU error state is exercised at a 390px viewport with 1.5x text scaling;
existing keyboard regression tests and a code review of the live-region semantics
cover the unchanged controls. No manual browser or screen-reader walkthrough
against a live Sendico deployment was performed.
The initial sign-up assertion remains a separate immutable submission. Optional
profile/ID/selfie collection, later account uploads and reviewed limits are now
covered by [Customer identity verification](identity-verification.md), task #48.
Proofs are not required to activate or log into a basic account.
+210
View File
@@ -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
`onboarding-12`. The final browser accessibility tree exposes the recipient text,
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.
+49
View File
@@ -302,6 +302,55 @@ that contradicts displayed balances or usage without explaining the fixtures.
- Keep unrelated accounts/history accessible during a pending operation. Do not
block the whole product behind an exchange spinner.
## Automatic currency accounts (task #72)
New customers receive RUB, USDT and USDC accounts through backend-owned setup
during signup. Existing customers receive missing defaults on their next wallet
load. The UI requires no account-creation form or support contact. During initial
loading it announces that the accounts are being prepared. Failure shows one
clear load error and a **Try again** action that reruns the same idempotent setup.
An unexpected empty snapshot has its own setup-failure fallback, rather than
being shown simultaneously with an error. A failed refresh retains existing
cards and the stale-data warning. Account-not-found copy requires loaded data.
The existing DTO → mapper → service → model → provider → UI boundaries remain
intact. Account creation belongs to the backend/provider, and widgets invoke the
existing provider refresh action. English and Russian use wrapping text and the
existing accessible button pattern; status text uses a live region. Dedicated
widget regressions cover loading, failure, retry and successful default accounts
on Accounts, Top up, Exchange and Payout at 320px with 1.6× text. Native device and
screen-reader QA remain a release check; tests do not claim physical-device QA.
Measurement: `sxchnge_currency_accounts_setup_total{outcome="ready|failed"}` and
`sxchnge_currency_accounts_setup_duration_seconds` cover the server setup attempt.
`ready` is emitted only after all default bindings are confirmed, including
idempotent completion of provider wallet/route setup for newly bound ledgers.
Each signup/resend/wallet load is a separate attempt; a retry is not a new unique
customer conversion. The failure fraction uses all attempts as its denominator;
the histogram measures one attempt, not time across multiple browser sessions.
No customer, ledger, currency, balance, credentials or form input is a metric
label. These server metrics do not measure client abandonment or prove that a
balance card was viewed. Existing transaction journey outcomes below are reused;
no speculative button-click events are added.
Scope and rollout: [task #72](https://git.sendico.io/tech/sxchnge/issues/72), branch
`wallet-72`, [PR #73](https://git.sendico.io/tech/sxchnge/pulls/73). The provider
prerequisite and deployment order are documented in [payments](../payments.md).
Merge and deployment remain separate. Final validation is recorded in the task
and PR, including any transient failures and environmental blockers.
Final validation (2026-10-23): `flutter gen-l10n`, `flutter analyze` in
`frontend/app`, the changed-Dart formatting check, `make lint-backend`, and
`git diff --check` passed. `make test-frontend` passed all 242 tests on the current
app base, including the eight setup recovery scenarios. The full `make test`
passed with `TESTCONTAINERS_RYUK_DISABLED=true GOFLAGS='-p=2'`: earlier attempts
hit dependency DNS and Mongo/cleanup-helper startup timeouts. Disabling only the
failing helper retained every integration assertion, test timeout and race check;
each test's existing container cleanup still ran. No security scan was requested.
The live local provider catalog confirms RUB scale 2 and USDT/USDC scale 6.
Provider-side concurrency and frozen-binding checks passed against a disposable
Mongo replica set; see [Sendico PR #2970](https://git.sendico.io/tech/sendico/pulls/2970).
## Journey measurement plan
Reuse the typed analytics abstraction and maintain a separate journey per active
+97
View File
@@ -2,6 +2,11 @@
Task [#20](https://git.sendico.io/tech/sxchnge/issues/20). Plan recorded before implementation.
> This original demo plan is retained as design history. Production identity
> uploads, staff review and authoritative limits are implemented by task #48;
> see [Customer identity verification](../identity-verification.md). Demo behavior
> below applies only when the backend adapters are not selected.
## User outcome and scope
Understand current access, decide whether higher limits are useful, and submit two
@@ -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
without inferring remaining, unused/exhausted/over-cap/zero-cap ratios and EN/RU
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.
+256
View File
@@ -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.
+98
View File
@@ -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.
+9 -1
View File
@@ -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.
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/`.
+1
View File
@@ -22,6 +22,7 @@ class AuthFeedback extends StatelessWidget {
AuthError.accountNotVerified => l.authVerifyEmail,
AuthError.invalidCredentials => l.authInvalidCredentials,
AuthError.emailInUse => l.authEmailInUse,
AuthError.signupNeedsAssistance => l.authSignupNeedsAssistance,
AuthError.unavailable => l.authUnavailable,
AuthError.unexpected => l.authUnexpected,
AuthError.factorLocked => l.factorLocked,
+164 -119
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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_kyc/sxchnge_kyc.dart';
import '../routing/routes.dart';
import '../security/challenge.dart';
import 'feedback.dart';
@@ -16,7 +19,8 @@ import 'validation.dart';
import '../generated/i18n/app_localizations.dart';
class AuthForm extends StatefulWidget {
const AuthForm({super.key});
const AuthForm({super.key, this.email});
final String? email;
@override
State<AuthForm> createState() => _AuthFormState();
@@ -30,7 +34,7 @@ class _AuthFormState extends State<AuthForm> {
bool _showPassword = false;
bool _showConfirmation = false;
bool _submitted = false;
final _email = TextEditingController();
late final _email = TextEditingController(text: widget.email);
final _name = TextEditingController();
AuthProvider get _provider =>
@@ -61,6 +65,8 @@ class _AuthFormState extends State<AuthForm> {
void _switch(bool signUp) {
if (signUp == _signUp) return;
FocusScope.of(context).unfocus();
TextInput.finishAutofillContext(shouldSave: false);
context.read<JourneyRecorder>().abandon(JourneyExit.modeChange);
context.read<LoginProvider>().reset();
context.read<SignupProvider>().reset();
@@ -93,9 +99,11 @@ class _AuthFormState extends State<AuthForm> {
return;
}
final email = _email.text.trim();
final delivery = signup.state.result!.delivery;
TextInput.finishAutofillContext();
_password.clear();
_confirmation.clear();
context.goNamed(AppRoutes.verificationSent, extra: email);
context.goNamed(AppRoutes.verificationSent, extra: delivery ?? email);
} else {
final login = context.read<LoginProvider>();
await login.login(
@@ -105,13 +113,13 @@ class _AuthFormState extends State<AuthForm> {
);
if (!mounted || _signUp) return;
if (login.state.error == AuthError.accountNotVerified) {
TextInput.finishAutofillContext(shouldSave: false);
_password.clear();
context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim());
return;
}
final challenge = login.state.result?.challenge;
if (challenge != null) {
_password.clear();
final grant = await verifyFactor(
context,
challenge: challenge,
@@ -121,14 +129,29 @@ class _AuthFormState extends State<AuthForm> {
);
if (!mounted || _signUp) return;
if (grant == null) {
TextInput.finishAutofillContext(shouldSave: false);
_password.clear();
login.reset();
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
return;
}
await login.complete(challenge, grant);
if (mounted && login.state.status == AuthStatus.success) {
context.go('/accounts');
final accepted = login.state.result!;
final offer = await context.read<IdentityOnboardingProvider>().offer(
accepted.userId,
);
if (mounted &&
!_signUp &&
identical(login.state.result, accepted) &&
login.state.status == AuthStatus.success) {
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,
),
const SizedBox(height: 32),
Form(
key: _form,
autovalidateMode: _submitted
? AutovalidateMode.onUserInteraction
: AutovalidateMode.disabled,
onChanged: () {
if (_provider.state.status == AuthStatus.success ||
_provider.state.status == AuthStatus.error) {
_provider.reset();
}
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_signUp) ...[
AutofillGroup(
// Leaving an unfinished form must not offer to save its credentials.
onDisposeAction: AutofillContextAction.cancel,
child: Form(
key: _form,
autovalidateMode: _submitted
? AutovalidateMode.onUserInteraction
: AutovalidateMode.disabled,
onChanged: () {
if (_provider.state.status == AuthStatus.success ||
_provider.state.status == AuthStatus.error) {
_provider.reset();
}
},
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(
key: const ValueKey('name'),
controller: _name,
key: const ValueKey('email'),
controller: _email,
enabled: !state.isLoading,
decoration: InputDecoration(
labelText: l.name,
prefixIcon: Icon(Icons.person_outline),
labelText: l.email,
hintText: l.emailHint,
prefixIcon: Icon(Icons.alternate_email),
errorMaxLines: 3,
),
keyboardType: TextInputType.emailAddress,
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
textInputAction: TextInputAction.next,
textCapitalization: TextCapitalization.words,
validator: (value) => AuthValidation.name(value, l),
autocorrect: false,
validator: (value) => AuthValidation.email(value, l),
),
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(
key: const ValueKey('confirmation'),
key: const ValueKey('password'),
enabled: !state.isLoading,
controller: _confirmation,
obscureText: !_showConfirmation,
controller: _password,
autofocus: widget.email?.isNotEmpty ?? false,
obscureText: !_showPassword,
autofillHints: [
_signUp
? AutofillHints.newPassword
: AutofillHints.password,
],
autocorrect: false,
enableSuggestions: false,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
textInputAction: _signUp
? TextInputAction.next
: TextInputAction.done,
onFieldSubmitted: (_) {
if (!_signUp) _submit();
},
decoration: InputDecoration(
labelText: l.confirmPassword,
labelText: l.password,
prefixIcon: const Icon(Icons.lock_outline),
errorMaxLines: 3,
helperText: _signUp ? l.passwordHint : null,
helperMaxLines: 3,
suffixIcon: IconButton(
tooltip: _showConfirmation
? l.hideConfirmation
: l.showConfirmation,
onPressed: () => setState(
() => _showConfirmation = !_showConfirmation,
),
tooltip: _showPassword ? l.hidePassword : l.showPassword,
onPressed: () =>
setState(() => _showPassword = !_showPassword),
icon: Icon(
_showConfirmation
_showPassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
),
),
validator: (value) =>
AuthValidation.confirmation(value, _password.text, l),
AuthValidation.password(value, l, signUp: _signUp),
),
],
const SizedBox(height: 28),
FilledButton(
key: const ValueKey('submit'),
onPressed: state.isLoading ? 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,
if (_signUp) ...[
const SizedBox(height: 20),
TextFormField(
key: const ValueKey('confirmation'),
enabled: !state.isLoading,
controller: _confirmation,
obscureText: !_showConfirmation,
autofillHints: const [AutofillHints.newPassword],
autocorrect: false,
enableSuggestions: false,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
decoration: InputDecoration(
labelText: l.confirmPassword,
prefixIcon: const Icon(Icons.lock_outline),
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
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.arrow_forward, size: 20),
],
),
validator: (value) =>
AuthValidation.confirmation(value, _password.text, l),
),
],
const SizedBox(height: 28),
FilledButton(
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 ||
+3 -2
View File
@@ -10,7 +10,8 @@ import 'form.dart';
import '../generated/i18n/app_localizations.dart';
class AuthPage extends StatefulWidget {
const AuthPage({super.key});
const AuthPage({super.key, this.email});
final String? email;
@override
State<AuthPage> createState() => _AuthPageState();
@@ -56,7 +57,7 @@ class _AuthPageState extends State<AuthPage> {
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: AuthForm(key: _formKey),
child: AuthForm(key: _formKey, email: widget.email),
),
),
],
+225
View File
@@ -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,
),
],
);
}
}
+33
View File
@@ -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,
};
+128
View File
@@ -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),
],
);
}
}
+161
View File
@@ -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),
],
},
);
}
}
+292
View File
@@ -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,
),
),
],
],
),
),
),
),
),
),
);
}
}
+165
View File
@@ -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,
);
}
+79
View File
@@ -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),
),
],
);
}
}
+206
View File
@@ -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(),
);
}
+14 -13
View File
@@ -22,16 +22,17 @@ String reasonText(AppLocalizations l, ReviewReason r) => switch (r) {
ReviewReason.passportExpired => l.kycExpiredReason,
ReviewReason.unableToVerify => l.kycDeniedReason,
};
String errorText(AppLocalizations l, KycError e) => switch (e) {
KycError.unavailable => l.kycErrorUnavailable,
KycError.internal => l.kycErrorInternal,
KycError.permission => l.kycErrorPermission,
KycError.format => l.kycErrorFormat,
KycError.tooLarge => l.kycErrorLarge,
KycError.unreadable => l.kycErrorUnreadable,
KycError.tooSmall => l.kycErrorSmall,
KycError.incomplete => l.kycErrorIncomplete,
KycError.sessionExpired => l.kycErrorSession,
KycError.rateLimited => l.kycErrorRate,
KycError.conflict => l.kycErrorConflict,
};
String errorText(AppLocalizations l, KycError e, {bool photo = false}) =>
switch (e) {
KycError.unavailable => l.kycErrorUnavailable,
KycError.internal => l.kycErrorInternal,
KycError.permission => photo ? l.kycErrorPermission : l.kycErrorAccess,
KycError.format => l.kycErrorFormat,
KycError.tooLarge => l.kycErrorLarge,
KycError.unreadable => l.kycErrorUnreadable,
KycError.tooSmall => l.kycErrorSmall,
KycError.incomplete => l.kycErrorIncomplete,
KycError.sessionExpired => l.kycErrorSession,
KycError.rateLimited => l.kycErrorRate,
KycError.conflict => l.kycErrorConflict,
};
+163
View File
@@ -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),
);
},
);
}
}
Loaded 100 of 218 files, more files were not shown because too many files have changed in this diff. Show more