Add saved payout destination UX and service contracts (#54)

This commit is contained in:
Stephan D committed 2026-10-16 12:01:33 +02:00
1 parent a5689876ff
commit 24af794112
49 files changed
+4687 -62

No files matched your search

+225
View File
@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'paste.dart';
import '../generated/i18n/app_localizations.dart';
class BankFields extends StatefulWidget {
const BankFields({
super.key,
required this.onChanged,
this.initial,
this.errorField,
});
final ValueChanged<DestinationDetails?> onChanged;
final RussianBankDestination? initial;
final String? errorField;
@override
State<BankFields> createState() => _BankFieldsState();
}
class _BankFieldsState extends State<BankFields> {
late final first = TextEditingController(text: widget.initial?.firstName);
late final last = TextEditingController(text: widget.initial?.lastName);
late final patronymic = TextEditingController(
text: widget.initial?.patronymic,
);
late final account = TextEditingController(text: widget.initial?.account);
final query = TextEditingController();
late final search = BankSearchProvider(
context.read<DestinationsProvider>().service,
);
late Bank? bank = widget.initial?.bank;
String? pastedHolder;
bool conflict = false;
Map<String, String> pasted = {};
bool get mismatch =>
bank != null &&
((pasted['bank'] != null && pasted['bank'] != bank!.name) ||
(pasted['correspondent'] != null &&
pasted['correspondent'] != bank!.correspondent));
void changed() => widget.onChanged(
bank == null
? null
: RussianBankDestination(
firstName: first.text,
lastName: last.text,
patronymic: patronymic.text,
bank: bank!,
account: account.text,
),
);
Future<void> paste() async {
final parsed = await showDialog<BankPaste>(
context: context,
builder: (_) => const BankPasteDialog(),
);
if (!mounted || parsed == null) return;
for (final entry in {
'firstName': first,
'lastName': last,
'patronymic': patronymic,
'account': account,
}.entries) {
if (parsed.conflicts.contains(entry.key)) {
entry.value.clear();
} else if (parsed.values[entry.key] case final String value) {
entry.value.text = value;
}
}
setState(() {
pasted = parsed.values;
conflict = parsed.conflicts.isNotEmpty;
pastedHolder = parsed.values['holder'];
});
if (parsed.conflicts.contains('bik')) {
setState(() => bank = null);
}
if (parsed.values['bik'] case final String bik) {
query.text = bik;
search.search(bik);
setState(() => bank = null);
}
changed();
}
@override
void dispose() {
for (final c in [first, last, patronymic, account, query]) {
c.dispose();
}
search.dispose();
super.dispose();
}
Widget field(
String label,
TextEditingController c, {
String? id,
bool numeric = false,
String? error,
}) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: TextField(
key: id == null ? null : ValueKey(id),
controller: c,
keyboardType: numeric ? TextInputType.number : TextInputType.name,
decoration: InputDecoration(labelText: label, errorText: error),
onChanged: (_) => changed(),
),
);
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l.dHolder, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
LayoutBuilder(
builder: (context, b) {
final a = field(
l.dFirst,
first,
id: 'destination-first',
error: widget.errorField == 'holder' ? l.dInvalid : null,
);
final z = field(l.dLast, last, id: 'destination-last');
return b.maxWidth >= 480
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: a),
const SizedBox(width: 12),
Expanded(child: z),
],
)
: Column(children: [a, z]);
},
),
field(l.dPatronymic, patronymic),
if (pastedHolder != null) SelectableText(pastedHolder!),
if (conflict) Text(l.dPasteConflict),
Text(l.dBankDetails, style: Theme.of(context).textTheme.titleMedium),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: paste,
icon: const Icon(Icons.content_paste),
label: Text(l.dPaste),
),
),
ListenableBuilder(
listenable: search,
builder: (context, _) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (bank == null) ...[
TextField(
controller: query,
decoration: InputDecoration(labelText: l.dBankSearch),
onChanged: search.search,
),
if (search.loading) const LinearProgressIndicator(),
for (final b in search.results)
ListTile(
title: Text(b.name),
subtitle: Text('${b.bik} · ${b.city}'),
onTap: () {
setState(() => bank = b);
changed();
},
),
if (search.error != null)
Text(
search.error == DestinationIssue.unavailable
? l.dBankFailure
: l.dBankUnknown,
),
if (search.error == DestinationIssue.unavailable)
TextButton(
onPressed: () => search.search(query.text),
child: Text(l.wRefresh),
),
] else ...[
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(bank!.name),
subtitle: Text('${l.dBik} ${bank!.bik}'),
trailing: TextButton(
onPressed: () {
setState(() => bank = null);
changed();
},
child: Text(l.dChangeBank),
),
),
ExpansionTile(
title: Text(l.dBankDetails),
children: [
SelectableText(
'${l.dCorrespondent}: ${bank!.correspondent}',
),
],
),
if (!bank!.supported) Text(l.dUnsupported),
],
],
),
),
const SizedBox(height: 16),
field(
l.dAccount,
account,
id: 'destination-account',
numeric: true,
error: widget.errorField == 'account' ? l.dInvalid : null,
),
],
);
}
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../generated/i18n/app_localizations.dart';
String destinationType(AppLocalizations l, DestinationType type) =>
switch (type) {
DestinationType.crypto => l.dCrypto,
DestinationType.card => l.dCard,
DestinationType.russianBank => l.dBank,
};
String destinationIssue(AppLocalizations l, DestinationIssue issue) =>
switch (issue) {
DestinationIssue.invalid => l.dInvalid,
DestinationIssue.unavailable => l.dTemporary,
DestinationIssue.uncertain => l.dUncertain,
DestinationIssue.conflict => l.dConflict,
DestinationIssue.removed => l.dRemoved,
DestinationIssue.expired => l.dCardExpired,
DestinationIssue.bankChanged => l.dBankChanged,
DestinationIssue.authorization => l.dAuthorization,
DestinationIssue.unsupported => l.dUnsupported,
DestinationIssue.duplicate => l.dDuplicate,
DestinationIssue.replacementRequired => l.dReplacementRequired,
};
String eligibilityText(AppLocalizations l, PayoutEligibility e) =>
e.reason != null
? destinationIssue(l, e.reason!)
: switch (e.state) {
EligibilityState.eligible => l.dEligible,
EligibilityState.temporarilyUnavailable => l.dTemporary,
EligibilityState.requiresAction => l.dAction,
EligibilityState.unsupported => l.dUnsupported,
};
+128
View File
@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../generated/i18n/app_localizations.dart';
class CryptoFields extends StatefulWidget {
const CryptoFields({
super.key,
required this.capabilities,
required this.onChanged,
this.initial,
this.errorField,
});
final DestinationCapabilities capabilities;
final ValueChanged<DestinationDetails?> onChanged;
final CryptoDestination? initial;
final String? errorField;
@override
State<CryptoFields> createState() => _CryptoFieldsState();
}
class _CryptoFieldsState extends State<CryptoFields> {
late final address = TextEditingController(text: widget.initial?.address);
late final memo = TextEditingController(text: widget.initial?.memo);
late String? assetId = widget.initial?.assetId;
late String? networkId = widget.initial?.networkId;
void changed() {
final a = widget.capabilities.assets
.where((a) => a.id == assetId && a.networkId == networkId)
.firstOrNull;
widget.onChanged(
a == null
? null
: CryptoDestination(
assetId: a.id,
networkId: a.networkId,
address: address.text,
memo: memo.text,
assetLabel: a.label,
networkLabel: a.networkLabel,
),
);
}
@override
void dispose() {
address.dispose();
memo.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final assets = widget.capabilities.assets;
final selected = assets
.where((a) => a.id == assetId && a.networkId == networkId)
.firstOrNull;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<String>(
key: ValueKey('asset-$assetId'),
initialValue: assets.any((a) => a.id == assetId) ? assetId : null,
isExpanded: true,
decoration: InputDecoration(labelText: l.dAsset),
items: [
for (final a in assets)
DropdownMenuItem(value: a.id, child: Text(a.label)),
],
onChanged: (v) {
setState(() {
assetId = v;
networkId = null;
memo.clear();
});
changed();
},
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
key: ValueKey('network-$assetId-$networkId'),
initialValue:
assets.any((a) => a.id == assetId && a.networkId == networkId)
? networkId
: null,
isExpanded: true,
decoration: InputDecoration(labelText: l.dNetwork),
items: [
for (final a in assets.where((a) => a.id == assetId))
DropdownMenuItem(value: a.networkId, child: Text(a.networkLabel)),
],
onChanged: (v) {
setState(() => networkId = v);
changed();
},
),
const SizedBox(height: 16),
TextField(
key: const ValueKey('destination-address'),
controller: address,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: l.dAddress,
errorText: widget.errorField == 'address' ? l.dInvalid : null,
),
onChanged: (_) => changed(),
),
if (selected?.memoSupported == true) ...[
const SizedBox(height: 16),
TextField(
key: const ValueKey('destination-memo'),
controller: memo,
decoration: InputDecoration(
labelText: l.dMemo,
errorText: widget.errorField == 'memo' ? l.dInvalid : null,
),
onChanged: (_) => changed(),
),
],
const SizedBox(height: 12),
Text(l.dCryptoHelp),
],
);
}
}
+161
View File
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'copy.dart';
import 'details.dart';
import 'form.dart';
import 'nickname.dart';
import '../generated/i18n/app_localizations.dart';
class DestinationDetailPage extends StatefulWidget {
const DestinationDetailPage({super.key, required this.destination});
final SavedDestination destination;
@override
State<DestinationDetailPage> createState() => _DestinationDetailPageState();
}
class _DestinationDetailPageState extends State<DestinationDetailPage> {
late final p = DestinationManagementProvider(
context.read<DestinationsProvider>().service,
widget.destination,
);
late final nickname = TextEditingController(
text: widget.destination.nickname,
);
Future<void> remove() async {
final l = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l.dRemoveTitle),
content: Text(l.dRemoveHelp),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l.wStay),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(l.dRemove),
),
],
),
);
if (!mounted || confirmed != true) return;
await p.remove();
if (mounted && p.removed) Navigator.pop(context);
}
@override
void dispose() {
p.dispose();
nickname.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return ListenableBuilder(
listenable: p,
builder: (context, _) => Scaffold(
appBar: AppBar(title: Text(l.dDetails)),
body: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 120),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DestinationDetailsView(details: p.destination.details),
const SizedBox(height: 24),
DestinationNickname(
controller: nickname,
enabled: !p.busy,
onChanged: (_) {},
),
if (p.busy) const LinearProgressIndicator(),
if (p.error != null) ...[
Semantics(
liveRegion: true,
child: Text(destinationIssue(l, p.error!)),
),
TextButton(
onPressed: p.busy ? null : p.refresh,
child: Text(l.dReload),
),
],
const SizedBox(height: 16),
FilledButton(
onPressed: p.busy
? null
: () async {
await p.rename(nickname.text);
if (context.mounted && p.error == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.dSaved)));
}
},
child: Text(l.dRename),
),
if (p.destination.details is CardDestination &&
context
.read<DestinationsProvider>()
.capabilities
?.cardRecovery ==
true)
OutlinedButton(
onPressed: p.busy
? null
: () async {
final restored =
await Navigator.push<DestinationInput>(
context,
MaterialPageRoute(
builder: (_) => DestinationFormPage(
initial: p.destination.details,
restoring: p.destination.input,
),
),
);
if (mounted && restored != null) {
await p.refresh();
}
},
child: Text(l.dUpdateAuthorization),
),
TextButton(
onPressed: p.busy
? null
: () async {
await Navigator.push<DestinationInput>(
context,
MaterialPageRoute(
builder: (_) => DestinationFormPage(
initial: p.destination.details,
replacing: p.destination.id,
),
),
);
},
child: Text(l.dReplace),
),
TextButton(
onPressed: p.busy ? null : remove,
child: Text(l.dRemove),
),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../generated/i18n/app_localizations.dart';
/// Structured review renders the immutable service snapshot, never a nickname.
class DestinationDetailsView extends StatelessWidget {
const DestinationDetailsView({super.key, required this.details});
final DestinationDetails details;
Widget field(String label, String value) => Padding(
padding: const EdgeInsets.only(top: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [Text(label), SelectableText(value)],
),
);
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: switch (details) {
CryptoDestination d => [
field(l.dAsset, d.assetLabel),
field(l.dNetwork, d.networkLabel),
field(l.dAddress, d.address),
field(l.dMemo, d.memo.isEmpty ? l.dNoMemo : d.memo),
],
CardDestination d => [field(l.dCard, d.display)],
RussianBankDestination d => [
field(l.dHolder, d.holder),
field(l.dBankSearch, d.bank.name),
field(l.dBik, d.bank.bik),
field(l.dAccount, d.account),
field(l.dCorrespondent, d.bank.correspondent),
],
},
);
}
}
+292
View File
@@ -0,0 +1,292 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'bank.dart';
import 'copy.dart';
import 'crypto.dart';
import 'nickname.dart';
import '../generated/i18n/app_localizations.dart';
class DestinationFormPage extends StatefulWidget {
const DestinationFormPage({
super.key,
this.payoutContext,
this.initial,
this.replacing,
this.restoring,
});
final PayoutContext? payoutContext;
final DestinationDetails? initial;
final String? replacing;
final SavedReference? restoring;
@override
State<DestinationFormPage> createState() => _DestinationFormPageState();
}
class _DestinationFormPageState extends State<DestinationFormPage> {
late final p = DestinationFormProvider(
context.read<DestinationsProvider>().service,
context.read<AnalyticsService>(),
context: widget.payoutContext,
initial: widget.initial,
replacing: widget.replacing,
restoring: widget.restoring,
);
late DestinationType? type = widget.initial?.type;
final nickname = TextEditingController();
final focus = FocusNode();
Future<void> proceed({
bool withoutSaving = false,
bool existing = false,
}) async {
if (existing) {
await p.useExisting();
} else {
await p.proceed(withoutSaving: withoutSaving);
}
if (!mounted) return;
if (p.result != null) {
final l = AppLocalizations.of(context)!;
if (p.saved?.outcome == SaveOutcome.created || widget.restoring != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
widget.restoring == null ? l.dSaved : l.dAuthorizationUpdated,
),
),
);
}
Navigator.pop(context, p.result);
return;
}
focus.requestFocus();
}
@override
void dispose() {
p.dispose();
nickname.dispose();
focus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final caps = context.watch<DestinationsProvider>().capabilities;
return ListenableBuilder(
listenable: p,
builder: (context, _) => PopScope(
canPop: !p.busy,
child: Scaffold(
appBar: AppBar(title: Text(l.dAdd)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (caps?.simulated == true) ...[
Text(l.dDemo),
const SizedBox(height: 16),
],
Text(l.dOwn),
const SizedBox(height: 20),
if (caps == null || caps.types.isEmpty)
Text(l.dNoCapabilities)
else ...[
DropdownButtonFormField<DestinationType>(
initialValue: caps.types.contains(type) ? type : null,
isExpanded: true,
decoration: InputDecoration(labelText: l.dType),
items: [
for (final t in caps.types)
DropdownMenuItem(
value: t,
child: Text(
destinationType(l, t),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
onChanged:
p.busy || p.uncertain || widget.restoring != null
? null
: (v) {
setState(() => type = v);
p.change(null);
},
),
const SizedBox(height: 20),
AbsorbPointer(
absorbing: p.busy || p.uncertain,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (type == DestinationType.crypto)
CryptoFields(
capabilities: caps,
initial: widget.initial is CryptoDestination
? widget.initial as CryptoDestination
: null,
errorField: p.errorField,
onChanged: p.change,
),
if (type == DestinationType.russianBank)
BankFields(
initial:
widget.initial is RussianBankDestination
? widget.initial as RussianBankDestination
: null,
errorField: p.errorField,
onChanged: p.change,
),
if (type == DestinationType.card &&
caps.simulated) ...[
Text(l.dCardDemo),
const SizedBox(height: 16),
RadioGroup<String>(
groupValue: p.details is CardDestination
? (p.details as CardDestination).capture
: null,
onChanged: (value) {
if (value != null) {
p.change(
CardDestination(
capture: value,
brand: 'Demo card',
last4: '4242',
expiryMonth: 12,
expiryYear: 2030,
),
);
}
},
child: Column(
children: [
RadioListTile<String>(
value: 'demo-card-ready',
title: Text(l.dCardReady),
),
RadioListTile<String>(
value: 'demo-card-recover',
title: Text(l.dCardRecover),
),
],
),
),
],
if (type == DestinationType.card && !caps.simulated)
Text(l.dNoCapabilities),
if (widget.payoutContext != null) ...[
const SizedBox(height: 16),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: p.save,
onChanged: (v) => p.preferences(save: v),
title: Text(l.dSaveFuture),
),
],
if (widget.restoring == null &&
(widget.payoutContext == null || p.save)) ...[
const SizedBox(height: 16),
DestinationNickname(
controller: nickname,
enabled: !p.busy && !p.uncertain,
onChanged: (v) => p.preferences(nickname: v),
),
],
],
),
),
const SizedBox(height: 16),
Focus(
focusNode: focus,
skipTraversal: true,
child: Semantics(
liveRegion: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (p.error != null)
Text(
p.uncertain
? l.dUncertain
: p.errorField != null
? l.dInvalid
: '${p.savingFailed ? '${l.dSaveFailed}\n' : ''}${destinationIssue(l, p.error!)}',
key: const ValueKey('destination-error'),
),
if (p.eligibility != null &&
!p.eligibility!.eligible)
Text(
'${p.saved != null ? '${l.dSavedIneligible}\n' : ''}${eligibilityText(l, p.eligibility!)}',
),
if (p.saved?.outcome ==
SaveOutcome.duplicate) ...[
Text(l.dDuplicate),
TextButton(
onPressed: p.busy
? null
: () => proceed(existing: true),
child: Text(l.dUseExisting),
),
],
if (p.saved?.outcome == SaveOutcome.created &&
p.result == null)
Text(l.dSaved),
],
),
),
),
if (p.busy) const LinearProgressIndicator(),
if (p.uncertain)
TextButton(
onPressed: p.busy ? null : () => proceed(),
child: Text(l.dCheckSave),
),
if (p.savingFailed && widget.payoutContext != null)
TextButton(
onPressed: p.busy
? null
: () => proceed(withoutSaving: true),
child: Text(l.dWithoutSave),
),
if (p.save && widget.payoutContext != null)
Text(l.dSaveHelp),
const SizedBox(height: 12),
FilledButton(
key: const ValueKey('destination-continue'),
onPressed: p.busy || p.details == null
? null
: () => proceed(),
child: Text(
widget.restoring != null
? l.dUpdateAuthorization
: widget.payoutContext == null
? l.dSave
: p.error != null && !p.uncertain
? l.dRetrySave
: l.dContinue,
),
),
],
],
),
),
),
),
),
),
);
}
}
+165
View File
@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'copy.dart';
import 'detail.dart';
import 'form.dart';
import '../generated/i18n/app_localizations.dart';
class DestinationsPage extends StatefulWidget {
const DestinationsPage({super.key, this.payoutContext});
final PayoutContext? payoutContext;
@override
State<DestinationsPage> createState() => _DestinationsPageState();
}
class _DestinationsPageState extends State<DestinationsPage> {
String query = '';
late final selection = DestinationSelectionProvider(
context.read<DestinationsProvider>().service,
widget.payoutContext ?? const PayoutContext(''),
);
Future<void> refresh() async {
final p = context.read<DestinationsProvider>();
await p.refresh();
if (!mounted) return;
await selection.assess(p.methods);
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) refresh();
});
}
Future<void> add() async {
final result = await Navigator.push<DestinationInput>(
context,
MaterialPageRoute(
builder: (_) =>
DestinationFormPage(payoutContext: widget.payoutContext),
),
);
if (!mounted) return;
if (result != null && widget.payoutContext != null) {
Navigator.pop(context, result);
} else {
await refresh();
}
}
Future<void> choose(SavedDestination d) async {
if (widget.payoutContext == null) {
await Navigator.push<void>(
context,
MaterialPageRoute(
builder: (_) => DestinationDetailPage(destination: d),
),
);
if (mounted) await refresh();
} else {
final ref = await selection.select(d);
if (mounted && ref != null) Navigator.pop(context, ref);
}
}
@override
void dispose() {
selection.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final p = context.watch<DestinationsProvider>();
return Scaffold(
appBar: AppBar(
title: Text(widget.payoutContext == null ? l.dTitle : l.dChoose),
actions: [
IconButton(
tooltip: l.dReload,
onPressed: p.loading ? null : refresh,
icon: const Icon(Icons.refresh),
),
],
),
body: ListenableBuilder(
listenable: selection,
builder: (context, _) => ListView(
padding: const EdgeInsets.all(24),
children: [
if (p.capabilities?.simulated == true) ...[
Text(l.dDemo),
const SizedBox(height: 16),
],
FilledButton.icon(
onPressed: p.loading || (p.capabilities?.types.isEmpty ?? true)
? null
: add,
icon: const Icon(Icons.add),
label: Text(widget.payoutContext == null ? l.dAdd : l.dNew),
),
if (p.capabilities?.types.isEmpty == true)
Text(l.dManagementUnavailable),
const SizedBox(height: 20),
TextField(
decoration: InputDecoration(
labelText: l.dSearch,
prefixIcon: const Icon(Icons.search),
),
onChanged: (v) => setState(() => query = v),
),
if (p.loading || selection.busy) const LinearProgressIndicator(),
if (p.failed) Text(l.dUnavailable),
if (selection.error != null)
Semantics(
liveRegion: true,
child: Text(destinationIssue(l, selection.error!)),
),
if (widget.payoutContext != null &&
!p.loading &&
p.methods.isNotEmpty &&
!selection.assessments.values.any((a) => a.eligible))
Text(l.dNoEligible),
for (final type
in p.capabilities?.types ?? <DestinationType>[]) ...[
const SizedBox(height: 24),
Text(
destinationType(l, type),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
if (p.search(query, type).isEmpty && !p.loading && !p.failed)
Text(query.isEmpty ? l.dEmpty : l.dNoMatches),
for (final d in p.search(query, type))
Card(
child: ListTile(
contentPadding: const EdgeInsets.all(16),
title: Text(d.label),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (d.nickname.isNotEmpty) Text(d.details.display),
if (selection.assessments[d.id]
case final PayoutEligibility e)
Text(eligibilityText(l, e)),
],
),
trailing: const Icon(Icons.chevron_right),
onTap: selection.busy ? null : () => choose(d),
),
),
],
],
),
),
);
}
}
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import '../generated/i18n/app_localizations.dart';
class DestinationNickname extends StatelessWidget {
const DestinationNickname({
super.key,
required this.controller,
required this.onChanged,
this.enabled = true,
});
final TextEditingController controller;
final ValueChanged<String> onChanged;
final bool enabled;
@override
Widget build(BuildContext context) => TextField(
key: const ValueKey('destination-nickname'),
controller: controller,
enabled: enabled,
maxLength: 80,
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.dNickname,
),
onChanged: onChanged,
);
}
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../generated/i18n/app_localizations.dart';
class BankPasteDialog extends StatefulWidget {
const BankPasteDialog({super.key});
@override
State<BankPasteDialog> createState() => _BankPasteDialogState();
}
class _BankPasteDialogState extends State<BankPasteDialog> {
final text = TextEditingController();
BankPaste? result;
@override
void dispose() {
text.clear();
text.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final labels = {
'firstName': l.dFirst,
'lastName': l.dLast,
'patronymic': l.dPatronymic,
'account': l.dAccount,
'bik': 'BIK',
'bank': l.dBankSearch,
'holder': l.dHolder,
'correspondent': l.dCorrespondent,
};
return AlertDialog(
title: Text(l.dPaste),
content: SizedBox(
width: 480,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(l.dPasteHelp),
const SizedBox(height: 12),
TextField(
controller: text,
maxLines: 6,
decoration: InputDecoration(labelText: l.dBankDetails),
onChanged: (_) => setState(() => result = null),
),
if (result != null) ...[
const SizedBox(height: 12),
if (result!.conflicts.isNotEmpty) Text(l.dPasteConflict),
for (final e in result!.values.entries)
Text('${labels[e.key]}: ${e.value}'),
],
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l.wClose),
),
FilledButton(
onPressed: result == null
? () => setState(() => result = BankPaste.parse(text.text))
: result!.values.isEmpty
? null
: () => Navigator.pop(context, result),
child: Text(result == null ? l.dPasteReview : l.dPasteApply),
),
],
);
}
}
+91 -1
View File
@@ -289,5 +289,95 @@
"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."
}
+91 -1
View File
@@ -289,5 +289,95 @@
"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": "Не удалось подтвердить, что это та же карта. Добавьте новые реквизиты."
}
+12
View File
@@ -37,6 +37,7 @@ class SxchngeApp extends StatefulWidget {
this.identityService,
this.limitsService,
this.walletService,
this.destinationService,
this.twoFactorService,
this.photoService = const FilePhotoService(),
this.loginService = const DummyLoginService(),
@@ -49,6 +50,7 @@ class SxchngeApp extends StatefulWidget {
final IdentityService? identityService;
final LimitsService? limitsService;
final WalletService? walletService;
final DestinationService? destinationService;
final TwoFactorService? twoFactorService;
final PhotoService photoService;
final LoginService loginService;
@@ -89,10 +91,17 @@ class _SxchngeAppState extends State<SxchngeApp> {
initialLocale: _locale.locale.languageCode,
factors: _authFactors,
));
late final _destinationService =
widget.destinationService ??
(_walletService.isSimulated && _walletService is DummyWalletService
? _walletService.destinations
: const UnavailableDestinationService());
late final _destinations = DestinationsProvider(_destinationService);
late final _wallet = WalletProvider(
_walletService,
_analytics,
factors: _factors,
destinations: _destinationService,
);
late final _limits =
widget.limitsService ??
@@ -131,6 +140,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
if (customer != _walletCustomer) {
_walletCustomer = customer;
_wallet.reset();
_destinations.reset();
}
final path = _router.routeInformationProvider.value.uri.path;
if (path == '/' && _previousPath != '/' && _previousPath != '/two-factor') {
@@ -145,6 +155,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
_router.dispose();
_journeys.dispose();
_wallet.dispose();
_destinations.dispose();
if (_walletService is BackendWalletService) {
_walletService.close();
}
@@ -161,6 +172,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
Widget build(BuildContext context) => MultiProvider(
providers: [
ChangeNotifierProvider.value(value: _wallet),
ChangeNotifierProvider.value(value: _destinations),
ChangeNotifierProvider.value(value: _security),
Provider<TwoFactorService>.value(value: _factors),
Provider<JourneyRecorder>.value(value: _journeys),
+7
View File
@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../auth/page.dart';
import '../destinations/list.dart';
import '../kyc/page.dart';
import '../security/challenge.dart';
import '../verification/page.dart';
@@ -82,6 +83,12 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsPage(),
routes: [
GoRoute(
path: 'payout-methods',
builder: (context, state) => const DestinationsPage(),
),
],
),
GoRoute(
path: '/top-up',
+1
View File
@@ -48,6 +48,7 @@ String fundsText(
_ => l.wFailedOutgoing,
};
String walletError(AppLocalizations l, WalletError e) => switch (e) {
WalletError.destinationChanged => l.dQuoteChanged,
WalletError.invalidAmount => l.wInvalidAmount,
WalletError.insufficientFunds => l.wInsufficient,
WalletError.limitExceeded => l.wLimitError,
+9
View File
@@ -106,6 +106,15 @@ class SettingsPage extends StatelessWidget {
),
),
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 SizedBox(height: 20),
TextButton(
+16 -2
View File
@@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
@@ -6,6 +8,8 @@ import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../destinations/details.dart';
import 'copy.dart';
import '../generated/i18n/app_localizations.dart';
@@ -49,13 +53,23 @@ class TransferSummary extends StatelessWidget {
q.request.kind == TransferKind.payout
? l.wRecipientGets
: 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,
),
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) ...[
const Divider(height: 32),
Text(l.wRecipient),
SelectableText(q.request.recipient),
if (q.payoutSnapshot != null)
DestinationDetailsView(details: q.payoutSnapshot!)
else
SelectableText(q.request.recipient),
],
],
),
+86 -24
View File
@@ -11,6 +11,7 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../security/challenge.dart';
import '../destinations/list.dart';
import 'copy.dart';
import 'summary.dart';
@@ -32,7 +33,7 @@ class TransferPageState extends State<TransferPage> {
source: widget.source,
);
final _amount = TextEditingController();
final _recipient = TextEditingController();
final _focus = FocusNode();
final _content = GlobalKey();
Timer? _timer;
@@ -41,7 +42,13 @@ class TransferPageState extends State<TransferPage> {
void initState() {
super.initState();
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), (_) {
if (mounted && _provider.quote != null) setState(() {});
@@ -118,7 +125,7 @@ class TransferPageState extends State<TransferPage> {
_provider.removeListener(_changed);
_provider.dispose();
_amount.dispose();
_recipient.dispose();
_focus.dispose();
super.dispose();
}
@@ -134,14 +141,16 @@ class TransferPageState extends State<TransferPage> {
final accounts = wallet.data?.accounts ?? [];
final source = wallet.account(p.source);
final live = !wallet.service.isSimulated;
final destinations = context.watch<DestinationsProvider>();
final legacyPayout =
live &&
!(destinations.capabilities?.types.isNotEmpty ?? false) &&
destinations.methods.isEmpty;
final expiresIn =
p.quote?.expiresAt.difference(DateTime.now()).inSeconds ?? 0;
final unavailableMethod =
live &&
((widget.kind == TransferKind.payout &&
(source?.payoutMethod.isEmpty ?? true)) ||
(widget.kind == TransferKind.topUp &&
!(source?.canFund ?? false)));
((widget.kind == TransferKind.topUp && !(source?.canFund ?? false)));
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
@@ -235,7 +244,11 @@ class TransferPageState extends State<TransferPage> {
FilledButton(
key: const ValueKey('transfer-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(
onPressed: p.busy ? null : p.edit,
@@ -316,23 +329,66 @@ class TransferPageState extends State<TransferPage> {
),
if (widget.kind == TransferKind.payout) ...[
const SizedBox(height: 20),
if (live && source?.payoutMethod.isNotEmpty == true)
InputDecorator(
decoration: InputDecoration(
labelText: l.wRecipient,
if (legacyPayout) ...[
if (source?.payoutMethod.isNotEmpty == true)
OutlinedButton.icon(
key: const ValueKey(
'choose-configured-destination',
),
icon: const Icon(
Icons.account_balance_outlined,
),
label: Text(source!.payoutLabel),
onPressed: p.busy
? null
: () => p.change(
recipient: source.payoutMethod,
payoutLabel: source.payoutLabel,
),
),
child: Text(source!.payoutLabel),
),
if (!live)
TextField(
key: const ValueKey('transfer-recipient'),
controller: _recipient,
enabled: !p.busy,
decoration: InputDecoration(
labelText: l.wRecipient,
hintText: l.wRecipientHint,
if (source?.payoutMethod.isEmpty ?? true)
Text(l.wNoPayoutMethod),
] else
OutlinedButton.icon(
key: const ValueKey(
'choose-payout-destination',
),
onChanged: (v) => p.change(recipient: v),
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) ...[
@@ -346,7 +402,13 @@ class TransferPageState extends State<TransferPage> {
const SizedBox(height: 28),
FilledButton(
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
: p.review,
child: Text(l.wReview),
@@ -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();
}
+245
View File
@@ -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);
if (hasMethod) {
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.pumpAndSettle();
expect(wallet.received, 'method-ref');
+15 -4
View File
@@ -6,9 +6,15 @@ import 'package:sxchnge_analytics/sxchnge_analytics.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/providers/locale.dart';
import 'destination_helpers.dart';
Future<void> tapFactor(WidgetTester t, String key) async {
final finder = find.byKey(ValueKey(key));
await t.ensureVisible(finder);
@@ -31,8 +37,16 @@ Future<DummyTwoFactorService> openFactor(
delay: Duration.zero,
method: method,
);
final wallet = DummyWalletService(
DummyIdentityService(delay: Duration.zero),
factors: service,
initialLocale: locale,
delay: Duration.zero,
);
await seedDestination(wallet.destinations);
await t.pumpWidget(
SxchngeApp(
walletService: wallet,
twoFactorService: service,
analyticsService: analytics,
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
@@ -174,10 +188,7 @@ void main() {
await openFactor(t, path: '/$kind', enabled: true);
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
if (kind == 'payout') {
await t.enterText(
find.byKey(const ValueKey('transfer-recipient')),
'Sample recipient',
);
await selectPayout(t);
}
await tapFactor(t, 'transfer-review');
await tapFactor(t, 'transfer-confirm');
+5 -8
View File
@@ -11,6 +11,8 @@ 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> tap(WidgetTester t, Finder f) async {
await t.ensureVisible(f);
await t.tap(f);
@@ -32,6 +34,7 @@ Future<DummyWalletService> open(
settlement: settlement,
initialLocale: locale,
);
await seedDestination(service.destinations);
await t.pumpWidget(
SxchngeApp(
identityService: identity,
@@ -78,10 +81,7 @@ void main() {
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
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-recipient')),
'Sample recipient',
);
await selectPayout(t);
await tap(t, find.byKey(const ValueKey('transfer-review')));
expect(find.text('Recipient receives'), findsOneWidget);
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
@@ -101,10 +101,7 @@ void main() {
);
s.failNextOperation = true;
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
await t.enterText(
find.byKey(const ValueKey('transfer-recipient')),
'Sample recipient',
);
await selectPayout(t);
await tap(t, find.byKey(const ValueKey('transfer-review')));
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
// Settlement is service-owned; refresh after the simulated processing period.
@@ -11,8 +11,11 @@ enum JourneyKind {
exchange,
payout,
security,
destination,
}
enum DestinationResolution { created, duplicate }
enum JourneyStep {
login,
signup,
@@ -23,6 +26,10 @@ enum JourneyStep {
kycSelfie,
kycSubmission,
kycStatus,
destinationDraft,
destinationSave,
destinationReconciliation,
destinationBinding,
transferDraft,
transferQuote,
transferSubmission,
@@ -78,6 +85,7 @@ class AnalyticsEvent {
required this.sequence,
required this.elapsedMs,
this.step,
this.resolution,
this.operation,
this.failure,
this.exit,
@@ -90,6 +98,7 @@ class AnalyticsEvent {
final JourneyKind kind;
final JourneySignal signal;
final JourneyStep? step;
final DestinationResolution? resolution;
final JourneyFailure? failure;
final JourneyExit? exit;
final int sequence;
@@ -105,6 +114,7 @@ class AnalyticsEvent {
JourneyKind.exchange => 'exchange',
JourneyKind.payout => 'payout',
JourneyKind.security => 'security',
JourneyKind.destination => 'destination',
_ => 'auth',
};
var stage = step == null ? 'journey' : _snake(step!.name);
@@ -123,6 +133,7 @@ class AnalyticsEvent {
'terminal': terminal,
'simulated': simulated,
if (step != null) 'step': _snake(step!.name),
if (resolution != null) 'destination_resolution': resolution!.name,
if (operation != null) 'operation': operation!,
if (failure != null) 'failure': _snake(failure!.name),
if (exit != null) 'exit': _snake(exit!.name),
@@ -210,6 +221,7 @@ class JourneyRecorder {
JourneyFailure? failure,
bool terminal = false,
bool cancelled = false,
DestinationResolution? resolution,
}) {
final journey = operation._journey;
if (_disposed || operation._finished || journey.closed) return;
@@ -227,6 +239,7 @@ class JourneyRecorder {
? JourneySignal.simulated
: JourneySignal.succeeded,
operation: operation,
resolution: resolution,
failure: failure,
terminal: complete,
simulated: simulated || (complete && journey.simulated),
@@ -258,6 +271,7 @@ class JourneyRecorder {
_Journey journey,
JourneySignal signal, {
JourneyOperation? operation,
DestinationResolution? resolution,
JourneyFailure? failure,
JourneyExit? exit,
bool terminal = false,
@@ -271,6 +285,7 @@ class JourneyRecorder {
sequence: ++journey.sequence,
elapsedMs: journey.time.elapsedMilliseconds,
step: operation?.step,
resolution: resolution,
operation: operation?.number,
failure: failure,
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
signals are simulated. Cross-session outcomes require backend event delivery;
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 OperationStatus { pending, completed, failed }
@@ -12,6 +14,7 @@ enum WalletError {
unsupported,
notFound,
uncertain,
destinationChanged,
}
class WalletException implements Exception {
@@ -64,12 +67,14 @@ class TransferRequest {
required this.amount,
this.destination,
this.recipient = '',
this.payoutDestination,
});
final TransferKind kind;
final String source;
final int amount;
final String? destination;
final String recipient;
final DestinationInput? payoutDestination;
}
class Quote {
@@ -82,11 +87,15 @@ class Quote {
required this.expiresAt,
required this.rateNumerator,
required this.rateDenominator,
this.payoutSnapshot,
this.payoutTerms,
});
final String id;
final TransferRequest request;
final int fee, debit, credit, rateNumerator, rateDenominator;
final DateTime expiresAt;
final DestinationDetails? payoutSnapshot;
final PayoutTerms? payoutTerms;
}
class WalletOperation {
@@ -116,3 +125,14 @@ class AccountProfile {
const AccountProfile(this.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 '../destinations/models.dart';
import '../models/wallet.dart';
import 'wallet.dart';
@@ -40,16 +41,18 @@ class TransferProvider extends ChangeNotifier {
if (!accounts.any((a) => a.id == destination) || destination == source) {
destination = accounts.where((a) => a.id != source).firstOrNull?.id;
}
if (!wallet.service.isSimulated && kind == TransferKind.payout) {
recipient = wallet.account(source)?.payoutMethod ?? '';
}
}
void _walletChanged() {
if (_disposed) return;
final previous = source;
_syncAccounts();
if (source != previous) notifyListeners();
if (source != previous) {
payoutDestination = null;
payoutLabel = '';
recipient = '';
notifyListeners();
}
}
final WalletProvider wallet;
@@ -58,6 +61,8 @@ class TransferProvider extends ChangeNotifier {
String source;
String? destination;
String amount = '', recipient = '';
DestinationInput? payoutDestination;
String payoutLabel = '';
Quote? quote;
WalletOperation? result;
WalletError? error;
@@ -72,7 +77,7 @@ class TransferProvider extends ChangeNotifier {
TransferKind.payout => JourneyKind.payout,
};
bool get hasDraft =>
amount.isNotEmpty || (wallet.service.isSimulated && recipient.isNotEmpty);
amount.isNotEmpty || payoutDestination != null || recipient.isNotEmpty;
bool get expired =>
quote != null && !DateTime.now().isBefore(quote!.expiresAt);
void change({
@@ -80,8 +85,17 @@ class TransferProvider extends ChangeNotifier {
String? destination,
String? amount,
String? recipient,
DestinationInput? payoutDestination,
String? payoutLabel,
}) {
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.destination = destination ?? this.destination;
this.amount = amount ?? this.amount;
@@ -96,6 +110,36 @@ class TransferProvider extends ChangeNotifier {
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() {
if (!busy && !uncertain) {
quote = null;
@@ -133,6 +177,7 @@ class TransferProvider extends ChangeNotifier {
amount: money,
destination: kind == TransferKind.exchange ? destination : null,
recipient: recipient.trim(),
payoutDestination: payoutDestination,
),
);
if (_disposed || revision != _revision) return;
@@ -142,6 +187,10 @@ class TransferProvider extends ChangeNotifier {
} catch (e) {
if (_disposed) return;
error = _code(e);
if (error == WalletError.destinationChanged) {
payoutDestination = null;
payoutLabel = '';
}
recorder.finish(op, failure: _failure(error!));
}
if (_disposed) return;
@@ -225,6 +274,11 @@ class TransferProvider extends ChangeNotifier {
uncertain = false;
} else {
error = _code(e);
if (error == WalletError.destinationChanged) {
quote = null;
payoutDestination = null;
payoutLabel = '';
}
}
// All transport/unexpected failures are ambiguous after execution started.
uncertain = [
@@ -247,6 +301,7 @@ class TransferProvider extends ChangeNotifier {
error = null;
factorError = null;
recipient = '';
payoutDestination = null;
recorder.finish(op, simulated: wallet.service.isSimulated);
wallet.track(value, recorder, journey);
_handedOff = true;
@@ -6,12 +6,19 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../destinations/service.dart';
import '../models/wallet.dart';
import '../services/contracts.dart';
class WalletProvider extends ChangeNotifier {
WalletProvider(this.service, this.analytics, {this.factors});
WalletProvider(
this.service,
this.analytics, {
this.factors,
this.destinations,
});
final WalletService service;
final DestinationService? destinations;
final TwoFactorService? factors;
final AnalyticsService analytics;
WalletSnapshot? data;
@@ -94,10 +94,15 @@ class BackendWalletService implements WalletService {
@override
Future<WalletSnapshot> fetch() => _mapped(send(''), BackendMapper.snapshot);
@override
Future<Quote> quote(TransferRequest request) => _mapped(
send('/quote', body: BackendMapper.request(request)),
BackendMapper.quote,
);
Future<Quote> quote(TransferRequest request) async {
if (request.payoutDestination != null)
throw const WalletException(WalletError.unsupported);
return _mapped(
send('/quote', body: BackendMapper.request(request)),
BackendMapper.quote,
);
}
@override
Future<WalletOperation> submit(
String quoteId,
@@ -5,6 +5,9 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_kyc/limits_transport.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 '../transport/wallet.dart';
import 'contracts.dart';
@@ -14,13 +17,17 @@ class DummyWalletService implements WalletService {
DummyWalletService(
this.identity, {
this.factors,
DummyDestinationService? destinations,
this.delay = const Duration(milliseconds: 350),
this.settlement = const Duration(seconds: 2),
DateTime Function()? now,
String initialLocale = 'en',
}) : now = now ?? DateTime.now,
}) : destinations = destinations ?? DummyDestinationService(now: now),
now = now ?? DateTime.now,
_locale = initialLocale;
final IdentityService identity;
final DummyDestinationService destinations;
final _destinationLeases = <String, DestinationLease>{};
final TwoFactorService? factors;
final Duration delay, settlement;
final DateTime Function() now;
@@ -59,6 +66,10 @@ class DummyWalletService implements WalletService {
};
int _eur(int minor, String id) => (minor * _weight(id) + 1099) ~/ 1100;
void _settle() {
_quotes.removeWhere((_, quote) => !now().isBefore(quote.expires));
_destinationLeases.removeWhere(
(_, lease) => !now().isBefore(lease.expiresAt),
);
for (final o in _submissions.values) {
if (o.status != 'pending' || now().isBefore(o.ready)) continue;
o.status = o.fail ? 'failed' : 'completed';
@@ -133,9 +144,19 @@ class DummyWalletService implements WalletService {
if (r.kind == TransferKind.exchange &&
(r.source == r.destination || r.destination == null))
throw const WalletException(WalletError.unsupported);
if (r.kind == TransferKind.payout &&
(r.recipient.trim().length < 6 || r.recipient.length > 140))
throw const WalletException(WalletError.invalidRecipient);
DestinationLease? lease;
if (r.kind == TransferKind.payout) {
if (r.payoutDestination == null)
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!);
final fee = switch (r.kind) {
TransferKind.topUp => 0,
@@ -146,19 +167,30 @@ class DummyWalletService implements WalletService {
final d = r.kind == TransferKind.exchange ? _weight(r.destination!) : 1;
final q = QuoteDTO(
'quote-${++_serial}',
WalletMapper.request(r),
WalletMapper.request(r, includePayoutInput: false),
fee,
r.kind == TransferKind.topUp ? r.amount : r.amount + fee,
r.amount * n ~/ d,
now().add(const Duration(seconds: 60)),
n,
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);
final verified = await _verified();
_settle();
_check(q, verified);
_quotes[q.id] = q;
if (lease != null) _destinationLeases[q.id] = lease;
return WalletMapper.quote(q);
}
@@ -193,6 +225,17 @@ class DummyWalletService implements WalletService {
if (_usedQuotes.contains(q.id))
throw const WalletException(WalletError.expiredQuote);
_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);
if (q.request.kind != 'topUp')
_account(q.request.source).available -= q.debit;
@@ -205,6 +248,7 @@ class DummyWalletService implements WalletService {
);
failNextOperation = false;
_submissions[idempotencyKey] = o;
_destinationLeases.remove(q.id);
if (loseNextResponse) {
loseNextResponse = false;
throw const WalletException(WalletError.uncertain);
@@ -1,3 +1,4 @@
import '../destinations/transport.dart';
import '../models/wallet.dart';
class AccountDTO {
@@ -23,11 +24,13 @@ class RequestDTO {
this.source,
this.destination,
this.amount,
this.recipient,
);
this.recipient, {
this.payoutInput,
});
final String kind, source, recipient;
final String? destination;
final int amount;
final DestinationInputDTO? payoutInput;
}
class QuoteDTO {
@@ -39,12 +42,16 @@ class QuoteDTO {
this.credit,
this.expires,
this.numerator,
this.denominator,
);
this.denominator, {
this.payoutSnapshot,
this.payoutTerms,
});
final String id;
final RequestDTO request;
final int fee, debit, credit, numerator, denominator;
final DateTime expires;
final DestinationDTO? payoutSnapshot;
final PayoutTermsDTO? payoutTerms;
}
class OperationDTO {
@@ -78,14 +85,28 @@ abstract final class WalletMapper {
payoutLabel: d.payoutLabel,
canFund: d.canFund,
);
static RequestDTO request(TransferRequest r) =>
RequestDTO(r.kind.name, r.source, r.destination, r.amount, r.recipient);
static RequestDTO request(
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(
kind: TransferKind.values.byName(d.kind),
source: d.source,
destination: d.destination,
amount: d.amount,
recipient: d.recipient,
payoutDestination: d.payoutInput == null
? null
: DestinationInputMapper.fromDTO(d.payoutInput!),
);
static Quote quote(QuoteDTO d) => Quote(
id: d.id,
@@ -96,6 +117,17 @@ abstract final class WalletMapper {
expiresAt: d.expires,
rateNumerator: d.numerator,
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(
id: d.id,
@@ -105,3 +137,14 @@ abstract final class WalletMapper {
);
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/services/backend.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);
await wallet.refresh();
expect(draft.source, 'ledger');
expect(draft.recipient, 'method');
expect(draft.recipient, isEmpty);
expect(draft.hasDraft, false);
draft.change(recipient: 'method');
expect(draft.hasDraft, true);
draft.change(amount: '20.01');
await draft.review();
await draft.submit(approval: const FactorGrant('approved-intent-key'));
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,
destination: kind == TransferKind.exchange ? 'usd' : null,
recipient: 'Sample recipient',
payoutDestination: OneTimeDestination(
CryptoDestination(
assetId: 'demo:ethereum:native',
networkId: 'eip155:1',
address: '0x1234567890123456789012345678901234567890',
assetLabel: 'ETH',
networkLabel: 'Ethereum',
),
),
),
);
await expectLater(
@@ -59,6 +68,15 @@ void main() {
source: 'eur',
amount: 1000,
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))!;
@@ -85,6 +103,15 @@ void main() {
source: 'eur',
amount: 1000,
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'))!;
@@ -39,6 +39,15 @@ void main() {
destination: to,
amount: amount,
recipient: recipient,
payoutDestination: OneTimeDestination(
CryptoDestination(
assetId: 'demo:ethereum:native',
networkId: 'eip155:1',
address: '0x1234567890123456789012345678901234567890',
assetLabel: 'ETH',
networkLabel: 'Ethereum',
),
),
),
);
return s.submit(q.id, key);
@@ -107,6 +116,15 @@ void main() {
source: 'eur',
amount: 7000,
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));
@@ -127,6 +145,15 @@ void main() {
source: 'eur',
amount: 6000,
recipient: 'Sample recipient',
payoutDestination: OneTimeDestination(
CryptoDestination(
assetId: 'demo:ethereum:native',
networkId: 'eip155:1',
address: '0x1234567890123456789012345678901234567890',
assetLabel: 'ETH',
networkLabel: 'Ethereum',
),
),
),
);
final b = await s.quote(
@@ -135,6 +162,15 @@ void main() {
source: 'eur',
amount: 6000,
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');
@@ -160,6 +196,15 @@ void main() {
source: 'eur',
amount: 11000,
recipient: 'Sample recipient',
payoutDestination: OneTimeDestination(
CryptoDestination(
assetId: 'demo:ethereum:native',
networkId: 'eip155:1',
address: '0x1234567890123456789012345678901234567890',
assetLabel: 'ETH',
networkLabel: 'Ethereum',
),
),
),
),
throwsA(