Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30f06c2f8f | ||
|
|
c6ab4680fd | ||
|
|
86b6aafe7d | ||
|
|
bd675357bb | ||
|
|
24af794112 | ||
|
|
a5689876ff |
No files matched your search
@@ -54,6 +54,9 @@ package-notifications-interface: generate-notifications
|
|||||||
|
|
||||||
# Local product stack. Releases are resolved afresh, then pinned by digest.
|
# Local product stack. Releases are resolved afresh, then pinned by digest.
|
||||||
COMPOSE = docker compose --project-directory . -f infra/local/compose.yml --env-file .local/dev/images.env
|
COMPOSE = docker compose --project-directory . -f infra/local/compose.yml --env-file .local/dev/images.env
|
||||||
|
ifneq ($(wildcard .local/dev/sendico/settings.env),)
|
||||||
|
COMPOSE += -f infra/local/sendico/compose.yml
|
||||||
|
endif
|
||||||
.PHONY: build up down logs ps test-dev
|
.PHONY: build up down logs ps test-dev
|
||||||
build:
|
build:
|
||||||
sh infra/local/tools.sh build
|
sh infra/local/tools.sh build
|
||||||
@@ -92,3 +95,7 @@ email:
|
|||||||
|
|
||||||
test-dev-tooling:
|
test-dev-tooling:
|
||||||
sh infra/local/tools.sh test
|
sh infra/local/tools.sh test
|
||||||
|
|
||||||
|
.PHONY: sendico
|
||||||
|
sendico:
|
||||||
|
sh infra/local/tools.sh sendico
|
||||||
@@ -968,3 +968,35 @@ remain separate capabilities. Unrelated notifications runtime changes are exclud
|
|||||||
Delivered in [PR #51](https://git.sendico.io/tech/sxchnge/pulls/51), targeting
|
Delivered in [PR #51](https://git.sendico.io/tech/sxchnge/pulls/51), targeting
|
||||||
`identity-verification-48`. Merge order is #47 → #49 → #51; deploy Sendico #2897
|
`identity-verification-48`. Merge order is #47 → #49 → #51; deploy Sendico #2897
|
||||||
before SXCHNGE #51. Both implementation branches are pushed and remain unmerged.
|
before SXCHNGE #51. Both implementation branches are pushed and remain unmerged.
|
||||||
|
|
||||||
|
## Saved payout destinations UX — #54
|
||||||
|
|
||||||
|
Branch `payout-destinations-54`, based on `local-stand-52`. Implements Settings
|
||||||
|
creation, details, rename, replacement and removal; explicit payout selection;
|
||||||
|
optional saving and one-time preparation for crypto, cards and Russian personal
|
||||||
|
accounts. Shared typed service/model/provider layers separate destination identity,
|
||||||
|
metadata/execution revisions, ownership, provider bindings, contextual eligibility,
|
||||||
|
save outcomes and financial outcomes. The customer-scoped demo replays stable
|
||||||
|
created/duplicate results, preserves immutable quote/history snapshots, rechecks at
|
||||||
|
acceptance, and demonstrates card-binding restoration without creating a second
|
||||||
|
card. Bank directory/paste handling follows the reviewed Sendico interface.
|
||||||
|
|
||||||
|
EN/RU and 320 px/1.6× layouts are tested. Formatting (44 changed Dart files),
|
||||||
|
analysis of app/wallet/analytics, `make test-frontend` (including 89 app tests), and
|
||||||
|
`flutter build web` with Wasm dry run passed. Browser smoke inspection confirmed
|
||||||
|
bank form rendering, labeled controls and keyboard focus. Full assistive-technology
|
||||||
|
and real-provider audits were not performed. No security scans were requested/run.
|
||||||
|
|
||||||
|
Persistence and bindings in this delivery are simulated and reset on reload.
|
||||||
|
Authenticated live mode retains explicit use of its provisioned payout destination;
|
||||||
|
new types fail closed until the backend supplies the documented capabilities and
|
||||||
|
registry/atomic-acceptance contracts. No routing, ownership-verification system,
|
||||||
|
provider integration or financial engine was added. See
|
||||||
|
[the specification and integration boundary](ux/payout-destinations.md).
|
||||||
|
|
||||||
|
Stacked merge order: #47 → #49 → #51 → #53 → this contribution. The unrelated
|
||||||
|
notifications runtime edit is preserved outside task commits. Delivery links follow.
|
||||||
|
|
||||||
|
Delivery: [task #54](https://git.sendico.io/tech/sxchnge/issues/54) and
|
||||||
|
[PR #55](https://git.sendico.io/tech/sxchnge/pulls/55). Implementation commit
|
||||||
|
`24af794`; PR base `local-stand-52`. No merge performed.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Local development
|
# Local development
|
||||||
|
|
||||||
|
For the connected Sendico stand, see [local Sendico integration](local-sendico.md).
|
||||||
|
|
||||||
Task [#42](https://git.sendico.io/tech/sxchnge/issues/42),
|
Task [#42](https://git.sendico.io/tech/sxchnge/issues/42),
|
||||||
[PR #43](https://git.sendico.io/tech/sxchnge/pulls/43), branch `dev-env-42`.
|
[PR #43](https://git.sendico.io/tech/sxchnge/pulls/43), branch `dev-env-42`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
`make test-dev` checks seven healthy services, verified browser TLS, the Vault
|
||||||
|
layout and least-privilege identities, Mongo transactions/lease rotation, and
|
||||||
|
authenticated Sendico currency and ledger reads through the same loopback
|
||||||
|
connection and token used by BFF. It sends no email and executes no payment.
|
||||||
|
An empty wallet is expected until dedicated subject-bound currency ledgers and
|
||||||
|
saved methods are provisioned as described in [payment provisioning](payments.md).
|
||||||
|
|
||||||
|
## Recovery
|
||||||
|
|
||||||
|
The existing stand had application processes stopped inside still-running Air
|
||||||
|
containers after a NATS disconnect and Mongo replica-state interruption. Restore
|
||||||
|
discovery first, then Treasury and the other service announcers. Wait until each
|
||||||
|
stage is registered before restarting dependent services: billing fees and
|
||||||
|
Ledger, then Limits/gateways/quotation/receiving routes, then payment orchestrator,
|
||||||
|
compliance assessment and Product BFF. The Sendico Makefile provides its ordered
|
||||||
|
backend startup workflow. Inspect current logs and health instead of assuming a
|
||||||
|
running development container has a live application process. Do not delete
|
||||||
|
volumes or reset financial data to recover service registration.
|
||||||
|
|
||||||
|
If an old Caddy leaf certificate expires while the stack is stopped, restart
|
||||||
|
frontend after startup so Caddy issues a fresh certificate. Preserve `caddy-data`
|
||||||
|
and the established CA. A subsequent verified TLS check must succeed.
|
||||||
|
|
||||||
|
## Acceptance and operator UX review
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -149,3 +149,68 @@ text uses a normal text node to remain exposed to assistive technology.
|
|||||||
Delivered in [PR #13](https://git.sendico.io/tech/sxchnge/pulls/13), branch
|
Delivered in [PR #13](https://git.sendico.io/tech/sxchnge/pulls/13), branch
|
||||||
`onboarding-12`. The final browser accessibility tree exposes the recipient text,
|
`onboarding-12`. The final browser accessibility tree exposes the recipient text,
|
||||||
demo status, status heading and recovery actions. PR is open for review.
|
demo status, status heading and recovery actions. PR is open for review.
|
||||||
|
|
||||||
|
## Browser credential autofill
|
||||||
|
|
||||||
|
Task: [#56](https://git.sendico.io/tech/sxchnge/issues/56), branch `auth-56`.
|
||||||
|
Delivery: [PR #58](https://git.sendico.io/tech/sxchnge/pulls/58), implementation
|
||||||
|
commit `86b6aaf`. Open for review; no merge or deployment is included.
|
||||||
|
The consumer auth form groups its mounted inputs in one `AutofillGroup` and
|
||||||
|
declares their purpose explicitly:
|
||||||
|
|
||||||
|
| Input | Flutter hints | Browser autocomplete |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Signup name | `name` with the name keyboard | `name` |
|
||||||
|
| Account email | `username`, then `email`, with the email keyboard | `username` |
|
||||||
|
| Login password | `password` | `current-password` |
|
||||||
|
| Signup password and confirmation | `newPassword` on both | `new-password` |
|
||||||
|
|
||||||
|
Flutter uses the first hint on web and Apple platforms. Email is the credential
|
||||||
|
username; the email keyboard remains configured separately. Visibility toggles
|
||||||
|
keep the password role, so autofill does not depend on detecting masked input.
|
||||||
|
The confirmation field participates in the same group as the first password,
|
||||||
|
allowing the browser to fill both without copying one controller into the other.
|
||||||
|
|
||||||
|
Signup acceptance finalizes autofill before clearing password controllers and
|
||||||
|
navigating to email verification. Login finalizes on success; when a second
|
||||||
|
factor is required, it waits for verification and login completion. The password
|
||||||
|
remains only in the existing form controller during that challenge, then clears
|
||||||
|
on completed navigation or cancellation. Invalid input, rejected credentials and
|
||||||
|
failed verification do not request saving. Switching auth mode, abandoning the
|
||||||
|
form or leaving for unverified-account recovery cancels the autofill context.
|
||||||
|
Provider/service/model boundaries and existing request/outcome telemetry are
|
||||||
|
unchanged; no field events or credential telemetry are added. Browser popup
|
||||||
|
display and pre-submit autofill abandonment remain outside current measurement.
|
||||||
|
|
||||||
|
### Verification and release check
|
||||||
|
|
||||||
|
Focused widget tests send a platform autofill response with all four signup
|
||||||
|
values and check EN/RU controller updates, grouped credential metadata, visibility
|
||||||
|
changes, mode switching and abandonment. Auth tests cover validation failures,
|
||||||
|
service rejection/retry, stale responses, signup/login acceptance, unverified
|
||||||
|
recovery, invalid second-factor codes and cancellation without premature saving.
|
||||||
|
Existing narrow/desktop layouts and enlarged-text tests cover the unchanged UI.
|
||||||
|
|
||||||
|
Native Safari verification remains a release check: computer-control permission
|
||||||
|
was unavailable in this session. On the deployed HTTPS origin with Safari
|
||||||
|
AutoFill enabled, focus the signup email and password fields, choose a suggested
|
||||||
|
strong password, verify that both password fields populate, then confirm that
|
||||||
|
login offers the saved credential. Repeat after showing/hiding passwords and
|
||||||
|
switching modes. Browser settings, stored credentials and browser heuristics
|
||||||
|
determine whether suggestions appear; automated Flutter tests cannot establish
|
||||||
|
that the system popup was displayed or that credentials were saved.
|
||||||
|
|
||||||
|
References: [Apple Password AutoFill](https://developer.apple.com/documentation/security/enabling-password-autofill-on-an-html-input-element),
|
||||||
|
[Flutter autofill hints](https://api.flutter.dev/flutter/material/TextField/autofillHints.html),
|
||||||
|
and [AutofillGroup](https://api.flutter.dev/flutter/widgets/AutofillGroup-class.html).
|
||||||
|
|
||||||
|
Recorded validation: `dart format --output=none --set-exit-if-changed` passes for
|
||||||
|
all five changed Dart files; `flutter analyze` passes in `frontend/app`;
|
||||||
|
`make test-frontend` passes all 212 tests across the app and shared packages.
|
||||||
|
`flutter build web` passes. The built page's default rendering path was inspected
|
||||||
|
in the in-app browser: login exposes `username`/`current-password` in one HTML
|
||||||
|
form; signup exposes `username`, `name` and two `new-password` inputs in one form.
|
||||||
|
This verifies the browser metadata, not Safari's credential popup. With Flutter
|
||||||
|
web accessibility semantics enabled, unfocused semantic inputs initially use
|
||||||
|
framework-default autocomplete values until focused; password-manager behavior
|
||||||
|
with assistive technology still requires a native browser audit.
|
||||||
@@ -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.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -63,6 +64,8 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
|
|
||||||
void _switch(bool signUp) {
|
void _switch(bool signUp) {
|
||||||
if (signUp == _signUp) return;
|
if (signUp == _signUp) return;
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
TextInput.finishAutofillContext(shouldSave: false);
|
||||||
context.read<JourneyRecorder>().abandon(JourneyExit.modeChange);
|
context.read<JourneyRecorder>().abandon(JourneyExit.modeChange);
|
||||||
context.read<LoginProvider>().reset();
|
context.read<LoginProvider>().reset();
|
||||||
context.read<SignupProvider>().reset();
|
context.read<SignupProvider>().reset();
|
||||||
@@ -95,6 +98,7 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final email = _email.text.trim();
|
final email = _email.text.trim();
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
_password.clear();
|
_password.clear();
|
||||||
_confirmation.clear();
|
_confirmation.clear();
|
||||||
context.goNamed(AppRoutes.verificationSent, extra: email);
|
context.goNamed(AppRoutes.verificationSent, extra: email);
|
||||||
@@ -107,13 +111,13 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
);
|
);
|
||||||
if (!mounted || _signUp) return;
|
if (!mounted || _signUp) return;
|
||||||
if (login.state.error == AuthError.accountNotVerified) {
|
if (login.state.error == AuthError.accountNotVerified) {
|
||||||
|
TextInput.finishAutofillContext(shouldSave: false);
|
||||||
_password.clear();
|
_password.clear();
|
||||||
context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim());
|
context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final challenge = login.state.result?.challenge;
|
final challenge = login.state.result?.challenge;
|
||||||
if (challenge != null) {
|
if (challenge != null) {
|
||||||
_password.clear();
|
|
||||||
final grant = await verifyFactor(
|
final grant = await verifyFactor(
|
||||||
context,
|
context,
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
@@ -123,6 +127,8 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
);
|
);
|
||||||
if (!mounted || _signUp) return;
|
if (!mounted || _signUp) return;
|
||||||
if (grant == null) {
|
if (grant == null) {
|
||||||
|
TextInput.finishAutofillContext(shouldSave: false);
|
||||||
|
_password.clear();
|
||||||
login.reset();
|
login.reset();
|
||||||
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
||||||
return;
|
return;
|
||||||
@@ -137,9 +143,13 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
!_signUp &&
|
!_signUp &&
|
||||||
identical(login.state.result, accepted) &&
|
identical(login.state.result, accepted) &&
|
||||||
login.state.status == AuthStatus.success) {
|
login.state.status == AuthStatus.success) {
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
|
_password.clear();
|
||||||
context.go(offer ? '/identity?onboarding=1' : '/accounts');
|
context.go(offer ? '/identity?onboarding=1' : '/accounts');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (login.state.status == AuthStatus.success) {
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,7 +205,10 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
expandedInsets: EdgeInsets.zero,
|
expandedInsets: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Form(
|
AutofillGroup(
|
||||||
|
// Leaving an unfinished form must not offer to save its credentials.
|
||||||
|
onDisposeAction: AutofillContextAction.cancel,
|
||||||
|
child: Form(
|
||||||
key: _form,
|
key: _form,
|
||||||
autovalidateMode: _submitted
|
autovalidateMode: _submitted
|
||||||
? AutovalidateMode.onUserInteraction
|
? AutovalidateMode.onUserInteraction
|
||||||
@@ -220,6 +233,8 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
errorMaxLines: 3,
|
errorMaxLines: 3,
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
|
keyboardType: TextInputType.name,
|
||||||
|
autofillHints: const [AutofillHints.name],
|
||||||
textCapitalization: TextCapitalization.words,
|
textCapitalization: TextCapitalization.words,
|
||||||
validator: (value) => AuthValidation.name(value, l),
|
validator: (value) => AuthValidation.name(value, l),
|
||||||
),
|
),
|
||||||
@@ -236,6 +251,10 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
errorMaxLines: 3,
|
errorMaxLines: 3,
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
autofillHints: const [
|
||||||
|
AutofillHints.username,
|
||||||
|
AutofillHints.email,
|
||||||
|
],
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
validator: (value) => AuthValidation.email(value, l),
|
validator: (value) => AuthValidation.email(value, l),
|
||||||
@@ -246,6 +265,11 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
enabled: !state.isLoading,
|
enabled: !state.isLoading,
|
||||||
controller: _password,
|
controller: _password,
|
||||||
obscureText: !_showPassword,
|
obscureText: !_showPassword,
|
||||||
|
autofillHints: [
|
||||||
|
_signUp
|
||||||
|
? AutofillHints.newPassword
|
||||||
|
: AutofillHints.password,
|
||||||
|
],
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
enableSuggestions: false,
|
enableSuggestions: false,
|
||||||
textInputAction: _signUp
|
textInputAction: _signUp
|
||||||
@@ -281,6 +305,7 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
enabled: !state.isLoading,
|
enabled: !state.isLoading,
|
||||||
controller: _confirmation,
|
controller: _confirmation,
|
||||||
obscureText: !_showConfirmation,
|
obscureText: !_showConfirmation,
|
||||||
|
autofillHints: const [AutofillHints.newPassword],
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
enableSuggestions: false,
|
enableSuggestions: false,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
@@ -310,7 +335,8 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
key: const ValueKey('submit'),
|
key: const ValueKey('submit'),
|
||||||
onPressed: state.isLoading || state.status == AuthStatus.success
|
onPressed:
|
||||||
|
state.isLoading || state.status == AuthStatus.success
|
||||||
? null
|
? null
|
||||||
: _submit,
|
: _submit,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -331,7 +357,9 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.arrow_forward, size: 20),
|
: const Icon(Icons.arrow_forward, size: 20),
|
||||||
],
|
],
|
||||||
@@ -341,6 +369,7 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
if (state.status == AuthStatus.success ||
|
if (state.status == AuthStatus.success ||
|
||||||
state.status == AuthStatus.error) ...[
|
state.status == AuthStatus.error) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'paste.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class BankFields extends StatefulWidget {
|
||||||
|
const BankFields({
|
||||||
|
super.key,
|
||||||
|
required this.onChanged,
|
||||||
|
this.initial,
|
||||||
|
this.errorField,
|
||||||
|
});
|
||||||
|
final ValueChanged<DestinationDetails?> onChanged;
|
||||||
|
final RussianBankDestination? initial;
|
||||||
|
final String? errorField;
|
||||||
|
@override
|
||||||
|
State<BankFields> createState() => _BankFieldsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BankFieldsState extends State<BankFields> {
|
||||||
|
late final first = TextEditingController(text: widget.initial?.firstName);
|
||||||
|
late final last = TextEditingController(text: widget.initial?.lastName);
|
||||||
|
late final patronymic = TextEditingController(
|
||||||
|
text: widget.initial?.patronymic,
|
||||||
|
);
|
||||||
|
late final account = TextEditingController(text: widget.initial?.account);
|
||||||
|
final query = TextEditingController();
|
||||||
|
late final search = BankSearchProvider(
|
||||||
|
context.read<DestinationsProvider>().service,
|
||||||
|
);
|
||||||
|
late Bank? bank = widget.initial?.bank;
|
||||||
|
String? pastedHolder;
|
||||||
|
bool conflict = false;
|
||||||
|
Map<String, String> pasted = {};
|
||||||
|
bool get mismatch =>
|
||||||
|
bank != null &&
|
||||||
|
((pasted['bank'] != null && pasted['bank'] != bank!.name) ||
|
||||||
|
(pasted['correspondent'] != null &&
|
||||||
|
pasted['correspondent'] != bank!.correspondent));
|
||||||
|
void changed() => widget.onChanged(
|
||||||
|
bank == null
|
||||||
|
? null
|
||||||
|
: RussianBankDestination(
|
||||||
|
firstName: first.text,
|
||||||
|
lastName: last.text,
|
||||||
|
patronymic: patronymic.text,
|
||||||
|
bank: bank!,
|
||||||
|
account: account.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Future<void> paste() async {
|
||||||
|
final parsed = await showDialog<BankPaste>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => const BankPasteDialog(),
|
||||||
|
);
|
||||||
|
if (!mounted || parsed == null) return;
|
||||||
|
for (final entry in {
|
||||||
|
'firstName': first,
|
||||||
|
'lastName': last,
|
||||||
|
'patronymic': patronymic,
|
||||||
|
'account': account,
|
||||||
|
}.entries) {
|
||||||
|
if (parsed.conflicts.contains(entry.key)) {
|
||||||
|
entry.value.clear();
|
||||||
|
} else if (parsed.values[entry.key] case final String value) {
|
||||||
|
entry.value.text = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
pasted = parsed.values;
|
||||||
|
conflict = parsed.conflicts.isNotEmpty;
|
||||||
|
pastedHolder = parsed.values['holder'];
|
||||||
|
});
|
||||||
|
if (parsed.conflicts.contains('bik')) {
|
||||||
|
setState(() => bank = null);
|
||||||
|
}
|
||||||
|
if (parsed.values['bik'] case final String bik) {
|
||||||
|
query.text = bik;
|
||||||
|
search.search(bik);
|
||||||
|
setState(() => bank = null);
|
||||||
|
}
|
||||||
|
changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final c in [first, last, patronymic, account, query]) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
search.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget field(
|
||||||
|
String label,
|
||||||
|
TextEditingController c, {
|
||||||
|
String? id,
|
||||||
|
bool numeric = false,
|
||||||
|
String? error,
|
||||||
|
}) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: TextField(
|
||||||
|
key: id == null ? null : ValueKey(id),
|
||||||
|
controller: c,
|
||||||
|
keyboardType: numeric ? TextInputType.number : TextInputType.name,
|
||||||
|
decoration: InputDecoration(labelText: label, errorText: error),
|
||||||
|
onChanged: (_) => changed(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(l.dHolder, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, b) {
|
||||||
|
final a = field(
|
||||||
|
l.dFirst,
|
||||||
|
first,
|
||||||
|
id: 'destination-first',
|
||||||
|
error: widget.errorField == 'holder' ? l.dInvalid : null,
|
||||||
|
);
|
||||||
|
final z = field(l.dLast, last, id: 'destination-last');
|
||||||
|
return b.maxWidth >= 480
|
||||||
|
? Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(child: a),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: z),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(children: [a, z]);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
field(l.dPatronymic, patronymic),
|
||||||
|
if (pastedHolder != null) SelectableText(pastedHolder!),
|
||||||
|
if (conflict) Text(l.dPasteConflict),
|
||||||
|
Text(l.dBankDetails, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: paste,
|
||||||
|
icon: const Icon(Icons.content_paste),
|
||||||
|
label: Text(l.dPaste),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListenableBuilder(
|
||||||
|
listenable: search,
|
||||||
|
builder: (context, _) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (bank == null) ...[
|
||||||
|
TextField(
|
||||||
|
controller: query,
|
||||||
|
decoration: InputDecoration(labelText: l.dBankSearch),
|
||||||
|
onChanged: search.search,
|
||||||
|
),
|
||||||
|
if (search.loading) const LinearProgressIndicator(),
|
||||||
|
for (final b in search.results)
|
||||||
|
ListTile(
|
||||||
|
title: Text(b.name),
|
||||||
|
subtitle: Text('${b.bik} · ${b.city}'),
|
||||||
|
onTap: () {
|
||||||
|
setState(() => bank = b);
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (search.error != null)
|
||||||
|
Text(
|
||||||
|
search.error == DestinationIssue.unavailable
|
||||||
|
? l.dBankFailure
|
||||||
|
: l.dBankUnknown,
|
||||||
|
),
|
||||||
|
if (search.error == DestinationIssue.unavailable)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => search.search(query.text),
|
||||||
|
child: Text(l.wRefresh),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(bank!.name),
|
||||||
|
subtitle: Text('${l.dBik} ${bank!.bik}'),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => bank = null);
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
child: Text(l.dChangeBank),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ExpansionTile(
|
||||||
|
title: Text(l.dBankDetails),
|
||||||
|
children: [
|
||||||
|
SelectableText(
|
||||||
|
'${l.dCorrespondent}: ${bank!.correspondent}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (!bank!.supported) Text(l.dUnsupported),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
field(
|
||||||
|
l.dAccount,
|
||||||
|
account,
|
||||||
|
id: 'destination-account',
|
||||||
|
numeric: true,
|
||||||
|
error: widget.errorField == 'account' ? l.dInvalid : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
String destinationType(AppLocalizations l, DestinationType type) =>
|
||||||
|
switch (type) {
|
||||||
|
DestinationType.crypto => l.dCrypto,
|
||||||
|
DestinationType.card => l.dCard,
|
||||||
|
DestinationType.russianBank => l.dBank,
|
||||||
|
};
|
||||||
|
String destinationIssue(AppLocalizations l, DestinationIssue issue) =>
|
||||||
|
switch (issue) {
|
||||||
|
DestinationIssue.invalid => l.dInvalid,
|
||||||
|
DestinationIssue.unavailable => l.dTemporary,
|
||||||
|
DestinationIssue.uncertain => l.dUncertain,
|
||||||
|
DestinationIssue.conflict => l.dConflict,
|
||||||
|
DestinationIssue.removed => l.dRemoved,
|
||||||
|
DestinationIssue.expired => l.dCardExpired,
|
||||||
|
DestinationIssue.bankChanged => l.dBankChanged,
|
||||||
|
DestinationIssue.authorization => l.dAuthorization,
|
||||||
|
DestinationIssue.unsupported => l.dUnsupported,
|
||||||
|
DestinationIssue.duplicate => l.dDuplicate,
|
||||||
|
DestinationIssue.replacementRequired => l.dReplacementRequired,
|
||||||
|
};
|
||||||
|
String eligibilityText(AppLocalizations l, PayoutEligibility e) =>
|
||||||
|
e.reason != null
|
||||||
|
? destinationIssue(l, e.reason!)
|
||||||
|
: switch (e.state) {
|
||||||
|
EligibilityState.eligible => l.dEligible,
|
||||||
|
EligibilityState.temporarilyUnavailable => l.dTemporary,
|
||||||
|
EligibilityState.requiresAction => l.dAction,
|
||||||
|
EligibilityState.unsupported => l.dUnsupported,
|
||||||
|
};
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class CryptoFields extends StatefulWidget {
|
||||||
|
const CryptoFields({
|
||||||
|
super.key,
|
||||||
|
required this.capabilities,
|
||||||
|
required this.onChanged,
|
||||||
|
this.initial,
|
||||||
|
this.errorField,
|
||||||
|
});
|
||||||
|
final DestinationCapabilities capabilities;
|
||||||
|
final ValueChanged<DestinationDetails?> onChanged;
|
||||||
|
final CryptoDestination? initial;
|
||||||
|
final String? errorField;
|
||||||
|
@override
|
||||||
|
State<CryptoFields> createState() => _CryptoFieldsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CryptoFieldsState extends State<CryptoFields> {
|
||||||
|
late final address = TextEditingController(text: widget.initial?.address);
|
||||||
|
late final memo = TextEditingController(text: widget.initial?.memo);
|
||||||
|
late String? assetId = widget.initial?.assetId;
|
||||||
|
late String? networkId = widget.initial?.networkId;
|
||||||
|
void changed() {
|
||||||
|
final a = widget.capabilities.assets
|
||||||
|
.where((a) => a.id == assetId && a.networkId == networkId)
|
||||||
|
.firstOrNull;
|
||||||
|
widget.onChanged(
|
||||||
|
a == null
|
||||||
|
? null
|
||||||
|
: CryptoDestination(
|
||||||
|
assetId: a.id,
|
||||||
|
networkId: a.networkId,
|
||||||
|
address: address.text,
|
||||||
|
memo: memo.text,
|
||||||
|
assetLabel: a.label,
|
||||||
|
networkLabel: a.networkLabel,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
address.dispose();
|
||||||
|
memo.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final assets = widget.capabilities.assets;
|
||||||
|
final selected = assets
|
||||||
|
.where((a) => a.id == assetId && a.networkId == networkId)
|
||||||
|
.firstOrNull;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
key: ValueKey('asset-$assetId'),
|
||||||
|
initialValue: assets.any((a) => a.id == assetId) ? assetId : null,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: InputDecoration(labelText: l.dAsset),
|
||||||
|
items: [
|
||||||
|
for (final a in assets)
|
||||||
|
DropdownMenuItem(value: a.id, child: Text(a.label)),
|
||||||
|
],
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() {
|
||||||
|
assetId = v;
|
||||||
|
networkId = null;
|
||||||
|
memo.clear();
|
||||||
|
});
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
key: ValueKey('network-$assetId-$networkId'),
|
||||||
|
initialValue:
|
||||||
|
assets.any((a) => a.id == assetId && a.networkId == networkId)
|
||||||
|
? networkId
|
||||||
|
: null,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: InputDecoration(labelText: l.dNetwork),
|
||||||
|
items: [
|
||||||
|
for (final a in assets.where((a) => a.id == assetId))
|
||||||
|
DropdownMenuItem(value: a.networkId, child: Text(a.networkLabel)),
|
||||||
|
],
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => networkId = v);
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
key: const ValueKey('destination-address'),
|
||||||
|
controller: address,
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.dAddress,
|
||||||
|
errorText: widget.errorField == 'address' ? l.dInvalid : null,
|
||||||
|
),
|
||||||
|
onChanged: (_) => changed(),
|
||||||
|
),
|
||||||
|
if (selected?.memoSupported == true) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
key: const ValueKey('destination-memo'),
|
||||||
|
controller: memo,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.dMemo,
|
||||||
|
errorText: widget.errorField == 'memo' ? l.dInvalid : null,
|
||||||
|
),
|
||||||
|
onChanged: (_) => changed(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(l.dCryptoHelp),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'copy.dart';
|
||||||
|
import 'details.dart';
|
||||||
|
import 'form.dart';
|
||||||
|
import 'nickname.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class DestinationDetailPage extends StatefulWidget {
|
||||||
|
const DestinationDetailPage({super.key, required this.destination});
|
||||||
|
final SavedDestination destination;
|
||||||
|
@override
|
||||||
|
State<DestinationDetailPage> createState() => _DestinationDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DestinationDetailPageState extends State<DestinationDetailPage> {
|
||||||
|
late final p = DestinationManagementProvider(
|
||||||
|
context.read<DestinationsProvider>().service,
|
||||||
|
widget.destination,
|
||||||
|
);
|
||||||
|
late final nickname = TextEditingController(
|
||||||
|
text: widget.destination.nickname,
|
||||||
|
);
|
||||||
|
Future<void> remove() async {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(l.dRemoveTitle),
|
||||||
|
content: Text(l.dRemoveHelp),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: Text(l.wStay),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: Text(l.dRemove),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!mounted || confirmed != true) return;
|
||||||
|
await p.remove();
|
||||||
|
if (mounted && p.removed) Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
p.dispose();
|
||||||
|
nickname.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: p,
|
||||||
|
builder: (context, _) => Scaffold(
|
||||||
|
appBar: AppBar(title: Text(l.dDetails)),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 120),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
DestinationDetailsView(details: p.destination.details),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
DestinationNickname(
|
||||||
|
controller: nickname,
|
||||||
|
enabled: !p.busy,
|
||||||
|
onChanged: (_) {},
|
||||||
|
),
|
||||||
|
if (p.busy) const LinearProgressIndicator(),
|
||||||
|
if (p.error != null) ...[
|
||||||
|
Semantics(
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(destinationIssue(l, p.error!)),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy ? null : p.refresh,
|
||||||
|
child: Text(l.dReload),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await p.rename(nickname.text);
|
||||||
|
if (context.mounted && p.error == null) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(l.dSaved)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(l.dRename),
|
||||||
|
),
|
||||||
|
if (p.destination.details is CardDestination &&
|
||||||
|
context
|
||||||
|
.read<DestinationsProvider>()
|
||||||
|
.capabilities
|
||||||
|
?.cardRecovery ==
|
||||||
|
true)
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final restored =
|
||||||
|
await Navigator.push<DestinationInput>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => DestinationFormPage(
|
||||||
|
initial: p.destination.details,
|
||||||
|
restoring: p.destination.input,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (mounted && restored != null) {
|
||||||
|
await p.refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(l.dUpdateAuthorization),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await Navigator.push<DestinationInput>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => DestinationFormPage(
|
||||||
|
initial: p.destination.details,
|
||||||
|
replacing: p.destination.id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text(l.dReplace),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy ? null : remove,
|
||||||
|
child: Text(l.dRemove),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Structured review renders the immutable service snapshot, never a nickname.
|
||||||
|
class DestinationDetailsView extends StatelessWidget {
|
||||||
|
const DestinationDetailsView({super.key, required this.details});
|
||||||
|
final DestinationDetails details;
|
||||||
|
Widget field(String label, String value) => Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [Text(label), SelectableText(value)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: switch (details) {
|
||||||
|
CryptoDestination d => [
|
||||||
|
field(l.dAsset, d.assetLabel),
|
||||||
|
field(l.dNetwork, d.networkLabel),
|
||||||
|
field(l.dAddress, d.address),
|
||||||
|
field(l.dMemo, d.memo.isEmpty ? l.dNoMemo : d.memo),
|
||||||
|
],
|
||||||
|
CardDestination d => [field(l.dCard, d.display)],
|
||||||
|
RussianBankDestination d => [
|
||||||
|
field(l.dHolder, d.holder),
|
||||||
|
field(l.dBankSearch, d.bank.name),
|
||||||
|
field(l.dBik, d.bank.bik),
|
||||||
|
field(l.dAccount, d.account),
|
||||||
|
field(l.dCorrespondent, d.bank.correspondent),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'bank.dart';
|
||||||
|
import 'copy.dart';
|
||||||
|
import 'crypto.dart';
|
||||||
|
import 'nickname.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class DestinationFormPage extends StatefulWidget {
|
||||||
|
const DestinationFormPage({
|
||||||
|
super.key,
|
||||||
|
this.payoutContext,
|
||||||
|
this.initial,
|
||||||
|
this.replacing,
|
||||||
|
this.restoring,
|
||||||
|
});
|
||||||
|
final PayoutContext? payoutContext;
|
||||||
|
final DestinationDetails? initial;
|
||||||
|
final String? replacing;
|
||||||
|
final SavedReference? restoring;
|
||||||
|
@override
|
||||||
|
State<DestinationFormPage> createState() => _DestinationFormPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DestinationFormPageState extends State<DestinationFormPage> {
|
||||||
|
late final p = DestinationFormProvider(
|
||||||
|
context.read<DestinationsProvider>().service,
|
||||||
|
context.read<AnalyticsService>(),
|
||||||
|
context: widget.payoutContext,
|
||||||
|
initial: widget.initial,
|
||||||
|
replacing: widget.replacing,
|
||||||
|
restoring: widget.restoring,
|
||||||
|
);
|
||||||
|
late DestinationType? type = widget.initial?.type;
|
||||||
|
final nickname = TextEditingController();
|
||||||
|
final focus = FocusNode();
|
||||||
|
Future<void> proceed({
|
||||||
|
bool withoutSaving = false,
|
||||||
|
bool existing = false,
|
||||||
|
}) async {
|
||||||
|
if (existing) {
|
||||||
|
await p.useExisting();
|
||||||
|
} else {
|
||||||
|
await p.proceed(withoutSaving: withoutSaving);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
if (p.result != null) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
if (p.saved?.outcome == SaveOutcome.created || widget.restoring != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
widget.restoring == null ? l.dSaved : l.dAuthorizationUpdated,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Navigator.pop(context, p.result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
focus.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
p.dispose();
|
||||||
|
nickname.dispose();
|
||||||
|
focus.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final caps = context.watch<DestinationsProvider>().capabilities;
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: p,
|
||||||
|
builder: (context, _) => PopScope(
|
||||||
|
canPop: !p.busy,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(title: Text(l.dAdd)),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (caps?.simulated == true) ...[
|
||||||
|
Text(l.dDemo),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
Text(l.dOwn),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
if (caps == null || caps.types.isEmpty)
|
||||||
|
Text(l.dNoCapabilities)
|
||||||
|
else ...[
|
||||||
|
DropdownButtonFormField<DestinationType>(
|
||||||
|
initialValue: caps.types.contains(type) ? type : null,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: InputDecoration(labelText: l.dType),
|
||||||
|
items: [
|
||||||
|
for (final t in caps.types)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: t,
|
||||||
|
child: Text(
|
||||||
|
destinationType(l, t),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged:
|
||||||
|
p.busy || p.uncertain || widget.restoring != null
|
||||||
|
? null
|
||||||
|
: (v) {
|
||||||
|
setState(() => type = v);
|
||||||
|
p.change(null);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
AbsorbPointer(
|
||||||
|
absorbing: p.busy || p.uncertain,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (type == DestinationType.crypto)
|
||||||
|
CryptoFields(
|
||||||
|
capabilities: caps,
|
||||||
|
initial: widget.initial is CryptoDestination
|
||||||
|
? widget.initial as CryptoDestination
|
||||||
|
: null,
|
||||||
|
errorField: p.errorField,
|
||||||
|
onChanged: p.change,
|
||||||
|
),
|
||||||
|
if (type == DestinationType.russianBank)
|
||||||
|
BankFields(
|
||||||
|
initial:
|
||||||
|
widget.initial is RussianBankDestination
|
||||||
|
? widget.initial as RussianBankDestination
|
||||||
|
: null,
|
||||||
|
errorField: p.errorField,
|
||||||
|
onChanged: p.change,
|
||||||
|
),
|
||||||
|
if (type == DestinationType.card &&
|
||||||
|
caps.simulated) ...[
|
||||||
|
Text(l.dCardDemo),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
RadioGroup<String>(
|
||||||
|
groupValue: p.details is CardDestination
|
||||||
|
? (p.details as CardDestination).capture
|
||||||
|
: null,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
p.change(
|
||||||
|
CardDestination(
|
||||||
|
capture: value,
|
||||||
|
brand: 'Demo card',
|
||||||
|
last4: '4242',
|
||||||
|
expiryMonth: 12,
|
||||||
|
expiryYear: 2030,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
RadioListTile<String>(
|
||||||
|
value: 'demo-card-ready',
|
||||||
|
title: Text(l.dCardReady),
|
||||||
|
),
|
||||||
|
RadioListTile<String>(
|
||||||
|
value: 'demo-card-recover',
|
||||||
|
title: Text(l.dCardRecover),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (type == DestinationType.card && !caps.simulated)
|
||||||
|
Text(l.dNoCapabilities),
|
||||||
|
if (widget.payoutContext != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
CheckboxListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
value: p.save,
|
||||||
|
onChanged: (v) => p.preferences(save: v),
|
||||||
|
title: Text(l.dSaveFuture),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (widget.restoring == null &&
|
||||||
|
(widget.payoutContext == null || p.save)) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DestinationNickname(
|
||||||
|
controller: nickname,
|
||||||
|
enabled: !p.busy && !p.uncertain,
|
||||||
|
onChanged: (v) => p.preferences(nickname: v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Focus(
|
||||||
|
focusNode: focus,
|
||||||
|
skipTraversal: true,
|
||||||
|
child: Semantics(
|
||||||
|
liveRegion: true,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (p.error != null)
|
||||||
|
Text(
|
||||||
|
p.uncertain
|
||||||
|
? l.dUncertain
|
||||||
|
: p.errorField != null
|
||||||
|
? l.dInvalid
|
||||||
|
: '${p.savingFailed ? '${l.dSaveFailed}\n' : ''}${destinationIssue(l, p.error!)}',
|
||||||
|
key: const ValueKey('destination-error'),
|
||||||
|
),
|
||||||
|
if (p.eligibility != null &&
|
||||||
|
!p.eligibility!.eligible)
|
||||||
|
Text(
|
||||||
|
'${p.saved != null ? '${l.dSavedIneligible}\n' : ''}${eligibilityText(l, p.eligibility!)}',
|
||||||
|
),
|
||||||
|
if (p.saved?.outcome ==
|
||||||
|
SaveOutcome.duplicate) ...[
|
||||||
|
Text(l.dDuplicate),
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () => proceed(existing: true),
|
||||||
|
child: Text(l.dUseExisting),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (p.saved?.outcome == SaveOutcome.created &&
|
||||||
|
p.result == null)
|
||||||
|
Text(l.dSaved),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (p.busy) const LinearProgressIndicator(),
|
||||||
|
if (p.uncertain)
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy ? null : () => proceed(),
|
||||||
|
child: Text(l.dCheckSave),
|
||||||
|
),
|
||||||
|
if (p.savingFailed && widget.payoutContext != null)
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () => proceed(withoutSaving: true),
|
||||||
|
child: Text(l.dWithoutSave),
|
||||||
|
),
|
||||||
|
if (p.save && widget.payoutContext != null)
|
||||||
|
Text(l.dSaveHelp),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton(
|
||||||
|
key: const ValueKey('destination-continue'),
|
||||||
|
onPressed: p.busy || p.details == null
|
||||||
|
? null
|
||||||
|
: () => proceed(),
|
||||||
|
child: Text(
|
||||||
|
widget.restoring != null
|
||||||
|
? l.dUpdateAuthorization
|
||||||
|
: widget.payoutContext == null
|
||||||
|
? l.dSave
|
||||||
|
: p.error != null && !p.uncertain
|
||||||
|
? l.dRetrySave
|
||||||
|
: l.dContinue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'copy.dart';
|
||||||
|
import 'detail.dart';
|
||||||
|
import 'form.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class DestinationsPage extends StatefulWidget {
|
||||||
|
const DestinationsPage({super.key, this.payoutContext});
|
||||||
|
final PayoutContext? payoutContext;
|
||||||
|
@override
|
||||||
|
State<DestinationsPage> createState() => _DestinationsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DestinationsPageState extends State<DestinationsPage> {
|
||||||
|
String query = '';
|
||||||
|
late final selection = DestinationSelectionProvider(
|
||||||
|
context.read<DestinationsProvider>().service,
|
||||||
|
widget.payoutContext ?? const PayoutContext(''),
|
||||||
|
);
|
||||||
|
Future<void> refresh() async {
|
||||||
|
final p = context.read<DestinationsProvider>();
|
||||||
|
await p.refresh();
|
||||||
|
if (!mounted) return;
|
||||||
|
await selection.assess(p.methods);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> add() async {
|
||||||
|
final result = await Navigator.push<DestinationInput>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
DestinationFormPage(payoutContext: widget.payoutContext),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (result != null && widget.payoutContext != null) {
|
||||||
|
Navigator.pop(context, result);
|
||||||
|
} else {
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> choose(SavedDestination d) async {
|
||||||
|
if (widget.payoutContext == null) {
|
||||||
|
await Navigator.push<void>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => DestinationDetailPage(destination: d),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (mounted) await refresh();
|
||||||
|
} else {
|
||||||
|
final ref = await selection.select(d);
|
||||||
|
if (mounted && ref != null) Navigator.pop(context, ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
selection.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final p = context.watch<DestinationsProvider>();
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(widget.payoutContext == null ? l.dTitle : l.dChoose),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: l.dReload,
|
||||||
|
onPressed: p.loading ? null : refresh,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: ListenableBuilder(
|
||||||
|
listenable: selection,
|
||||||
|
builder: (context, _) => ListView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
children: [
|
||||||
|
if (p.capabilities?.simulated == true) ...[
|
||||||
|
Text(l.dDemo),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: p.loading || (p.capabilities?.types.isEmpty ?? true)
|
||||||
|
? null
|
||||||
|
: add,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: Text(widget.payoutContext == null ? l.dAdd : l.dNew),
|
||||||
|
),
|
||||||
|
if (p.capabilities?.types.isEmpty == true)
|
||||||
|
Text(l.dManagementUnavailable),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
TextField(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.dSearch,
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
),
|
||||||
|
onChanged: (v) => setState(() => query = v),
|
||||||
|
),
|
||||||
|
if (p.loading || selection.busy) const LinearProgressIndicator(),
|
||||||
|
if (p.failed) Text(l.dUnavailable),
|
||||||
|
if (selection.error != null)
|
||||||
|
Semantics(
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(destinationIssue(l, selection.error!)),
|
||||||
|
),
|
||||||
|
if (widget.payoutContext != null &&
|
||||||
|
!p.loading &&
|
||||||
|
p.methods.isNotEmpty &&
|
||||||
|
!selection.assessments.values.any((a) => a.eligible))
|
||||||
|
Text(l.dNoEligible),
|
||||||
|
for (final type
|
||||||
|
in p.capabilities?.types ?? <DestinationType>[]) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
destinationType(l, type),
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (p.search(query, type).isEmpty && !p.loading && !p.failed)
|
||||||
|
Text(query.isEmpty ? l.dEmpty : l.dNoMatches),
|
||||||
|
for (final d in p.search(query, type))
|
||||||
|
Card(
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.all(16),
|
||||||
|
title: Text(d.label),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (d.nickname.isNotEmpty) Text(d.details.display),
|
||||||
|
if (selection.assessments[d.id]
|
||||||
|
case final PayoutEligibility e)
|
||||||
|
Text(eligibilityText(l, e)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: selection.busy ? null : () => choose(d),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class DestinationNickname extends StatelessWidget {
|
||||||
|
const DestinationNickname({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.onChanged,
|
||||||
|
this.enabled = true,
|
||||||
|
});
|
||||||
|
final TextEditingController controller;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
final bool enabled;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => TextField(
|
||||||
|
key: const ValueKey('destination-nickname'),
|
||||||
|
controller: controller,
|
||||||
|
enabled: enabled,
|
||||||
|
maxLength: 80,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: AppLocalizations.of(context)!.dNickname,
|
||||||
|
),
|
||||||
|
onChanged: onChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class BankPasteDialog extends StatefulWidget {
|
||||||
|
const BankPasteDialog({super.key});
|
||||||
|
@override
|
||||||
|
State<BankPasteDialog> createState() => _BankPasteDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BankPasteDialogState extends State<BankPasteDialog> {
|
||||||
|
final text = TextEditingController();
|
||||||
|
BankPaste? result;
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
text.clear();
|
||||||
|
text.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final labels = {
|
||||||
|
'firstName': l.dFirst,
|
||||||
|
'lastName': l.dLast,
|
||||||
|
'patronymic': l.dPatronymic,
|
||||||
|
'account': l.dAccount,
|
||||||
|
'bik': 'BIK',
|
||||||
|
'bank': l.dBankSearch,
|
||||||
|
'holder': l.dHolder,
|
||||||
|
'correspondent': l.dCorrespondent,
|
||||||
|
};
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(l.dPaste),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 480,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(l.dPasteHelp),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: text,
|
||||||
|
maxLines: 6,
|
||||||
|
decoration: InputDecoration(labelText: l.dBankDetails),
|
||||||
|
onChanged: (_) => setState(() => result = null),
|
||||||
|
),
|
||||||
|
if (result != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (result!.conflicts.isNotEmpty) Text(l.dPasteConflict),
|
||||||
|
for (final e in result!.values.entries)
|
||||||
|
Text('${labels[e.key]}: ${e.value}'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(l.wClose),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: result == null
|
||||||
|
? () => setState(() => result = BankPaste.parse(text.text))
|
||||||
|
: result!.values.isEmpty
|
||||||
|
? null
|
||||||
|
: () => Navigator.pop(context, result),
|
||||||
|
child: Text(result == null ? l.dPasteReview : l.dPasteApply),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -289,5 +289,95 @@
|
|||||||
"type": "int"
|
"type": "int"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dTitle": "Payout methods",
|
||||||
|
"dAdd": "Add payout method",
|
||||||
|
"dChoose": "Payout destination",
|
||||||
|
"dNew": "Use a new destination",
|
||||||
|
"dSave": "Save payout method",
|
||||||
|
"dContinue": "Continue",
|
||||||
|
"dSaveFuture": "Save for future payouts",
|
||||||
|
"dSaveHelp": "Continue will also save this destination.",
|
||||||
|
"dNickname": "Name for this method (optional)",
|
||||||
|
"dSaved": "Payout method saved.",
|
||||||
|
"dSaveFailed": "Couldn’t save these details. Your input is still here.",
|
||||||
|
"dRetrySave": "Retry saving",
|
||||||
|
"dWithoutSave": "Continue without saving",
|
||||||
|
"dUncertain": "We couldn’t confirm saving. This method may already be saved.",
|
||||||
|
"dCheckSave": "Check save status",
|
||||||
|
"dDuplicate": "This destination is already saved. Use the existing method.",
|
||||||
|
"dUseExisting": "Use existing method",
|
||||||
|
"dCrypto": "Crypto address",
|
||||||
|
"dCard": "Bank card",
|
||||||
|
"dBank": "Russian bank account",
|
||||||
|
"dType": "Destination type",
|
||||||
|
"dSearch": "Search saved methods",
|
||||||
|
"dEmpty": "No saved methods in this category.",
|
||||||
|
"dNoMatches": "No matching methods.",
|
||||||
|
"dEligible": "Available",
|
||||||
|
"dTemporary": "Temporarily unavailable for this payout.",
|
||||||
|
"dAction": "Action required",
|
||||||
|
"dUnsupported": "This method isn’t supported for this payout.",
|
||||||
|
"dUnavailable": "Couldn’t load methods. Try again.",
|
||||||
|
"dNoCapabilities": "No destination types are currently available.",
|
||||||
|
"dDemo": "Demo destinations — saving lasts until reload. No real payout or card collection is connected.",
|
||||||
|
"dAsset": "Receiving asset",
|
||||||
|
"dNetwork": "Network",
|
||||||
|
"dAddress": "Wallet address",
|
||||||
|
"dMemo": "Memo / destination tag",
|
||||||
|
"dNoMemo": "No memo / tag",
|
||||||
|
"dCryptoHelp": "Use the network supported by your receiving wallet. A valid address format does not guarantee delivery.",
|
||||||
|
"dHolder": "Account holder",
|
||||||
|
"dFirst": "First name",
|
||||||
|
"dLast": "Last name",
|
||||||
|
"dPatronymic": "Patronymic (optional)",
|
||||||
|
"dBankDetails": "Bank details",
|
||||||
|
"dBankSearch": "Bank name or BIK",
|
||||||
|
"dAccount": "20-digit account number",
|
||||||
|
"dCorrespondent": "Correspondent account",
|
||||||
|
"dChangeBank": "Change bank",
|
||||||
|
"dBankUnknown": "Bank not found. Check the name or BIK.",
|
||||||
|
"dBankFailure": "Bank directory is unavailable. Your input is preserved.",
|
||||||
|
"dBankChanged": "Bank details changed. Review them and add a replacement destination.",
|
||||||
|
"dPaste": "Paste bank details",
|
||||||
|
"dPasteHelp": "Paste labelled bank details. Review recognized values before applying them.",
|
||||||
|
"dPasteReview": "Review recognized details",
|
||||||
|
"dPasteApply": "Use these details",
|
||||||
|
"dPasteConflict": "Conflicting values were found. Correct the affected fields in the form.",
|
||||||
|
"dInvalid": "Check the entered details and correct the highlighted field.",
|
||||||
|
"dCardDemo": "Choose a simulated secure card capture. Do not enter a real card number.",
|
||||||
|
"dCardReady": "Demo card •••• 4242",
|
||||||
|
"dCardRecover": "Demo card requiring renewed authorization",
|
||||||
|
"dCardExpired": "This card has expired. Add a replacement card.",
|
||||||
|
"dAuthorization": "Authorization needs updating. Follow the service-supported recovery or add a replacement.",
|
||||||
|
"dRemoved": "This method was removed. Your draft is preserved. Choose a destination to continue.",
|
||||||
|
"dConflict": "This method changed in another session. Refresh and review the latest version.",
|
||||||
|
"dRename": "Rename",
|
||||||
|
"dRemove": "Remove",
|
||||||
|
"dReplace": "Add replacement",
|
||||||
|
"dRemoveTitle": "Remove payout method?",
|
||||||
|
"dRemoveHelp": "Removed methods cannot be used for new payouts. Submitted payouts and required financial records are retained.",
|
||||||
|
"dDetails": "Destination details",
|
||||||
|
"dReload": "Refresh methods",
|
||||||
|
"dSelect": "Select destination",
|
||||||
|
"dQuoteChanged": "Payout terms changed. Your draft is preserved. Select a destination and review a new quote.",
|
||||||
|
"dOwn": "Use a wallet, card, or account that belongs to you.",
|
||||||
|
"dSavedIneligible": "Saved. This method is unavailable for the current payout.",
|
||||||
|
"dSaveFailedLabel": "Saving failed",
|
||||||
|
"dNoEligible": "No methods are available for this payout. You can add a destination or refresh.",
|
||||||
|
"dDemoTerms": "Demo receiving amounts use a fixed 1:1 ratio. No real payout is made.",
|
||||||
|
"dTiming": "Expected processing time: {seconds} seconds",
|
||||||
|
"@dTiming": {
|
||||||
|
"placeholders": {
|
||||||
|
"seconds": {
|
||||||
|
"type": "int"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dBik": "BIK",
|
||||||
|
"dBankMismatch": "Pasted bank details differ from the selected bank. Check the details before continuing.",
|
||||||
|
"dManagementUnavailable": "Saving payout methods isn’t available yet. You can still use your configured payout destination.",
|
||||||
|
"dUpdateAuthorization": "Update authorization",
|
||||||
|
"dAuthorizationUpdated": "Authorization updated.",
|
||||||
|
"dReplacementRequired": "Couldn’t confirm this is the same card. Add a replacement destination."
|
||||||
}
|
}
|
||||||
@@ -289,5 +289,95 @@
|
|||||||
"type": "int"
|
"type": "int"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dTitle": "Способы вывода",
|
||||||
|
"dAdd": "Добавить способ вывода",
|
||||||
|
"dChoose": "Реквизиты для вывода",
|
||||||
|
"dNew": "Использовать новые реквизиты",
|
||||||
|
"dSave": "Сохранить способ вывода",
|
||||||
|
"dContinue": "Продолжить",
|
||||||
|
"dSaveFuture": "Сохранить для будущих выводов",
|
||||||
|
"dSaveHelp": "При продолжении реквизиты также будут сохранены.",
|
||||||
|
"dNickname": "Название способа вывода (необязательно)",
|
||||||
|
"dSaved": "Способ вывода сохранён.",
|
||||||
|
"dSaveFailed": "Не удалось сохранить реквизиты. Введённые данные остались в форме.",
|
||||||
|
"dRetrySave": "Повторить сохранение",
|
||||||
|
"dWithoutSave": "Продолжить без сохранения",
|
||||||
|
"dUncertain": "Не удалось подтвердить сохранение. Возможно, способ уже сохранён.",
|
||||||
|
"dCheckSave": "Проверить статус сохранения",
|
||||||
|
"dDuplicate": "Эти реквизиты уже сохранены. Используйте существующий способ.",
|
||||||
|
"dUseExisting": "Использовать существующий способ",
|
||||||
|
"dCrypto": "Криптоадрес",
|
||||||
|
"dCard": "Банковская карта",
|
||||||
|
"dBank": "Российский банковский счёт",
|
||||||
|
"dType": "Тип реквизитов",
|
||||||
|
"dSearch": "Поиск сохранённых способов",
|
||||||
|
"dEmpty": "В этой категории нет сохранённых способов.",
|
||||||
|
"dNoMatches": "Подходящие способы не найдены.",
|
||||||
|
"dEligible": "Доступно",
|
||||||
|
"dTemporary": "Временно недоступно для этого вывода.",
|
||||||
|
"dAction": "Требуется действие",
|
||||||
|
"dUnsupported": "Этот способ не поддерживается для данного вывода.",
|
||||||
|
"dUnavailable": "Не удалось загрузить способы. Повторите попытку.",
|
||||||
|
"dNoCapabilities": "Сейчас нет доступных типов реквизитов.",
|
||||||
|
"dDemo": "Демонстрационные реквизиты — сохранение действует до перезагрузки. Реальный вывод и ввод карт не подключены.",
|
||||||
|
"dAsset": "Получаемый актив",
|
||||||
|
"dNetwork": "Сеть",
|
||||||
|
"dAddress": "Адрес кошелька",
|
||||||
|
"dMemo": "Memo / тег назначения",
|
||||||
|
"dNoMemo": "Без memo / тега",
|
||||||
|
"dCryptoHelp": "Выберите сеть, поддерживаемую кошельком получателя. Верный формат адреса не гарантирует зачисление.",
|
||||||
|
"dHolder": "Владелец счёта",
|
||||||
|
"dFirst": "Имя",
|
||||||
|
"dLast": "Фамилия",
|
||||||
|
"dPatronymic": "Отчество (необязательно)",
|
||||||
|
"dBankDetails": "Банковские реквизиты",
|
||||||
|
"dBankSearch": "Название банка или БИК",
|
||||||
|
"dAccount": "20-значный номер счёта",
|
||||||
|
"dCorrespondent": "Корреспондентский счёт",
|
||||||
|
"dChangeBank": "Изменить банк",
|
||||||
|
"dBankUnknown": "Банк не найден. Проверьте название или БИК.",
|
||||||
|
"dBankFailure": "Справочник банков недоступен. Введённые данные сохранены.",
|
||||||
|
"dBankChanged": "Банковские реквизиты изменились. Проверьте их и добавьте новые реквизиты.",
|
||||||
|
"dPaste": "Вставить банковские реквизиты",
|
||||||
|
"dPasteHelp": "Вставьте реквизиты с названиями полей. Проверьте распознанные значения перед применением.",
|
||||||
|
"dPasteReview": "Проверить распознанные реквизиты",
|
||||||
|
"dPasteApply": "Использовать эти реквизиты",
|
||||||
|
"dPasteConflict": "Найдены противоречивые значения. Исправьте соответствующие поля в форме.",
|
||||||
|
"dInvalid": "Проверьте введённые реквизиты и исправьте выделенное поле.",
|
||||||
|
"dCardDemo": "Выберите демонстрационный результат безопасного ввода карты. Не вводите настоящий номер карты.",
|
||||||
|
"dCardReady": "Демо-карта •••• 4242",
|
||||||
|
"dCardRecover": "Демо-карта с истёкшей авторизацией",
|
||||||
|
"dCardExpired": "Срок действия карты истёк. Добавьте новую карту.",
|
||||||
|
"dAuthorization": "Необходимо обновить авторизацию. Используйте доступное восстановление или добавьте новые реквизиты.",
|
||||||
|
"dRemoved": "Этот способ удалён. Черновик сохранён. Выберите реквизиты для продолжения.",
|
||||||
|
"dConflict": "Этот способ изменён в другой сессии. Обновите и проверьте актуальную версию.",
|
||||||
|
"dRename": "Переименовать",
|
||||||
|
"dRemove": "Удалить",
|
||||||
|
"dReplace": "Добавить новые реквизиты",
|
||||||
|
"dRemoveTitle": "Удалить способ вывода?",
|
||||||
|
"dRemoveHelp": "Удалённые способы недоступны для новых выводов. Отправленные заявки и обязательные финансовые записи сохраняются.",
|
||||||
|
"dDetails": "Реквизиты",
|
||||||
|
"dReload": "Обновить способы",
|
||||||
|
"dSelect": "Выбрать реквизиты",
|
||||||
|
"dQuoteChanged": "Условия вывода изменились. Черновик сохранён. Выберите реквизиты и проверьте новую котировку.",
|
||||||
|
"dOwn": "Используйте принадлежащий вам кошелёк, карту или счёт.",
|
||||||
|
"dSavedIneligible": "Сохранено. Этот способ недоступен для текущего вывода.",
|
||||||
|
"dSaveFailedLabel": "Ошибка сохранения",
|
||||||
|
"dNoEligible": "Для этого вывода нет доступных способов. Добавьте реквизиты или обновите список.",
|
||||||
|
"dDemoTerms": "Демонстрационные суммы зачисления рассчитаны по фиксированному курсу 1:1. Реальный вывод не выполняется.",
|
||||||
|
"dTiming": "Ожидаемое время обработки: {seconds} сек.",
|
||||||
|
"@dTiming": {
|
||||||
|
"placeholders": {
|
||||||
|
"seconds": {
|
||||||
|
"type": "int"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dBik": "БИК",
|
||||||
|
"dBankMismatch": "Вставленные реквизиты отличаются от выбранного банка. Проверьте их перед продолжением.",
|
||||||
|
"dManagementUnavailable": "Сохранение способов вывода пока недоступно. Вы можете использовать ранее настроенные реквизиты для вывода.",
|
||||||
|
"dUpdateAuthorization": "Обновить авторизацию",
|
||||||
|
"dAuthorizationUpdated": "Авторизация обновлена.",
|
||||||
|
"dReplacementRequired": "Не удалось подтвердить, что это та же карта. Добавьте новые реквизиты."
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,7 @@ class SxchngeApp extends StatefulWidget {
|
|||||||
this.identityService,
|
this.identityService,
|
||||||
this.limitsService,
|
this.limitsService,
|
||||||
this.walletService,
|
this.walletService,
|
||||||
|
this.destinationService,
|
||||||
this.twoFactorService,
|
this.twoFactorService,
|
||||||
this.photoService = const FilePhotoService(),
|
this.photoService = const FilePhotoService(),
|
||||||
this.loginService = const DummyLoginService(),
|
this.loginService = const DummyLoginService(),
|
||||||
@@ -49,6 +50,7 @@ class SxchngeApp extends StatefulWidget {
|
|||||||
final IdentityService? identityService;
|
final IdentityService? identityService;
|
||||||
final LimitsService? limitsService;
|
final LimitsService? limitsService;
|
||||||
final WalletService? walletService;
|
final WalletService? walletService;
|
||||||
|
final DestinationService? destinationService;
|
||||||
final TwoFactorService? twoFactorService;
|
final TwoFactorService? twoFactorService;
|
||||||
final PhotoService photoService;
|
final PhotoService photoService;
|
||||||
final LoginService loginService;
|
final LoginService loginService;
|
||||||
@@ -89,10 +91,17 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
initialLocale: _locale.locale.languageCode,
|
initialLocale: _locale.locale.languageCode,
|
||||||
factors: _authFactors,
|
factors: _authFactors,
|
||||||
));
|
));
|
||||||
|
late final _destinationService =
|
||||||
|
widget.destinationService ??
|
||||||
|
(_walletService.isSimulated && _walletService is DummyWalletService
|
||||||
|
? _walletService.destinations
|
||||||
|
: const UnavailableDestinationService());
|
||||||
|
late final _destinations = DestinationsProvider(_destinationService);
|
||||||
late final _wallet = WalletProvider(
|
late final _wallet = WalletProvider(
|
||||||
_walletService,
|
_walletService,
|
||||||
_analytics,
|
_analytics,
|
||||||
factors: _factors,
|
factors: _factors,
|
||||||
|
destinations: _destinationService,
|
||||||
);
|
);
|
||||||
late final _limits =
|
late final _limits =
|
||||||
widget.limitsService ??
|
widget.limitsService ??
|
||||||
@@ -131,6 +140,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
if (customer != _walletCustomer) {
|
if (customer != _walletCustomer) {
|
||||||
_walletCustomer = customer;
|
_walletCustomer = customer;
|
||||||
_wallet.reset();
|
_wallet.reset();
|
||||||
|
_destinations.reset();
|
||||||
}
|
}
|
||||||
final path = _router.routeInformationProvider.value.uri.path;
|
final path = _router.routeInformationProvider.value.uri.path;
|
||||||
if (path == '/' && _previousPath != '/' && _previousPath != '/two-factor') {
|
if (path == '/' && _previousPath != '/' && _previousPath != '/two-factor') {
|
||||||
@@ -145,6 +155,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
_router.dispose();
|
_router.dispose();
|
||||||
_journeys.dispose();
|
_journeys.dispose();
|
||||||
_wallet.dispose();
|
_wallet.dispose();
|
||||||
|
_destinations.dispose();
|
||||||
if (_walletService is BackendWalletService) {
|
if (_walletService is BackendWalletService) {
|
||||||
_walletService.close();
|
_walletService.close();
|
||||||
}
|
}
|
||||||
@@ -161,6 +172,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
Widget build(BuildContext context) => MultiProvider(
|
Widget build(BuildContext context) => MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider.value(value: _wallet),
|
ChangeNotifierProvider.value(value: _wallet),
|
||||||
|
ChangeNotifierProvider.value(value: _destinations),
|
||||||
ChangeNotifierProvider.value(value: _security),
|
ChangeNotifierProvider.value(value: _security),
|
||||||
Provider<TwoFactorService>.value(value: _factors),
|
Provider<TwoFactorService>.value(value: _factors),
|
||||||
Provider<JourneyRecorder>.value(value: _journeys),
|
Provider<JourneyRecorder>.value(value: _journeys),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
import '../auth/page.dart';
|
import '../auth/page.dart';
|
||||||
|
import '../destinations/list.dart';
|
||||||
import '../kyc/page.dart';
|
import '../kyc/page.dart';
|
||||||
import '../security/challenge.dart';
|
import '../security/challenge.dart';
|
||||||
import '../verification/page.dart';
|
import '../verification/page.dart';
|
||||||
@@ -82,6 +83,12 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
builder: (context, state) => const SettingsPage(),
|
builder: (context, state) => const SettingsPage(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'payout-methods',
|
||||||
|
builder: (context, state) => const DestinationsPage(),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/top-up',
|
path: '/top-up',
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ String fundsText(
|
|||||||
_ => l.wFailedOutgoing,
|
_ => l.wFailedOutgoing,
|
||||||
};
|
};
|
||||||
String walletError(AppLocalizations l, WalletError e) => switch (e) {
|
String walletError(AppLocalizations l, WalletError e) => switch (e) {
|
||||||
|
WalletError.destinationChanged => l.dQuoteChanged,
|
||||||
WalletError.invalidAmount => l.wInvalidAmount,
|
WalletError.invalidAmount => l.wInvalidAmount,
|
||||||
WalletError.insufficientFunds => l.wInsufficient,
|
WalletError.insufficientFunds => l.wInsufficient,
|
||||||
WalletError.limitExceeded => l.wLimitError,
|
WalletError.limitExceeded => l.wLimitError,
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ class SettingsPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.account_balance_outlined),
|
||||||
|
title: Text(l.dTitle),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => context.push('/settings/payout-methods'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
const SecuritySettings(),
|
const SecuritySettings(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
@@ -6,6 +8,8 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import '../destinations/details.dart';
|
||||||
|
|
||||||
import 'copy.dart';
|
import 'copy.dart';
|
||||||
|
|
||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
@@ -49,12 +53,22 @@ class TransferSummary extends StatelessWidget {
|
|||||||
q.request.kind == TransferKind.payout
|
q.request.kind == TransferKind.payout
|
||||||
? l.wRecipientGets
|
? l.wRecipientGets
|
||||||
: l.wCredit,
|
: l.wCredit,
|
||||||
money(context, b, q.credit),
|
q.payoutTerms == null
|
||||||
|
? money(context, b, q.credit)
|
||||||
|
: '${NumberFormat.decimalPatternDigits(locale: Localizations.localeOf(context).toLanguageTag(), decimalDigits: q.payoutTerms!.precision).format(q.payoutTerms!.amount / pow(10, q.payoutTerms!.precision))} ${q.payoutTerms!.assetLabel}',
|
||||||
large: true,
|
large: true,
|
||||||
),
|
),
|
||||||
|
if (q.payoutTerms != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(l.dTiming(q.payoutTerms!.expectedSeconds)),
|
||||||
|
if (p.service.isSimulated) Text(l.dDemoTerms),
|
||||||
|
],
|
||||||
if (q.request.kind == TransferKind.payout) ...[
|
if (q.request.kind == TransferKind.payout) ...[
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
Text(l.wRecipient),
|
Text(l.wRecipient),
|
||||||
|
if (q.payoutSnapshot != null)
|
||||||
|
DestinationDetailsView(details: q.payoutSnapshot!)
|
||||||
|
else
|
||||||
SelectableText(q.request.recipient),
|
SelectableText(q.request.recipient),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
|
|||||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
import '../security/challenge.dart';
|
import '../security/challenge.dart';
|
||||||
|
import '../destinations/list.dart';
|
||||||
|
|
||||||
import 'copy.dart';
|
import 'copy.dart';
|
||||||
import 'summary.dart';
|
import 'summary.dart';
|
||||||
@@ -32,7 +33,7 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
source: widget.source,
|
source: widget.source,
|
||||||
);
|
);
|
||||||
final _amount = TextEditingController();
|
final _amount = TextEditingController();
|
||||||
final _recipient = TextEditingController();
|
|
||||||
final _focus = FocusNode();
|
final _focus = FocusNode();
|
||||||
final _content = GlobalKey();
|
final _content = GlobalKey();
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
@@ -41,7 +42,13 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) _provider.addListener(_changed);
|
if (mounted) {
|
||||||
|
_provider.addListener(_changed);
|
||||||
|
final destinations = context.read<DestinationsProvider>();
|
||||||
|
if (destinations.capabilities == null && !destinations.loading) {
|
||||||
|
unawaited(destinations.refresh());
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
if (mounted && _provider.quote != null) setState(() {});
|
if (mounted && _provider.quote != null) setState(() {});
|
||||||
@@ -118,7 +125,7 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
_provider.removeListener(_changed);
|
_provider.removeListener(_changed);
|
||||||
_provider.dispose();
|
_provider.dispose();
|
||||||
_amount.dispose();
|
_amount.dispose();
|
||||||
_recipient.dispose();
|
|
||||||
_focus.dispose();
|
_focus.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -134,14 +141,16 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
final accounts = wallet.data?.accounts ?? [];
|
final accounts = wallet.data?.accounts ?? [];
|
||||||
final source = wallet.account(p.source);
|
final source = wallet.account(p.source);
|
||||||
final live = !wallet.service.isSimulated;
|
final live = !wallet.service.isSimulated;
|
||||||
|
final destinations = context.watch<DestinationsProvider>();
|
||||||
|
final legacyPayout =
|
||||||
|
live &&
|
||||||
|
!(destinations.capabilities?.types.isNotEmpty ?? false) &&
|
||||||
|
destinations.methods.isEmpty;
|
||||||
final expiresIn =
|
final expiresIn =
|
||||||
p.quote?.expiresAt.difference(DateTime.now()).inSeconds ?? 0;
|
p.quote?.expiresAt.difference(DateTime.now()).inSeconds ?? 0;
|
||||||
final unavailableMethod =
|
final unavailableMethod =
|
||||||
live &&
|
live &&
|
||||||
((widget.kind == TransferKind.payout &&
|
((widget.kind == TransferKind.topUp && !(source?.canFund ?? false)));
|
||||||
(source?.payoutMethod.isEmpty ?? true)) ||
|
|
||||||
(widget.kind == TransferKind.topUp &&
|
|
||||||
!(source?.canFund ?? false)));
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -235,7 +244,11 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
FilledButton(
|
FilledButton(
|
||||||
key: const ValueKey('transfer-confirm'),
|
key: const ValueKey('transfer-confirm'),
|
||||||
onPressed: p.busy ? null : _confirm,
|
onPressed: p.busy ? null : _confirm,
|
||||||
child: Text(l.wConfirm),
|
child: Text(
|
||||||
|
widget.kind == TransferKind.payout
|
||||||
|
? '${l.wPayout} ${money(context, source!, p.quote!.debit)}'
|
||||||
|
: l.wConfirm,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: p.busy ? null : p.edit,
|
onPressed: p.busy ? null : p.edit,
|
||||||
@@ -316,23 +329,66 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
),
|
),
|
||||||
if (widget.kind == TransferKind.payout) ...[
|
if (widget.kind == TransferKind.payout) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
if (live && source?.payoutMethod.isNotEmpty == true)
|
if (legacyPayout) ...[
|
||||||
InputDecorator(
|
if (source?.payoutMethod.isNotEmpty == true)
|
||||||
decoration: InputDecoration(
|
OutlinedButton.icon(
|
||||||
labelText: l.wRecipient,
|
key: const ValueKey(
|
||||||
|
'choose-configured-destination',
|
||||||
),
|
),
|
||||||
child: Text(source!.payoutLabel),
|
icon: const Icon(
|
||||||
|
Icons.account_balance_outlined,
|
||||||
),
|
),
|
||||||
if (!live)
|
label: Text(source!.payoutLabel),
|
||||||
TextField(
|
onPressed: p.busy
|
||||||
key: const ValueKey('transfer-recipient'),
|
? null
|
||||||
controller: _recipient,
|
: () => p.change(
|
||||||
enabled: !p.busy,
|
recipient: source.payoutMethod,
|
||||||
decoration: InputDecoration(
|
payoutLabel: source.payoutLabel,
|
||||||
labelText: l.wRecipient,
|
|
||||||
hintText: l.wRecipientHint,
|
|
||||||
),
|
),
|
||||||
onChanged: (v) => p.change(recipient: v),
|
),
|
||||||
|
if (source?.payoutMethod.isEmpty ?? true)
|
||||||
|
Text(l.wNoPayoutMethod),
|
||||||
|
] else
|
||||||
|
OutlinedButton.icon(
|
||||||
|
key: const ValueKey(
|
||||||
|
'choose-payout-destination',
|
||||||
|
),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.account_balance_wallet_outlined,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
p.payoutDestination == null
|
||||||
|
? l.dSelect
|
||||||
|
: p.payoutLabel,
|
||||||
|
),
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final selected =
|
||||||
|
await Navigator.push<
|
||||||
|
DestinationInput
|
||||||
|
>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
DestinationsPage(
|
||||||
|
payoutContext:
|
||||||
|
PayoutContext(
|
||||||
|
p.source,
|
||||||
|
amount: source
|
||||||
|
?.parse(
|
||||||
|
p.amount,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!context.mounted ||
|
||||||
|
selected == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await p.selectPayout(selected);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (unavailableMethod) ...[
|
if (unavailableMethod) ...[
|
||||||
@@ -346,7 +402,13 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
key: const ValueKey('transfer-review'),
|
key: const ValueKey('transfer-review'),
|
||||||
onPressed: p.busy || unavailableMethod
|
onPressed:
|
||||||
|
p.busy ||
|
||||||
|
unavailableMethod ||
|
||||||
|
(widget.kind == TransferKind.payout &&
|
||||||
|
(legacyPayout
|
||||||
|
? p.recipient.isEmpty
|
||||||
|
: p.payoutDestination == null))
|
||||||
? null
|
? null
|
||||||
: p.review,
|
: p.review,
|
||||||
child: Text(l.wReview),
|
child: Text(l.wReview),
|
||||||
|
|||||||
@@ -92,6 +92,14 @@ void main() {
|
|||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
|
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
tester
|
tester
|
||||||
.widget<TextFormField>(find.byKey(const ValueKey('email')))
|
.widget<TextFormField>(find.byKey(const ValueKey('email')))
|
||||||
@@ -153,6 +161,14 @@ void main() {
|
|||||||
'/verification-sent',
|
'/verification-sent',
|
||||||
);
|
);
|
||||||
expect(backend.sends, 1);
|
expect(backend.sends, 1);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
find.textContaining('a verification email is on its way'),
|
find.textContaining('a verification email is on its way'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
@@ -198,6 +214,14 @@ void main() {
|
|||||||
find.text('Сервис недоступен. Попробуйте ещё раз.'),
|
find.text('Сервис недоступен. Попробуйте ещё раз.'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.tap(button);
|
await tester.tap(button);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(service.calls, hasLength(2));
|
expect(service.calls, hasLength(2));
|
||||||
@@ -207,6 +231,12 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
||||||
expect(find.byKey(const ValueKey('auth-error')), findsNothing);
|
expect(find.byKey(const ValueKey('auth-error')), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -237,6 +267,14 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const ValueKey('confirmation')), findsOneWidget);
|
expect(find.byKey(const ValueKey('confirmation')), findsOneWidget);
|
||||||
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -55,6 +55,12 @@ void main() {
|
|||||||
await submit(tester);
|
await submit(tester);
|
||||||
expect(find.text('Enter your email address.'), findsOneWidget);
|
expect(find.text('Enter your email address.'), findsOneWidget);
|
||||||
expect(find.text('Enter your password.'), findsOneWidget);
|
expect(find.text('Enter your password.'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log.where(
|
||||||
|
(call) => call.method == 'TextInput.finishAutofillContext',
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.byKey(const ValueKey('email')),
|
find.byKey(const ValueKey('email')),
|
||||||
'test@example.com',
|
'test@example.com',
|
||||||
@@ -79,6 +85,12 @@ void main() {
|
|||||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -104,12 +116,26 @@ void main() {
|
|||||||
);
|
);
|
||||||
await submit(tester);
|
await submit(tester);
|
||||||
expect(find.text('Passwords do not match.'), findsOneWidget);
|
expect(find.text('Passwords do not match.'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.byKey(const ValueKey('confirmation')),
|
find.byKey(const ValueKey('confirmation')),
|
||||||
'example-password',
|
'example-password',
|
||||||
);
|
);
|
||||||
await submit(tester);
|
await submit(tester);
|
||||||
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.ensureVisible(
|
await tester.ensureVisible(
|
||||||
find.byKey(const ValueKey('demo-confirmation')),
|
find.byKey(const ValueKey('demo-confirmation')),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/main.dart';
|
||||||
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
|
Finder field(String key) => find.byKey(ValueKey(key));
|
||||||
|
|
||||||
|
EditableTextState editor(WidgetTester tester, String key) =>
|
||||||
|
tester.state<EditableTextState>(
|
||||||
|
find.descendant(of: field(key), matching: find.byType(EditableText)),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final language in ['en', 'ru']) {
|
||||||
|
testWidgets(
|
||||||
|
'platform autofill fills the signup credential group ($language)',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1440, 1200);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
localeProvider: LocaleProvider(initialLocale: Locale(language)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(
|
||||||
|
find.text(language == 'ru' ? 'Регистрация' : 'Sign up'),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.showKeyboard(field('email'));
|
||||||
|
|
||||||
|
final configuration = tester.testTextInput.setClientArgs!;
|
||||||
|
expect(
|
||||||
|
configuration['inputType']['name'],
|
||||||
|
'TextInputType.emailAddress',
|
||||||
|
);
|
||||||
|
final fields = (configuration['fields'] as List).cast<Map>();
|
||||||
|
expect(fields, hasLength(4));
|
||||||
|
expect(
|
||||||
|
fields.map((field) => field['autofill']['hints'].first),
|
||||||
|
containsAll(['name', 'username', 'newPassword', 'newPassword']),
|
||||||
|
);
|
||||||
|
|
||||||
|
const values = {
|
||||||
|
'name': 'Alex Example',
|
||||||
|
'email': 'alex@example.com',
|
||||||
|
'password': 'generated-example-password',
|
||||||
|
'confirmation': 'generated-example-password',
|
||||||
|
};
|
||||||
|
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
|
||||||
|
SystemChannels.textInput.name,
|
||||||
|
SystemChannels.textInput.codec.encodeMethodCall(
|
||||||
|
MethodCall('TextInputClient.updateEditingStateWithTag', [
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
for (final entry in values.entries)
|
||||||
|
editor(tester, entry.key).autofillId: TextEditingValue(
|
||||||
|
text: entry.value,
|
||||||
|
).toJSON(),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
(_) {},
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
for (final entry in values.entries) {
|
||||||
|
expect(
|
||||||
|
editor(tester, entry.key).currentTextEditingValue.text,
|
||||||
|
entry.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A visibility change must retain both the supplied value and its role.
|
||||||
|
await tester.tap(
|
||||||
|
find.byTooltip(
|
||||||
|
language == 'ru' ? 'Показать пароль' : 'Show password',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(editor(tester, 'password').widget.obscureText, isFalse);
|
||||||
|
expect(editor(tester, 'password').widget.autofillHints, [
|
||||||
|
'newPassword',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
editor(tester, 'password').currentTextEditingValue.text,
|
||||||
|
values['password'],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text(language == 'ru' ? 'Войти' : 'Log in'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.showKeyboard(field('email'));
|
||||||
|
final loginFields =
|
||||||
|
(tester.testTextInput.setClientArgs!['fields'] as List).cast<Map>();
|
||||||
|
expect(loginFields, hasLength(2));
|
||||||
|
expect(loginFields.map((field) => field['autofill']['hints'].first), [
|
||||||
|
'username',
|
||||||
|
'password',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
editor(tester, 'password').currentTextEditingValue.text,
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('leaving an unfinished auth form cancels saving', (tester) async {
|
||||||
|
await tester.pumpWidget(const SxchngeApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(field('email'), 'alex@example.com');
|
||||||
|
await tester.enterText(field('password'), 'unfinished-password');
|
||||||
|
tester.testTextInput.log.clear();
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
const testDestination = CryptoDestination(
|
||||||
|
assetId: 'demo:ethereum:native',
|
||||||
|
networkId: 'eip155:1',
|
||||||
|
address: '0x1234567890123456789012345678901234567890',
|
||||||
|
assetLabel: 'ETH',
|
||||||
|
networkLabel: 'Ethereum',
|
||||||
|
);
|
||||||
|
Future<void> seedDestination(DummyDestinationService service) async {
|
||||||
|
await service.create(
|
||||||
|
testDestination,
|
||||||
|
'Test wallet',
|
||||||
|
'test-destination-creation',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> selectPayout(WidgetTester t) async {
|
||||||
|
final button = find.byKey(const ValueKey('choose-payout-destination'));
|
||||||
|
await t.ensureVisible(button);
|
||||||
|
await t.tap(button);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
final row = find.text('Test wallet');
|
||||||
|
await t.ensureVisible(row);
|
||||||
|
await t.tap(row);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/main.dart';
|
||||||
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
|
import 'destination_helpers.dart';
|
||||||
|
|
||||||
|
Future<void> click(WidgetTester t, Finder finder) async {
|
||||||
|
await t.ensureVisible(finder);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await t.tap(finder);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<DummyWalletService> open(
|
||||||
|
WidgetTester t, {
|
||||||
|
String path = '/settings/payout-methods',
|
||||||
|
String locale = 'en',
|
||||||
|
DummyDestinationService? destinations,
|
||||||
|
DummyAnalyticsService? analytics,
|
||||||
|
}) async {
|
||||||
|
t.platformDispatcher.defaultRouteNameTestValue = path;
|
||||||
|
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||||
|
final wallet = DummyWalletService(
|
||||||
|
DummyIdentityService(delay: Duration.zero),
|
||||||
|
delay: Duration.zero,
|
||||||
|
destinations: destinations,
|
||||||
|
initialLocale: locale,
|
||||||
|
settlement: Duration.zero,
|
||||||
|
);
|
||||||
|
await t.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
walletService: wallet,
|
||||||
|
analyticsService: analytics,
|
||||||
|
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
addTearDown(() => t.pumpWidget(const SizedBox.shrink()));
|
||||||
|
return wallet;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cryptoForm(WidgetTester t, {bool payout = false}) async {
|
||||||
|
await click(
|
||||||
|
t,
|
||||||
|
find.text(payout ? 'Use a new destination' : 'Add payout method').first,
|
||||||
|
);
|
||||||
|
await click(t, find.byType(DropdownButtonFormField<DestinationType>));
|
||||||
|
await click(t, find.text('Crypto address').last);
|
||||||
|
await click(t, find.byKey(const ValueKey('asset-null')));
|
||||||
|
await click(t, find.text('ETH').last);
|
||||||
|
await click(t, find.byType(DropdownButtonFormField<String>).last);
|
||||||
|
await click(t, find.text('Ethereum').last);
|
||||||
|
await t.enterText(
|
||||||
|
find.byKey(const ValueKey('destination-address')),
|
||||||
|
testDestination.address,
|
||||||
|
);
|
||||||
|
await t.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('Settings saves without nickname and exposes renamed method', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
final wallet = await open(t);
|
||||||
|
await cryptoForm(t);
|
||||||
|
expect(find.text('Save for future payouts'), findsNothing);
|
||||||
|
await click(t, find.byKey(const ValueKey('destination-continue')));
|
||||||
|
expect((await wallet.destinations.list()).length, 1);
|
||||||
|
expect(find.textContaining('ETH · Ethereum'), findsOneWidget);
|
||||||
|
await click(t, find.textContaining('ETH · Ethereum'));
|
||||||
|
await t.enterText(
|
||||||
|
find.byKey(const ValueKey('destination-nickname')),
|
||||||
|
'My own wallet',
|
||||||
|
);
|
||||||
|
await click(t, find.text('Rename'));
|
||||||
|
expect((await wallet.destinations.list()).single.nickname, 'My own wallet');
|
||||||
|
await click(t, find.text('Remove'));
|
||||||
|
expect(find.textContaining('Submitted payouts'), findsOneWidget);
|
||||||
|
await click(t, find.widgetWithText(FilledButton, 'Remove'));
|
||||||
|
expect(await wallet.destinations.list(), isEmpty);
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
});
|
||||||
|
testWidgets('new one-time payout keeps saving unchecked and never persists', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
final wallet = await open(t, path: '/payout');
|
||||||
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||||
|
await click(t, find.byKey(const ValueKey('choose-payout-destination')));
|
||||||
|
await cryptoForm(t, payout: true);
|
||||||
|
expect(
|
||||||
|
t.widget<CheckboxListTile>(find.byType(CheckboxListTile)).value,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const ValueKey('destination-nickname')), findsNothing);
|
||||||
|
await click(t, find.byKey(const ValueKey('destination-continue')));
|
||||||
|
await click(t, find.byKey(const ValueKey('transfer-review')));
|
||||||
|
expect(find.textContaining(testDestination.address), findsOneWidget);
|
||||||
|
await click(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||||
|
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
||||||
|
expect(await wallet.destinations.list(), isEmpty);
|
||||||
|
});
|
||||||
|
testWidgets(
|
||||||
|
'optional saving failure permits explicit one-time continuation',
|
||||||
|
(t) async {
|
||||||
|
final service = DummyDestinationService();
|
||||||
|
final wallet = await open(t, path: '/payout', destinations: service);
|
||||||
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||||
|
await click(t, find.byKey(const ValueKey('choose-payout-destination')));
|
||||||
|
await cryptoForm(t, payout: true);
|
||||||
|
await click(t, find.text('Save for future payouts'));
|
||||||
|
service.nextSaveError = DestinationIssue.conflict;
|
||||||
|
await click(t, find.byKey(const ValueKey('destination-continue')));
|
||||||
|
expect(find.textContaining('Couldn’t save'), findsOneWidget);
|
||||||
|
await click(t, find.text('Continue without saving'));
|
||||||
|
await click(t, find.byKey(const ValueKey('transfer-review')));
|
||||||
|
await click(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||||
|
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
||||||
|
expect(await wallet.destinations.list(), isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets(
|
||||||
|
'saved destination is never selected automatically and removal preserves amount',
|
||||||
|
(t) async {
|
||||||
|
final service = DummyDestinationService();
|
||||||
|
await seedDestination(service);
|
||||||
|
final wallet = await open(t, path: '/payout', destinations: service);
|
||||||
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||||
|
expect(
|
||||||
|
t
|
||||||
|
.widget<FilledButton>(find.byKey(const ValueKey('transfer-review')))
|
||||||
|
.onPressed,
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
await selectPayout(t);
|
||||||
|
await click(t, find.byKey(const ValueKey('transfer-review')));
|
||||||
|
final d = (await service.list()).single;
|
||||||
|
await service.remove(d.id, d.metadataRevision, d.executionRevision);
|
||||||
|
await click(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||||
|
expect(find.textContaining('Payout terms changed'), findsOneWidget);
|
||||||
|
expect(find.text('5'), findsOneWidget);
|
||||||
|
expect((await t.runAsync(wallet.fetch))!.operations, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
for (final locale in ['en', 'ru']) {
|
||||||
|
testWidgets('all method forms fit $locale at 320px with large text', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
t.view.physicalSize = const Size(320, 1100);
|
||||||
|
t.view.devicePixelRatio = 1;
|
||||||
|
t.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||||
|
addTearDown(t.view.resetPhysicalSize);
|
||||||
|
addTearDown(t.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
await open(t, locale: locale);
|
||||||
|
await click(
|
||||||
|
t,
|
||||||
|
find
|
||||||
|
.text(
|
||||||
|
locale == 'en' ? 'Add payout method' : 'Добавить способ вывода',
|
||||||
|
)
|
||||||
|
.first,
|
||||||
|
);
|
||||||
|
for (final name
|
||||||
|
in locale == 'en'
|
||||||
|
? ['Crypto address', 'Bank card', 'Russian bank account']
|
||||||
|
: [
|
||||||
|
'Криптоадрес',
|
||||||
|
'Банковская карта',
|
||||||
|
'Российский банковский счёт',
|
||||||
|
]) {
|
||||||
|
await click(t, find.byType(DropdownButtonFormField<DestinationType>));
|
||||||
|
await click(t, find.text(name).last);
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
testWidgets('semantic save outcomes contain no destination fields', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
final analytics = DummyAnalyticsService();
|
||||||
|
await open(t, analytics: analytics);
|
||||||
|
await cryptoForm(t);
|
||||||
|
await click(t, find.byKey(const ValueKey('destination-continue')));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
final events = analytics.events
|
||||||
|
.where((e) => e.kind == JourneyKind.destination)
|
||||||
|
.toList();
|
||||||
|
expect(
|
||||||
|
events.any(
|
||||||
|
(e) =>
|
||||||
|
e.step == JourneyStep.destinationSave &&
|
||||||
|
e.signal == JourneySignal.simulated,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
events.map((e) => e.properties.toString()).join(),
|
||||||
|
isNot(contains(testDestination.address)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'binding recovery uses secure capture and preserves the saved card',
|
||||||
|
(t) async {
|
||||||
|
final service = DummyDestinationService();
|
||||||
|
final created = await service.create(
|
||||||
|
const CardDestination(
|
||||||
|
capture: 'demo-card-recover',
|
||||||
|
brand: '',
|
||||||
|
last4: '',
|
||||||
|
expiryMonth: 0,
|
||||||
|
expiryYear: 0,
|
||||||
|
),
|
||||||
|
'Own card',
|
||||||
|
'creation-key-000001',
|
||||||
|
);
|
||||||
|
await open(t, destinations: service);
|
||||||
|
await click(t, find.text('Own card'));
|
||||||
|
await click(t, find.text('Update authorization'));
|
||||||
|
expect(find.byKey(const ValueKey('destination-nickname')), findsNothing);
|
||||||
|
await click(t, find.text('Demo card •••• 4242'));
|
||||||
|
await click(t, find.byKey(const ValueKey('destination-continue')));
|
||||||
|
final saved = await service.get(created.destinationId);
|
||||||
|
expect(saved.executionRevision, 2);
|
||||||
|
expect(saved.nickname, 'Own card');
|
||||||
|
expect((await service.list()).length, 1);
|
||||||
|
expect(
|
||||||
|
(await service.assess(
|
||||||
|
saved.input,
|
||||||
|
const PayoutContext('eur'),
|
||||||
|
)).eligible,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -76,6 +76,14 @@ void main() {
|
|||||||
await t.ensureVisible(review);
|
await t.ensureVisible(review);
|
||||||
if (hasMethod) {
|
if (hasMethod) {
|
||||||
expect(find.text(destinationLabel), findsOneWidget);
|
expect(find.text(destinationLabel), findsOneWidget);
|
||||||
|
final choose = find.byKey(
|
||||||
|
const ValueKey('choose-configured-destination'),
|
||||||
|
);
|
||||||
|
expect(t.widget<FilledButton>(review).onPressed, isNull);
|
||||||
|
await t.ensureVisible(choose);
|
||||||
|
await t.tap(choose);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await t.ensureVisible(review);
|
||||||
await t.tap(review);
|
await t.tap(review);
|
||||||
await t.pumpAndSettle();
|
await t.pumpAndSettle();
|
||||||
expect(wallet.received, 'method-ref');
|
expect(wallet.received, 'method-ref');
|
||||||
|
|||||||
@@ -6,9 +6,15 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
|||||||
|
|
||||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_app/main.dart';
|
import 'package:sxchnge_app/main.dart';
|
||||||
import 'package:sxchnge_app/providers/locale.dart';
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
|
import 'destination_helpers.dart';
|
||||||
|
|
||||||
Future<void> tapFactor(WidgetTester t, String key) async {
|
Future<void> tapFactor(WidgetTester t, String key) async {
|
||||||
final finder = find.byKey(ValueKey(key));
|
final finder = find.byKey(ValueKey(key));
|
||||||
await t.ensureVisible(finder);
|
await t.ensureVisible(finder);
|
||||||
@@ -31,8 +37,16 @@ Future<DummyTwoFactorService> openFactor(
|
|||||||
delay: Duration.zero,
|
delay: Duration.zero,
|
||||||
method: method,
|
method: method,
|
||||||
);
|
);
|
||||||
|
final wallet = DummyWalletService(
|
||||||
|
DummyIdentityService(delay: Duration.zero),
|
||||||
|
factors: service,
|
||||||
|
initialLocale: locale,
|
||||||
|
delay: Duration.zero,
|
||||||
|
);
|
||||||
|
await seedDestination(wallet.destinations);
|
||||||
await t.pumpWidget(
|
await t.pumpWidget(
|
||||||
SxchngeApp(
|
SxchngeApp(
|
||||||
|
walletService: wallet,
|
||||||
twoFactorService: service,
|
twoFactorService: service,
|
||||||
analyticsService: analytics,
|
analyticsService: analytics,
|
||||||
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
|
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
|
||||||
@@ -134,13 +148,38 @@ void main() {
|
|||||||
await signIn(t);
|
await signIn(t);
|
||||||
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
|
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
|
||||||
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||||
|
expect(
|
||||||
|
t.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal),
|
analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal),
|
||||||
isEmpty,
|
isEmpty,
|
||||||
);
|
);
|
||||||
|
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '000000');
|
||||||
|
await tapFactor(t, 'two-factor-confirm');
|
||||||
|
expect(find.byKey(const ValueKey('two-factor-error')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
t.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456');
|
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456');
|
||||||
await tapFactor(t, 'two-factor-confirm');
|
await tapFactor(t, 'two-factor-confirm');
|
||||||
expect(find.byKey(const ValueKey('account-eur')), findsOneWidget);
|
expect(find.byKey(const ValueKey('account-eur')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
t.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
final login = analytics.events
|
final login = analytics.events
|
||||||
.where((e) => e.kind == JourneyKind.login)
|
.where((e) => e.kind == JourneyKind.login)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -158,6 +197,20 @@ void main() {
|
|||||||
await signIn(t);
|
await signIn(t);
|
||||||
await tapFactor(t, 'two-factor-cancel');
|
await tapFactor(t, 'two-factor-cancel');
|
||||||
expect(find.byKey(const ValueKey('email')), findsOneWidget);
|
expect(find.byKey(const ValueKey('email')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
t.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
t.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||||
expect(
|
expect(
|
||||||
t
|
t
|
||||||
@@ -174,10 +227,7 @@ void main() {
|
|||||||
await openFactor(t, path: '/$kind', enabled: true);
|
await openFactor(t, path: '/$kind', enabled: true);
|
||||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
|
||||||
if (kind == 'payout') {
|
if (kind == 'payout') {
|
||||||
await t.enterText(
|
await selectPayout(t);
|
||||||
find.byKey(const ValueKey('transfer-recipient')),
|
|
||||||
'Sample recipient',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
await tapFactor(t, 'transfer-review');
|
await tapFactor(t, 'transfer-review');
|
||||||
await tapFactor(t, 'transfer-confirm');
|
await tapFactor(t, 'transfer-confirm');
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
|||||||
import 'package:sxchnge_app/main.dart';
|
import 'package:sxchnge_app/main.dart';
|
||||||
import 'package:sxchnge_app/providers/locale.dart';
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
|
import 'destination_helpers.dart';
|
||||||
|
|
||||||
Future<void> tap(WidgetTester t, Finder f) async {
|
Future<void> tap(WidgetTester t, Finder f) async {
|
||||||
await t.ensureVisible(f);
|
await t.ensureVisible(f);
|
||||||
await t.tap(f);
|
await t.tap(f);
|
||||||
@@ -32,6 +34,7 @@ Future<DummyWalletService> open(
|
|||||||
settlement: settlement,
|
settlement: settlement,
|
||||||
initialLocale: locale,
|
initialLocale: locale,
|
||||||
);
|
);
|
||||||
|
await seedDestination(service.destinations);
|
||||||
await t.pumpWidget(
|
await t.pumpWidget(
|
||||||
SxchngeApp(
|
SxchngeApp(
|
||||||
identityService: identity,
|
identityService: identity,
|
||||||
@@ -78,10 +81,7 @@ void main() {
|
|||||||
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
||||||
await tap(t, find.text('Pay out').last);
|
await tap(t, find.text('Pay out').last);
|
||||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||||
await t.enterText(
|
await selectPayout(t);
|
||||||
find.byKey(const ValueKey('transfer-recipient')),
|
|
||||||
'Sample recipient',
|
|
||||||
);
|
|
||||||
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||||
expect(find.text('Recipient receives'), findsOneWidget);
|
expect(find.text('Recipient receives'), findsOneWidget);
|
||||||
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||||
@@ -101,10 +101,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
s.failNextOperation = true;
|
s.failNextOperation = true;
|
||||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
|
||||||
await t.enterText(
|
await selectPayout(t);
|
||||||
find.byKey(const ValueKey('transfer-recipient')),
|
|
||||||
'Sample recipient',
|
|
||||||
);
|
|
||||||
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||||
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||||
// Settlement is service-owned; refresh after the simulated processing period.
|
// Settlement is service-owned; refresh after the simulated processing period.
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ enum JourneyKind {
|
|||||||
exchange,
|
exchange,
|
||||||
payout,
|
payout,
|
||||||
security,
|
security,
|
||||||
|
destination,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum DestinationResolution { created, duplicate }
|
||||||
|
|
||||||
enum JourneyStep {
|
enum JourneyStep {
|
||||||
login,
|
login,
|
||||||
signup,
|
signup,
|
||||||
@@ -23,6 +26,10 @@ enum JourneyStep {
|
|||||||
kycSelfie,
|
kycSelfie,
|
||||||
kycSubmission,
|
kycSubmission,
|
||||||
kycStatus,
|
kycStatus,
|
||||||
|
destinationDraft,
|
||||||
|
destinationSave,
|
||||||
|
destinationReconciliation,
|
||||||
|
destinationBinding,
|
||||||
transferDraft,
|
transferDraft,
|
||||||
transferQuote,
|
transferQuote,
|
||||||
transferSubmission,
|
transferSubmission,
|
||||||
@@ -78,6 +85,7 @@ class AnalyticsEvent {
|
|||||||
required this.sequence,
|
required this.sequence,
|
||||||
required this.elapsedMs,
|
required this.elapsedMs,
|
||||||
this.step,
|
this.step,
|
||||||
|
this.resolution,
|
||||||
this.operation,
|
this.operation,
|
||||||
this.failure,
|
this.failure,
|
||||||
this.exit,
|
this.exit,
|
||||||
@@ -90,6 +98,7 @@ class AnalyticsEvent {
|
|||||||
final JourneyKind kind;
|
final JourneyKind kind;
|
||||||
final JourneySignal signal;
|
final JourneySignal signal;
|
||||||
final JourneyStep? step;
|
final JourneyStep? step;
|
||||||
|
final DestinationResolution? resolution;
|
||||||
final JourneyFailure? failure;
|
final JourneyFailure? failure;
|
||||||
final JourneyExit? exit;
|
final JourneyExit? exit;
|
||||||
final int sequence;
|
final int sequence;
|
||||||
@@ -105,6 +114,7 @@ class AnalyticsEvent {
|
|||||||
JourneyKind.exchange => 'exchange',
|
JourneyKind.exchange => 'exchange',
|
||||||
JourneyKind.payout => 'payout',
|
JourneyKind.payout => 'payout',
|
||||||
JourneyKind.security => 'security',
|
JourneyKind.security => 'security',
|
||||||
|
JourneyKind.destination => 'destination',
|
||||||
_ => 'auth',
|
_ => 'auth',
|
||||||
};
|
};
|
||||||
var stage = step == null ? 'journey' : _snake(step!.name);
|
var stage = step == null ? 'journey' : _snake(step!.name);
|
||||||
@@ -123,6 +133,7 @@ class AnalyticsEvent {
|
|||||||
'terminal': terminal,
|
'terminal': terminal,
|
||||||
'simulated': simulated,
|
'simulated': simulated,
|
||||||
if (step != null) 'step': _snake(step!.name),
|
if (step != null) 'step': _snake(step!.name),
|
||||||
|
if (resolution != null) 'destination_resolution': resolution!.name,
|
||||||
if (operation != null) 'operation': operation!,
|
if (operation != null) 'operation': operation!,
|
||||||
if (failure != null) 'failure': _snake(failure!.name),
|
if (failure != null) 'failure': _snake(failure!.name),
|
||||||
if (exit != null) 'exit': _snake(exit!.name),
|
if (exit != null) 'exit': _snake(exit!.name),
|
||||||
@@ -210,6 +221,7 @@ class JourneyRecorder {
|
|||||||
JourneyFailure? failure,
|
JourneyFailure? failure,
|
||||||
bool terminal = false,
|
bool terminal = false,
|
||||||
bool cancelled = false,
|
bool cancelled = false,
|
||||||
|
DestinationResolution? resolution,
|
||||||
}) {
|
}) {
|
||||||
final journey = operation._journey;
|
final journey = operation._journey;
|
||||||
if (_disposed || operation._finished || journey.closed) return;
|
if (_disposed || operation._finished || journey.closed) return;
|
||||||
@@ -227,6 +239,7 @@ class JourneyRecorder {
|
|||||||
? JourneySignal.simulated
|
? JourneySignal.simulated
|
||||||
: JourneySignal.succeeded,
|
: JourneySignal.succeeded,
|
||||||
operation: operation,
|
operation: operation,
|
||||||
|
resolution: resolution,
|
||||||
failure: failure,
|
failure: failure,
|
||||||
terminal: complete,
|
terminal: complete,
|
||||||
simulated: simulated || (complete && journey.simulated),
|
simulated: simulated || (complete && journey.simulated),
|
||||||
@@ -258,6 +271,7 @@ class JourneyRecorder {
|
|||||||
_Journey journey,
|
_Journey journey,
|
||||||
JourneySignal signal, {
|
JourneySignal signal, {
|
||||||
JourneyOperation? operation,
|
JourneyOperation? operation,
|
||||||
|
DestinationResolution? resolution,
|
||||||
JourneyFailure? failure,
|
JourneyFailure? failure,
|
||||||
JourneyExit? exit,
|
JourneyExit? exit,
|
||||||
bool terminal = false,
|
bool terminal = false,
|
||||||
@@ -271,6 +285,7 @@ class JourneyRecorder {
|
|||||||
sequence: ++journey.sequence,
|
sequence: ++journey.sequence,
|
||||||
elapsedMs: journey.time.elapsedMilliseconds,
|
elapsedMs: journey.time.elapsedMilliseconds,
|
||||||
step: operation?.step,
|
step: operation?.step,
|
||||||
|
resolution: resolution,
|
||||||
operation: operation?.number,
|
operation: operation?.number,
|
||||||
failure: failure,
|
failure: failure,
|
||||||
exit: exit,
|
exit: exit,
|
||||||
|
|||||||
@@ -47,3 +47,17 @@ once on success/failure. Closing the app while pending does not count as draft
|
|||||||
abandonment. No amounts, account IDs or recipient data enter events. All demo
|
abandonment. No amounts, account IDs or recipient data enter events. All demo
|
||||||
signals are simulated. Cross-session outcomes require backend event delivery;
|
signals are simulated. Cross-session outcomes require backend event delivery;
|
||||||
client-only analytics are not production accounting or conversion evidence.
|
client-only analytics are not production accounting or conversion evidence.
|
||||||
|
|
||||||
|
## Saved destinations — #54
|
||||||
|
|
||||||
|
The injectable `DestinationService` adds canonical crypto, secure-card capture and
|
||||||
|
Russian bank models, saved/one-time input, independent metadata/execution revisions,
|
||||||
|
stable created/duplicate outcomes, contextual eligibility and management providers.
|
||||||
|
`DummyDestinationService` is customer-scoped and process-local. It has isolated
|
||||||
|
fixtures for directory/capture/recovery states, not live banking or card collection.
|
||||||
|
The demo wallet binds immutable destination snapshots to quotes and rechecks at its
|
||||||
|
synchronous acceptance boundary. Production must enforce those checks atomically
|
||||||
|
in the backend. Live mode currently advertises no new destination capabilities and
|
||||||
|
preserves explicit selection of the already provisioned wallet method. It never
|
||||||
|
uses a demo store. See [the UX/service contract](../../../docs/ux/payout-destinations.md)
|
||||||
|
for integration, security, retention, localization and acceptance requirements.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/// Local, explicit-label paste parsing. Raw pasted text is never retained.
|
||||||
|
class BankPaste {
|
||||||
|
BankPaste._(this.values, this.conflicts);
|
||||||
|
final Map<String, String> values;
|
||||||
|
final Set<String> conflicts;
|
||||||
|
static BankPaste parse(String text) {
|
||||||
|
final values = <String, String>{};
|
||||||
|
final conflicts = <String>{};
|
||||||
|
const labels = {
|
||||||
|
'бик': 'bik',
|
||||||
|
'bik': 'bik',
|
||||||
|
'счет': 'account',
|
||||||
|
'счёт': 'account',
|
||||||
|
'номер счета': 'account',
|
||||||
|
'номер счёта': 'account',
|
||||||
|
'account': 'account',
|
||||||
|
'account number': 'account',
|
||||||
|
'имя': 'firstName',
|
||||||
|
'first name': 'firstName',
|
||||||
|
'фамилия': 'lastName',
|
||||||
|
'last name': 'lastName',
|
||||||
|
'отчество': 'patronymic',
|
||||||
|
'patronymic': 'patronymic',
|
||||||
|
'получатель': 'holder',
|
||||||
|
'фио': 'holder',
|
||||||
|
'account holder': 'holder',
|
||||||
|
'банк': 'bank',
|
||||||
|
'bank': 'bank',
|
||||||
|
'корреспондентский счет': 'correspondent',
|
||||||
|
'корреспондентский счёт': 'correspondent',
|
||||||
|
'correspondent account': 'correspondent',
|
||||||
|
};
|
||||||
|
for (final line in text.split(RegExp(r'[\r\n]+'))) {
|
||||||
|
final match = RegExp(r'^\s*([^:=]+)\s*[:=]\s*(.+?)\s*$').firstMatch(line);
|
||||||
|
if (match == null) continue;
|
||||||
|
final key = labels[match[1]!.trim().toLowerCase()];
|
||||||
|
if (key == null) continue;
|
||||||
|
var value = match[2]!.trim();
|
||||||
|
if (['bik', 'account', 'correspondent'].contains(key))
|
||||||
|
value = value.replaceAll(RegExp(r'\s'), '');
|
||||||
|
if (values.containsKey(key) && values[key] != value) conflicts.add(key);
|
||||||
|
values[key] = value;
|
||||||
|
}
|
||||||
|
for (final key in conflicts) {
|
||||||
|
values.remove(key);
|
||||||
|
}
|
||||||
|
return BankPaste._(Map.unmodifiable(values), Set.unmodifiable(conflicts));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'key.dart';
|
||||||
|
import 'models.dart';
|
||||||
|
import 'service.dart';
|
||||||
|
import 'transport.dart';
|
||||||
|
|
||||||
|
/// Shared only by injected demo sessions. Never production persistence.
|
||||||
|
class DestinationMemory {
|
||||||
|
final records = <String, Map<String, SavedDestination>>{};
|
||||||
|
final attempts = <String, Map<String, (String, CreationResult)>>{};
|
||||||
|
final audit = <DestinationAudit>[];
|
||||||
|
final recoveries = <String, Map<String, (String, String)>>{};
|
||||||
|
final fingerprints = <String, Map<String, String?>>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service-authoritative simulation. No raw card capture and no live rails.
|
||||||
|
class DummyDestinationService implements DestinationService {
|
||||||
|
DummyDestinationService({
|
||||||
|
this.customer = 'demo-customer',
|
||||||
|
DestinationMemory? memory,
|
||||||
|
DateTime Function()? now,
|
||||||
|
}) : memory = memory ?? DestinationMemory(),
|
||||||
|
now = now ?? DateTime.now;
|
||||||
|
final String customer;
|
||||||
|
final DestinationMemory memory;
|
||||||
|
final DateTime Function() now;
|
||||||
|
final availability = <String, PayoutEligibility>{};
|
||||||
|
int requirementsRevision = 1;
|
||||||
|
bool loseNextSaveResponse = false;
|
||||||
|
bool reusableCards = true, cardRecovery = true;
|
||||||
|
DestinationIssue? nextSaveError;
|
||||||
|
bool directoryUnavailable = false;
|
||||||
|
List<Bank> directory = [
|
||||||
|
const Bank(
|
||||||
|
bik: '044525225',
|
||||||
|
name: 'Demo Bank',
|
||||||
|
city: 'Moscow',
|
||||||
|
correspondent: '30101810400000000225',
|
||||||
|
revision: 'demo-1',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
static const assets = [
|
||||||
|
CryptoAsset('demo:ethereum:native', 'ETH', 'eip155:1', 'Ethereum'),
|
||||||
|
CryptoAsset(
|
||||||
|
'demo:ethereum:usdc',
|
||||||
|
'USDC · Ethereum · demo token A',
|
||||||
|
'eip155:1',
|
||||||
|
'Ethereum',
|
||||||
|
),
|
||||||
|
CryptoAsset(
|
||||||
|
'demo:ethereum:usdc-alt',
|
||||||
|
'USDC · Ethereum · demo token B',
|
||||||
|
'eip155:1',
|
||||||
|
'Ethereum',
|
||||||
|
),
|
||||||
|
CryptoAsset('demo:polygon:usdc', 'USDC · Polygon', 'eip155:137', 'Polygon'),
|
||||||
|
CryptoAsset(
|
||||||
|
'demo:xrp:native',
|
||||||
|
'XRP',
|
||||||
|
'xrpl:mainnet',
|
||||||
|
'XRP Ledger',
|
||||||
|
memoRequired: true,
|
||||||
|
memoSupported: true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
Map<String, SavedDestination> get _records =>
|
||||||
|
memory.records.putIfAbsent(customer, () => {});
|
||||||
|
Map<String, (String, CreationResult)> get _attempts =>
|
||||||
|
memory.attempts.putIfAbsent(customer, () => {});
|
||||||
|
@override
|
||||||
|
bool get isSimulated => true;
|
||||||
|
@override
|
||||||
|
Future<DestinationCapabilities> capabilities() async =>
|
||||||
|
DestinationCapabilities(
|
||||||
|
types: DestinationType.values,
|
||||||
|
assets: assets,
|
||||||
|
reusableCards: true,
|
||||||
|
cardRecovery: true,
|
||||||
|
simulated: true,
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
Future<List<SavedDestination>> list() async =>
|
||||||
|
_records.values.where((d) => !d.removed).toList();
|
||||||
|
SavedDestination _get(String id) {
|
||||||
|
final d = _records[id];
|
||||||
|
if (d == null) throw const DestinationException(DestinationIssue.removed);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SavedDestination> get(String id) async => _get(id);
|
||||||
|
void _audit(String reference, String action, String outcome) => memory.audit
|
||||||
|
.add(DestinationAudit(customer, reference, action, outcome, now()));
|
||||||
|
DestinationDetails _validate(DestinationDetails details) {
|
||||||
|
switch (details) {
|
||||||
|
case CryptoDestination d:
|
||||||
|
final asset = assets
|
||||||
|
.where((a) => a.id == d.assetId && a.networkId == d.networkId)
|
||||||
|
.firstOrNull;
|
||||||
|
if (asset == null)
|
||||||
|
throw const DestinationException(
|
||||||
|
DestinationIssue.invalid,
|
||||||
|
field: 'asset',
|
||||||
|
);
|
||||||
|
final address = d.address.trim();
|
||||||
|
final valid = asset.networkId.startsWith('eip155:')
|
||||||
|
? RegExp(r'^0x[0-9a-fA-F]{40}$').hasMatch(address)
|
||||||
|
: RegExp(r'^r[1-9A-HJ-NP-Za-km-z]{24,34}$').hasMatch(address);
|
||||||
|
if (!valid)
|
||||||
|
throw const DestinationException(
|
||||||
|
DestinationIssue.invalid,
|
||||||
|
field: 'address',
|
||||||
|
);
|
||||||
|
if (asset.memoRequired && !RegExp(r'^\d{1,10}$').hasMatch(d.memo))
|
||||||
|
throw const DestinationException(
|
||||||
|
DestinationIssue.invalid,
|
||||||
|
field: 'memo',
|
||||||
|
);
|
||||||
|
if (!asset.memoSupported && d.memo.isNotEmpty)
|
||||||
|
throw const DestinationException(
|
||||||
|
DestinationIssue.invalid,
|
||||||
|
field: 'memo',
|
||||||
|
);
|
||||||
|
return CryptoDestination(
|
||||||
|
assetId: asset.id,
|
||||||
|
networkId: asset.networkId,
|
||||||
|
address: address,
|
||||||
|
memo: d.memo,
|
||||||
|
assetLabel: asset.label,
|
||||||
|
networkLabel: asset.networkLabel,
|
||||||
|
);
|
||||||
|
case RussianBankDestination d:
|
||||||
|
final account = d.account.replaceAll(RegExp(r'\s'), '');
|
||||||
|
if (d.firstName.trim().isEmpty || d.lastName.trim().isEmpty)
|
||||||
|
throw const DestinationException(
|
||||||
|
DestinationIssue.invalid,
|
||||||
|
field: 'holder',
|
||||||
|
);
|
||||||
|
if (!RegExp(r'^\d{9}$').hasMatch(d.bank.bik) ||
|
||||||
|
!validBankAccount(account, d.bank.bik))
|
||||||
|
throw const DestinationException(
|
||||||
|
DestinationIssue.invalid,
|
||||||
|
field: 'account',
|
||||||
|
);
|
||||||
|
if (directoryUnavailable)
|
||||||
|
throw const DestinationException(
|
||||||
|
DestinationIssue.unavailable,
|
||||||
|
field: 'bank',
|
||||||
|
);
|
||||||
|
final bank = directory.where((b) => b.bik == d.bank.bik).firstOrNull;
|
||||||
|
if (bank == null)
|
||||||
|
throw const DestinationException(
|
||||||
|
DestinationIssue.invalid,
|
||||||
|
field: 'bank',
|
||||||
|
);
|
||||||
|
if (bank.correspondent != d.bank.correspondent)
|
||||||
|
throw const DestinationException(
|
||||||
|
DestinationIssue.bankChanged,
|
||||||
|
field: 'bank',
|
||||||
|
);
|
||||||
|
// Directory display values are authoritative; identity is never guessed.
|
||||||
|
return RussianBankDestination(
|
||||||
|
firstName: d.firstName.trim(),
|
||||||
|
lastName: d.lastName.trim(),
|
||||||
|
patronymic: d.patronymic.trim(),
|
||||||
|
bank: bank,
|
||||||
|
account: account,
|
||||||
|
);
|
||||||
|
case CardDestination d:
|
||||||
|
if (![
|
||||||
|
'demo-card-ready',
|
||||||
|
'demo-card-recover',
|
||||||
|
'demo-card-unknown',
|
||||||
|
].contains(d.capture))
|
||||||
|
throw const DestinationException(
|
||||||
|
DestinationIssue.invalid,
|
||||||
|
field: 'card',
|
||||||
|
);
|
||||||
|
// These are secure-collection fixtures, not user-entered card metadata.
|
||||||
|
return CardDestination(
|
||||||
|
capture: d.capture,
|
||||||
|
brand: 'Demo card',
|
||||||
|
last4: d.capture == 'demo-card-unknown' ? '0027' : '4242',
|
||||||
|
expiryMonth: 12,
|
||||||
|
expiryYear: now().year + 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DestinationValidation> validate(DestinationDetails details) async =>
|
||||||
|
DestinationValidation(DestinationMapper.clone(_validate(details)));
|
||||||
|
|
||||||
|
String? _identity(DestinationDetails details) => switch (details) {
|
||||||
|
CryptoDestination d => jsonEncode([
|
||||||
|
d.type.name,
|
||||||
|
d.assetId,
|
||||||
|
d.networkId,
|
||||||
|
d.networkId.startsWith('eip155:') ? d.address.toLowerCase() : d.address,
|
||||||
|
d.memo,
|
||||||
|
]),
|
||||||
|
RussianBankDestination d => jsonEncode([
|
||||||
|
d.type.name,
|
||||||
|
d.bank.bik,
|
||||||
|
d.account,
|
||||||
|
d.firstName,
|
||||||
|
d.lastName,
|
||||||
|
d.patronymic,
|
||||||
|
]),
|
||||||
|
CardDestination d =>
|
||||||
|
d.capture.isEmpty
|
||||||
|
? (memory.fingerprints[customer] ?? <String, String?>{})[d.identity]
|
||||||
|
: d.capture == 'demo-card-unknown'
|
||||||
|
? null
|
||||||
|
: 'demo-card-fingerprint:4242',
|
||||||
|
};
|
||||||
|
DestinationDetails _resolve(DestinationInput input) {
|
||||||
|
if (input is OneTimeDestination) return _validate(input.details);
|
||||||
|
final ref = input as SavedReference;
|
||||||
|
final d = _get(ref.id);
|
||||||
|
if (d.removed) throw const DestinationException(DestinationIssue.removed);
|
||||||
|
if (d.executionRevision != ref.executionRevision)
|
||||||
|
throw const DestinationException(DestinationIssue.conflict);
|
||||||
|
return d.details;
|
||||||
|
}
|
||||||
|
|
||||||
|
PayoutEligibility _assess(DestinationInput input, PayoutContext context) {
|
||||||
|
final d = _resolve(input);
|
||||||
|
if (input is SavedReference) {
|
||||||
|
final saved = _get(input.id);
|
||||||
|
if (saved.ownership.actionRequired)
|
||||||
|
return const PayoutEligibility(
|
||||||
|
EligibilityState.requiresAction,
|
||||||
|
reason: DestinationIssue.authorization,
|
||||||
|
);
|
||||||
|
final override = availability[input.id];
|
||||||
|
if (override != null) return override;
|
||||||
|
if (d is CardDestination) {
|
||||||
|
if (d.expiryYear < now().year ||
|
||||||
|
d.expiryYear == now().year && d.expiryMonth < now().month)
|
||||||
|
return const PayoutEligibility(
|
||||||
|
EligibilityState.requiresAction,
|
||||||
|
reason: DestinationIssue.expired,
|
||||||
|
);
|
||||||
|
final ready = saved.bindings.any(
|
||||||
|
(b) =>
|
||||||
|
b.reusable &&
|
||||||
|
b.state == BindingState.ready &&
|
||||||
|
(b.expiresAt == null || now().isBefore(b.expiresAt!)),
|
||||||
|
);
|
||||||
|
if (!ready) {
|
||||||
|
final recoverable =
|
||||||
|
cardRecovery && saved.bindings.any((b) => b.recoverable);
|
||||||
|
final temporary = saved.bindings.any(
|
||||||
|
(b) => b.state == BindingState.unavailable,
|
||||||
|
);
|
||||||
|
return PayoutEligibility(
|
||||||
|
recoverable
|
||||||
|
? EligibilityState.requiresAction
|
||||||
|
: temporary
|
||||||
|
? EligibilityState.temporarilyUnavailable
|
||||||
|
: EligibilityState.unsupported,
|
||||||
|
reason: recoverable
|
||||||
|
? DestinationIssue.authorization
|
||||||
|
: temporary
|
||||||
|
? DestinationIssue.unavailable
|
||||||
|
: DestinationIssue.unsupported,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (context.source.isNotEmpty &&
|
||||||
|
!['eur', 'usd', 'gbp'].contains(context.source))
|
||||||
|
return const PayoutEligibility(
|
||||||
|
EligibilityState.unsupported,
|
||||||
|
reason: DestinationIssue.unsupported,
|
||||||
|
);
|
||||||
|
if (d is RussianBankDestination) {
|
||||||
|
if (directoryUnavailable)
|
||||||
|
return const PayoutEligibility(
|
||||||
|
EligibilityState.temporarilyUnavailable,
|
||||||
|
reason: DestinationIssue.unavailable,
|
||||||
|
);
|
||||||
|
final current = directory.where((b) => b.bik == d.bank.bik).firstOrNull;
|
||||||
|
if (current == null || !current.supported)
|
||||||
|
return const PayoutEligibility(
|
||||||
|
EligibilityState.unsupported,
|
||||||
|
reason: DestinationIssue.unsupported,
|
||||||
|
);
|
||||||
|
if (current.correspondent != d.bank.correspondent)
|
||||||
|
return const PayoutEligibility(
|
||||||
|
EligibilityState.requiresAction,
|
||||||
|
reason: DestinationIssue.bankChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (d is CardDestination && d.capture == 'demo-card-recover')
|
||||||
|
return const PayoutEligibility(
|
||||||
|
EligibilityState.requiresAction,
|
||||||
|
reason: DestinationIssue.authorization,
|
||||||
|
);
|
||||||
|
return PayoutEligibility(
|
||||||
|
EligibilityState.eligible,
|
||||||
|
canSave: d is! CardDestination || reusableCards || cardRecovery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PayoutEligibility> assess(
|
||||||
|
DestinationInput input,
|
||||||
|
PayoutContext context,
|
||||||
|
) async => _assess(input, context);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CreationResult> create(
|
||||||
|
DestinationDetails details,
|
||||||
|
String nickname,
|
||||||
|
String idempotencyKey, {
|
||||||
|
String? replaces,
|
||||||
|
}) async {
|
||||||
|
if (idempotencyKey.length < 16 || nickname.length > 80)
|
||||||
|
throw const DestinationException(DestinationIssue.invalid);
|
||||||
|
final payload = jsonEncode([
|
||||||
|
DestinationMapper.details(details).value,
|
||||||
|
nickname,
|
||||||
|
replaces,
|
||||||
|
]);
|
||||||
|
final previous = _attempts[idempotencyKey];
|
||||||
|
if (previous != null) {
|
||||||
|
if (previous.$1 != payload)
|
||||||
|
throw const DestinationException(DestinationIssue.conflict);
|
||||||
|
return previous.$2;
|
||||||
|
}
|
||||||
|
final error = nextSaveError;
|
||||||
|
nextSaveError = null;
|
||||||
|
if (error != null) {
|
||||||
|
_audit(idempotencyKey, 'create', 'failed');
|
||||||
|
throw DestinationException(error);
|
||||||
|
}
|
||||||
|
if (replaces != null && _get(replaces).removed)
|
||||||
|
throw const DestinationException(DestinationIssue.removed);
|
||||||
|
final normalized = _validate(details);
|
||||||
|
if (normalized is CardDestination && !reusableCards && !cardRecovery)
|
||||||
|
throw const DestinationException(DestinationIssue.unsupported);
|
||||||
|
final identity = _identity(normalized);
|
||||||
|
final existing = identity == null
|
||||||
|
? null
|
||||||
|
: _records.values
|
||||||
|
.where((r) => !r.removed && _identity(r.details) == identity)
|
||||||
|
.firstOrNull;
|
||||||
|
final result = CreationResult(
|
||||||
|
existing == null ? SaveOutcome.created : SaveOutcome.duplicate,
|
||||||
|
existing?.id ?? destinationKey(),
|
||||||
|
);
|
||||||
|
// Synchronous mutation is the demo's linearization boundary; no await gap.
|
||||||
|
if (existing == null) {
|
||||||
|
_records[result.destinationId] = SavedDestination(
|
||||||
|
id: result.destinationId,
|
||||||
|
details: normalized is CardDestination
|
||||||
|
? CardDestination(
|
||||||
|
identity: result.destinationId,
|
||||||
|
brand: normalized.brand,
|
||||||
|
last4: normalized.last4,
|
||||||
|
expiryMonth: normalized.expiryMonth,
|
||||||
|
expiryYear: normalized.expiryYear,
|
||||||
|
)
|
||||||
|
: DestinationMapper.clone(normalized),
|
||||||
|
nickname: nickname.trim(),
|
||||||
|
bindings: normalized is CardDestination
|
||||||
|
? [
|
||||||
|
ProviderBinding(
|
||||||
|
reference: destinationKey(),
|
||||||
|
state:
|
||||||
|
!reusableCards ||
|
||||||
|
normalized.capture == 'demo-card-recover'
|
||||||
|
? BindingState.expired
|
||||||
|
: BindingState.ready,
|
||||||
|
reusable: reusableCards,
|
||||||
|
recoverable: cardRecovery,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (existing == null && normalized is CardDestination)
|
||||||
|
memory.fingerprints.putIfAbsent(
|
||||||
|
customer,
|
||||||
|
() => {},
|
||||||
|
)[result.destinationId] = identity;
|
||||||
|
_attempts[idempotencyKey] = (payload, result);
|
||||||
|
_audit(
|
||||||
|
result.destinationId,
|
||||||
|
replaces == null ? 'create' : 'replacement',
|
||||||
|
result.outcome.name,
|
||||||
|
);
|
||||||
|
if (loseNextSaveResponse) {
|
||||||
|
loseNextSaveResponse = false;
|
||||||
|
throw const DestinationException(DestinationIssue.uncertain);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CreationResult?> creation(String idempotencyKey) async =>
|
||||||
|
_attempts[idempotencyKey]?.$2;
|
||||||
|
@override
|
||||||
|
Future<SavedDestination> rename(
|
||||||
|
String id,
|
||||||
|
String nickname,
|
||||||
|
int metadataRevision,
|
||||||
|
) async {
|
||||||
|
final d = _get(id);
|
||||||
|
if (d.removed) throw const DestinationException(DestinationIssue.removed);
|
||||||
|
if (d.metadataRevision != metadataRevision) {
|
||||||
|
_audit(id, 'rename', 'conflict');
|
||||||
|
throw const DestinationException(DestinationIssue.conflict);
|
||||||
|
}
|
||||||
|
if (nickname.length > 80)
|
||||||
|
throw const DestinationException(
|
||||||
|
DestinationIssue.invalid,
|
||||||
|
field: 'nickname',
|
||||||
|
);
|
||||||
|
final updated = SavedDestination(
|
||||||
|
id: d.id,
|
||||||
|
details: d.details,
|
||||||
|
nickname: nickname.trim(),
|
||||||
|
metadataRevision: d.metadataRevision + 1,
|
||||||
|
executionRevision: d.executionRevision,
|
||||||
|
bindings: d.bindings,
|
||||||
|
lastUsed: d.lastUsed,
|
||||||
|
ownership: d.ownership,
|
||||||
|
);
|
||||||
|
_records[id] = updated;
|
||||||
|
_audit(id, 'rename', 'succeeded');
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> remove(
|
||||||
|
String id,
|
||||||
|
int metadataRevision,
|
||||||
|
int executionRevision,
|
||||||
|
) async {
|
||||||
|
final d = _get(id);
|
||||||
|
if (d.removed) return;
|
||||||
|
if (d.metadataRevision != metadataRevision ||
|
||||||
|
d.executionRevision != executionRevision) {
|
||||||
|
_audit(id, 'remove', 'conflict');
|
||||||
|
throw const DestinationException(DestinationIssue.conflict);
|
||||||
|
}
|
||||||
|
_records[id] = SavedDestination(
|
||||||
|
id: d.id,
|
||||||
|
details: d.details,
|
||||||
|
nickname: d.nickname,
|
||||||
|
metadataRevision: d.metadataRevision + 1,
|
||||||
|
executionRevision: d.executionRevision + 1,
|
||||||
|
removed: true,
|
||||||
|
bindings: d.bindings,
|
||||||
|
lastUsed: d.lastUsed,
|
||||||
|
ownership: d.ownership,
|
||||||
|
);
|
||||||
|
_audit(id, 'remove', 'succeeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Bank>> banks(String query, {Future<void>? abortTrigger}) async {
|
||||||
|
if (directoryUnavailable)
|
||||||
|
throw const DestinationException(DestinationIssue.unavailable);
|
||||||
|
final value = query.trim().toLowerCase();
|
||||||
|
if (RegExp(r'^\d+$').hasMatch(value) && value.length > 9)
|
||||||
|
throw const DestinationException(DestinationIssue.invalid, field: 'bik');
|
||||||
|
if (value.length < 2) return [];
|
||||||
|
return directory
|
||||||
|
.where(
|
||||||
|
(b) => value.length == 9 && RegExp(r'^\d+$').hasMatch(value)
|
||||||
|
? b.bik == value
|
||||||
|
: b.name.toLowerCase().contains(value) || b.bik.contains(value),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
DestinationDetails _snapshot(DestinationDetails details) =>
|
||||||
|
details is CardDestination
|
||||||
|
? CardDestination(
|
||||||
|
identity: details.identity,
|
||||||
|
brand: details.brand,
|
||||||
|
last4: details.last4,
|
||||||
|
expiryMonth: details.expiryMonth,
|
||||||
|
expiryYear: details.expiryYear,
|
||||||
|
)
|
||||||
|
: DestinationMapper.clone(details);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DestinationLease> prepare(
|
||||||
|
DestinationInput input,
|
||||||
|
PayoutContext context,
|
||||||
|
) async {
|
||||||
|
final eligibility = _assess(input, context);
|
||||||
|
if (!eligibility.eligible)
|
||||||
|
throw DestinationException(
|
||||||
|
eligibility.reason ?? DestinationIssue.unsupported,
|
||||||
|
);
|
||||||
|
return DestinationLease(
|
||||||
|
input: input,
|
||||||
|
snapshot: _snapshot(_resolve(input)),
|
||||||
|
context: context,
|
||||||
|
requirementsRevision: requirementsRevision,
|
||||||
|
expiresAt: now().add(const Duration(seconds: 60)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronous demo acceptance boundary; production enforcement belongs in BFF.
|
||||||
|
void accept(DestinationLease lease) {
|
||||||
|
if (!now().isBefore(lease.expiresAt))
|
||||||
|
throw const DestinationException(DestinationIssue.expired);
|
||||||
|
if (requirementsRevision != lease.requirementsRevision)
|
||||||
|
throw const DestinationException(DestinationIssue.conflict);
|
||||||
|
final result = _assess(lease.input, lease.context);
|
||||||
|
if (!result.eligible)
|
||||||
|
throw DestinationException(result.reason ?? DestinationIssue.unsupported);
|
||||||
|
if (lease.input case SavedReference ref) {
|
||||||
|
final d = _get(ref.id);
|
||||||
|
_records[d.id] = SavedDestination(
|
||||||
|
id: d.id,
|
||||||
|
details: d.details,
|
||||||
|
nickname: d.nickname,
|
||||||
|
metadataRevision: d.metadataRevision,
|
||||||
|
executionRevision: d.executionRevision,
|
||||||
|
bindings: d.bindings,
|
||||||
|
lastUsed: now(),
|
||||||
|
ownership: d.ownership,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SavedDestination> restoreCard(
|
||||||
|
String id,
|
||||||
|
int executionRevision,
|
||||||
|
CardDestination capture,
|
||||||
|
String idempotencyKey,
|
||||||
|
) async {
|
||||||
|
if (idempotencyKey.length < 16)
|
||||||
|
throw const DestinationException(DestinationIssue.invalid);
|
||||||
|
final payload = jsonEncode([
|
||||||
|
id,
|
||||||
|
executionRevision,
|
||||||
|
DestinationMapper.details(capture).value,
|
||||||
|
]);
|
||||||
|
final attempts = memory.recoveries.putIfAbsent(customer, () => {});
|
||||||
|
final previous = attempts[idempotencyKey];
|
||||||
|
if (previous != null) {
|
||||||
|
if (previous.$1 != payload)
|
||||||
|
throw const DestinationException(DestinationIssue.conflict);
|
||||||
|
return _get(previous.$2);
|
||||||
|
}
|
||||||
|
final d = _get(id);
|
||||||
|
if (d.removed) throw const DestinationException(DestinationIssue.removed);
|
||||||
|
if (d.executionRevision != executionRevision)
|
||||||
|
throw const DestinationException(DestinationIssue.conflict);
|
||||||
|
if (!cardRecovery || d.details is! CardDestination)
|
||||||
|
throw const DestinationException(DestinationIssue.unsupported);
|
||||||
|
final normalized = _validate(capture) as CardDestination;
|
||||||
|
final fingerprint = _identity(normalized);
|
||||||
|
if (fingerprint == null || fingerprint != _identity(d.details))
|
||||||
|
throw const DestinationException(DestinationIssue.replacementRequired);
|
||||||
|
if (capture.capture != 'demo-card-ready')
|
||||||
|
throw const DestinationException(DestinationIssue.authorization);
|
||||||
|
final updated = SavedDestination(
|
||||||
|
id: d.id,
|
||||||
|
details: CardDestination(
|
||||||
|
identity: (d.details as CardDestination).identity,
|
||||||
|
brand: normalized.brand,
|
||||||
|
last4: normalized.last4,
|
||||||
|
expiryMonth: normalized.expiryMonth,
|
||||||
|
expiryYear: normalized.expiryYear,
|
||||||
|
),
|
||||||
|
nickname: d.nickname,
|
||||||
|
metadataRevision: d.metadataRevision,
|
||||||
|
executionRevision: d.executionRevision + 1,
|
||||||
|
lastUsed: d.lastUsed,
|
||||||
|
ownership: d.ownership,
|
||||||
|
bindings: [
|
||||||
|
ProviderBinding(
|
||||||
|
reference: destinationKey(),
|
||||||
|
state: BindingState.ready,
|
||||||
|
reusable: true,
|
||||||
|
recoverable: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
_records[id] = updated;
|
||||||
|
attempts[idempotencyKey] = (payload, id);
|
||||||
|
_audit(id, 'bindingRestored', 'succeeded');
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void clearTransient() {
|
||||||
|
/* No transient server records survive method calls. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool validBankAccount(String account, String bik) {
|
||||||
|
if (!RegExp(r'^\d{20}$').hasMatch(account) ||
|
||||||
|
!RegExp(r'^\d{9}$').hasMatch(bik))
|
||||||
|
return false;
|
||||||
|
final digits = bik.substring(6) + account;
|
||||||
|
var sum = 0;
|
||||||
|
for (var i = 0; i < digits.length; i++) {
|
||||||
|
sum += int.parse(digits[i]) * [7, 1, 3][i % 3];
|
||||||
|
}
|
||||||
|
return sum % 10 == 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
|
import 'key.dart';
|
||||||
|
import 'models.dart';
|
||||||
|
import 'service.dart';
|
||||||
|
|
||||||
|
class DestinationFormProvider extends ChangeNotifier {
|
||||||
|
DestinationFormProvider(
|
||||||
|
this.service,
|
||||||
|
this.analytics, {
|
||||||
|
this.context,
|
||||||
|
this.initial,
|
||||||
|
this.replacing,
|
||||||
|
this.restoring,
|
||||||
|
}) : recorder = JourneyRecorder(analytics),
|
||||||
|
details = initial {
|
||||||
|
if (context == null) _startJourney();
|
||||||
|
}
|
||||||
|
final DestinationService service;
|
||||||
|
final AnalyticsService analytics;
|
||||||
|
final JourneyRecorder recorder;
|
||||||
|
final PayoutContext? context;
|
||||||
|
final DestinationDetails? initial;
|
||||||
|
final String? replacing;
|
||||||
|
final SavedReference? restoring;
|
||||||
|
DestinationDetails? details;
|
||||||
|
String nickname = '';
|
||||||
|
bool save = false, busy = false, _disposed = false, uncertain = false;
|
||||||
|
DestinationIssue? error;
|
||||||
|
String? errorField;
|
||||||
|
CreationResult? saved;
|
||||||
|
PayoutEligibility? eligibility;
|
||||||
|
DestinationInput? result;
|
||||||
|
String? _key;
|
||||||
|
bool _creationStarted = false;
|
||||||
|
bool _journeyStarted = false;
|
||||||
|
void _startJourney() {
|
||||||
|
if (_journeyStarted) return;
|
||||||
|
_journeyStarted = true;
|
||||||
|
final op = recorder.begin(
|
||||||
|
JourneyKind.destination,
|
||||||
|
JourneyStep.destinationDraft,
|
||||||
|
simulated: service.isSimulated,
|
||||||
|
);
|
||||||
|
recorder.finish(op, simulated: service.isSimulated);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool savingFailed = false;
|
||||||
|
bool get hasDraft => details != null || nickname.isNotEmpty;
|
||||||
|
void change(DestinationDetails? value) {
|
||||||
|
if (busy || uncertain) return;
|
||||||
|
details = value;
|
||||||
|
error = null;
|
||||||
|
errorField = null;
|
||||||
|
eligibility = null;
|
||||||
|
result = null;
|
||||||
|
saved = null;
|
||||||
|
_key = null;
|
||||||
|
_creationStarted = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void preferences({bool? save, String? nickname}) {
|
||||||
|
if (busy || uncertain) return;
|
||||||
|
if (save == true) _startJourney();
|
||||||
|
if (save == false && this.save) {
|
||||||
|
recorder.abandon(JourneyExit.modeChange);
|
||||||
|
_journeyStarted = false;
|
||||||
|
}
|
||||||
|
this.save = save ?? this.save;
|
||||||
|
this.nickname = nickname ?? this.nickname;
|
||||||
|
_key = null;
|
||||||
|
saved = null;
|
||||||
|
error = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> proceed({bool withoutSaving = false}) async {
|
||||||
|
if (busy || details == null) return;
|
||||||
|
if (withoutSaving && !uncertain) recorder.abandon(JourneyExit.modeChange);
|
||||||
|
if (uncertain && !withoutSaving) {
|
||||||
|
await reconcile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
error = null;
|
||||||
|
errorField = null;
|
||||||
|
savingFailed = false;
|
||||||
|
if (!uncertain) _creationStarted = false;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
// Validation precedes both persistence and payout assessment.
|
||||||
|
final normalized = (await service.validate(details!)).details;
|
||||||
|
if (_disposed) return;
|
||||||
|
if (restoring != null) {
|
||||||
|
_key ??= destinationKey();
|
||||||
|
final bindingOperation = recorder.begin(
|
||||||
|
JourneyKind.destination,
|
||||||
|
JourneyStep.destinationBinding,
|
||||||
|
simulated: service.isSimulated,
|
||||||
|
);
|
||||||
|
final restored = await service.restoreCard(
|
||||||
|
restoring!.id,
|
||||||
|
restoring!.executionRevision,
|
||||||
|
normalized as CardDestination,
|
||||||
|
_key!,
|
||||||
|
);
|
||||||
|
recorder.finish(
|
||||||
|
bindingOperation,
|
||||||
|
terminal: true,
|
||||||
|
simulated: service.isSimulated,
|
||||||
|
);
|
||||||
|
if (!_disposed) result = restored.input;
|
||||||
|
} else if (context != null) {
|
||||||
|
eligibility = await service.assess(
|
||||||
|
OneTimeDestination(normalized),
|
||||||
|
context!,
|
||||||
|
);
|
||||||
|
if (_disposed) return;
|
||||||
|
}
|
||||||
|
if (restoring == null &&
|
||||||
|
(context == null || save) &&
|
||||||
|
!withoutSaving &&
|
||||||
|
saved == null) {
|
||||||
|
if (eligibility?.canSave == false)
|
||||||
|
throw const DestinationException(DestinationIssue.unsupported);
|
||||||
|
_key ??= destinationKey();
|
||||||
|
final op = recorder.begin(
|
||||||
|
JourneyKind.destination,
|
||||||
|
JourneyStep.destinationSave,
|
||||||
|
simulated: service.isSimulated,
|
||||||
|
);
|
||||||
|
_creationStarted = true;
|
||||||
|
try {
|
||||||
|
saved = await service.create(
|
||||||
|
normalized,
|
||||||
|
nickname,
|
||||||
|
_key!,
|
||||||
|
replaces: replacing,
|
||||||
|
);
|
||||||
|
_creationStarted = false;
|
||||||
|
if (_disposed) return;
|
||||||
|
recorder.finish(
|
||||||
|
op,
|
||||||
|
simulated: service.isSimulated,
|
||||||
|
terminal: true,
|
||||||
|
resolution: DestinationResolution.values.byName(
|
||||||
|
saved!.outcome.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
savingFailed = true;
|
||||||
|
recorder.finish(
|
||||||
|
op,
|
||||||
|
failure: JourneyFailure.unavailable,
|
||||||
|
simulated: service.isSimulated,
|
||||||
|
);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (restoring != null) {
|
||||||
|
// Restored bindings never create or rename a saved destination.
|
||||||
|
} else if (context == null) {
|
||||||
|
if (saved?.outcome == SaveOutcome.created)
|
||||||
|
result = SavedReference(
|
||||||
|
saved!.destinationId,
|
||||||
|
(await service.get(saved!.destinationId)).executionRevision,
|
||||||
|
);
|
||||||
|
} else if (eligibility!.eligible) {
|
||||||
|
if (withoutSaving || saved == null) {
|
||||||
|
result = OneTimeDestination(normalized);
|
||||||
|
} else if (saved!.outcome == SaveOutcome.created) {
|
||||||
|
final d = await service.get(saved!.destinationId);
|
||||||
|
result = d.input;
|
||||||
|
}
|
||||||
|
// Duplicates need explicit Use existing method, never another create.
|
||||||
|
}
|
||||||
|
} on DestinationException catch (e) {
|
||||||
|
error = e.issue;
|
||||||
|
errorField = e.field;
|
||||||
|
uncertain =
|
||||||
|
_creationStarted &&
|
||||||
|
[
|
||||||
|
DestinationIssue.uncertain,
|
||||||
|
DestinationIssue.unavailable,
|
||||||
|
].contains(e.issue);
|
||||||
|
} catch (_) {
|
||||||
|
error = DestinationIssue.unavailable;
|
||||||
|
uncertain = _creationStarted;
|
||||||
|
}
|
||||||
|
if (_disposed) return;
|
||||||
|
busy = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> useExisting() async {
|
||||||
|
if (busy || saved == null) return;
|
||||||
|
error = null;
|
||||||
|
busy = true;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final d = await service.get(saved!.destinationId);
|
||||||
|
if (d.removed) throw const DestinationException(DestinationIssue.removed);
|
||||||
|
if (context != null)
|
||||||
|
eligibility = await service.assess(d.input, context!);
|
||||||
|
if (context == null || eligibility!.eligible) result = d.input;
|
||||||
|
} on DestinationException catch (e) {
|
||||||
|
error = e.issue;
|
||||||
|
} catch (_) {
|
||||||
|
error = DestinationIssue.unavailable;
|
||||||
|
}
|
||||||
|
if (_disposed) return;
|
||||||
|
busy = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reconcile() async {
|
||||||
|
if (busy || _key == null) return;
|
||||||
|
busy = true;
|
||||||
|
notifyListeners();
|
||||||
|
final op = recorder.begin(
|
||||||
|
JourneyKind.destination,
|
||||||
|
JourneyStep.destinationReconciliation,
|
||||||
|
simulated: service.isSimulated,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final outcome = await service.creation(_key!);
|
||||||
|
if (_disposed) return;
|
||||||
|
saved = outcome;
|
||||||
|
recorder.finish(
|
||||||
|
op,
|
||||||
|
simulated: service.isSimulated,
|
||||||
|
terminal: outcome != null,
|
||||||
|
resolution: outcome == null
|
||||||
|
? null
|
||||||
|
: DestinationResolution.values.byName(outcome.outcome.name),
|
||||||
|
);
|
||||||
|
uncertain = false;
|
||||||
|
_creationStarted = false;
|
||||||
|
error = null;
|
||||||
|
// Absence is an authoritative lookup result, not a timeout inference.
|
||||||
|
if (outcome?.outcome == SaveOutcome.created && context == null) {
|
||||||
|
final d = await service.get(outcome!.destinationId);
|
||||||
|
if (d.removed)
|
||||||
|
throw const DestinationException(DestinationIssue.removed);
|
||||||
|
result = d.input;
|
||||||
|
}
|
||||||
|
} on DestinationException catch (e) {
|
||||||
|
recorder.finish(
|
||||||
|
op,
|
||||||
|
simulated: service.isSimulated,
|
||||||
|
failure: JourneyFailure.unavailable,
|
||||||
|
);
|
||||||
|
error = e.issue;
|
||||||
|
} catch (_) {
|
||||||
|
recorder.finish(
|
||||||
|
op,
|
||||||
|
simulated: service.isSimulated,
|
||||||
|
failure: JourneyFailure.unavailable,
|
||||||
|
);
|
||||||
|
error = DestinationIssue.unavailable;
|
||||||
|
}
|
||||||
|
if (_disposed) return;
|
||||||
|
busy = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
recorder.dispose(abandonActive: !uncertain);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
/// An opaque attempt/record reference, never derived from destination details.
|
||||||
|
String destinationKey() => List.generate(
|
||||||
|
16,
|
||||||
|
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||||
|
).join();
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'models.dart';
|
||||||
|
import 'service.dart';
|
||||||
|
|
||||||
|
class DestinationManagementProvider extends ChangeNotifier {
|
||||||
|
DestinationManagementProvider(this.service, this.destination);
|
||||||
|
final DestinationService service;
|
||||||
|
SavedDestination destination;
|
||||||
|
DestinationIssue? error;
|
||||||
|
bool busy = false, removed = false, _disposed = false;
|
||||||
|
Future<void> rename(String nickname) => _run(() async {
|
||||||
|
destination = await service.rename(
|
||||||
|
destination.id,
|
||||||
|
nickname,
|
||||||
|
destination.metadataRevision,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Future<void> remove() => _run(() async {
|
||||||
|
await service.remove(
|
||||||
|
destination.id,
|
||||||
|
destination.metadataRevision,
|
||||||
|
destination.executionRevision,
|
||||||
|
);
|
||||||
|
removed = true;
|
||||||
|
});
|
||||||
|
Future<void> refresh() => _run(() async {
|
||||||
|
destination = await service.get(destination.id);
|
||||||
|
removed = destination.removed;
|
||||||
|
});
|
||||||
|
Future<void> _run(Future<void> Function() action) async {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
error = null;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} on DestinationException catch (e) {
|
||||||
|
error = e.issue;
|
||||||
|
} catch (_) {
|
||||||
|
error = DestinationIssue.unavailable;
|
||||||
|
}
|
||||||
|
if (_disposed) return;
|
||||||
|
busy = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
enum DestinationType { crypto, card, russianBank }
|
||||||
|
|
||||||
|
enum EligibilityState {
|
||||||
|
eligible,
|
||||||
|
temporarilyUnavailable,
|
||||||
|
requiresAction,
|
||||||
|
unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DestinationIssue {
|
||||||
|
invalid,
|
||||||
|
unavailable,
|
||||||
|
uncertain,
|
||||||
|
conflict,
|
||||||
|
removed,
|
||||||
|
expired,
|
||||||
|
bankChanged,
|
||||||
|
authorization,
|
||||||
|
unsupported,
|
||||||
|
duplicate,
|
||||||
|
replacementRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SaveOutcome { created, duplicate }
|
||||||
|
|
||||||
|
enum BindingState { ready, expired, revoked, unavailable }
|
||||||
|
|
||||||
|
class DestinationException implements Exception {
|
||||||
|
const DestinationException(this.issue, {this.field});
|
||||||
|
final DestinationIssue issue;
|
||||||
|
final String? field;
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class DestinationDetails {
|
||||||
|
const DestinationDetails();
|
||||||
|
DestinationType get type;
|
||||||
|
String get display;
|
||||||
|
String get review;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CryptoDestination extends DestinationDetails {
|
||||||
|
const CryptoDestination({
|
||||||
|
required this.assetId,
|
||||||
|
required this.networkId,
|
||||||
|
required this.address,
|
||||||
|
this.memo = '',
|
||||||
|
required this.assetLabel,
|
||||||
|
required this.networkLabel,
|
||||||
|
});
|
||||||
|
final String assetId, networkId, address, memo, assetLabel, networkLabel;
|
||||||
|
@override
|
||||||
|
DestinationType get type => DestinationType.crypto;
|
||||||
|
@override
|
||||||
|
String get display =>
|
||||||
|
'$assetLabel · $networkLabel · ${address.length > 14 ? '${address.substring(0, 6)}…${address.substring(address.length - 6)}' : address}';
|
||||||
|
@override
|
||||||
|
String get review =>
|
||||||
|
'$assetLabel · $networkLabel\n$address${memo.isEmpty ? '' : '\n$memo'}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secure capture references are short lived, not PAN/CVV or destination IDs.
|
||||||
|
class CardDestination extends DestinationDetails {
|
||||||
|
const CardDestination({
|
||||||
|
this.capture = '',
|
||||||
|
this.identity = '',
|
||||||
|
required this.brand,
|
||||||
|
required this.last4,
|
||||||
|
required this.expiryMonth,
|
||||||
|
required this.expiryYear,
|
||||||
|
});
|
||||||
|
final String capture, identity, brand, last4;
|
||||||
|
final int expiryMonth, expiryYear;
|
||||||
|
@override
|
||||||
|
DestinationType get type => DestinationType.card;
|
||||||
|
@override
|
||||||
|
String get display => '$brand · •••• $last4';
|
||||||
|
@override
|
||||||
|
String get review => display;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RussianBankDestination extends DestinationDetails {
|
||||||
|
const RussianBankDestination({
|
||||||
|
required this.firstName,
|
||||||
|
required this.lastName,
|
||||||
|
this.patronymic = '',
|
||||||
|
required this.bank,
|
||||||
|
required this.account,
|
||||||
|
});
|
||||||
|
final String firstName, lastName, patronymic, account;
|
||||||
|
final Bank bank;
|
||||||
|
String get holder =>
|
||||||
|
[firstName, lastName, patronymic].where((s) => s.isNotEmpty).join(' ');
|
||||||
|
@override
|
||||||
|
DestinationType get type => DestinationType.russianBank;
|
||||||
|
@override
|
||||||
|
String get display =>
|
||||||
|
'${bank.name} · •••• ${account.length < 4 ? account : account.substring(account.length - 4)}';
|
||||||
|
@override
|
||||||
|
String get review =>
|
||||||
|
'$holder\n${bank.name}\n${bank.bik}\n$account\n${bank.correspondent}';
|
||||||
|
}
|
||||||
|
|
||||||
|
class Bank {
|
||||||
|
const Bank({
|
||||||
|
required this.bik,
|
||||||
|
required this.name,
|
||||||
|
required this.city,
|
||||||
|
required this.correspondent,
|
||||||
|
required this.revision,
|
||||||
|
this.supported = true,
|
||||||
|
});
|
||||||
|
final String bik, name, city, correspondent, revision;
|
||||||
|
final bool supported;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CryptoAsset {
|
||||||
|
const CryptoAsset(
|
||||||
|
this.id,
|
||||||
|
this.label,
|
||||||
|
this.networkId,
|
||||||
|
this.networkLabel, {
|
||||||
|
this.memoRequired = false,
|
||||||
|
this.memoSupported = false,
|
||||||
|
});
|
||||||
|
final String id, label, networkId, networkLabel;
|
||||||
|
final bool memoRequired, memoSupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProviderBinding {
|
||||||
|
const ProviderBinding({
|
||||||
|
required this.reference,
|
||||||
|
required this.state,
|
||||||
|
required this.reusable,
|
||||||
|
this.recoverable = false,
|
||||||
|
this.expiresAt,
|
||||||
|
});
|
||||||
|
final String reference;
|
||||||
|
final BindingState state;
|
||||||
|
final bool reusable, recoverable;
|
||||||
|
final DateTime? expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OwnershipAssessment {
|
||||||
|
const OwnershipAssessment({
|
||||||
|
this.declaredOwn = true,
|
||||||
|
this.verified = false,
|
||||||
|
this.actionRequired = false,
|
||||||
|
});
|
||||||
|
final bool declaredOwn, verified, actionRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SavedDestination {
|
||||||
|
SavedDestination({
|
||||||
|
required this.id,
|
||||||
|
required this.details,
|
||||||
|
this.nickname = '',
|
||||||
|
this.metadataRevision = 1,
|
||||||
|
this.executionRevision = 1,
|
||||||
|
this.removed = false,
|
||||||
|
this.lastUsed,
|
||||||
|
this.ownership = const OwnershipAssessment(),
|
||||||
|
List<ProviderBinding> bindings = const [],
|
||||||
|
}) : bindings = List.unmodifiable(bindings);
|
||||||
|
final String id, nickname;
|
||||||
|
final DestinationDetails details;
|
||||||
|
final int metadataRevision, executionRevision;
|
||||||
|
final bool removed;
|
||||||
|
final DateTime? lastUsed;
|
||||||
|
final OwnershipAssessment ownership;
|
||||||
|
final List<ProviderBinding> bindings;
|
||||||
|
String get label => nickname.isEmpty ? details.display : nickname;
|
||||||
|
SavedReference get input => SavedReference(id, executionRevision);
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class DestinationInput {
|
||||||
|
const DestinationInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
class SavedReference extends DestinationInput {
|
||||||
|
const SavedReference(this.id, this.executionRevision);
|
||||||
|
final String id;
|
||||||
|
final int executionRevision;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OneTimeDestination extends DestinationInput {
|
||||||
|
const OneTimeDestination(this.details);
|
||||||
|
final DestinationDetails details;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PayoutContext {
|
||||||
|
const PayoutContext(this.source, {this.amount});
|
||||||
|
final String source;
|
||||||
|
final int? amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PayoutEligibility {
|
||||||
|
const PayoutEligibility(this.state, {this.reason, this.canSave = true});
|
||||||
|
final EligibilityState state;
|
||||||
|
final DestinationIssue? reason;
|
||||||
|
final bool canSave;
|
||||||
|
bool get eligible => state == EligibilityState.eligible;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DestinationValidation {
|
||||||
|
const DestinationValidation(this.details);
|
||||||
|
final DestinationDetails details;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DestinationCapabilities {
|
||||||
|
DestinationCapabilities({
|
||||||
|
List<DestinationType> types = const [],
|
||||||
|
List<CryptoAsset> assets = const [],
|
||||||
|
this.reusableCards = false,
|
||||||
|
this.cardRecovery = false,
|
||||||
|
this.simulated = false,
|
||||||
|
}) : types = List.unmodifiable(types),
|
||||||
|
assets = List.unmodifiable(assets);
|
||||||
|
final List<DestinationType> types;
|
||||||
|
final List<CryptoAsset> assets;
|
||||||
|
final bool reusableCards, cardRecovery, simulated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome identity never changes when a record is later renamed or removed.
|
||||||
|
class CreationResult {
|
||||||
|
const CreationResult(this.outcome, this.destinationId);
|
||||||
|
final SaveOutcome outcome;
|
||||||
|
final String destinationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DestinationLease {
|
||||||
|
const DestinationLease({
|
||||||
|
required this.input,
|
||||||
|
required this.snapshot,
|
||||||
|
required this.context,
|
||||||
|
required this.requirementsRevision,
|
||||||
|
required this.expiresAt,
|
||||||
|
});
|
||||||
|
final DestinationInput input;
|
||||||
|
final DestinationDetails snapshot;
|
||||||
|
final PayoutContext context;
|
||||||
|
final int requirementsRevision;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DestinationAudit {
|
||||||
|
const DestinationAudit(
|
||||||
|
this.actor,
|
||||||
|
this.reference,
|
||||||
|
this.action,
|
||||||
|
this.outcome,
|
||||||
|
this.at,
|
||||||
|
);
|
||||||
|
final String actor, reference, action, outcome;
|
||||||
|
final DateTime at;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'models.dart';
|
||||||
|
import 'service.dart';
|
||||||
|
|
||||||
|
class DestinationsProvider extends ChangeNotifier {
|
||||||
|
DestinationsProvider(this.service);
|
||||||
|
final DestinationService service;
|
||||||
|
DestinationCapabilities? capabilities;
|
||||||
|
List<SavedDestination> methods = [];
|
||||||
|
bool loading = false, failed = false, _disposed = false;
|
||||||
|
int _revision = 0;
|
||||||
|
Future<void> refresh() async {
|
||||||
|
final revision = ++_revision;
|
||||||
|
loading = true;
|
||||||
|
failed = false;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final caps = await service.capabilities();
|
||||||
|
final rows = await service.list();
|
||||||
|
if (_disposed || revision != _revision) return;
|
||||||
|
capabilities = caps;
|
||||||
|
methods = rows;
|
||||||
|
} catch (_) {
|
||||||
|
if (_disposed || revision != _revision) return;
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
if (_disposed) return;
|
||||||
|
loading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SavedDestination> search(String query, DestinationType type) {
|
||||||
|
final q = query.trim().toLowerCase();
|
||||||
|
final rows = methods
|
||||||
|
.where(
|
||||||
|
(d) =>
|
||||||
|
d.details.type == type &&
|
||||||
|
'${d.label} ${d.details.display}'.toLowerCase().contains(q),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
rows.sort(
|
||||||
|
(a, b) => (b.lastUsed ?? DateTime(1970)).compareTo(
|
||||||
|
a.lastUsed ?? DateTime(1970),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
_revision++;
|
||||||
|
methods = [];
|
||||||
|
capabilities = null;
|
||||||
|
loading = failed = false;
|
||||||
|
service.clearTransient();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_revision++;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'models.dart';
|
||||||
|
import 'service.dart';
|
||||||
|
|
||||||
|
class BankSearchProvider extends ChangeNotifier {
|
||||||
|
BankSearchProvider(this.service);
|
||||||
|
final DestinationService service;
|
||||||
|
Timer? _timer;
|
||||||
|
Completer<void>? _abort;
|
||||||
|
int _generation = 0;
|
||||||
|
bool _disposed = false, loading = false;
|
||||||
|
String query = '';
|
||||||
|
List<Bank> results = [];
|
||||||
|
DestinationIssue? error;
|
||||||
|
void search(String value) {
|
||||||
|
_timer?.cancel();
|
||||||
|
if (_abort != null && !_abort!.isCompleted) _abort!.complete();
|
||||||
|
_abort = null;
|
||||||
|
final generation = ++_generation;
|
||||||
|
query = value;
|
||||||
|
results = [];
|
||||||
|
error = null;
|
||||||
|
loading = false;
|
||||||
|
if (value.trim().length >= 2) {
|
||||||
|
_timer = Timer(
|
||||||
|
const Duration(milliseconds: 350),
|
||||||
|
() => _lookup(value, generation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _lookup(String value, int generation) async {
|
||||||
|
loading = true;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final abort = Completer<void>();
|
||||||
|
_abort = abort;
|
||||||
|
final rows = await service.banks(value, abortTrigger: abort.future);
|
||||||
|
if (_disposed || generation != _generation) return;
|
||||||
|
results = rows;
|
||||||
|
if (rows.isEmpty) error = DestinationIssue.invalid;
|
||||||
|
} on DestinationException catch (e) {
|
||||||
|
if (!_disposed && generation == _generation) error = e.issue;
|
||||||
|
} catch (_) {
|
||||||
|
if (!_disposed && generation == _generation)
|
||||||
|
error = DestinationIssue.unavailable;
|
||||||
|
}
|
||||||
|
if (_disposed || generation != _generation) return;
|
||||||
|
loading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_generation++;
|
||||||
|
_timer?.cancel();
|
||||||
|
if (_abort != null && !_abort!.isCompleted) _abort!.complete();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'models.dart';
|
||||||
|
import 'service.dart';
|
||||||
|
|
||||||
|
class DestinationSelectionProvider extends ChangeNotifier {
|
||||||
|
DestinationSelectionProvider(this.service, this.context);
|
||||||
|
final DestinationService service;
|
||||||
|
final PayoutContext context;
|
||||||
|
final assessments = <String, PayoutEligibility>{};
|
||||||
|
bool busy = false, _disposed = false;
|
||||||
|
DestinationIssue? error;
|
||||||
|
Future<void> assess(List<SavedDestination> methods) async {
|
||||||
|
for (final d in methods) {
|
||||||
|
try {
|
||||||
|
final assessment = await service.assess(d.input, context);
|
||||||
|
if (_disposed) return;
|
||||||
|
assessments[d.id] = assessment;
|
||||||
|
} on DestinationException catch (e) {
|
||||||
|
if (_disposed) return;
|
||||||
|
assessments[d.id] = PayoutEligibility(
|
||||||
|
EligibilityState.requiresAction,
|
||||||
|
reason: e.issue,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
if (_disposed) return;
|
||||||
|
assessments[d.id] = const PayoutEligibility(
|
||||||
|
EligibilityState.temporarilyUnavailable,
|
||||||
|
reason: DestinationIssue.unavailable,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SavedReference?> select(SavedDestination d) async {
|
||||||
|
if (busy) return null;
|
||||||
|
busy = true;
|
||||||
|
error = null;
|
||||||
|
notifyListeners();
|
||||||
|
SavedReference? result;
|
||||||
|
try {
|
||||||
|
final current = await service.get(d.id);
|
||||||
|
final a = await service.assess(current.input, context);
|
||||||
|
if (_disposed) return null;
|
||||||
|
assessments[d.id] = a;
|
||||||
|
if (a.eligible) result = current.input;
|
||||||
|
} on DestinationException catch (e) {
|
||||||
|
error = e.issue;
|
||||||
|
} catch (_) {
|
||||||
|
error = DestinationIssue.unavailable;
|
||||||
|
}
|
||||||
|
if (_disposed) return null;
|
||||||
|
busy = false;
|
||||||
|
notifyListeners();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import 'models.dart';
|
||||||
|
|
||||||
|
abstract interface class DestinationService {
|
||||||
|
bool get isSimulated;
|
||||||
|
Future<DestinationCapabilities> capabilities();
|
||||||
|
Future<List<SavedDestination>> list();
|
||||||
|
Future<SavedDestination> get(String id);
|
||||||
|
Future<DestinationValidation> validate(DestinationDetails details);
|
||||||
|
Future<PayoutEligibility> assess(
|
||||||
|
DestinationInput input,
|
||||||
|
PayoutContext context,
|
||||||
|
);
|
||||||
|
Future<CreationResult> create(
|
||||||
|
DestinationDetails details,
|
||||||
|
String nickname,
|
||||||
|
String idempotencyKey, {
|
||||||
|
String? replaces,
|
||||||
|
});
|
||||||
|
Future<CreationResult?> creation(String idempotencyKey);
|
||||||
|
Future<SavedDestination> rename(
|
||||||
|
String id,
|
||||||
|
String nickname,
|
||||||
|
int metadataRevision,
|
||||||
|
);
|
||||||
|
Future<void> remove(String id, int metadataRevision, int executionRevision);
|
||||||
|
Future<List<Bank>> banks(String query, {Future<void>? abortTrigger});
|
||||||
|
Future<DestinationLease> prepare(
|
||||||
|
DestinationInput input,
|
||||||
|
PayoutContext context,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<SavedDestination> restoreCard(
|
||||||
|
String id,
|
||||||
|
int executionRevision,
|
||||||
|
CardDestination capture,
|
||||||
|
String idempotencyKey,
|
||||||
|
);
|
||||||
|
void clearTransient();
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import 'models.dart';
|
||||||
|
|
||||||
|
/// Local DTO boundary: transport fields never escape services into providers/UI.
|
||||||
|
class DestinationDTO {
|
||||||
|
const DestinationDTO(this.value);
|
||||||
|
final Map<String, Object?> value;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract final class DestinationMapper {
|
||||||
|
static DestinationDTO details(DestinationDetails value) =>
|
||||||
|
DestinationDTO(switch (value) {
|
||||||
|
CryptoDestination d => {
|
||||||
|
'type': 'crypto',
|
||||||
|
'assetId': d.assetId,
|
||||||
|
'networkId': d.networkId,
|
||||||
|
'address': d.address,
|
||||||
|
'memo': d.memo,
|
||||||
|
'assetLabel': d.assetLabel,
|
||||||
|
'networkLabel': d.networkLabel,
|
||||||
|
},
|
||||||
|
CardDestination d => {
|
||||||
|
'type': 'card',
|
||||||
|
'capture': d.capture,
|
||||||
|
'identity': d.identity,
|
||||||
|
'brand': d.brand,
|
||||||
|
'last4': d.last4,
|
||||||
|
'expiryMonth': d.expiryMonth,
|
||||||
|
'expiryYear': d.expiryYear,
|
||||||
|
},
|
||||||
|
RussianBankDestination d => {
|
||||||
|
'type': 'russianBank',
|
||||||
|
'firstName': d.firstName,
|
||||||
|
'lastName': d.lastName,
|
||||||
|
'patronymic': d.patronymic,
|
||||||
|
'account': d.account,
|
||||||
|
'bank': bank(d.bank),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
static Map<String, Object?> bank(Bank b) => {
|
||||||
|
'bik': b.bik,
|
||||||
|
'name': b.name,
|
||||||
|
'city': b.city,
|
||||||
|
'correspondent': b.correspondent,
|
||||||
|
'revision': b.revision,
|
||||||
|
'supported': b.supported,
|
||||||
|
};
|
||||||
|
static Bank fromBank(Map<String, dynamic> v) => Bank(
|
||||||
|
bik: v['bik'] as String,
|
||||||
|
name: v['name'] as String,
|
||||||
|
city: v['city'] as String,
|
||||||
|
correspondent: v['correspondent'] as String,
|
||||||
|
revision: v['revision'] as String,
|
||||||
|
supported: v['supported'] as bool,
|
||||||
|
);
|
||||||
|
static DestinationDetails fromDTO(DestinationDTO dto) {
|
||||||
|
final d = dto.value;
|
||||||
|
return switch (d['type']) {
|
||||||
|
'crypto' => CryptoDestination(
|
||||||
|
assetId: d['assetId'] as String,
|
||||||
|
networkId: d['networkId'] as String,
|
||||||
|
address: d['address'] as String,
|
||||||
|
memo: d['memo'] as String,
|
||||||
|
assetLabel: d['assetLabel'] as String,
|
||||||
|
networkLabel: d['networkLabel'] as String,
|
||||||
|
),
|
||||||
|
'card' => CardDestination(
|
||||||
|
capture: d['capture'] as String,
|
||||||
|
identity: d['identity'] as String,
|
||||||
|
brand: d['brand'] as String,
|
||||||
|
last4: d['last4'] as String,
|
||||||
|
expiryMonth: d['expiryMonth'] as int,
|
||||||
|
expiryYear: d['expiryYear'] as int,
|
||||||
|
),
|
||||||
|
'russianBank' => RussianBankDestination(
|
||||||
|
firstName: d['firstName'] as String,
|
||||||
|
lastName: d['lastName'] as String,
|
||||||
|
patronymic: d['patronymic'] as String,
|
||||||
|
account: d['account'] as String,
|
||||||
|
bank: fromBank(Map<String, dynamic>.from(d['bank'] as Map)),
|
||||||
|
),
|
||||||
|
_ => throw const DestinationException(DestinationIssue.unsupported),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static DestinationDetails clone(DestinationDetails d) => fromDTO(details(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class DestinationInputDTO {
|
||||||
|
const DestinationInputDTO();
|
||||||
|
}
|
||||||
|
|
||||||
|
class SavedInputDTO extends DestinationInputDTO {
|
||||||
|
const SavedInputDTO(this.id, this.executionRevision);
|
||||||
|
final String id;
|
||||||
|
final int executionRevision;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OneTimeInputDTO extends DestinationInputDTO {
|
||||||
|
const OneTimeInputDTO(this.details);
|
||||||
|
final DestinationDTO details;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract final class DestinationInputMapper {
|
||||||
|
static DestinationInputDTO toDTO(DestinationInput input) => switch (input) {
|
||||||
|
SavedReference r => SavedInputDTO(r.id, r.executionRevision),
|
||||||
|
OneTimeDestination d => OneTimeInputDTO(
|
||||||
|
DestinationMapper.details(d.details),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
static DestinationInput fromDTO(DestinationInputDTO dto) => switch (dto) {
|
||||||
|
SavedInputDTO d => SavedReference(d.id, d.executionRevision),
|
||||||
|
OneTimeInputDTO d => OneTimeDestination(
|
||||||
|
DestinationMapper.fromDTO(d.details),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import 'models.dart';
|
||||||
|
import 'service.dart';
|
||||||
|
|
||||||
|
/// Fail closed when the live backend has not supplied the destination contract.
|
||||||
|
/// Never substitutes a demo store for an authenticated live wallet.
|
||||||
|
class UnavailableDestinationService implements DestinationService {
|
||||||
|
const UnavailableDestinationService();
|
||||||
|
@override
|
||||||
|
bool get isSimulated => false;
|
||||||
|
@override
|
||||||
|
Future<DestinationCapabilities> capabilities() async =>
|
||||||
|
DestinationCapabilities();
|
||||||
|
@override
|
||||||
|
Future<List<SavedDestination>> list() async => [];
|
||||||
|
Never _unsupported() =>
|
||||||
|
throw const DestinationException(DestinationIssue.unsupported);
|
||||||
|
@override
|
||||||
|
Future<SavedDestination> get(String id) async => _unsupported();
|
||||||
|
@override
|
||||||
|
Future<DestinationValidation> validate(DestinationDetails details) async =>
|
||||||
|
_unsupported();
|
||||||
|
@override
|
||||||
|
Future<PayoutEligibility> assess(
|
||||||
|
DestinationInput input,
|
||||||
|
PayoutContext context,
|
||||||
|
) async => const PayoutEligibility(
|
||||||
|
EligibilityState.unsupported,
|
||||||
|
reason: DestinationIssue.unsupported,
|
||||||
|
canSave: false,
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
Future<CreationResult> create(
|
||||||
|
DestinationDetails details,
|
||||||
|
String nickname,
|
||||||
|
String idempotencyKey, {
|
||||||
|
String? replaces,
|
||||||
|
}) async => _unsupported();
|
||||||
|
@override
|
||||||
|
Future<CreationResult?> creation(String idempotencyKey) async =>
|
||||||
|
_unsupported();
|
||||||
|
@override
|
||||||
|
Future<SavedDestination> rename(
|
||||||
|
String id,
|
||||||
|
String nickname,
|
||||||
|
int metadataRevision,
|
||||||
|
) async => _unsupported();
|
||||||
|
@override
|
||||||
|
Future<void> remove(
|
||||||
|
String id,
|
||||||
|
int metadataRevision,
|
||||||
|
int executionRevision,
|
||||||
|
) async => _unsupported();
|
||||||
|
@override
|
||||||
|
Future<List<Bank>> banks(String query, {Future<void>? abortTrigger}) async =>
|
||||||
|
_unsupported();
|
||||||
|
@override
|
||||||
|
Future<DestinationLease> prepare(
|
||||||
|
DestinationInput input,
|
||||||
|
PayoutContext context,
|
||||||
|
) async => _unsupported();
|
||||||
|
@override
|
||||||
|
Future<SavedDestination> restoreCard(
|
||||||
|
String id,
|
||||||
|
int executionRevision,
|
||||||
|
CardDestination capture,
|
||||||
|
String idempotencyKey,
|
||||||
|
) async => _unsupported();
|
||||||
|
@override
|
||||||
|
void clearTransient() {}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import '../destinations/models.dart';
|
||||||
|
|
||||||
enum TransferKind { topUp, exchange, payout }
|
enum TransferKind { topUp, exchange, payout }
|
||||||
|
|
||||||
enum OperationStatus { pending, completed, failed }
|
enum OperationStatus { pending, completed, failed }
|
||||||
@@ -12,6 +14,7 @@ enum WalletError {
|
|||||||
unsupported,
|
unsupported,
|
||||||
notFound,
|
notFound,
|
||||||
uncertain,
|
uncertain,
|
||||||
|
destinationChanged,
|
||||||
}
|
}
|
||||||
|
|
||||||
class WalletException implements Exception {
|
class WalletException implements Exception {
|
||||||
@@ -64,12 +67,14 @@ class TransferRequest {
|
|||||||
required this.amount,
|
required this.amount,
|
||||||
this.destination,
|
this.destination,
|
||||||
this.recipient = '',
|
this.recipient = '',
|
||||||
|
this.payoutDestination,
|
||||||
});
|
});
|
||||||
final TransferKind kind;
|
final TransferKind kind;
|
||||||
final String source;
|
final String source;
|
||||||
final int amount;
|
final int amount;
|
||||||
final String? destination;
|
final String? destination;
|
||||||
final String recipient;
|
final String recipient;
|
||||||
|
final DestinationInput? payoutDestination;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Quote {
|
class Quote {
|
||||||
@@ -82,11 +87,15 @@ class Quote {
|
|||||||
required this.expiresAt,
|
required this.expiresAt,
|
||||||
required this.rateNumerator,
|
required this.rateNumerator,
|
||||||
required this.rateDenominator,
|
required this.rateDenominator,
|
||||||
|
this.payoutSnapshot,
|
||||||
|
this.payoutTerms,
|
||||||
});
|
});
|
||||||
final String id;
|
final String id;
|
||||||
final TransferRequest request;
|
final TransferRequest request;
|
||||||
final int fee, debit, credit, rateNumerator, rateDenominator;
|
final int fee, debit, credit, rateNumerator, rateDenominator;
|
||||||
final DateTime expiresAt;
|
final DateTime expiresAt;
|
||||||
|
final DestinationDetails? payoutSnapshot;
|
||||||
|
final PayoutTerms? payoutTerms;
|
||||||
}
|
}
|
||||||
|
|
||||||
class WalletOperation {
|
class WalletOperation {
|
||||||
@@ -116,3 +125,14 @@ class AccountProfile {
|
|||||||
const AccountProfile(this.locale);
|
const AccountProfile(this.locale);
|
||||||
final String locale;
|
final String locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PayoutTerms {
|
||||||
|
const PayoutTerms(
|
||||||
|
this.amount,
|
||||||
|
this.precision,
|
||||||
|
this.assetLabel,
|
||||||
|
this.expectedSeconds,
|
||||||
|
);
|
||||||
|
final int amount, precision, expectedSeconds;
|
||||||
|
final String assetLabel;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
|||||||
|
|
||||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
|
import '../destinations/models.dart';
|
||||||
import '../models/wallet.dart';
|
import '../models/wallet.dart';
|
||||||
import 'wallet.dart';
|
import 'wallet.dart';
|
||||||
|
|
||||||
@@ -40,16 +41,18 @@ class TransferProvider extends ChangeNotifier {
|
|||||||
if (!accounts.any((a) => a.id == destination) || destination == source) {
|
if (!accounts.any((a) => a.id == destination) || destination == source) {
|
||||||
destination = accounts.where((a) => a.id != source).firstOrNull?.id;
|
destination = accounts.where((a) => a.id != source).firstOrNull?.id;
|
||||||
}
|
}
|
||||||
if (!wallet.service.isSimulated && kind == TransferKind.payout) {
|
|
||||||
recipient = wallet.account(source)?.payoutMethod ?? '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _walletChanged() {
|
void _walletChanged() {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
final previous = source;
|
final previous = source;
|
||||||
_syncAccounts();
|
_syncAccounts();
|
||||||
if (source != previous) notifyListeners();
|
if (source != previous) {
|
||||||
|
payoutDestination = null;
|
||||||
|
payoutLabel = '';
|
||||||
|
recipient = '';
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final WalletProvider wallet;
|
final WalletProvider wallet;
|
||||||
@@ -58,6 +61,8 @@ class TransferProvider extends ChangeNotifier {
|
|||||||
String source;
|
String source;
|
||||||
String? destination;
|
String? destination;
|
||||||
String amount = '', recipient = '';
|
String amount = '', recipient = '';
|
||||||
|
DestinationInput? payoutDestination;
|
||||||
|
String payoutLabel = '';
|
||||||
Quote? quote;
|
Quote? quote;
|
||||||
WalletOperation? result;
|
WalletOperation? result;
|
||||||
WalletError? error;
|
WalletError? error;
|
||||||
@@ -72,7 +77,7 @@ class TransferProvider extends ChangeNotifier {
|
|||||||
TransferKind.payout => JourneyKind.payout,
|
TransferKind.payout => JourneyKind.payout,
|
||||||
};
|
};
|
||||||
bool get hasDraft =>
|
bool get hasDraft =>
|
||||||
amount.isNotEmpty || (wallet.service.isSimulated && recipient.isNotEmpty);
|
amount.isNotEmpty || payoutDestination != null || recipient.isNotEmpty;
|
||||||
bool get expired =>
|
bool get expired =>
|
||||||
quote != null && !DateTime.now().isBefore(quote!.expiresAt);
|
quote != null && !DateTime.now().isBefore(quote!.expiresAt);
|
||||||
void change({
|
void change({
|
||||||
@@ -80,8 +85,17 @@ class TransferProvider extends ChangeNotifier {
|
|||||||
String? destination,
|
String? destination,
|
||||||
String? amount,
|
String? amount,
|
||||||
String? recipient,
|
String? recipient,
|
||||||
|
DestinationInput? payoutDestination,
|
||||||
|
String? payoutLabel,
|
||||||
}) {
|
}) {
|
||||||
if (busy || uncertain) return;
|
if (busy || uncertain) return;
|
||||||
|
if (source != null && source != this.source) {
|
||||||
|
this.payoutDestination = null;
|
||||||
|
this.payoutLabel = '';
|
||||||
|
recipient = '';
|
||||||
|
}
|
||||||
|
this.payoutDestination = payoutDestination ?? this.payoutDestination;
|
||||||
|
this.payoutLabel = payoutLabel ?? this.payoutLabel;
|
||||||
this.source = source ?? this.source;
|
this.source = source ?? this.source;
|
||||||
this.destination = destination ?? this.destination;
|
this.destination = destination ?? this.destination;
|
||||||
this.amount = amount ?? this.amount;
|
this.amount = amount ?? this.amount;
|
||||||
@@ -96,6 +110,36 @@ class TransferProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> selectPayout(DestinationInput input) async {
|
||||||
|
if (busy || uncertain || wallet.destinations == null) return;
|
||||||
|
busy = true;
|
||||||
|
error = null;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final service = wallet.destinations!;
|
||||||
|
final assessment = await service.assess(
|
||||||
|
input,
|
||||||
|
PayoutContext(source, amount: wallet.account(source)?.parse(amount)),
|
||||||
|
);
|
||||||
|
if (!assessment.eligible)
|
||||||
|
throw const DestinationException(DestinationIssue.unsupported);
|
||||||
|
final details = input is OneTimeDestination
|
||||||
|
? input.details
|
||||||
|
: (await service.get((input as SavedReference).id)).details;
|
||||||
|
if (_disposed) return;
|
||||||
|
busy = false;
|
||||||
|
change(payoutDestination: input, payoutLabel: details.display);
|
||||||
|
} catch (_) {
|
||||||
|
if (_disposed) return;
|
||||||
|
payoutDestination = null;
|
||||||
|
payoutLabel = '';
|
||||||
|
error = WalletError.destinationChanged;
|
||||||
|
}
|
||||||
|
if (_disposed) return;
|
||||||
|
busy = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void edit() {
|
void edit() {
|
||||||
if (!busy && !uncertain) {
|
if (!busy && !uncertain) {
|
||||||
quote = null;
|
quote = null;
|
||||||
@@ -133,6 +177,7 @@ class TransferProvider extends ChangeNotifier {
|
|||||||
amount: money,
|
amount: money,
|
||||||
destination: kind == TransferKind.exchange ? destination : null,
|
destination: kind == TransferKind.exchange ? destination : null,
|
||||||
recipient: recipient.trim(),
|
recipient: recipient.trim(),
|
||||||
|
payoutDestination: payoutDestination,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (_disposed || revision != _revision) return;
|
if (_disposed || revision != _revision) return;
|
||||||
@@ -142,6 +187,10 @@ class TransferProvider extends ChangeNotifier {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
error = _code(e);
|
error = _code(e);
|
||||||
|
if (error == WalletError.destinationChanged) {
|
||||||
|
payoutDestination = null;
|
||||||
|
payoutLabel = '';
|
||||||
|
}
|
||||||
recorder.finish(op, failure: _failure(error!));
|
recorder.finish(op, failure: _failure(error!));
|
||||||
}
|
}
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
@@ -225,6 +274,11 @@ class TransferProvider extends ChangeNotifier {
|
|||||||
uncertain = false;
|
uncertain = false;
|
||||||
} else {
|
} else {
|
||||||
error = _code(e);
|
error = _code(e);
|
||||||
|
if (error == WalletError.destinationChanged) {
|
||||||
|
quote = null;
|
||||||
|
payoutDestination = null;
|
||||||
|
payoutLabel = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// All transport/unexpected failures are ambiguous after execution started.
|
// All transport/unexpected failures are ambiguous after execution started.
|
||||||
uncertain = [
|
uncertain = [
|
||||||
@@ -247,6 +301,7 @@ class TransferProvider extends ChangeNotifier {
|
|||||||
error = null;
|
error = null;
|
||||||
factorError = null;
|
factorError = null;
|
||||||
recipient = '';
|
recipient = '';
|
||||||
|
payoutDestination = null;
|
||||||
recorder.finish(op, simulated: wallet.service.isSimulated);
|
recorder.finish(op, simulated: wallet.service.isSimulated);
|
||||||
wallet.track(value, recorder, journey);
|
wallet.track(value, recorder, journey);
|
||||||
_handedOff = true;
|
_handedOff = true;
|
||||||
|
|||||||
@@ -6,12 +6,19 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
|||||||
|
|
||||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
|
import '../destinations/service.dart';
|
||||||
import '../models/wallet.dart';
|
import '../models/wallet.dart';
|
||||||
import '../services/contracts.dart';
|
import '../services/contracts.dart';
|
||||||
|
|
||||||
class WalletProvider extends ChangeNotifier {
|
class WalletProvider extends ChangeNotifier {
|
||||||
WalletProvider(this.service, this.analytics, {this.factors});
|
WalletProvider(
|
||||||
|
this.service,
|
||||||
|
this.analytics, {
|
||||||
|
this.factors,
|
||||||
|
this.destinations,
|
||||||
|
});
|
||||||
final WalletService service;
|
final WalletService service;
|
||||||
|
final DestinationService? destinations;
|
||||||
final TwoFactorService? factors;
|
final TwoFactorService? factors;
|
||||||
final AnalyticsService analytics;
|
final AnalyticsService analytics;
|
||||||
WalletSnapshot? data;
|
WalletSnapshot? data;
|
||||||
|
|||||||
@@ -94,10 +94,15 @@ class BackendWalletService implements WalletService {
|
|||||||
@override
|
@override
|
||||||
Future<WalletSnapshot> fetch() => _mapped(send(''), BackendMapper.snapshot);
|
Future<WalletSnapshot> fetch() => _mapped(send(''), BackendMapper.snapshot);
|
||||||
@override
|
@override
|
||||||
Future<Quote> quote(TransferRequest request) => _mapped(
|
Future<Quote> quote(TransferRequest request) async {
|
||||||
|
if (request.payoutDestination != null)
|
||||||
|
throw const WalletException(WalletError.unsupported);
|
||||||
|
return _mapped(
|
||||||
send('/quote', body: BackendMapper.request(request)),
|
send('/quote', body: BackendMapper.request(request)),
|
||||||
BackendMapper.quote,
|
BackendMapper.quote,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<WalletOperation> submit(
|
Future<WalletOperation> submit(
|
||||||
String quoteId,
|
String quoteId,
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
|
|||||||
import 'package:sxchnge_kyc/limits_transport.dart';
|
import 'package:sxchnge_kyc/limits_transport.dart';
|
||||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import '../destinations/dummy.dart';
|
||||||
|
import '../destinations/models.dart';
|
||||||
|
import '../destinations/transport.dart';
|
||||||
import '../models/wallet.dart';
|
import '../models/wallet.dart';
|
||||||
import '../transport/wallet.dart';
|
import '../transport/wallet.dart';
|
||||||
import 'contracts.dart';
|
import 'contracts.dart';
|
||||||
@@ -14,13 +17,17 @@ class DummyWalletService implements WalletService {
|
|||||||
DummyWalletService(
|
DummyWalletService(
|
||||||
this.identity, {
|
this.identity, {
|
||||||
this.factors,
|
this.factors,
|
||||||
|
DummyDestinationService? destinations,
|
||||||
this.delay = const Duration(milliseconds: 350),
|
this.delay = const Duration(milliseconds: 350),
|
||||||
this.settlement = const Duration(seconds: 2),
|
this.settlement = const Duration(seconds: 2),
|
||||||
DateTime Function()? now,
|
DateTime Function()? now,
|
||||||
String initialLocale = 'en',
|
String initialLocale = 'en',
|
||||||
}) : now = now ?? DateTime.now,
|
}) : destinations = destinations ?? DummyDestinationService(now: now),
|
||||||
|
now = now ?? DateTime.now,
|
||||||
_locale = initialLocale;
|
_locale = initialLocale;
|
||||||
final IdentityService identity;
|
final IdentityService identity;
|
||||||
|
final DummyDestinationService destinations;
|
||||||
|
final _destinationLeases = <String, DestinationLease>{};
|
||||||
final TwoFactorService? factors;
|
final TwoFactorService? factors;
|
||||||
final Duration delay, settlement;
|
final Duration delay, settlement;
|
||||||
final DateTime Function() now;
|
final DateTime Function() now;
|
||||||
@@ -59,6 +66,10 @@ class DummyWalletService implements WalletService {
|
|||||||
};
|
};
|
||||||
int _eur(int minor, String id) => (minor * _weight(id) + 1099) ~/ 1100;
|
int _eur(int minor, String id) => (minor * _weight(id) + 1099) ~/ 1100;
|
||||||
void _settle() {
|
void _settle() {
|
||||||
|
_quotes.removeWhere((_, quote) => !now().isBefore(quote.expires));
|
||||||
|
_destinationLeases.removeWhere(
|
||||||
|
(_, lease) => !now().isBefore(lease.expiresAt),
|
||||||
|
);
|
||||||
for (final o in _submissions.values) {
|
for (final o in _submissions.values) {
|
||||||
if (o.status != 'pending' || now().isBefore(o.ready)) continue;
|
if (o.status != 'pending' || now().isBefore(o.ready)) continue;
|
||||||
o.status = o.fail ? 'failed' : 'completed';
|
o.status = o.fail ? 'failed' : 'completed';
|
||||||
@@ -133,9 +144,19 @@ class DummyWalletService implements WalletService {
|
|||||||
if (r.kind == TransferKind.exchange &&
|
if (r.kind == TransferKind.exchange &&
|
||||||
(r.source == r.destination || r.destination == null))
|
(r.source == r.destination || r.destination == null))
|
||||||
throw const WalletException(WalletError.unsupported);
|
throw const WalletException(WalletError.unsupported);
|
||||||
if (r.kind == TransferKind.payout &&
|
DestinationLease? lease;
|
||||||
(r.recipient.trim().length < 6 || r.recipient.length > 140))
|
if (r.kind == TransferKind.payout) {
|
||||||
|
if (r.payoutDestination == null)
|
||||||
throw const WalletException(WalletError.invalidRecipient);
|
throw const WalletException(WalletError.invalidRecipient);
|
||||||
|
try {
|
||||||
|
lease = await destinations.prepare(
|
||||||
|
r.payoutDestination!,
|
||||||
|
PayoutContext(r.source, amount: r.amount),
|
||||||
|
);
|
||||||
|
} on DestinationException {
|
||||||
|
throw const WalletException(WalletError.destinationChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (r.kind == TransferKind.exchange) _account(r.destination!);
|
if (r.kind == TransferKind.exchange) _account(r.destination!);
|
||||||
final fee = switch (r.kind) {
|
final fee = switch (r.kind) {
|
||||||
TransferKind.topUp => 0,
|
TransferKind.topUp => 0,
|
||||||
@@ -146,19 +167,30 @@ class DummyWalletService implements WalletService {
|
|||||||
final d = r.kind == TransferKind.exchange ? _weight(r.destination!) : 1;
|
final d = r.kind == TransferKind.exchange ? _weight(r.destination!) : 1;
|
||||||
final q = QuoteDTO(
|
final q = QuoteDTO(
|
||||||
'quote-${++_serial}',
|
'quote-${++_serial}',
|
||||||
WalletMapper.request(r),
|
WalletMapper.request(r, includePayoutInput: false),
|
||||||
fee,
|
fee,
|
||||||
r.kind == TransferKind.topUp ? r.amount : r.amount + fee,
|
r.kind == TransferKind.topUp ? r.amount : r.amount + fee,
|
||||||
r.amount * n ~/ d,
|
r.amount * n ~/ d,
|
||||||
now().add(const Duration(seconds: 60)),
|
now().add(const Duration(seconds: 60)),
|
||||||
n,
|
n,
|
||||||
d,
|
d,
|
||||||
|
payoutTerms: lease == null
|
||||||
|
? null
|
||||||
|
: PayoutTermsDTO(r.amount, 2, switch (lease.snapshot) {
|
||||||
|
CryptoDestination d => d.assetLabel,
|
||||||
|
RussianBankDestination() => 'RUB',
|
||||||
|
CardDestination() => _account(r.source).currency,
|
||||||
|
}, settlement.inSeconds),
|
||||||
|
payoutSnapshot: lease == null
|
||||||
|
? null
|
||||||
|
: DestinationMapper.details(lease.snapshot),
|
||||||
);
|
);
|
||||||
if (q.credit <= 0) throw const WalletException(WalletError.invalidAmount);
|
if (q.credit <= 0) throw const WalletException(WalletError.invalidAmount);
|
||||||
final verified = await _verified();
|
final verified = await _verified();
|
||||||
_settle();
|
_settle();
|
||||||
_check(q, verified);
|
_check(q, verified);
|
||||||
_quotes[q.id] = q;
|
_quotes[q.id] = q;
|
||||||
|
if (lease != null) _destinationLeases[q.id] = lease;
|
||||||
return WalletMapper.quote(q);
|
return WalletMapper.quote(q);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +225,17 @@ class DummyWalletService implements WalletService {
|
|||||||
if (_usedQuotes.contains(q.id))
|
if (_usedQuotes.contains(q.id))
|
||||||
throw const WalletException(WalletError.expiredQuote);
|
throw const WalletException(WalletError.expiredQuote);
|
||||||
_check(q, verified);
|
_check(q, verified);
|
||||||
|
final lease = _destinationLeases[q.id];
|
||||||
|
if (q.request.kind == 'payout' && lease == null)
|
||||||
|
throw const WalletException(WalletError.expiredQuote);
|
||||||
|
if (lease != null) {
|
||||||
|
try {
|
||||||
|
destinations.accept(lease);
|
||||||
|
} on DestinationException {
|
||||||
|
throw const WalletException(WalletError.destinationChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No asynchronous gap between destination acceptance and financial mutation.
|
||||||
_usedQuotes.add(q.id);
|
_usedQuotes.add(q.id);
|
||||||
if (q.request.kind != 'topUp')
|
if (q.request.kind != 'topUp')
|
||||||
_account(q.request.source).available -= q.debit;
|
_account(q.request.source).available -= q.debit;
|
||||||
@@ -205,6 +248,7 @@ class DummyWalletService implements WalletService {
|
|||||||
);
|
);
|
||||||
failNextOperation = false;
|
failNextOperation = false;
|
||||||
_submissions[idempotencyKey] = o;
|
_submissions[idempotencyKey] = o;
|
||||||
|
_destinationLeases.remove(q.id);
|
||||||
if (loseNextResponse) {
|
if (loseNextResponse) {
|
||||||
loseNextResponse = false;
|
loseNextResponse = false;
|
||||||
throw const WalletException(WalletError.uncertain);
|
throw const WalletException(WalletError.uncertain);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import '../destinations/transport.dart';
|
||||||
import '../models/wallet.dart';
|
import '../models/wallet.dart';
|
||||||
|
|
||||||
class AccountDTO {
|
class AccountDTO {
|
||||||
@@ -23,11 +24,13 @@ class RequestDTO {
|
|||||||
this.source,
|
this.source,
|
||||||
this.destination,
|
this.destination,
|
||||||
this.amount,
|
this.amount,
|
||||||
this.recipient,
|
this.recipient, {
|
||||||
);
|
this.payoutInput,
|
||||||
|
});
|
||||||
final String kind, source, recipient;
|
final String kind, source, recipient;
|
||||||
final String? destination;
|
final String? destination;
|
||||||
final int amount;
|
final int amount;
|
||||||
|
final DestinationInputDTO? payoutInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
class QuoteDTO {
|
class QuoteDTO {
|
||||||
@@ -39,12 +42,16 @@ class QuoteDTO {
|
|||||||
this.credit,
|
this.credit,
|
||||||
this.expires,
|
this.expires,
|
||||||
this.numerator,
|
this.numerator,
|
||||||
this.denominator,
|
this.denominator, {
|
||||||
);
|
this.payoutSnapshot,
|
||||||
|
this.payoutTerms,
|
||||||
|
});
|
||||||
final String id;
|
final String id;
|
||||||
final RequestDTO request;
|
final RequestDTO request;
|
||||||
final int fee, debit, credit, numerator, denominator;
|
final int fee, debit, credit, numerator, denominator;
|
||||||
final DateTime expires;
|
final DateTime expires;
|
||||||
|
final DestinationDTO? payoutSnapshot;
|
||||||
|
final PayoutTermsDTO? payoutTerms;
|
||||||
}
|
}
|
||||||
|
|
||||||
class OperationDTO {
|
class OperationDTO {
|
||||||
@@ -78,14 +85,28 @@ abstract final class WalletMapper {
|
|||||||
payoutLabel: d.payoutLabel,
|
payoutLabel: d.payoutLabel,
|
||||||
canFund: d.canFund,
|
canFund: d.canFund,
|
||||||
);
|
);
|
||||||
static RequestDTO request(TransferRequest r) =>
|
static RequestDTO request(
|
||||||
RequestDTO(r.kind.name, r.source, r.destination, r.amount, r.recipient);
|
TransferRequest r, {
|
||||||
|
bool includePayoutInput = true,
|
||||||
|
}) => RequestDTO(
|
||||||
|
r.kind.name,
|
||||||
|
r.source,
|
||||||
|
r.destination,
|
||||||
|
r.amount,
|
||||||
|
r.recipient,
|
||||||
|
payoutInput: !includePayoutInput || r.payoutDestination == null
|
||||||
|
? null
|
||||||
|
: DestinationInputMapper.toDTO(r.payoutDestination!),
|
||||||
|
);
|
||||||
static TransferRequest fromRequest(RequestDTO d) => TransferRequest(
|
static TransferRequest fromRequest(RequestDTO d) => TransferRequest(
|
||||||
kind: TransferKind.values.byName(d.kind),
|
kind: TransferKind.values.byName(d.kind),
|
||||||
source: d.source,
|
source: d.source,
|
||||||
destination: d.destination,
|
destination: d.destination,
|
||||||
amount: d.amount,
|
amount: d.amount,
|
||||||
recipient: d.recipient,
|
recipient: d.recipient,
|
||||||
|
payoutDestination: d.payoutInput == null
|
||||||
|
? null
|
||||||
|
: DestinationInputMapper.fromDTO(d.payoutInput!),
|
||||||
);
|
);
|
||||||
static Quote quote(QuoteDTO d) => Quote(
|
static Quote quote(QuoteDTO d) => Quote(
|
||||||
id: d.id,
|
id: d.id,
|
||||||
@@ -96,6 +117,17 @@ abstract final class WalletMapper {
|
|||||||
expiresAt: d.expires,
|
expiresAt: d.expires,
|
||||||
rateNumerator: d.numerator,
|
rateNumerator: d.numerator,
|
||||||
rateDenominator: d.denominator,
|
rateDenominator: d.denominator,
|
||||||
|
payoutTerms: d.payoutTerms == null
|
||||||
|
? null
|
||||||
|
: PayoutTerms(
|
||||||
|
d.payoutTerms!.amount,
|
||||||
|
d.payoutTerms!.precision,
|
||||||
|
d.payoutTerms!.assetLabel,
|
||||||
|
d.payoutTerms!.expectedSeconds,
|
||||||
|
),
|
||||||
|
payoutSnapshot: d.payoutSnapshot == null
|
||||||
|
? null
|
||||||
|
: DestinationMapper.fromDTO(d.payoutSnapshot!),
|
||||||
);
|
);
|
||||||
static WalletOperation operation(OperationDTO d) => WalletOperation(
|
static WalletOperation operation(OperationDTO d) => WalletOperation(
|
||||||
id: d.id,
|
id: d.id,
|
||||||
@@ -105,3 +137,14 @@ abstract final class WalletMapper {
|
|||||||
);
|
);
|
||||||
static AccountProfile profile(ProfileDTO dto) => AccountProfile(dto.locale);
|
static AccountProfile profile(ProfileDTO dto) => AccountProfile(dto.locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PayoutTermsDTO {
|
||||||
|
const PayoutTermsDTO(
|
||||||
|
this.amount,
|
||||||
|
this.precision,
|
||||||
|
this.assetLabel,
|
||||||
|
this.expectedSeconds,
|
||||||
|
);
|
||||||
|
final int amount, precision, expectedSeconds;
|
||||||
|
final String assetLabel;
|
||||||
|
}
|
||||||
@@ -6,3 +6,15 @@ export 'src/services/dummy.dart';
|
|||||||
export 'src/providers/limits.dart';
|
export 'src/providers/limits.dart';
|
||||||
export 'src/services/backend.dart';
|
export 'src/services/backend.dart';
|
||||||
export 'src/services/factors.dart';
|
export 'src/services/factors.dart';
|
||||||
|
|
||||||
|
export 'src/destinations/models.dart';
|
||||||
|
export 'src/destinations/service.dart';
|
||||||
|
export 'src/destinations/dummy.dart';
|
||||||
|
export 'src/destinations/provider.dart';
|
||||||
|
export 'src/destinations/bank_paste.dart';
|
||||||
|
export 'src/destinations/unavailable.dart';
|
||||||
|
export 'src/destinations/form.dart';
|
||||||
|
export 'src/destinations/search.dart';
|
||||||
|
export 'src/destinations/selection.dart';
|
||||||
|
export 'src/destinations/management.dart';
|
||||||
|
export 'src/destinations/key.dart';
|
||||||
@@ -235,11 +235,52 @@ void main() {
|
|||||||
addTearDown(draft.dispose);
|
addTearDown(draft.dispose);
|
||||||
await wallet.refresh();
|
await wallet.refresh();
|
||||||
expect(draft.source, 'ledger');
|
expect(draft.source, 'ledger');
|
||||||
expect(draft.recipient, 'method');
|
expect(draft.recipient, isEmpty);
|
||||||
expect(draft.hasDraft, false);
|
expect(draft.hasDraft, false);
|
||||||
|
draft.change(recipient: 'method');
|
||||||
|
expect(draft.hasDraft, true);
|
||||||
draft.change(amount: '20.01');
|
draft.change(amount: '20.01');
|
||||||
await draft.review();
|
await draft.review();
|
||||||
await draft.submit(approval: const FactorGrant('approved-intent-key'));
|
await draft.submit(approval: const FactorGrant('approved-intent-key'));
|
||||||
expect(draft.result, isNotNull);
|
expect(draft.result, isNotNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('new typed destinations cannot be silently dropped into the legacy live API', () async {
|
||||||
|
var calls = 0;
|
||||||
|
final service = BackendWalletService(
|
||||||
|
baseUri: Uri.parse('https://example.test'),
|
||||||
|
accessToken: () => 'test-token',
|
||||||
|
client: MockClient((request) async {
|
||||||
|
calls++;
|
||||||
|
return http.Response('{}', 500);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
addTearDown(service.close);
|
||||||
|
await expectLater(
|
||||||
|
service.quote(
|
||||||
|
const TransferRequest(
|
||||||
|
kind: TransferKind.payout,
|
||||||
|
source: 'ledger',
|
||||||
|
amount: 100,
|
||||||
|
payoutDestination: OneTimeDestination(
|
||||||
|
CryptoDestination(
|
||||||
|
assetId: 'asset',
|
||||||
|
networkId: 'network',
|
||||||
|
address: 'address',
|
||||||
|
assetLabel: 'Asset',
|
||||||
|
networkLabel: 'Network',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
throwsA(
|
||||||
|
isA<WalletException>().having(
|
||||||
|
(e) => e.code,
|
||||||
|
'code',
|
||||||
|
WalletError.unsupported,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(calls, 0);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/src/destinations/transport.dart';
|
||||||
|
|
||||||
|
const crypto = CryptoDestination(
|
||||||
|
assetId: 'demo:ethereum:native',
|
||||||
|
networkId: 'eip155:1',
|
||||||
|
address: '0x1234567890123456789012345678901234567890',
|
||||||
|
assetLabel: 'Untrusted label',
|
||||||
|
networkLabel: 'Untrusted network',
|
||||||
|
);
|
||||||
|
const input = OneTimeDestination(crypto);
|
||||||
|
const card = CardDestination(
|
||||||
|
capture: 'demo-card-ready',
|
||||||
|
brand: 'Untrusted brand',
|
||||||
|
last4: '9999',
|
||||||
|
expiryMonth: 1,
|
||||||
|
expiryYear: 1900,
|
||||||
|
);
|
||||||
|
const bank = Bank(
|
||||||
|
bik: '044525225',
|
||||||
|
name: 'Demo Bank',
|
||||||
|
city: 'Moscow',
|
||||||
|
correspondent: '30101810400000000225',
|
||||||
|
revision: 'demo-1',
|
||||||
|
);
|
||||||
|
const russian = RussianBankDestination(
|
||||||
|
firstName: 'Anna Maria',
|
||||||
|
lastName: 'de la Cruz',
|
||||||
|
bank: bank,
|
||||||
|
account: '40817810899910004312',
|
||||||
|
);
|
||||||
|
Matcher issue(DestinationIssue value) =>
|
||||||
|
throwsA(isA<DestinationException>().having((e) => e.issue, 'issue', value));
|
||||||
|
void main() {
|
||||||
|
late DummyDestinationService service;
|
||||||
|
setUp(() {
|
||||||
|
service = DummyDestinationService();
|
||||||
|
});
|
||||||
|
test('one-time validation, quotation and acceptance never save', () async {
|
||||||
|
final lease = await service.prepare(
|
||||||
|
input,
|
||||||
|
const PayoutContext('eur', amount: 100),
|
||||||
|
);
|
||||||
|
expect(lease.snapshot.display, contains('ETH'));
|
||||||
|
service.accept(lease);
|
||||||
|
expect(await service.list(), isEmpty);
|
||||||
|
});
|
||||||
|
test('idempotency replays creation and rejects changed payload', () async {
|
||||||
|
final first = await service.create(
|
||||||
|
crypto,
|
||||||
|
'Private name',
|
||||||
|
'creation-key-000001',
|
||||||
|
);
|
||||||
|
expect(first.outcome, SaveOutcome.created);
|
||||||
|
expect(
|
||||||
|
(await service.create(
|
||||||
|
crypto,
|
||||||
|
'Private name',
|
||||||
|
'creation-key-000001',
|
||||||
|
)).destinationId,
|
||||||
|
first.destinationId,
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
service.create(crypto, 'Changed', 'creation-key-000001'),
|
||||||
|
issue(DestinationIssue.conflict),
|
||||||
|
);
|
||||||
|
expect((await service.list()).length, 1);
|
||||||
|
});
|
||||||
|
test(
|
||||||
|
'duplicate outcome is stable after rename, removal and concurrent create',
|
||||||
|
() async {
|
||||||
|
final results = await Future.wait(
|
||||||
|
List.generate(
|
||||||
|
8,
|
||||||
|
(i) => service.create(crypto, 'Name $i', 'creation-key-00000$i'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(results.where((r) => r.outcome == SaveOutcome.created).length, 1);
|
||||||
|
expect(results.map((r) => r.destinationId).toSet().length, 1);
|
||||||
|
final d = await service.get(results.first.destinationId);
|
||||||
|
final renamed = await service.rename(
|
||||||
|
d.id,
|
||||||
|
'Original stays',
|
||||||
|
d.metadataRevision,
|
||||||
|
);
|
||||||
|
await service.remove(
|
||||||
|
d.id,
|
||||||
|
renamed.metadataRevision,
|
||||||
|
renamed.executionRevision,
|
||||||
|
);
|
||||||
|
final replay = await service.create(
|
||||||
|
crypto,
|
||||||
|
'Name 1',
|
||||||
|
'creation-key-000001',
|
||||||
|
);
|
||||||
|
expect(replay.outcome, SaveOutcome.duplicate);
|
||||||
|
expect(replay.destinationId, d.id);
|
||||||
|
expect(
|
||||||
|
(await service.creation('creation-key-000001'))!.outcome,
|
||||||
|
SaveOutcome.duplicate,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'save timeout reconciles a created record separately from duplicate',
|
||||||
|
() async {
|
||||||
|
service.loseNextSaveResponse = true;
|
||||||
|
await expectLater(
|
||||||
|
service.create(crypto, '', 'creation-key-000001'),
|
||||||
|
issue(DestinationIssue.uncertain),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await service.creation('creation-key-000001'))!.outcome,
|
||||||
|
SaveOutcome.created,
|
||||||
|
);
|
||||||
|
service.loseNextSaveResponse = true;
|
||||||
|
await expectLater(
|
||||||
|
service.create(crypto, '', 'creation-key-000002'),
|
||||||
|
issue(DestinationIssue.uncertain),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await service.creation('creation-key-000002'))!.outcome,
|
||||||
|
SaveOutcome.duplicate,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test('customer isolation includes duplicates, attempt lookup, reads and mutations', () async {
|
||||||
|
final memory = DestinationMemory();
|
||||||
|
final a = DummyDestinationService(customer: 'a', memory: memory);
|
||||||
|
final b = DummyDestinationService(customer: 'b', memory: memory);
|
||||||
|
final saved = await a.create(crypto, 'A', 'creation-key-000001');
|
||||||
|
expect(await b.creation('creation-key-000001'), isNull);
|
||||||
|
await expectLater(
|
||||||
|
b.get(saved.destinationId),
|
||||||
|
issue(DestinationIssue.removed),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
b.rename(saved.destinationId, 'B', 1),
|
||||||
|
issue(DestinationIssue.removed),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
b.remove(saved.destinationId, 1, 1),
|
||||||
|
issue(DestinationIssue.removed),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await b.create(crypto, 'B', 'creation-key-000001')).outcome,
|
||||||
|
SaveOutcome.created,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('metadata concurrency never changes execution revision or invalidates lease', () async {
|
||||||
|
final result = await service.create(crypto, '', 'creation-key-000001');
|
||||||
|
final d = await service.get(result.destinationId);
|
||||||
|
final lease = await service.prepare(d.input, const PayoutContext('eur'));
|
||||||
|
final changed = await service.rename(d.id, 'New label', d.metadataRevision);
|
||||||
|
expect(changed.executionRevision, d.executionRevision);
|
||||||
|
expect(changed.metadataRevision, d.metadataRevision + 1);
|
||||||
|
service.accept(lease);
|
||||||
|
await expectLater(
|
||||||
|
service.rename(d.id, 'Stale', d.metadataRevision),
|
||||||
|
issue(DestinationIssue.conflict),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test(
|
||||||
|
'removal invalidates draft lease and never mutates previous snapshots',
|
||||||
|
() async {
|
||||||
|
final result = await service.create(crypto, '', 'creation-key-000001');
|
||||||
|
final d = await service.get(result.destinationId);
|
||||||
|
final lease = await service.prepare(d.input, const PayoutContext('eur'));
|
||||||
|
await service.remove(d.id, d.metadataRevision, d.executionRevision);
|
||||||
|
expect(() => service.accept(lease), issue(DestinationIssue.removed));
|
||||||
|
expect(lease.snapshot.review, contains(crypto.address));
|
||||||
|
expect(await service.list(), isEmpty);
|
||||||
|
expect(service.memory.audit.map((a) => a.action), contains('remove'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'requirements and contextual availability are rechecked at acceptance',
|
||||||
|
() async {
|
||||||
|
final result = await service.create(crypto, '', 'creation-key-000001');
|
||||||
|
final d = await service.get(result.destinationId);
|
||||||
|
final lease = await service.prepare(d.input, const PayoutContext('eur'));
|
||||||
|
service.availability[d.id] = const PayoutEligibility(
|
||||||
|
EligibilityState.temporarilyUnavailable,
|
||||||
|
reason: DestinationIssue.unavailable,
|
||||||
|
);
|
||||||
|
expect(() => service.accept(lease), issue(DestinationIssue.unavailable));
|
||||||
|
service.availability.clear();
|
||||||
|
service.requirementsRevision++;
|
||||||
|
expect(() => service.accept(lease), issue(DestinationIssue.conflict));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'network and canonical token identity distinguish duplicate addresses',
|
||||||
|
() async {
|
||||||
|
final ids = <String>{};
|
||||||
|
for (final a in DummyDestinationService.assets.where(
|
||||||
|
(a) => a.networkId.startsWith('eip155:'),
|
||||||
|
)) {
|
||||||
|
final result = await service.create(
|
||||||
|
CryptoDestination(
|
||||||
|
assetId: a.id,
|
||||||
|
networkId: a.networkId,
|
||||||
|
address: crypto.address,
|
||||||
|
memo: '',
|
||||||
|
assetLabel: a.label,
|
||||||
|
networkLabel: a.networkLabel,
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
destinationKey(),
|
||||||
|
);
|
||||||
|
ids.add(result.destinationId);
|
||||||
|
}
|
||||||
|
expect(ids.length, 4);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'address presentation preserves case; normalization is service-owned',
|
||||||
|
() async {
|
||||||
|
const upper = CryptoDestination(
|
||||||
|
assetId: 'demo:ethereum:native',
|
||||||
|
networkId: 'eip155:1',
|
||||||
|
address: '0xABCDEFabcdefABCDEFabcdefABCDEFabcdefABCD',
|
||||||
|
assetLabel: 'ETH',
|
||||||
|
networkLabel: 'Ethereum',
|
||||||
|
);
|
||||||
|
final first = await service.create(upper, '', 'creation-key-000001');
|
||||||
|
final lower = CryptoDestination(
|
||||||
|
assetId: upper.assetId,
|
||||||
|
networkId: upper.networkId,
|
||||||
|
address: upper.address.toLowerCase(),
|
||||||
|
assetLabel: 'ETH',
|
||||||
|
networkLabel: 'Ethereum',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await service.create(lower, '', 'creation-key-000002')).destinationId,
|
||||||
|
first.destinationId,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await service.get(first.destinationId)).details.review,
|
||||||
|
contains(upper.address),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test('memo requirements are validated before saving', () async {
|
||||||
|
const d = CryptoDestination(
|
||||||
|
assetId: 'demo:xrp:native',
|
||||||
|
networkId: 'xrpl:mainnet',
|
||||||
|
address: 'r123456789ABCDEFGHJKLMNPQRSTU',
|
||||||
|
assetLabel: 'XRP',
|
||||||
|
networkLabel: 'XRP Ledger',
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
service.create(d, '', 'creation-key-000001'),
|
||||||
|
issue(DestinationIssue.invalid),
|
||||||
|
);
|
||||||
|
expect(await service.list(), isEmpty);
|
||||||
|
});
|
||||||
|
test('card captures never trust user display metadata; recovery preserves record', () async {
|
||||||
|
final r = await service.create(card, '', 'creation-key-000001');
|
||||||
|
final saved = await service.get(r.destinationId);
|
||||||
|
expect(saved.details.display, contains('4242'));
|
||||||
|
expect(saved.details.display, isNot(contains('9999')));
|
||||||
|
const recovery = CardDestination(
|
||||||
|
capture: 'demo-card-recover',
|
||||||
|
brand: '',
|
||||||
|
last4: '',
|
||||||
|
expiryMonth: 0,
|
||||||
|
expiryYear: 0,
|
||||||
|
);
|
||||||
|
final other = DummyDestinationService();
|
||||||
|
final pending = await other.create(recovery, '', 'creation-key-000001');
|
||||||
|
final d = await other.get(pending.destinationId);
|
||||||
|
expect(
|
||||||
|
(await other.assess(d.input, const PayoutContext('eur'))).state,
|
||||||
|
EligibilityState.requiresAction,
|
||||||
|
);
|
||||||
|
expect((await other.list()).length, 1);
|
||||||
|
});
|
||||||
|
test('unreliable card fingerprint does not merge by last four', () async {
|
||||||
|
const d = CardDestination(
|
||||||
|
capture: 'demo-card-unknown',
|
||||||
|
brand: '',
|
||||||
|
last4: '',
|
||||||
|
expiryMonth: 0,
|
||||||
|
expiryYear: 0,
|
||||||
|
);
|
||||||
|
final a = await service.create(d, '', 'creation-key-000001');
|
||||||
|
final b = await service.create(d, '', 'creation-key-000002');
|
||||||
|
expect(a.destinationId, isNot(b.destinationId));
|
||||||
|
expect(b.outcome, SaveOutcome.created);
|
||||||
|
});
|
||||||
|
test(
|
||||||
|
'bank control key and directory changes are separate from ownership',
|
||||||
|
() async {
|
||||||
|
expect(validBankAccount('40817810899910004312', bank.bik), isTrue);
|
||||||
|
final r = await service.create(russian, '', 'creation-key-000001');
|
||||||
|
final saved = await service.get(r.destinationId);
|
||||||
|
expect(saved.ownership.verified, isFalse);
|
||||||
|
service.directory = [
|
||||||
|
const Bank(
|
||||||
|
bik: '044525225',
|
||||||
|
name: 'New cosmetic name',
|
||||||
|
city: 'Moscow',
|
||||||
|
correspondent: '30101810400000000225',
|
||||||
|
revision: 'demo-2',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
(await service.assess(
|
||||||
|
saved.input,
|
||||||
|
const PayoutContext('eur'),
|
||||||
|
)).eligible,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
service.directory = [
|
||||||
|
const Bank(
|
||||||
|
bik: '044525225',
|
||||||
|
name: 'New bank',
|
||||||
|
city: 'Moscow',
|
||||||
|
correspondent: 'changed',
|
||||||
|
revision: 'demo-3',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
(await service.assess(saved.input, const PayoutContext('eur'))).reason,
|
||||||
|
DestinationIssue.bankChanged,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await service.get(saved.id)).details.review,
|
||||||
|
contains(bank.correspondent),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'paste conflicts are never guessed and full holder names remain unsplit',
|
||||||
|
() {
|
||||||
|
final parsed = BankPaste.parse(
|
||||||
|
'БИК: 044525225\nБИК: 044525999\nНомер счета: 0000 1234\nФИО: Anna Maria de la Cruz',
|
||||||
|
);
|
||||||
|
expect(parsed.conflicts, contains('bik'));
|
||||||
|
expect(parsed.values, isNot(contains('bik')));
|
||||||
|
expect(parsed.values['account'], '00001234');
|
||||||
|
expect(parsed.values['holder'], 'Anna Maria de la Cruz');
|
||||||
|
expect(parsed.values, isNot(contains('firstName')));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test('all details round trip through DTO mapper', () {
|
||||||
|
for (final d in <DestinationDetails>[crypto, card, russian]) {
|
||||||
|
expect(DestinationMapper.clone(d).review, d.review);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test(
|
||||||
|
'optional save failure can continue one-time without claiming saved',
|
||||||
|
() async {
|
||||||
|
final p = DestinationFormProvider(
|
||||||
|
service,
|
||||||
|
DummyAnalyticsService(),
|
||||||
|
context: const PayoutContext('eur'),
|
||||||
|
);
|
||||||
|
addTearDown(p.dispose);
|
||||||
|
p.change(crypto);
|
||||||
|
p.preferences(save: true);
|
||||||
|
service.nextSaveError = DestinationIssue.conflict;
|
||||||
|
await p.proceed();
|
||||||
|
expect(p.savingFailed, isTrue);
|
||||||
|
expect(p.saved, isNull);
|
||||||
|
await p.proceed(withoutSaving: true);
|
||||||
|
expect(p.result, isA<OneTimeDestination>());
|
||||||
|
expect(await service.list(), isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test('saving and contextual eligibility have independent outcomes', () async {
|
||||||
|
final p = DestinationFormProvider(
|
||||||
|
service,
|
||||||
|
DummyAnalyticsService(),
|
||||||
|
context: const PayoutContext('unsupported'),
|
||||||
|
);
|
||||||
|
addTearDown(p.dispose);
|
||||||
|
p.change(crypto);
|
||||||
|
p.preferences(save: true);
|
||||||
|
await p.proceed();
|
||||||
|
expect(p.saved!.outcome, SaveOutcome.created);
|
||||||
|
expect(p.eligibility!.state, EligibilityState.unsupported);
|
||||||
|
expect(p.result, isNull);
|
||||||
|
expect(p.error, isNull);
|
||||||
|
});
|
||||||
|
test('explicit duplicate reuse preserves existing nickname', () async {
|
||||||
|
final first = await service.create(
|
||||||
|
crypto,
|
||||||
|
'Original',
|
||||||
|
'creation-key-000001',
|
||||||
|
);
|
||||||
|
final p = DestinationFormProvider(
|
||||||
|
service,
|
||||||
|
DummyAnalyticsService(),
|
||||||
|
context: const PayoutContext('eur'),
|
||||||
|
);
|
||||||
|
addTearDown(p.dispose);
|
||||||
|
p.change(crypto);
|
||||||
|
p.preferences(save: true, nickname: 'New');
|
||||||
|
await p.proceed();
|
||||||
|
expect(p.saved!.outcome, SaveOutcome.duplicate);
|
||||||
|
expect(p.result, isNull);
|
||||||
|
await p.useExisting();
|
||||||
|
expect((p.result as SavedReference).id, first.destinationId);
|
||||||
|
expect((await service.get(first.destinationId)).nickname, 'Original');
|
||||||
|
});
|
||||||
|
test(
|
||||||
|
'wallet acceptance rejects removed destination without debiting',
|
||||||
|
() async {
|
||||||
|
final wallet = DummyWalletService(
|
||||||
|
DummyIdentityService(delay: Duration.zero),
|
||||||
|
delay: Duration.zero,
|
||||||
|
destinations: service,
|
||||||
|
);
|
||||||
|
final created = await service.create(crypto, '', 'creation-key-000001');
|
||||||
|
final d = await service.get(created.destinationId);
|
||||||
|
final q = await wallet.quote(
|
||||||
|
TransferRequest(
|
||||||
|
kind: TransferKind.payout,
|
||||||
|
source: 'eur',
|
||||||
|
amount: 1000,
|
||||||
|
payoutDestination: d.input,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await service.remove(d.id, d.metadataRevision, d.executionRevision);
|
||||||
|
await expectLater(
|
||||||
|
wallet.submit(q.id, 'request'),
|
||||||
|
throwsA(
|
||||||
|
isA<WalletException>().having(
|
||||||
|
(e) => e.code,
|
||||||
|
'code',
|
||||||
|
WalletError.destinationChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect((await wallet.fetch()).accounts.first.available, 7500);
|
||||||
|
expect((await wallet.fetch()).operations, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('large collections search safe metadata and sort recent use without choosing', () async {
|
||||||
|
for (var i = 0; i < 240; i++) {
|
||||||
|
await service.create(
|
||||||
|
CryptoDestination(
|
||||||
|
assetId: crypto.assetId,
|
||||||
|
networkId: crypto.networkId,
|
||||||
|
address: '0x' + i.toRadixString(16).padLeft(40, '0'),
|
||||||
|
assetLabel: 'ETH',
|
||||||
|
networkLabel: 'Ethereum',
|
||||||
|
),
|
||||||
|
'Wallet ' + i.toString(),
|
||||||
|
destinationKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final rows = await service.list();
|
||||||
|
service.accept(
|
||||||
|
await service.prepare(rows.last.input, const PayoutContext('eur')),
|
||||||
|
);
|
||||||
|
final p = DestinationsProvider(service);
|
||||||
|
addTearDown(p.dispose);
|
||||||
|
await p.refresh();
|
||||||
|
expect(p.search('', DestinationType.crypto).first.id, rows.last.id);
|
||||||
|
expect(
|
||||||
|
p.search('Wallet 239', DestinationType.crypto).single.id,
|
||||||
|
rows.last.id,
|
||||||
|
);
|
||||||
|
expect(p.search('not found', DestinationType.crypto), isEmpty);
|
||||||
|
});
|
||||||
|
test('cards without supported reuse or recovery cannot be saved', () async {
|
||||||
|
service.reusableCards = false;
|
||||||
|
service.cardRecovery = false;
|
||||||
|
expect(
|
||||||
|
(await service.assess(
|
||||||
|
const OneTimeDestination(card),
|
||||||
|
const PayoutContext('eur'),
|
||||||
|
)).canSave,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
service.create(card, '', 'creation-key-000001'),
|
||||||
|
issue(DestinationIssue.unsupported),
|
||||||
|
);
|
||||||
|
expect(await service.list(), isEmpty);
|
||||||
|
});
|
||||||
|
test('existing card survives loss of its only reusable binding', () async {
|
||||||
|
final result = await service.create(card, '', 'creation-key-000001');
|
||||||
|
final d = await service.get(result.destinationId);
|
||||||
|
expect((d.details as CardDestination).capture, isEmpty);
|
||||||
|
final old = await service.prepare(d.input, const PayoutContext('eur'));
|
||||||
|
service.memory.records[service.customer]![d.id] = SavedDestination(
|
||||||
|
id: d.id,
|
||||||
|
details: d.details,
|
||||||
|
metadataRevision: d.metadataRevision,
|
||||||
|
executionRevision: d.executionRevision + 1,
|
||||||
|
bindings: [
|
||||||
|
const ProviderBinding(
|
||||||
|
reference: 'binding',
|
||||||
|
state: BindingState.revoked,
|
||||||
|
reusable: true,
|
||||||
|
recoverable: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final changed = await service.get(d.id);
|
||||||
|
expect(
|
||||||
|
(await service.assess(changed.input, const PayoutContext('eur'))).state,
|
||||||
|
EligibilityState.requiresAction,
|
||||||
|
);
|
||||||
|
expect(() => service.accept(old), issue(DestinationIssue.conflict));
|
||||||
|
expect((await service.list()).length, 1);
|
||||||
|
service.cardRecovery = false;
|
||||||
|
expect(
|
||||||
|
(await service.assess(changed.input, const PayoutContext('eur'))).state,
|
||||||
|
EligibilityState.unsupported,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test(
|
||||||
|
'accepted payout snapshot survives nickname changes and removal',
|
||||||
|
() async {
|
||||||
|
final wallet = DummyWalletService(
|
||||||
|
DummyIdentityService(delay: Duration.zero),
|
||||||
|
delay: Duration.zero,
|
||||||
|
destinations: service,
|
||||||
|
);
|
||||||
|
final result = await service.create(crypto, '', 'creation-key-000001');
|
||||||
|
final d = await service.get(result.destinationId);
|
||||||
|
final q = await wallet.quote(
|
||||||
|
TransferRequest(
|
||||||
|
kind: TransferKind.payout,
|
||||||
|
source: 'eur',
|
||||||
|
amount: 1000,
|
||||||
|
payoutDestination: d.input,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final renamed = await service.rename(
|
||||||
|
d.id,
|
||||||
|
'After quote',
|
||||||
|
d.metadataRevision,
|
||||||
|
);
|
||||||
|
final accepted = await wallet.submit(q.id, 'payout-key');
|
||||||
|
await service.remove(
|
||||||
|
d.id,
|
||||||
|
renamed.metadataRevision,
|
||||||
|
renamed.executionRevision,
|
||||||
|
);
|
||||||
|
expect((await wallet.fetch()).operations.single.id, accepted.id);
|
||||||
|
expect(accepted.quote.payoutSnapshot!.review, contains(crypto.address));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('card recovery preserves identity, invalidates execution and replays idempotently', () async {
|
||||||
|
const capture = CardDestination(
|
||||||
|
capture: 'demo-card-recover',
|
||||||
|
brand: '',
|
||||||
|
last4: '',
|
||||||
|
expiryMonth: 0,
|
||||||
|
expiryYear: 0,
|
||||||
|
);
|
||||||
|
final created = await service.create(
|
||||||
|
capture,
|
||||||
|
'Keep label',
|
||||||
|
'creation-key-000001',
|
||||||
|
);
|
||||||
|
final d = await service.get(created.destinationId);
|
||||||
|
final restored = await service.restoreCard(
|
||||||
|
d.id,
|
||||||
|
d.executionRevision,
|
||||||
|
card,
|
||||||
|
'card-recovery-key-0001',
|
||||||
|
);
|
||||||
|
expect(restored.id, d.id);
|
||||||
|
expect(restored.nickname, d.nickname);
|
||||||
|
expect(restored.metadataRevision, d.metadataRevision);
|
||||||
|
expect(restored.executionRevision, d.executionRevision + 1);
|
||||||
|
expect(
|
||||||
|
(await service.assess(
|
||||||
|
restored.input,
|
||||||
|
const PayoutContext('eur'),
|
||||||
|
)).eligible,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await service.restoreCard(
|
||||||
|
d.id,
|
||||||
|
d.executionRevision,
|
||||||
|
card,
|
||||||
|
'card-recovery-key-0001',
|
||||||
|
)).executionRevision,
|
||||||
|
restored.executionRevision,
|
||||||
|
);
|
||||||
|
expect((await service.list()).length, 1);
|
||||||
|
const unidentified = CardDestination(
|
||||||
|
capture: 'demo-card-unknown',
|
||||||
|
brand: '',
|
||||||
|
last4: '',
|
||||||
|
expiryMonth: 0,
|
||||||
|
expiryYear: 0,
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
service.restoreCard(
|
||||||
|
d.id,
|
||||||
|
restored.executionRevision,
|
||||||
|
unidentified,
|
||||||
|
'card-recovery-key-0002',
|
||||||
|
),
|
||||||
|
issue(DestinationIssue.replacementRequired),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -27,6 +27,15 @@ void main() {
|
|||||||
amount: 1000,
|
amount: 1000,
|
||||||
destination: kind == TransferKind.exchange ? 'usd' : null,
|
destination: kind == TransferKind.exchange ? 'usd' : null,
|
||||||
recipient: 'Sample recipient',
|
recipient: 'Sample recipient',
|
||||||
|
payoutDestination: OneTimeDestination(
|
||||||
|
CryptoDestination(
|
||||||
|
assetId: 'demo:ethereum:native',
|
||||||
|
networkId: 'eip155:1',
|
||||||
|
address: '0x1234567890123456789012345678901234567890',
|
||||||
|
assetLabel: 'ETH',
|
||||||
|
networkLabel: 'Ethereum',
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await expectLater(
|
await expectLater(
|
||||||
@@ -59,6 +68,15 @@ void main() {
|
|||||||
source: 'eur',
|
source: 'eur',
|
||||||
amount: 1000,
|
amount: 1000,
|
||||||
recipient: 'Sample recipient',
|
recipient: 'Sample recipient',
|
||||||
|
payoutDestination: OneTimeDestination(
|
||||||
|
CryptoDestination(
|
||||||
|
assetId: 'demo:ethereum:native',
|
||||||
|
networkId: 'eip155:1',
|
||||||
|
address: '0x1234567890123456789012345678901234567890',
|
||||||
|
assetLabel: 'ETH',
|
||||||
|
networkLabel: 'Ethereum',
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final c = (await factor.begin(FactorPurpose.payout, q.id))!;
|
final c = (await factor.begin(FactorPurpose.payout, q.id))!;
|
||||||
@@ -85,6 +103,15 @@ void main() {
|
|||||||
source: 'eur',
|
source: 'eur',
|
||||||
amount: 1000,
|
amount: 1000,
|
||||||
recipient: 'Sample recipient',
|
recipient: 'Sample recipient',
|
||||||
|
payoutDestination: OneTimeDestination(
|
||||||
|
CryptoDestination(
|
||||||
|
assetId: 'demo:ethereum:native',
|
||||||
|
networkId: 'eip155:1',
|
||||||
|
address: '0x1234567890123456789012345678901234567890',
|
||||||
|
assetLabel: 'ETH',
|
||||||
|
networkLabel: 'Ethereum',
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final c = (await factor.begin(FactorPurpose.payout, 'different-quote'))!;
|
final c = (await factor.begin(FactorPurpose.payout, 'different-quote'))!;
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ void main() {
|
|||||||
destination: to,
|
destination: to,
|
||||||
amount: amount,
|
amount: amount,
|
||||||
recipient: recipient,
|
recipient: recipient,
|
||||||
|
payoutDestination: OneTimeDestination(
|
||||||
|
CryptoDestination(
|
||||||
|
assetId: 'demo:ethereum:native',
|
||||||
|
networkId: 'eip155:1',
|
||||||
|
address: '0x1234567890123456789012345678901234567890',
|
||||||
|
assetLabel: 'ETH',
|
||||||
|
networkLabel: 'Ethereum',
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return s.submit(q.id, key);
|
return s.submit(q.id, key);
|
||||||
@@ -107,6 +116,15 @@ void main() {
|
|||||||
source: 'eur',
|
source: 'eur',
|
||||||
amount: 7000,
|
amount: 7000,
|
||||||
recipient: 'Sample recipient',
|
recipient: 'Sample recipient',
|
||||||
|
payoutDestination: OneTimeDestination(
|
||||||
|
CryptoDestination(
|
||||||
|
assetId: 'demo:ethereum:native',
|
||||||
|
networkId: 'eip155:1',
|
||||||
|
address: '0x1234567890123456789012345678901234567890',
|
||||||
|
assetLabel: 'ETH',
|
||||||
|
networkLabel: 'Ethereum',
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
now = now.add(const Duration(seconds: 61));
|
now = now.add(const Duration(seconds: 61));
|
||||||
@@ -127,6 +145,15 @@ void main() {
|
|||||||
source: 'eur',
|
source: 'eur',
|
||||||
amount: 6000,
|
amount: 6000,
|
||||||
recipient: 'Sample recipient',
|
recipient: 'Sample recipient',
|
||||||
|
payoutDestination: OneTimeDestination(
|
||||||
|
CryptoDestination(
|
||||||
|
assetId: 'demo:ethereum:native',
|
||||||
|
networkId: 'eip155:1',
|
||||||
|
address: '0x1234567890123456789012345678901234567890',
|
||||||
|
assetLabel: 'ETH',
|
||||||
|
networkLabel: 'Ethereum',
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final b = await s.quote(
|
final b = await s.quote(
|
||||||
@@ -135,6 +162,15 @@ void main() {
|
|||||||
source: 'eur',
|
source: 'eur',
|
||||||
amount: 6000,
|
amount: 6000,
|
||||||
recipient: 'Sample recipient',
|
recipient: 'Sample recipient',
|
||||||
|
payoutDestination: OneTimeDestination(
|
||||||
|
CryptoDestination(
|
||||||
|
assetId: 'demo:ethereum:native',
|
||||||
|
networkId: 'eip155:1',
|
||||||
|
address: '0x1234567890123456789012345678901234567890',
|
||||||
|
assetLabel: 'ETH',
|
||||||
|
networkLabel: 'Ethereum',
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await s.submit(a.id, 'first');
|
await s.submit(a.id, 'first');
|
||||||
@@ -160,6 +196,15 @@ void main() {
|
|||||||
source: 'eur',
|
source: 'eur',
|
||||||
amount: 11000,
|
amount: 11000,
|
||||||
recipient: 'Sample recipient',
|
recipient: 'Sample recipient',
|
||||||
|
payoutDestination: OneTimeDestination(
|
||||||
|
CryptoDestination(
|
||||||
|
assetId: 'demo:ethereum:native',
|
||||||
|
networkId: 'eip155:1',
|
||||||
|
address: '0x1234567890123456789012345678901234567890',
|
||||||
|
assetLabel: 'ETH',
|
||||||
|
networkLabel: 'Ethereum',
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
throwsA(
|
throwsA(
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
ARG DOCKER_IMAGE
|
ARG DOCKER_IMAGE
|
||||||
FROM ${DOCKER_IMAGE}
|
FROM ${DOCKER_IMAGE}
|
||||||
RUN apk add --no-cache curl jq git openssl util-linux shellcheck
|
RUN apk add --no-cache curl jq git openssl util-linux shellcheck socat
|
||||||
@@ -54,24 +54,31 @@ provision_mongo() {
|
|||||||
{ cat "$WORK/admin.js"; cat infra/local/mongo/mongo-client.js; } | mongo >/dev/null || fail 'Browser client provisioning failed'
|
{ cat "$WORK/admin.js"; cat infra/local/mongo/mongo-client.js; } | mongo >/dev/null || fail 'Browser client provisioning failed'
|
||||||
}
|
}
|
||||||
provision_agents() {
|
provision_agents() {
|
||||||
for service in bff notifications nats; do
|
services='bff notifications nats'
|
||||||
|
if [ -f "$STATE/sendico/settings.env" ]; then services="$services sendico"; fi
|
||||||
|
for service in $services; do
|
||||||
directory="$STATE/agents/$service"
|
directory="$STATE/agents/$service"
|
||||||
mkdir -p "$directory"; chmod 755 "$directory"
|
mkdir -p "$directory"; chmod 755 "$directory"
|
||||||
case "$service" in
|
case "$service" in
|
||||||
bff) paths='services/bff/jwt';;
|
bff) paths='services/bff/jwt';;
|
||||||
notifications) paths='infrastructure/nats/auth services/notifications/email/resend';;
|
notifications) paths='infrastructure/nats/auth services/notifications/email/resend';;
|
||||||
nats) paths=infrastructure/nats/auth;;
|
nats) paths=infrastructure/nats/auth;;
|
||||||
|
sendico) paths=services/bff/sendico;;
|
||||||
esac
|
esac
|
||||||
: > "$WORK/policy"
|
: > "$WORK/policy"
|
||||||
for path in $paths; do printf 'path "kv/data/%s" { capabilities = ["read"] }\n' "$path" >> "$WORK/policy"; done
|
for path in $paths; do printf 'path "kv/data/%s" { capabilities = ["read"] }\n' "$path" >> "$WORK/policy"; done
|
||||||
if [ "$service" != nats ]; then printf 'path "database/creds/%s" { capabilities = ["read"] }\n' "$service" >> "$WORK/policy"; fi
|
case "$service" in bff|notifications) printf 'path "database/creds/%s" { capabilities = ["read"] }\n' "$service" >> "$WORK/policy";; esac
|
||||||
jq -n --rawfile policy "$WORK/policy" '{policy:$policy}' | vault "sys/policies/acl/$service" POST >/dev/null
|
jq -n --rawfile policy "$WORK/policy" '{policy:$policy}' | vault "sys/policies/acl/$service" POST >/dev/null
|
||||||
|
if [ "$service" = sendico ]; then
|
||||||
|
printf '{"token_policies":["sendico"],"token_ttl":"5m","token_max_ttl":"5m","secret_id_ttl":"0","secret_id_num_uses":0}' | vault "auth/approle/role/$service" POST >/dev/null
|
||||||
|
else
|
||||||
jq -n --arg service "$service" '{token_policies:[$service],token_period:"1h",secret_id_ttl:"0",secret_id_num_uses:0}' | vault "auth/approle/role/$service" POST >/dev/null
|
jq -n --arg service "$service" '{token_policies:[$service],token_period:"1h",secret_id_ttl:"0",secret_id_num_uses:0}' | vault "auth/approle/role/$service" POST >/dev/null
|
||||||
|
fi
|
||||||
vault "auth/approle/role/$service/role-id" | jq -r '.data.role_id' | write "$directory/role-id" 444
|
vault "auth/approle/role/$service/role-id" | jq -r '.data.role_id' | write "$directory/role-id" 444
|
||||||
if [ ! -f "$directory/secret-id" ]; then
|
if [ ! -f "$directory/secret-id" ]; then
|
||||||
printf '{}' | vault "auth/approle/role/$service/secret-id" POST | jq -r '.data.secret_id' | write "$directory/secret-id" 444
|
printf '{}' | vault "auth/approle/role/$service/secret-id" POST | jq -r '.data.secret_id' | write "$directory/secret-id" 444
|
||||||
fi
|
fi
|
||||||
if ! cmp -s "infra/local/vault/$service.hcl" "$directory/config.hcl"; then
|
if [ "$service" != sendico ] && ! cmp -s "infra/local/vault/$service.hcl" "$directory/config.hcl"; then
|
||||||
cat "infra/local/vault/$service.hcl" | write "$directory/config.hcl" 444
|
cat "infra/local/vault/$service.hcl" | write "$directory/config.hcl" 444
|
||||||
if compose ps --status running --services "$service" | grep -qx "$service"; then
|
if compose ps --status running --services "$service" | grep -qx "$service"; then
|
||||||
compose up -d --no-deps --force-recreate --wait --wait-timeout 120 "$service"
|
compose up -d --no-deps --force-recreate --wait --wait-timeout 120 "$service"
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ trap cleanup EXIT
|
|||||||
trap 'exit 130' INT
|
trap 'exit 130' INT
|
||||||
trap 'exit 143' TERM
|
trap 'exit 143' TERM
|
||||||
fail() { echo "$*" >&2; exit 1; }
|
fail() { echo "$*" >&2; exit 1; }
|
||||||
compose() { docker compose --project-directory . -f infra/local/compose.yml --env-file "$STATE/images.env" "$@"; }
|
compose() {
|
||||||
|
if [ -f "$STATE/sendico/settings.env" ]; then
|
||||||
|
docker compose --project-directory . -f infra/local/compose.yml -f infra/local/sendico/compose.yml --env-file "$STATE/images.env" "$@"
|
||||||
|
else
|
||||||
|
docker compose --project-directory . -f infra/local/compose.yml --env-file "$STATE/images.env" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
write() {
|
write() {
|
||||||
target=$1; mode=${2:-600}
|
target=$1; mode=${2:-600}
|
||||||
mkdir -p "$(dirname "$target")"
|
mkdir -p "$(dirname "$target")"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ case "${1:-}" in
|
|||||||
images) . infra/local/scripts/images.sh; images;;
|
images) . infra/local/scripts/images.sh; images;;
|
||||||
up) . infra/local/scripts/bootstrap.sh; up;;
|
up) . infra/local/scripts/bootstrap.sh; up;;
|
||||||
email) . infra/local/scripts/bootstrap.sh; email;;
|
email) . infra/local/scripts/bootstrap.sh; email;;
|
||||||
|
sendico) . infra/local/scripts/bootstrap.sh; . infra/local/scripts/sendico.sh; configure_sendico;;
|
||||||
verify|verify-infra) . infra/local/scripts/verify.sh; existing; infrastructure; if [ "$1" = verify ]; then product; fi;;
|
verify|verify-infra) . infra/local/scripts/verify.sh; existing; infrastructure; if [ "$1" = verify ]; then product; fi;;
|
||||||
rotation) . infra/local/scripts/verify.sh; . infra/local/scripts/rotation.sh; existing; rotation;;
|
rotation) . infra/local/scripts/verify.sh; . infra/local/scripts/rotation.sh; existing; rotation;;
|
||||||
test) sh infra/local/scripts/test.sh;;
|
test) sh infra/local/scripts/test.sh;;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Operator input is JSON on stdin. Only credential values are stored in Vault.
|
||||||
|
configure_sendico() {
|
||||||
|
existing
|
||||||
|
cat > "$WORK/input"
|
||||||
|
jq -e '
|
||||||
|
(.organization_ref|test("^[0-9a-f]{24}$") and .!="000000000000000000000000") and
|
||||||
|
(.source_account_ref|test("^[0-9a-f]{24}$") and .!="000000000000000000000000") and
|
||||||
|
([.client_id,.device_id,.login,.password,.client_secret] | all(.[]; type=="string" and test("\\S"))) and
|
||||||
|
([.client_id,.device_id] | all(.[]; test("^[A-Za-z0-9._-]+$")))
|
||||||
|
' "$WORK/input" >/dev/null || fail 'Invalid Sendico operator input'
|
||||||
|
jq '{login,password,client_secret}' "$WORK/input" | put services/bff/sendico
|
||||||
|
mkdir -p "$STATE/sendico"
|
||||||
|
jq -r '"SENDICO_ORGANIZATION_REF="+.organization_ref+"\nSENDICO_SOURCE_ACCOUNT_REF="+.source_account_ref+"\nSENDICO_CLIENT_ID="+.client_id+"\nSENDICO_DEVICE_ID="+.device_id' "$WORK/input" | write "$STATE/sendico/settings.env"
|
||||||
|
{
|
||||||
|
cat infra/local/bff/runtime.yml
|
||||||
|
jq -r '"\n sendico:\n api_url: http://127.0.0.1:18081/api/v1\n organization_ref: "+.organization_ref+"\n source_account_ref: "+.source_account_ref+"\n token_file: /run/sendico/access-token"' "$WORK/input"
|
||||||
|
} | write "$STATE/sendico/runtime.yml" 644
|
||||||
|
provision_agents
|
||||||
|
echo 'Local Sendico connection configured. Run make up.'
|
||||||
|
}
|
||||||
@@ -4,4 +4,12 @@ umask 077
|
|||||||
if [ "$SXCHNGE_SERVICE" = notifications ]; then
|
if [ "$SXCHNGE_SERVICE" = notifications ]; then
|
||||||
exec service -config.file=/etc/sxchnge/notifications.yml
|
exec service -config.file=/etc/sxchnge/notifications.yml
|
||||||
fi
|
fi
|
||||||
|
if [ "${SXCHNGE_SENDICO_LOCAL:-}" = 1 ]; then
|
||||||
|
attempt=0
|
||||||
|
until [ -s /run/sendico/access-token ] && [ "$(($(date +%s) - $(stat -c %Y /run/sendico/access-token)))" -lt 1500 ]; do
|
||||||
|
attempt=$((attempt+1))
|
||||||
|
[ "$attempt" -lt 120 ] || { echo 'Local Sendico session did not become ready' >&2; exit 1; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
fi
|
||||||
exec service -config.file=/etc/sxchnge/bff.yml
|
exec service -config.file=/etc/sxchnge/bff.yml
|
||||||
@@ -4,6 +4,7 @@ set -o pipefail
|
|||||||
# Shell syntax and static analysis cover the actual container shell scripts.
|
# Shell syntax and static analysis cover the actual container shell scripts.
|
||||||
shellcheck -s ash -x -S warning infra/local/scripts/dispatch.sh infra/local/scripts/test.sh
|
shellcheck -s ash -x -S warning infra/local/scripts/dispatch.sh infra/local/scripts/test.sh
|
||||||
shellcheck -x -S warning infra/local/tools.sh infra/local/scripts/start-service.sh infra/local/mongo/start.sh infra/local/nats/start.sh infra/local/vault/start.sh
|
shellcheck -x -S warning infra/local/tools.sh infra/local/scripts/start-service.sh infra/local/mongo/start.sh infra/local/nats/start.sh infra/local/vault/start.sh
|
||||||
|
shellcheck -s ash -x -S warning infra/local/sendico/session.sh infra/local/sendico/check.sh
|
||||||
. infra/local/scripts/common.sh
|
. infra/local/scripts/common.sh
|
||||||
. infra/local/scripts/images.sh
|
. infra/local/scripts/images.sh
|
||||||
. infra/local/scripts/bootstrap.sh
|
. infra/local/scripts/bootstrap.sh
|
||||||
@@ -93,3 +94,23 @@ retire_secrets
|
|||||||
retire_secrets
|
retire_secrets
|
||||||
jq -e '.=={"infrastructure/nats/auth":true}' "$WORK/retirement" >/dev/null
|
jq -e '.=={"infrastructure/nats/auth":true}' "$WORK/retirement" >/dev/null
|
||||||
echo 'PASS: obsolete caller secrets retired idempotently; NATS authentication preserved'
|
echo 'PASS: obsolete caller secrets retired idempotently; NATS authentication preserved'
|
||||||
|
# Local integration configuration keeps credentials out of runtime settings and
|
||||||
|
# rejects malformed operator inputs before mutating Vault or service identities.
|
||||||
|
. infra/local/scripts/sendico.sh
|
||||||
|
STATE="$WORK/sendico-state"; mkdir "$STATE"
|
||||||
|
existing() { :; }
|
||||||
|
put() { cat > "$WORK/sendico-credentials"; }
|
||||||
|
provision_agents() { touch "$WORK/sendico-agents"; }
|
||||||
|
printf '{}' > "$WORK/sendico-invalid"
|
||||||
|
if (configure_sendico < "$WORK/sendico-invalid") >/dev/null 2>&1; then fail 'Invalid Sendico configuration accepted'; fi
|
||||||
|
[ ! -e "$WORK/sendico-credentials" ] || fail 'Invalid Sendico input mutated credentials'
|
||||||
|
cat > "$WORK/sendico-input" <<'JSON'
|
||||||
|
{"organization_ref":"000000000000000000000001","source_account_ref":"000000000000000000000002","client_id":"fixture.client","device_id":"fixture-device","login":"fixture@example.test","password":"fixture-password","client_secret":"fixture-secret"}
|
||||||
|
JSON
|
||||||
|
configure_sendico < "$WORK/sendico-input" >/dev/null
|
||||||
|
jq -e 'keys==["client_secret","login","password"]' "$WORK/sendico-credentials" >/dev/null
|
||||||
|
[ -f "$WORK/sendico-agents" ] || fail 'Sendico identity was not provisioned'
|
||||||
|
if grep -Eq 'fixture-secret|fixture-password|fixture@example' "$STATE/sendico/settings.env" "$STATE/sendico/runtime.yml"; then fail 'Credential leaked into runtime configuration'; fi
|
||||||
|
grep -q 'source_account_ref: 000000000000000000000002' "$STATE/sendico/runtime.yml"
|
||||||
|
grep -q 'api_url: http://127.0.0.1:18081/api/v1' "$STATE/sendico/runtime.yml"
|
||||||
|
echo 'PASS: Sendico operator input validation, secret separation and local runtime configuration'
|
||||||
@@ -2,11 +2,16 @@ policies() (
|
|||||||
root_token=$VAULT_TOKEN
|
root_token=$VAULT_TOKEN
|
||||||
identity=''
|
identity=''
|
||||||
trap 'if [ -n "$identity" ]; then printf "{}" | VAULT_TOKEN=$identity vault auth/token/revoke-self POST >/dev/null; fi' EXIT
|
trap 'if [ -n "$identity" ]; then printf "{}" | VAULT_TOKEN=$identity vault auth/token/revoke-self POST >/dev/null; fi' EXIT
|
||||||
for service in bff notifications nats; do
|
services='bff notifications nats'
|
||||||
|
if [ -f "$STATE/sendico/settings.env" ]; then services="$services sendico"; fi
|
||||||
|
for service in $services; do
|
||||||
directory="$STATE/agents/$service"
|
directory="$STATE/agents/$service"
|
||||||
login=$(jq -n --rawfile role "$directory/role-id" --rawfile secret "$directory/secret-id" '{role_id:($role|rtrimstr("\n")),secret_id:($secret|rtrimstr("\n"))}' | vault auth/approle/login POST)
|
login=$(jq -n --rawfile role "$directory/role-id" --rawfile secret "$directory/secret-id" '{role_id:($role|rtrimstr("\n")),secret_id:($secret|rtrimstr("\n"))}' | vault auth/approle/login POST)
|
||||||
identity=$(printf '%s' "$login" | jq -er '.auth.client_token')
|
identity=$(printf '%s' "$login" | jq -er '.auth.client_token')
|
||||||
printf '%s' "$login" | jq '{token:.auth.client_token,paths:["kv/data/bootstrap/mongo","kv/data/infrastructure/mongo/bootstrap","sys/init","kv/data/bff","kv/data/caller","kv/data/services/notifications/callers/bff","kv/data/email","kv/data/nats","kv/data/tls"]}' | vault sys/capabilities POST | jq -e '.data | all(.[]; .==["deny"])' >/dev/null || fail 'Service can access bootstrap credentials'
|
printf '%s' "$login" | jq '{token:.auth.client_token,paths:["kv/data/bootstrap/mongo","kv/data/infrastructure/mongo/bootstrap","sys/init","kv/data/bff","kv/data/caller","kv/data/services/notifications/callers/bff","kv/data/email","kv/data/nats","kv/data/tls"]}' | vault sys/capabilities POST | jq -e '.data | all(.[]; .==["deny"])' >/dev/null || fail 'Service can access bootstrap credentials'
|
||||||
|
if [ "$service" = sendico ]; then
|
||||||
|
printf '%s' "$login" | jq '{token:.auth.client_token,paths:["database/creds/bff","database/creds/notifications","kv/data/services/bff/jwt","kv/data/infrastructure/nats/auth"]}' | vault sys/capabilities POST | jq -e '.data | all(.[]; .==["deny"])' >/dev/null || fail 'Sendico session identity has unrelated secret access'
|
||||||
|
fi
|
||||||
VAULT_TOKEN=$(printf '%s' "$login" | jq -er '.auth.client_token')
|
VAULT_TOKEN=$(printf '%s' "$login" | jq -er '.auth.client_token')
|
||||||
printf '{}' | vault auth/token/revoke-self POST >/dev/null
|
printf '{}' | vault auth/token/revoke-self POST >/dev/null
|
||||||
VAULT_TOKEN=$root_token
|
VAULT_TOKEN=$root_token
|
||||||
@@ -44,12 +49,18 @@ infrastructure() {
|
|||||||
# Resolve through Docker DNS while verifying the certificate for localhost.
|
# Resolve through Docker DNS while verifying the certificate for localhost.
|
||||||
https() { curl -sS --max-time 5 --cacert "$STATE/ca.pem" --connect-to localhost:5126:frontend:8443 "$@"; }
|
https() { curl -sS --max-time 5 --cacert "$STATE/ca.pem" --connect-to localhost:5126:frontend:8443 "$@"; }
|
||||||
product() {
|
product() {
|
||||||
compose ps --format json | jq -se 'length==6 and all(.[];.Health=="healthy")' >/dev/null || fail 'Expected six healthy services'
|
expected=6
|
||||||
|
if [ -f "$STATE/sendico/settings.env" ]; then expected=7; fi
|
||||||
|
compose ps --format json | jq -se --argjson expected "$expected" 'length==$expected and all(.[];.Health=="healthy")' >/dev/null || fail "Expected $expected healthy services"
|
||||||
https -f https://localhost:5126/ >/dev/null
|
https -f https://localhost:5126/ >/dev/null
|
||||||
https -f https://localhost:5126/api/v1/health >/dev/null
|
https -f https://localhost:5126/api/v1/health >/dev/null
|
||||||
echo 'PASS: six healthy services, frontend and proxied BFF use verified TLS; no email sent'
|
if [ "$expected" = 7 ]; then compose exec -T sendico sh /etc/sendico/check.sh; fi
|
||||||
|
echo "PASS: $expected healthy services, frontend and proxied BFF use verified TLS; no email sent"
|
||||||
}
|
}
|
||||||
layout() {
|
layout() {
|
||||||
|
if [ -f "$STATE/sendico/settings.env" ]; then
|
||||||
|
kv services/bff/sendico | jq -e 'keys==["client_secret","login","password"]' >/dev/null || fail 'Sendico secret contains non-secret configuration'
|
||||||
|
fi
|
||||||
kv services/notifications/email/resend | jq -e 'keys==["api_key"]' >/dev/null || fail 'Non-secret email configuration remains in Vault'
|
kv services/notifications/email/resend | jq -e 'keys==["api_key"]' >/dev/null || fail 'Non-secret email configuration remains in Vault'
|
||||||
for old in bootstrap/mongo bff caller services/notifications/callers/bff email nats tls frontend-tls notifications-tls; do
|
for old in bootstrap/mongo bff caller services/notifications/callers/bff email nats tls frontend-tls notifications-tls; do
|
||||||
[ "$(vault "kv/metadata/$old" GET yes)" = null ] || fail "Obsolete KV entry remains: $old"
|
[ "$(vault "kv/metadata/$old" GET yes)" = null ] || fail "Obsolete KV entry remains: $old"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
check=$(mktemp -d /private/check.XXXXXX)
|
||||||
|
trap 'rm -rf "$check"' EXIT
|
||||||
|
printf 'Authorization: Bearer %s\n' "$(cat /run/sendico/access-token)" > "$check/header"
|
||||||
|
for path in "payments/fx/currencies/$SENDICO_ORGANIZATION_REF" "ledger_accounts/$SENDICO_ORGANIZATION_REF"; do
|
||||||
|
curl -fsS --max-time 15 -H "@$check/header" "http://127.0.0.1:18081/api/v1/$path" > "$check/response" 2>/dev/null || { echo 'Local Sendico authenticated API check failed' >&2; exit 1; }
|
||||||
|
jq -e '.status=="success"' "$check/response" >/dev/null
|
||||||
|
done
|
||||||
|
echo 'PASS: local Sendico authenticated currency and ledger APIs are accessible'
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Enabled by make sendico; settings contain no credentials.
|
||||||
|
services:
|
||||||
|
bff:
|
||||||
|
environment:
|
||||||
|
SXCHNGE_SENDICO_LOCAL: '1'
|
||||||
|
volumes:
|
||||||
|
- ./.local/dev/sendico/runtime.yml:/etc/sxchnge/bff.yml:ro
|
||||||
|
- sendico-session:/run/sendico:ro
|
||||||
|
|
||||||
|
sendico:
|
||||||
|
image: sxchnge-local/tools:dev
|
||||||
|
restart: unless-stopped
|
||||||
|
# Keep the adapter's loopback-only HTTP rule; forward to the public stand API.
|
||||||
|
network_mode: service:bff
|
||||||
|
entrypoint: [/bin/sh, /etc/sendico/session.sh]
|
||||||
|
env_file: ./.local/dev/sendico/settings.env
|
||||||
|
volumes:
|
||||||
|
- ./infra/local/sendico/session.sh:/etc/sendico/session.sh:ro
|
||||||
|
- ./infra/local/sendico/check.sh:/etc/sendico/check.sh:ro
|
||||||
|
- ./.local/dev/agents/sendico:/etc/agent:ro
|
||||||
|
- sendico-session:/run/sendico
|
||||||
|
tmpfs: [/private:mode=0700]
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD-SHELL, 'test -f /private/ready && test $$(($$(date +%s) - $$(stat -c %Y /private/ready))) -lt 1500 && curl -fsS --max-time 2 http://127.0.0.1:18081/api/v1/health >/dev/null']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sendico-session:
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
mkdir -p /private /run/sendico
|
||||||
|
chmod 755 /run/sendico
|
||||||
|
cd /private
|
||||||
|
socat TCP4-LISTEN:18081,bind=127.0.0.1,reuseaddr,fork TCP4:host.docker.internal:8080 &
|
||||||
|
bridge=$!
|
||||||
|
trap 'kill "$bridge" 2>/dev/null || true' EXIT
|
||||||
|
trap 'exit 0' INT TERM
|
||||||
|
|
||||||
|
session() {
|
||||||
|
jq -n --rawfile role /etc/agent/role-id --rawfile secret /etc/agent/secret-id \
|
||||||
|
'{role_id:($role|rtrimstr("\n")),secret_id:($secret|rtrimstr("\n"))}' > login.json
|
||||||
|
curl -fsS --max-time 15 -H 'Content-Type: application/json' --data-binary @login.json \
|
||||||
|
http://vault:8200/v1/auth/approle/login > vault.json 2>/dev/null || return 1
|
||||||
|
jq -er '"X-Vault-Token: "+.auth.client_token' vault.json > vault.header || return 1
|
||||||
|
curl -fsS --max-time 15 -H @vault.header http://vault:8200/v1/kv/data/services/bff/sendico > credentials.json 2>/dev/null || return 1
|
||||||
|
jq -e --arg client "$SENDICO_CLIENT_ID" --arg device "$SENDICO_DEVICE_ID" \
|
||||||
|
'.data.data | {login:{login,password},clientSecret:.client_secret,clientId:$client,deviceId:$device}' credentials.json > request.json || return 1
|
||||||
|
curl -fsS --max-time 15 -H @vault.header -X POST http://vault:8200/v1/auth/token/revoke-self >/dev/null 2>&1 || return 1
|
||||||
|
rm -f vault.header vault.json credentials.json login.json
|
||||||
|
curl -fsS --max-time 20 -H 'Content-Type: application/json' --data-binary @request.json \
|
||||||
|
http://127.0.0.1:18081/api/v1/accounts/login/api > response.json 2>/dev/null || return 1
|
||||||
|
rm -f request.json
|
||||||
|
jq -er '.data.accessToken.token' response.json > token 2>/dev/null || return 1
|
||||||
|
jq -e --arg source "$SENDICO_SOURCE_ACCOUNT_REF" \
|
||||||
|
'.data.accessToken.token | split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson | .id==$source and .pending!=true and .exp>(now+1500)' response.json >/dev/null 2>&1 || return 1
|
||||||
|
cp token /run/sendico/access-token.new
|
||||||
|
chmod 444 /run/sendico/access-token.new
|
||||||
|
mv /run/sendico/access-token.new /run/sendico/access-token
|
||||||
|
rm -f token response.json
|
||||||
|
touch ready
|
||||||
|
}
|
||||||
|
|
||||||
|
while :; do
|
||||||
|
if session; then
|
||||||
|
echo 'Local Sendico session ready; next renewal in 20 minutes.'
|
||||||
|
sleep 1200 & wait $!
|
||||||
|
else
|
||||||
|
echo 'Local Sendico session unavailable; check stand health and integration credentials. Retrying in 5 minutes.' >&2
|
||||||
|
rm -f request.json response.json token vault.header vault.json credentials.json login.json
|
||||||
|
sleep 300 & wait $!
|
||||||
|
fi
|
||||||
|
done
|
||||||
Reference in new issue
Block a user