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

@@ -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';