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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user