Add saved payout destination UX and service contracts (#54)
This commit is contained in:
1 parent
a5689876ff
commit
24af794112
49 files changed
+4687
-62
No files matched your search
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user