615 lines
20 KiB
Dart
615 lines
20 KiB
Dart
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;
|
|
}
|