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 = >{}; final attempts = >{}; final audit = []; final recoveries = >{}; final fingerprints = >{}; } /// 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 = {}; int requirementsRevision = 1; bool loseNextSaveResponse = false; bool reusableCards = true, cardRecovery = true; DestinationIssue? nextSaveError; bool directoryUnavailable = false; List 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 get _records => memory.records.putIfAbsent(customer, () => {}); Map get _attempts => memory.attempts.putIfAbsent(customer, () => {}); @override bool get isSimulated => true; @override Future capabilities() async => DestinationCapabilities( types: DestinationType.values, assets: assets, reusableCards: true, cardRecovery: true, simulated: true, ); @override Future> 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 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 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] ?? {})[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 assess( DestinationInput input, PayoutContext context, ) async => _assess(input, context); @override Future 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 creation(String idempotencyKey) async => _attempts[idempotencyKey]?.$2; @override Future 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 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> banks(String query, {Future? 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 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 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; }