← Code knowledge base
Reviewed Flutter implementation · 2026-10-28

banking_models.dart

mobile_flutter/lib/core/models/banking_models.dart

Immutable source snapshot based on revision ce45c9c3bbde. Original line numbers are retained. This is implementation evidence; check the public API catalogue before adapting it.

The mobile /api/v1/mobile routes belong to the example backend. Company credentials stay on that backend. Some sections outside the documented product scope may be omitted.

Lines 1–2190

import 'dart:convert'; import 'equals_money.dart'; enum OnboardingTaskStatus { pending, inProgress, complete, blocked } enum CardStatus { active, frozen, pending, cancelled } enum TransactionType { card, transfer, topUp, payment, fee } class Money {  const Money({    required this.currency,    required this.minorUnits,    double? decimalAmount,  }) : _decimalAmount = decimalAmount;   factory Money.fromJson(    Map<String, dynamic> json, {    bool preservePrecision = false,  }) {    final currency =        (json['currency'] ?? json['Currency'])?.toString() ?? 'EUR';    final minorUnitValue = json['minorUnits'] ??        json['MinorUnits'] ??        json['amountMinor'] ??        json['AmountMinor'];    final decimalValue = json['available'] ??        json['Available'] ??        json['amount'] ??        json['Amount'] ??        json['value'] ??        json['Value'];     return Money(      currency: currency,      minorUnits: minorUnitValue == null          ? _parseDecimalAmount(decimalValue)          : _parseWholeMinorUnits(minorUnitValue),      decimalAmount: preservePrecision && minorUnitValue == null          ? _parseDecimal(decimalValue)          : null,    );  }   final String currency;  final int minorUnits;  final double? _decimalAmount;   /// Provider amount in its own currency. Ledger parsing keeps native crypto  /// decimals here; existing cent-based constructors retain their behavior.  double get decimalAmount => _decimalAmount ?? minorUnits / 100;  bool get isPositive => decimalAmount > 0;  bool get isNegative => decimalAmount < 0;   Money get negated => Money(        currency: currency,        minorUnits: -minorUnits,        decimalAmount: _decimalAmount == null ? null : -_decimalAmount,      );   /// Human display form: `\$4,562.35`, `€6,234.75`, `-£12.00`,  /// `1,420.00 USDC`. Crypto keeps meaningful decimals and a minimum of two.  String get formatted => formatAmount(currency, decimalAmount);   /// Fiat identity uses the banking provider's full supported currency list.  /// Keep this separate from the amount formatter's existing decimal rules.  static bool isFiatCurrency(String currency) {    final code = currency.trim().toUpperCase();    return _fiatCodes.contains(code) ||        equalsSupportedCurrencyCodes.contains(code);  }   static const _fiatSymbols = {'USD': '\$', 'EUR': '€', 'GBP': '£'};  static const _sixDecimalAssets = {'USDC', 'USDT'};  static const _fiatCodes = {    'USD',    'EUR',    'GBP',    'AED',    'CHF',    'CAD',    'AUD',    'JPY',    'SEK',    'NOK',    'DKK',    'PLN',    'CZK',    'HUF',    'RON',    'BGN',    'TRY',    'ZAR',    'SGD',    'HKD',    'NZD',    'MXN',    'BRL',    'INR',    'SAR',    'QAR',    'KWD',    'BHD',  };   /// One formatter for every amount in the app so decimals stay consistent:  /// fiat two decimals with a symbol where we have one, USDC/USDT up to six  /// decimals and other crypto up to eight, with a minimum of two decimals.  /// When Private Mode is on every amount renders as dots. Set by the  /// private-mode controller before the app rebuilds.  static bool maskAmounts = false;   static String formatAmount(String currency, double amount) {    if (maskAmounts) return '••••';    return formatPlainAmount(currency, amount);  }   /// [formatAmount] without the Private Mode mask, for text that leaves the  /// device (a shared invitation) where dots would be meaningless.  static String formatPlainAmount(String currency, double amount) {    final code = currency.trim().toUpperCase();    final sign = amount < 0 ? '-' : '';    final absolute = amount.abs();    final decimals = _sixDecimalAssets.contains(code)        ? 6        : _fiatCodes.contains(code) || code.isEmpty            ? 2            : null;    var fixed = absolute.toStringAsFixed(decimals ?? 8);    if (decimals != 2) {      final minimumLength = fixed.indexOf('.') + 3;      while (fixed.length > minimumLength && fixed.endsWith('0')) {        fixed = fixed.substring(0, fixed.length - 1);      }    }    final dot = fixed.indexOf('.');    final whole = _groupThousands(dot < 0 ? fixed : fixed.substring(0, dot));    final fraction = dot < 0 ? '' : fixed.substring(dot);    final symbol = _fiatSymbols[code];    if (symbol != null) return '$sign$symbol$whole$fraction';    return '$sign$whole$fraction${code.isEmpty ? '' : ' $code'}';  }   static String _groupThousands(String whole) {    final buffer = StringBuffer();    for (var index = 0; index < whole.length; index++) {      final remaining = whole.length - index;      buffer.write(whole[index]);      if (remaining > 1 && remaining % 3 == 1) buffer.write(',');    }    return buffer.toString();  }   static int _parseWholeMinorUnits(Object? value) {    if (value is int) {      return value;    }    if (value is num) {      return value.round();    }    if (value is String) {      return int.tryParse(value) ??          (double.tryParse(value.replaceAll(',', '.')) ?? 0).round();    }     return 0;  }   static int _parseDecimalAmount(Object? value) {    if (value is int) {      return value * 100;    }    if (value is num) {      return (value * 100).round();    }    if (value is String) {      return ((double.tryParse(value.replaceAll(',', '.')) ?? 0) * 100).round();    }     return 0;  }} class UserProfile {  const UserProfile({    required this.id,    required this.name,    required this.email,    required this.accountType,    required this.kycStatus,    required this.businessStatus,    required this.onboardingStatus,    this.interlaceKycApproved = false,    this.nickname,  });   factory UserProfile.fromJson(Map<String, dynamic> json) {    final firstName = json['firstName'] ?? json['FirstName'];    final lastName = json['lastName'] ?? json['LastName'];    final composedName = [firstName, lastName]        .where((part) => part != null && part.toString().trim().isNotEmpty)        .join(' ');     return UserProfile(      id: (json['id'] ?? json['Id'] ?? json['userId'] ?? json['UserId'])              ?.toString() ??          '',      name: (json['name'] ??                  json['Name'] ??                  json['fullName'] ??                  json['FullName'] ??                  (composedName.isEmpty ? null : composedName))              ?.toString() ??          'User',      email: (json['email'] ?? json['Email'])?.toString() ?? '',      nickname: (json['nickname'] ?? json['Nickname'])?.toString(),      accountType: (json['accountType'] ?? json['AccountType'])?.toString() ??          'personal',      kycStatus:          (json['kycStatus'] ?? json['KycStatus'])?.toString() ?? 'pending',      businessStatus:          (json['businessStatus'] ?? json['BusinessStatus'])?.toString() ??              'not_started',      interlaceKycApproved:          (json['interlaceKycApproved'] ?? json['InterlaceKycApproved']) ==              true,      onboardingStatus:          (json['onboardingStatus'] ?? json['OnboardingStatus'])?.toString() ??              'not_started',    );  }   final String id;  final String name;  final String email;  final String accountType;  final String kycStatus;  final String businessStatus;  final String onboardingStatus;  final bool interlaceKycApproved;  final String? nickname;   bool get isKycReady => _isReadyStatus(kycStatus);   bool get isBusinessAccount => accountType.toLowerCase().trim() == 'business';   bool get isBankOnboarded => _isReadyStatus(onboardingStatus);} extension DashboardReadiness on DashboardSnapshot {  bool get canUseBanking => profile.isBankOnboarded || accounts.isNotEmpty;   bool get canOrderCard => profile.interlaceKycApproved;} bool _isReadyStatus(String value) {  final normalized = value.toLowerCase().replaceAll('_', '-').trim();  return normalized == 'approved' ||      normalized == 'verified' ||      normalized == 'complete' ||      normalized == 'completed' ||      normalized == 'active' ||      normalized == 'ready';} class AccountBalance {  const AccountBalance({    required this.id,    required this.name,    required this.iban,    required this.balance,    required this.available,    this.accountNumber = '',    this.provider = '',    this.status = '',    this.supportedCurrencies = const [],    this.currencyBalances = const [],    this.linkedBankAccounts = const [],    this.hasBalanceCurrency = true,    this.budgetId = '',    this.parentAccountId = '',    this.accountType = '',    this.accountHolderName = '',  });   factory AccountBalance.fromJson(Map<String, dynamic> json) {    final linkedBankAccounts = _bankAccountDetailsList(      json['linkedBankAccounts'] ?? json['LinkedBankAccounts'],    );    final primaryBank = linkedBankAccounts.firstOrNull;    final accountId = json['id'] ??        json['accountId'] ??        json['AccountId'] ??        json['balanceId'] ??        json['BalanceId'];    final displayName =        json['name'] ?? json['displayName'] ?? json['DisplayName'];    final iban = json['iban'] ??        json['Iban'] ??        primaryBank?.iban ??        json['ibanLast4'] ??        json['IbanLast4'];    final balanceValue = json['balance'] ?? json['Balance'];    final balanceCurrency = balanceValue is Map        ? (balanceValue['currency'] ?? balanceValue['Currency'])            ?.toString()            .trim()        : null;    final hasBalanceCurrency = balanceCurrency?.isNotEmpty == true;    return AccountBalance(      id: accountId?.toString() ?? '',      name: displayName?.toString() ?? 'Account',      iban: iban?.toString() ?? '',      balance: Money.fromJson(_moneyJson(balanceValue,          currency: hasBalanceCurrency ? balanceCurrency! : 'USD')),      available: Money.fromJson(        _moneyJson(          json['available'] ??              json['availableBalance'] ??              json['AvailableBalance'],        ),      ),      accountNumber:          (json['accountNumber'] ?? json['AccountNumber'])?.toString() ??              primaryBank?.accountNumber ??              '',      provider: (json['provider'] ?? json['Provider'])?.toString() ?? '',      status: (json['status'] ?? json['Status'])?.toString() ?? '',      supportedCurrencies: _stringList(        json['supportedCurrencies'] ?? json['SupportedCurrencies'],      ),      currencyBalances: _currencyBalanceList(json),      linkedBankAccounts: linkedBankAccounts,      hasBalanceCurrency: hasBalanceCurrency,      budgetId: (json['budgetId'] ?? json['BudgetId'])?.toString() ?? '',      parentAccountId:          (json['parentAccountId'] ?? json['ParentAccountId'])?.toString() ??              '',      accountType:          (json['accountType'] ?? json['AccountType'])?.toString() ?? '',      accountHolderName:          (json['accountHolderName'] ?? json['AccountHolderName'])                  ?.toString() ??              '',    );  }   final String id;  final String name;  final String iban;  final Money balance;  final Money available;  final String accountNumber;  final String provider;  final String status;  final List<String> supportedCurrencies;  final List<Money> currencyBalances;  final List<BankAccountDetails> linkedBankAccounts;   /// The API supplied the balance currency. A legacy display fallback must  /// never become a currency filter for an otherwise unqualified account.  final bool hasBalanceCurrency;   /// Unified Equals accounts expose the shared owner as [id] and the actual  /// allocated account as [budgetId]. Keep both; they are not interchangeable.  final String budgetId;  final String parentAccountId;  final String accountType;  final String accountHolderName;} class BankAccountDetails {  const BankAccountDetails({    required this.currency,    required this.accountNumber,    required this.iban,    required this.bankName,    required this.swift,    required this.routingNumber,    required this.routingType,    required this.status,  });   factory BankAccountDetails.fromJson(Map<String, dynamic> json) {    return BankAccountDetails(      currency: (json['currency'] ?? json['Currency'])?.toString() ?? '',      accountNumber:          (json['accountNumber'] ?? json['AccountNumber'])?.toString() ?? '',      iban: (json['iban'] ?? json['Iban'])?.toString() ?? '',      bankName: (json['bankName'] ?? json['BankName'])?.toString() ?? '',      swift: (json['swift'] ??                  json['Swift'] ??                  json['swiftCode'] ??                  json['SwiftCode'])              ?.toString() ??          '',      routingNumber:          (json['routingNumber'] ?? json['RoutingNumber'])?.toString() ?? '',      routingType:          (json['routingType'] ?? json['RoutingType'])?.toString() ?? '',      status: (json['status'] ?? json['Status'])?.toString() ?? '',    );  }   final String currency;  final String accountNumber;  final String iban;  final String bankName;  final String swift;  final String routingNumber;  final String routingType;  final String status;   String get displayReference {    if (iban.trim().isNotEmpty) {      return iban;    }    if (accountNumber.trim().isNotEmpty) {      return accountNumber;    }    return '';  }} List<BankAccountDetails> _bankAccountDetailsList(Object? value) {  if (value is! List) {    return const [];  }   return value      .whereType<Map>()      .map((item) => item.map((key, value) => MapEntry(key.toString(), value)))      .map(BankAccountDetails.fromJson)      .toList();} List<String> _stringList(Object? value) {  if (value is! List) {    return const [];  }   return value      .map((item) => item?.toString().trim() ?? '')      .where((item) => item.isNotEmpty)      .toList();} double _parseDecimal(Object? value) {  if (value is num) {    return value.toDouble();  }  if (value is String) {    return double.tryParse(value.replaceAll(',', '.')) ?? 0;  }   return 0;} double? _parseNullableDecimal(Object? value) {  if (value == null) {    return null;  }   return _parseDecimal(value);} int? _parseNullableInt(Object? value) {  if (value is int) {    return value;  }  if (value is num) {    return value.round();  }  if (value is String) {    return int.tryParse(value);  }   return null;} class PaymentCard {  const PaymentCard({    required this.id,    required this.label,    required this.last4,    required this.network,    required this.currency,    required this.status,    required this.balance,    this.hasReportedBalance = true,    required this.spendThisMonth,    required this.limit,    required this.virtual,    this.canSetPin = false,    this.bankProvider = '',    this.budgetId = '',    this.budgetName = '',    this.cardTypeId,    this.cardTypeName = '',    this.discountCode = '',    this.cardImageUrl = '',    this.cardThumbnailUrl = '',    this.cardPreviewUrl = '',    this.cardBackImageUrl = '',    this.cardBackThumbnailUrl = '',    this.cardBackPreviewUrl = '',    this.cardImageAlt = '',    this.cardTextColor = '',  });   factory PaymentCard.fromJson(Map<String, dynamic> json) {    final source = _unwrapCardJson(json);    final cardId = json['id'] ?? json['Id'] ?? json['cardId'] ?? json['CardId'];    final resolvedCardId =        source['id'] ?? source['Id'] ?? source['cardId'] ?? source['CardId'];    final artwork = _cardArtworkSource(source);    final label = source['label'] ??        source['name'] ??        source['Nickname'] ??        source['CardName'] ??        source['cardTypeName'] ??        source['CardTypeName'] ??        source['productCode'];    final network = _firstString(source, const [          'network',          'Network',          'brand',          'Brand',          'cardNetwork',          'CardNetwork',          'paymentNetwork',          'PaymentNetwork',          'cardBrand',          'CardBrand',          'cardScheme',          'CardScheme',          'scheme',          'Scheme',        ]) ??        _firstString(artwork, const [          'network',          'Network',          'brand',          'Brand',          'cardNetwork',          'CardNetwork',          'paymentNetwork',          'PaymentNetwork',          'cardBrand',          'CardBrand',          'cardScheme',          'CardScheme',          'scheme',          'Scheme',        ]);    final cardNumber =        (source['cardNumber'] ?? source['CardNumber'])?.toString();    final currency = _cardCurrencyFromJson(source);    final balanceJson = _cardBalanceJson(source, currency: currency);    return PaymentCard(      id: resolvedCardId?.toString() ?? cardId?.toString() ?? '',      label: label?.toString() ?? 'Card',      last4: (source['last4'] ?? source['Last4'])?.toString() ??          (cardNumber == null || cardNumber.length < 4              ? ''              : cardNumber.substring(cardNumber.length - 4)),      network: network ?? _cardNetworkFromNumber(cardNumber),      currency: currency,      status: _cardStatus(          (source['status'] ?? source['Status'])?.toString() ?? 'pending'),      balance:          Money.fromJson(balanceJson ?? _moneyJson(null, currency: currency)),      hasReportedBalance: balanceJson != null,      spendThisMonth: Money.fromJson(        _moneyJson(          source['spendThisMonth'] ?? source['SpendThisMonth'],          currency: currency,        ),      ),      limit: Money.fromJson(        _moneyJson(source['limit'] ?? source['Limit'], currency: currency),      ),      virtual: (source['virtual'] as bool?) ??          (source['Virtual'] as bool?) ??          ((source['CardType'] ?? source['cardType'])                  ?.toString()                  .toLowerCase()                  .contains('virtual') ??              true),      // Hoppa flags cards whose PIN the customer may choose in the app.      canSetPin: (source['canSetPin'] as bool?) ??          (source['CanSetPin'] as bool?) ??          (json['canSetPin'] as bool?) ??          false,      bankProvider: _cardBankProvider(source),      budgetId: _firstString(source, const [            'budgetId',            'BudgetId',            'budgetID',            'BudgetID',            'cardBudgetId',            'CardBudgetId',          ]) ??          _firstString(_nestedMap(source, const ['budget', 'Budget']),              const ['id', 'Id', 'accountId', 'AccountId']) ??          '',      budgetName: _firstString(source, const [            'budgetName',            'BudgetName',          ]) ??          _firstString(_nestedMap(source, const ['budget', 'Budget']), const [            'name',            'Name',            'displayName',            'DisplayName',          ]) ??          '',      cardTypeId: _firstInt(source, const [            'cardTypeId',            'CardTypeId',            'cardTypeTierId',            'CardTypeTierId',          ]) ??          _firstInt(artwork, const ['id', 'Id', 'cardTypeId', 'CardTypeId']),      discountCode: _firstString(source, const [            'discountCode',            'DiscountCode',            'discount_code'          ])?.trim() ??          '',      cardTypeName: _firstString(source, const [            'cardTypeName',            'CardTypeName',          ]) ??          _firstString(artwork, const [            'name',            'Name',            'displayName',            'DisplayName',            'cardTypeName',            'CardTypeName',          ]) ??          '',      cardImageUrl: _normalizeCardArtworkUrl(_firstString(artwork, const [        'cardImageUrl',        'CardImageUrl',        'imageUrl',        'ImageUrl',      ])),      cardThumbnailUrl: _normalizeCardArtworkUrl(_firstString(artwork, const [        'cardThumbnailUrl',        'CardThumbnailUrl',        'thumbnailUrl',        'ThumbnailUrl',      ])),      cardPreviewUrl: _normalizeCardArtworkUrl(_firstString(artwork, const [        'cardPreviewUrl',        'CardPreviewUrl',        'previewUrl',        'PreviewUrl',      ])),      cardBackImageUrl: _normalizeCardArtworkUrl(_firstString(artwork, const [        'cardBackImageUrl',        'CardBackImageUrl',      ])),      cardBackThumbnailUrl:          _normalizeCardArtworkUrl(_firstString(artwork, const [        'cardBackThumbnailUrl',        'CardBackThumbnailUrl',      ])),      cardBackPreviewUrl: _normalizeCardArtworkUrl(_firstString(artwork, const [        'cardBackPreviewUrl',        'CardBackPreviewUrl',      ])),      cardImageAlt: _firstString(artwork, const [            'cardImageAlt',            'CardImageAlt',            'imageAlt',            'ImageAlt',          ]) ??          '',      cardTextColor: _firstString(artwork, const [            'cardTextColor',            'CardTextColor',            'textColor',            'TextColor',          ]) ??          '',    );  }   final String id;  final String label;  final String last4;  final String network;  final String currency;  final CardStatus status;  final Money balance;   /// Whether this response reported a balance, including an explicit zero.  /// A balance borrowed from the card list is not a current detail balance.  final bool hasReportedBalance;  final Money spendThisMonth;  final Money limit;  final bool virtual;   /// True when the provider lets the customer set this card's PIN in-app.  final bool canSetPin;  final String bankProvider;  final String budgetId;  final String budgetName;  final int? cardTypeId;  final String cardTypeName;  final String discountCode;  final String cardImageUrl;  final String cardThumbnailUrl;  final String cardPreviewUrl;  final String cardBackImageUrl;  final String cardBackThumbnailUrl;  final String cardBackPreviewUrl;  final String cardImageAlt;  final String cardTextColor;   String get artworkUrl {    if (cardImageUrl.isNotEmpty) return cardImageUrl;    if (cardPreviewUrl.isNotEmpty) return cardPreviewUrl;    return cardThumbnailUrl;  }   String get backArtworkUrl {    if (cardBackImageUrl.isNotEmpty) return cardBackImageUrl;    if (cardBackPreviewUrl.isNotEmpty) return cardBackPreviewUrl;    return cardBackThumbnailUrl;  }   /// Older tiers can omit back artwork; retain their existing presentation.  String get secureArtworkUrl =>      backArtworkUrl.isNotEmpty ? backArtworkUrl : artworkUrl;   PaymentCard withFallback(PaymentCard fallback) {    return PaymentCard(      id: id.isEmpty ? fallback.id : id,      label: label == 'Card' || label.isEmpty ? fallback.label : label,      last4: last4.isEmpty ? fallback.last4 : last4,      network: network.isEmpty ? fallback.network : network,      currency: currency.isEmpty ? fallback.currency : currency,      status: status,      balance: hasReportedBalance ? balance : fallback.balance,      hasReportedBalance: hasReportedBalance,      spendThisMonth: spendThisMonth.minorUnits == 0          ? fallback.spendThisMonth          : spendThisMonth,      limit: limit.minorUnits == 0 ? fallback.limit : limit,      virtual: virtual,      bankProvider: bankProvider.isEmpty ? fallback.bankProvider : bankProvider,      budgetId: budgetId.isEmpty ? fallback.budgetId : budgetId,      budgetName: budgetName.isEmpty ? fallback.budgetName : budgetName,      cardTypeId: cardTypeId ?? fallback.cardTypeId,      cardTypeName: cardTypeName.isEmpty ? fallback.cardTypeName : cardTypeName,      cardImageUrl: cardImageUrl.isEmpty ? fallback.cardImageUrl : cardImageUrl,      cardThumbnailUrl: cardThumbnailUrl.isEmpty          ? fallback.cardThumbnailUrl          : cardThumbnailUrl,      cardPreviewUrl:          cardPreviewUrl.isEmpty ? fallback.cardPreviewUrl : cardPreviewUrl,      cardBackImageUrl: cardBackImageUrl.isEmpty          ? fallback.cardBackImageUrl          : cardBackImageUrl,      cardBackThumbnailUrl: cardBackThumbnailUrl.isEmpty          ? fallback.cardBackThumbnailUrl          : cardBackThumbnailUrl,      cardBackPreviewUrl: cardBackPreviewUrl.isEmpty          ? fallback.cardBackPreviewUrl          : cardBackPreviewUrl,      cardImageAlt: cardImageAlt.isEmpty ? fallback.cardImageAlt : cardImageAlt,      cardTextColor:          cardTextColor.isEmpty ? fallback.cardTextColor : cardTextColor,    );  }   /// Card name for lists: provider label, otherwise card type or form factor.  String get displayLabel {    final trimmed = label.trim();    if (trimmed.isNotEmpty && trimmed.toLowerCase() != 'card') return trimmed;    if (cardTypeName.trim().isNotEmpty) return cardTypeName.trim();    return virtual ? 'Virtual card' : 'Physical card';  }   bool get isEqualsMoney {    final normalized = bankProvider.toLowerCase().trim();    return normalized == 'equalsmoney' || normalized == 'equals money';  }   String get providerLabel => isEqualsMoney ? 'Fiat account' : 'Crypto card';   String get statusLabel {    return switch (status) {      CardStatus.active => 'Active',      CardStatus.frozen => 'Frozen',      CardStatus.pending => 'Pending',      CardStatus.cancelled => 'Cancelled',    };  }} String _normalizeCardArtworkUrl(String? value) {  final normalized = value?.trim() ?? '';  final markdownLink =      RegExp(r'^\[[^\]]*\]\((https?://[^)]+)\)$').firstMatch(normalized);  final unwrapped = markdownLink?.group(1) ?? normalized;  if (unwrapped.startsWith('/')) {    return 'https://dashboard.hoppa.global$unwrapped';  }  return unwrapped;} String _cardNetworkFromNumber(String? cardNumber) {  final digits = cardNumber?.replaceAll(RegExp(r'\D'), '') ?? '';  if (digits.startsWith('4')) return 'Visa';  if (digits.length < 4) return '';   final firstTwo = int.tryParse(digits.substring(0, 2));  final firstFour = int.tryParse(digits.substring(0, 4));  if ((firstTwo != null && firstTwo >= 51 && firstTwo <= 55) ||      (firstFour != null && firstFour >= 2221 && firstFour <= 2720)) {    return 'Mastercard';  }  return '';} Map<String, dynamic> _cardArtworkSource(Map<String, dynamic> source) {  for (final keys in const [    ['cardTypeMetadata', 'CardTypeMetadata'],    ['cardType', 'CardType'],    ['cardProduct', 'CardProduct'],    ['cardBenefit', 'CardBenefit'],  ]) {    final nested = _nestedMap(source, keys);    if (nested != null) {      return {        ...nested,        for (final entry in source.entries)          if (entry.value != null &&              (entry.value is! String ||                  (entry.value as String).trim().isNotEmpty))            entry.key: entry.value,      };    }  }  return source;} int? _firstInt(Map<String, dynamic>? source, List<String> keys) {  if (source == null) return null;  for (final key in keys) {    final value = source[key];    if (value is int) return value;    if (value is num) return value.round();    final parsed = int.tryParse(value?.toString() ?? '');    if (parsed != null) return parsed;  }  return null;} String _cardBankProvider(Map<String, dynamic> source) {  final provider = _firstString(source, const [    'bankProvider',    'BankProvider',    'provider',    'Provider',    'cardProvider',    'CardProvider',  ]);  if (provider != null) {    final normalized = provider.toLowerCase().trim();    if (normalized == '2') {      return 'equalsmoney';    }    return normalized;  }   final providerType = source['bankProviderType'] ??      source['BankProviderType'] ??      source['providerType'] ??      source['ProviderType'];  if (providerType?.toString() == '2') {    return 'equalsmoney';  }   return '';} String? _firstString(Map<String, dynamic>? source, List<String> keys) {  if (source == null) {    return null;  }   for (final key in keys) {    final value = source[key];    if (value != null && value.toString().trim().isNotEmpty) {      return value.toString().trim();    }  }   return null;} Map<String, dynamic>? _nestedMap(    Map<String, dynamic> source, List<String> keys) {  for (final key in keys) {    final value = source[key];    if (value is Map<String, dynamic>) {      return value;    }    if (value is Map) {      return value.map((key, value) => MapEntry(key.toString(), value));    }  }   return null;} Map<String, dynamic> _unwrapCardJson(Map<String, dynamic> json) {  for (final key in const [    'card',    'Card',    'data',    'Data',    'result',    'Result',    'payload',    'Payload',  ]) {    final value = json[key];    if (value is Map<String, dynamic>) {      return _unwrapCardJson(value);    }    if (value is Map) {      return _unwrapCardJson(        value.map((key, value) => MapEntry(key.toString(), value)),      );    }  }   return json;} class LedgerTransaction {  const LedgerTransaction({    required this.id,    required this.title,    required this.subtitle,    required this.amount,    required this.bookedAt,    required this.type,    this.hasBookedAt = true,    this.accountId = '',    this.cardId = '',    this.walletId = '',    this.budgetId = '',    this.rawType = '',    this.status = '',    this.isPrimary = true,    this.transactionAmount,    this.metadata = const {},    this.merchantLogoUrl = '',    this.merchantCategory = '',    this.cardFees = const [],  });   factory LedgerTransaction.fromJson(Map<String, dynamic> json) {    final metadata = _transactionMetadata(json);    final bookedAtValue = _firstPresent(json, _bookedAtKeys) ??        _firstPresent(metadata, _bookedAtKeys);    final parsedBookedAt = _parseDateTime(bookedAtValue);     final currency = _firstString(json, const [          'currency',          'Currency',          'settlementCurrency',          'SettlementCurrency',        ]) ??        'USD';    final parsedAmount = Money.fromJson(      _moneyJson(        json['amount'] ?? json['Amount'],        currency: currency,      ),      preservePrecision: true,    );    final rawType = (json['type'] ?? json['Type'])?.toString() ?? '';    final direction = (_firstPresent(json, _directionKeys) ??            _firstPresent(metadata, _directionKeys))        ?.toString()        .toLowerCase()        .trim();    final isCredit =        const {'credit', 'credited', 'incoming', 'inbound'}.contains(direction);    final boomFi = _boomFiFields(metadata);    final boomFiExchange =        boomFi != null && rawType.toLowerCase() == 'exchange';    final isDebit = (boomFiExchange &&            boomFi['sellcurrency']?.toString().toUpperCase() ==                currency.toUpperCase()) ||        const {          'debit',          'debited',          'outgoing',          'outbound',          'withdrawal',          'payment',        }.contains(direction) ||        (!isCredit &&            (_isOutgoingType(rawType) ||                _isUsdToCryptoFunding(                    rawType, parsedAmount.currency, metadata)));    final amount = isDebit && parsedAmount.isPositive        ? parsedAmount.negated        : parsedAmount;     final status =        (json['status'] ?? json['Status'] ?? json['state'] ?? json['State'])                ?.toString() ??            '';    final transactionCurrency = _firstString(json, const [      'transactionCurrency',      'TransactionCurrency',      'originalCurrency',      'OriginalCurrency',      'localCurrency',      'LocalCurrency',    ]);    final transactionAmountValue = json['transactionAmount'] ??        json['TransactionAmount'] ??        json['originalAmount'] ??        json['OriginalAmount'] ??        json['localAmount'] ??        json['LocalAmount'];    final parsedTransactionAmount =        transactionCurrency == null || transactionAmountValue == null            ? null            : Money.fromJson(                _moneyJson(                  transactionAmountValue,                  currency: transactionCurrency,                ),                preservePrecision: true,              );    final transactionAmount = parsedTransactionAmount != null &&            amount.isNegative &&            parsedTransactionAmount.isPositive        ? parsedTransactionAmount.negated        : parsedTransactionAmount;    final merchant = _firstString(json, const [      'merchantName',      'MerchantName',      'merchant',      'Merchant',    ]);    final explicitTitle = _firstString(json, const ['title', 'Title']);    final detail = _firstString(json, const [      'detail',      'Detail',      'remark',      'Remark',      'description',      'Description',      'reference',      'Reference',    ]);    final typeLabel = _ledgerTransactionTypeLabel(rawType);    final title = _boomFiTitle(rawType, currency, boomFi) ??        _equalsIncomingRemitter(metadata, amount, rawType) ??        _usefulTransactionTitle(merchant, currency, rawType, status) ??        _usefulTransactionTitle(explicitTitle, currency, rawType, status) ??        _usefulTransactionTitle(detail, currency, rawType, status) ??        typeLabel;    final enrichment = _nestedMap(json, const [      'enrichment',      'Enrichment',      'merchantEnrichment',      'MerchantEnrichment',      'merchantDetails',      'MerchantDetails',    ]);     return LedgerTransaction(      id: (json['id'] ??                  json['Id'] ??                  json['transactionId'] ??                  json['TransactionId'])              ?.toString() ??          '',      title: title,      subtitle: (json['subtitle'] ?? json['Subtitle'])?.toString() ?? status,      amount: amount,      bookedAt: parsedBookedAt ?? DateTime.now(),      hasBookedAt: parsedBookedAt != null,      type: _transactionType(rawType.isEmpty ? 'card' : rawType),      accountId: _firstString(json, const [            'accountId',            'AccountId',            'sourceAccountId',            'SourceAccountId',          ]) ??          '',      cardId: _firstString(json, const ['cardId', 'CardId', 'card_id']) ??          _firstString(metadata, const ['cardId', 'CardId', 'card_id']) ??          '',      walletId: _firstString(json, const [            'walletId',            'WalletId',            'externalWalletId',            'ExternalWalletId',          ]) ??          '',      budgetId: _firstString(json, const [            'budgetId',            'BudgetId',            'sourceBudgetId',            'SourceBudgetId',            'destinationBudgetId',            'DestinationBudgetId',          ]) ??          '',      rawType: rawType,      status: status,      isPrimary: (json['isPrimary'] ?? json['IsPrimary']) != false,      transactionAmount: transactionAmount,      metadata: metadata,      merchantLogoUrl: _firstString(json, const [            'merchantLogoUrl',            'MerchantLogoUrl',            'logoUrl',            'LogoUrl',          ]) ??          _firstString(enrichment, const [            'merchantLogoUrl',            'MerchantLogoUrl',            'logoUrl',            'LogoUrl',          ]) ??          '',      merchantCategory: _firstString(json, const [            'merchantCategory',            'MerchantCategory',            'categoryName',            'CategoryName',          ]) ??          _firstString(enrichment, const [            'merchantCategory',            'MerchantCategory',            'categoryName',            'CategoryName',          ]) ??          '',    );  }   final String id;  final String title;  final String subtitle;  final Money amount;  final DateTime bookedAt;   /// Whether the API supplied a valid booking date. The internal fallback  /// keeps sorting compatible but must not be presented as a real timestamp.  final bool hasBookedAt;  final TransactionType type;  final String accountId;  final String cardId;  final String walletId;  final String budgetId;  final String rawType;  final String status;   /// False for related provider ledger legs that the API excludes from its  /// economic-operation statistics. Missing flags remain primary.  final bool isPrimary;  final Money? transactionAmount;  final Map<String, dynamic> metadata;  final String merchantLogoUrl;  final String merchantCategory;  final List<LedgerTransaction> cardFees;   LedgerTransaction withCardFees(List<LedgerTransaction> fees) =>      LedgerTransaction(        id: id,        title: title,        subtitle: subtitle,        amount: amount,        bookedAt: bookedAt,        type: type,        hasBookedAt: hasBookedAt,        accountId: accountId,        cardId: cardId,        walletId: walletId,        budgetId: budgetId,        rawType: rawType,        status: status,        isPrimary: isPrimary,        transactionAmount: transactionAmount,        metadata: metadata,        merchantLogoUrl: merchantLogoUrl,        merchantCategory: merchantCategory,        cardFees: List.unmodifiable(fees),      );   Money get displayAmount => transactionAmount ?? amount;   bool get isBoomFi => _boomFiFields(metadata) != null;   bool get isBoomFiExchange => isBoomFi && rawType.toLowerCase() == 'exchange';   Money? get exchangeReceivedAmount {    if (!isBoomFiExchange) return null;    final fields = _boomFiFields(metadata)!;    final currency = fields['buycurrency']?.toString().trim().toUpperCase();    final value = double.tryParse('${fields['buyamount']}');    if (currency == null ||        currency.isEmpty ||        value == null ||        !value.isFinite ||        value <= 0) {      return null;    }    return Money.fromJson({'currency': currency, 'amount': fields['buyamount']},        preservePrecision: true);  }   Money? get secondarySettlementAmount {    if (exchangeReceivedAmount case final received?) return received;    final local = transactionAmount;    if (local == null ||        (local.currency.toUpperCase() == amount.currency.toUpperCase() &&            local.decimalAmount == amount.decimalAmount)) {      return null;    }    return amount;  }   String get displayType => _ledgerTransactionTypeLabel(rawType);} Map<String, dynamic>? _boomFiFields(Map<String, dynamic> metadata) {  final fields = {    for (final entry in metadata.entries)      entry.key.toLowerCase().replaceAll('_', ''): entry.value,  };  return fields['provider']?.toString().toLowerCase() == 'boomfi'      ? fields      : null;} String? _boomFiTitle(    String type, String currency, Map<String, dynamic>? fields) {  if (fields == null) return null;  switch (type.toLowerCase()) {    case 'exchange':      final sell = fields['sellcurrency']?.toString().toUpperCase();      final buy = fields['buycurrency']?.toString().toUpperCase();      return sell != null && buy != null && sell.isNotEmpty && buy.isNotEmpty          ? '$sell → $buy'          : 'Currency conversion';    case 'withdrawal':      return Money.isFiatCurrency(currency)          ? 'Bank withdrawal'          : 'Crypto withdrawal';    case 'crypto_deposit':    case 'deposit':      return '${currency.toUpperCase()} deposit';    default:      return 'ZBX';  }} /// The payment rail describes how a deposit arrived, not who sent it.String? _equalsIncomingRemitter(    Map<String, dynamic> metadata, Money amount, String rawType) {  final fields = {    for (final entry in metadata.entries)      entry.key.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''): entry.value,  };  final provider = fields['provider']      ?.toString()      .toLowerCase()      .replaceAll(RegExp(r'[^a-z0-9]'), '');  final source = fields['source']?.toString().toLowerCase();  if (provider != 'equalsmoney' ||      !amount.isPositive ||      (source != 'external_credit' &&          (source != null && source.isNotEmpty ||              rawType.toLowerCase() != 'deposit'))) {    return null;  }  return _firstString(fields, const ['remittername']);} Map<String, dynamic> _transactionMetadata(Map<String, dynamic> json) {  final metadata = <String, dynamic>{};  for (final key in const ['metadata', 'Metadata', 'meta', 'Meta']) {    final value = json[key];    if (value is Map<String, dynamic>) metadata.addAll(value);    if (value is Map) {      metadata.addAll(        value.map((key, value) => MapEntry(key.toString(), value)),      );    }    if (value is String && value.trim().isNotEmpty) {      try {        final decoded = jsonDecode(value);        if (decoded is Map) {          metadata.addAll(            decoded.map((key, value) => MapEntry(key.toString(), value)),          );        }      } on FormatException {        // Some providers return an opaque metadata string. Keep it visible.        metadata['value'] = value;      }    }  }   for (final key in const [    'enrichment',    'Enrichment',    'merchantEnrichment',    'MerchantEnrichment',    'merchantDetails',    'MerchantDetails',    'merchantName',    'MerchantName',    'merchantLogoUrl',    'MerchantLogoUrl',    'merchantCategory',    'MerchantCategory',    'mcc',    'Mcc',    'merchantCity',    'MerchantCity',    'merchantCountry',    'MerchantCountry',    'network',    'Network',    'networkName',    'NetworkName',    'chain',    'Chain',    'chainName',    'ChainName',    'blockchain',    'Blockchain',    'paymentRail',    'PaymentRail',    'paymentNetwork',    'PaymentNetwork',    'provider',    'Provider',    'source',    'Source',    'externalTransactionId',    'ExternalTransactionId',    'relatedCardTransactionId',    'RelatedCardTransactionId',    'clientTransactionId',    'ClientTransactionId',    'cardTransactionId',    'CardTransactionId',    // Provider grouping keys the fee and duplicate folding reads.    'transactionGroupId',    'TransactionGroupId',    'parentTransactionId',    'ParentTransactionId',    'relationType',    'RelationType',    'tradeId',    'TradeId',    'feeAmount',    'FeeAmount',    'feeCurrency',    'FeeCurrency',    'remitterName',    'RemitterName',    'remitter_name',    'paymentMethod',    'PaymentMethod',    'schemeName',    'SchemeName',  ]) {    final value = json[key];    if (value != null) metadata.putIfAbsent(key, () => value);  }   return Map.unmodifiable(metadata);} const _bookedAtKeys = [  'bookedAt',  'BookedAt',  'postedAt',  'PostedAt',  'transactionTime',  'TransactionTime',  'transactionDate',  'TransactionDate',  'bookingDate',  'BookingDate',  'bookingDateTime',  'BookingDateTime',  'valueDate',  'ValueDate',  'completedAt',  'CompletedAt',  'settledAt',  'SettledAt',  'executedAt',  'ExecutedAt',  'createdAt',  'CreatedAt',  'createdDate',  'CreatedDate',  'created',  'Created',  'timestamp',  'Timestamp',  'date',  'Date',  'updatedAt',  'UpdatedAt',]; const _directionKeys = [  'direction',  'Direction',  'creditDebitIndicator',  'CreditDebitIndicator',  'credit_debit_indicator',  'debitCredit',  'DebitCredit',  'flow',  'Flow',]; Object? _firstPresent(Map<String, dynamic> json, List<String> keys) {  for (final key in keys) {    final value = json[key];    if (value == null) continue;    if (value is String && value.trim().isEmpty) continue;    return value;  }  return null;} DateTime? _parseDateTime(Object? value) {  if (value == null) return null;  if (value is DateTime) return value;  if (value is num) {    final millis = value > 100000000000 ? value.round() : value.round() * 1000;    return DateTime.fromMillisecondsSinceEpoch(millis, isUtc: true).toLocal();  }  final text = value.toString().trim();  if (text.isEmpty) return null;  final numeric = int.tryParse(text);  if (numeric != null) return _parseDateTime(numeric);  return DateTime.tryParse(text)?.toLocal();} /// Provider types that move money out of the account or card when the/// payload carries an unsigned amount and no explicit direction.bool _isUsdToCryptoFunding(  String rawType,  String currency,  Map<String, dynamic> metadata,) {  if (currency.trim().toUpperCase() != 'USD') return false;  String compact(String value) =>      value.replaceAll(RegExp(r'[^a-zA-Z0-9]'), '').toLowerCase();  final type = compact(rawType);  // This operation stores the USD spent as an unsigned amount. Received  // crypto is separate metadata and must never be substituted at a 1:1 rate.  if (type == 'quantumtocryptoexchange') return true;  if (type != 'cryptoexchange') return false;  final operation = _firstString(metadata, const [    'operation',    'Operation',    'operationType',    'OperationType',  ]);  return operation != null &&      const {'quantumtocryptoexchange', 'quantumtocrypto', 'usdtocrypto'}          .contains(compact(operation));} bool _isOutgoingType(String rawType) {  final normalized = rawType.trim().toLowerCase();  final numeric = int.tryParse(normalized);  if (numeric != null) {    return const {1, 3, 5, 7, 8, 9, 10, 13, 15, 16}.contains(numeric);  }  final compact = normalized.replaceAll(RegExp(r'[\s_-]+'), '');  if (compact.contains('unload')) return true;  if (const ['reversal', 'refund', 'topup', 'deposit', 'load', 'credit']      .any(compact.contains)) {    return false;  }  // Provider transfer legs: money leaving the customer for the programme  // master account is an outflow; the mirror leg ("from master") is not.  if (compact.contains('frommaster') || compact.contains('mastertouser')) {    return false;  }  return const [    'withdraw',    'unload',    'payment',    'purchase',    'fee',    'payout',    'send',    'transferout',    'tomaster',    'usertomaster',    'outgoing',    'debit',  ].any(compact.contains);} String? _usefulTransactionTitle(  String? value,  String currency,  String type,  String status,) {  final title = value?.trim() ?? '';  if (title.isEmpty) return null;  final normalized = title.toLowerCase().replaceAll(RegExp(r'[:\s]+$'), '');  if (normalized == 'transaction' ||      RegExp(r'^type\s*\d+$').hasMatch(normalized) ||      normalized == currency.toLowerCase() ||      normalized == type.toLowerCase() ||      normalized == status.toLowerCase()) {    return null;  }  return title;} String _ledgerTransactionTypeLabel(String value) {  final normalized = value.trim().toLowerCase();  final numericType = int.tryParse(normalized);  if (numericType != null) {    return switch (numericType) {      0 || 4 || 6 || 14 => 'Card payment reversal',      2 => 'Card top up',      3 => 'Card unload',      7 || 8 || 15 || 16 => 'Card fee',      9 || 10 => 'Card payment fee',      11 => 'Card frozen',      12 => 'Card unfrozen',      13 => 'Card withdrawal',      _ => 'Card payment',    };  }  if (normalized.isEmpty) return 'Transaction';  return normalized      .replaceAll(RegExp(r'[_-]+'), ' ')      .split(RegExp(r'\s+'))      .where((word) => word.isNotEmpty)      .map((word) => word[0].toUpperCase() + word.substring(1))      .join(' ');} class Payee {  const Payee({    required this.id,    required this.name,    required this.accountReference,    this.firstName = '',    this.lastName = '',    this.userName = '',    this.paymentType = '',    this.paymentMethod = '',    this.currency = '',    this.bankName = '',    this.provider = '',    this.status = '',    this.requiresConfirmation = false,  });   factory Payee.fromJson(Map<String, dynamic> json) {    final id = json['id'] ?? json['payeeId'] ?? json['PayeeId'];    final firstName =        (json['firstName'] ?? json['FirstName'])?.toString() ?? '';    final lastName = (json['lastName'] ?? json['LastName'])?.toString() ?? '';    final userName = (json['userName'] ?? json['UserName'])?.toString() ?? '';    final name = json['name'] ??        json['displayName'] ??        json['DisplayName'] ??        [firstName, lastName].where((part) => part.trim().isNotEmpty).join(' ');    return Payee(      id: id?.toString() ?? '',      name:          name?.toString() ?? (userName.trim().isNotEmpty ? userName : 'Payee'),      accountReference: (json['iban'] ??                  json['Iban'] ??                  json['accountReference'] ??                  json['AccountReference'] ??                  json['accountNumber'] ??                  json['AccountNumber'])              ?.toString() ??          '',      firstName: firstName,      lastName: lastName,      userName: userName,      paymentType:          (json['paymentType'] ?? json['PaymentType'])?.toString() ?? '',      paymentMethod:          (json['paymentMethod'] ?? json['PaymentMethod'])?.toString() ?? '',      currency: (json['currency'] ?? json['Currency'])?.toString() ?? '',      bankName: (json['bankName'] ?? json['BankName'])?.toString() ?? '',      provider: (json['provider'] ?? json['Provider'])?.toString() ?? '',      status: (json['status'] ?? json['Status'])?.toString() ?? '',      requiresConfirmation: (json['requiresConfirmation'] ??              json['RequiresConfirmation']) as bool? ??          false,    );  }   final String id;  final String name;  final String accountReference;  final String firstName;  final String lastName;  final String userName;  final String paymentType;  final String paymentMethod;  final String currency;  final String bankName;  final String provider;  final String status;  final bool requiresConfirmation;} class PayoutQuote {  const PayoutQuote({    required this.id,    this.quoteRequestId = '',    required this.sourceAmount,    required this.sourceCurrency,    required this.targetAmount,    required this.targetCurrency,    required this.exchangeRate,    required this.fee,    required this.expiresAt,    this.provider = '',  });   factory PayoutQuote.fromJson(Map<String, dynamic> json) {    final fees = json['fees'] ?? json['Fees'];    return PayoutQuote(      id: (json['quoteId'] ??                  json['QuoteId'] ??                  json['id'] ??                  json['Id'] ??                  json['orderId'] ??                  json['OrderId'])              ?.toString() ??          '',      quoteRequestId:          (json['quoteRequestId'] ?? json['QuoteRequestId'])?.toString() ?? '',      sourceAmount: _parseDecimal(json['sourceAmount'] ?? json['SourceAmount']),      sourceCurrency: (json['sourceCurrency'] ??                  json['SourceCurrency'] ??                  json['fromCurrency'] ??                  json['FromCurrency'])              ?.toString() ??          '',      targetAmount: _parseDecimal(json['targetAmount'] ?? json['TargetAmount']),      targetCurrency: (json['targetCurrency'] ??                  json['TargetCurrency'] ??                  json['toCurrency'] ??                  json['ToCurrency'])              ?.toString() ??          '',      exchangeRate: _parseDecimal(        json['exchangeRate'] ??            json['ExchangeRate'] ??            json['rate'] ??            json['Rate'],      ),      fee: _parseNullableDecimal(json['fee'] ?? json['Fee']) ??          _feeFromList(fees),      provider: (json['provider'] ?? json['Provider'])?.toString() ?? '',      expiresAt: DateTime.tryParse(        (json['expiresAt'] ??                    json['ExpiresAt'] ??                    json['expirationTime'] ??                    json['ExpirationTime'])                ?.toString() ??            '',      ),    );  }   final String id;  final String quoteRequestId;  final double sourceAmount;  final String sourceCurrency;  final double targetAmount;  final String targetCurrency;  final double exchangeRate;  final double? fee;  final String provider;  final DateTime? expiresAt;   PayoutQuote withFallback({    required double sourceAmount,    required String sourceCurrency,    required String targetCurrency,  }) {    final resolvedSourceAmount =        this.sourceAmount <= 0 ? sourceAmount : this.sourceAmount;    final resolvedSourceCurrency =        this.sourceCurrency.isEmpty ? sourceCurrency : this.sourceCurrency;    final resolvedTargetCurrency =        this.targetCurrency.isEmpty ? targetCurrency : this.targetCurrency;    final resolvedTargetAmount = targetAmount > 0        ? targetAmount        : resolvedSourceCurrency == resolvedTargetCurrency            ? resolvedSourceAmount            : exchangeRate > 0                ? resolvedSourceAmount * exchangeRate                : 0.0;     return PayoutQuote(      id: id,      quoteRequestId: quoteRequestId,      sourceAmount: resolvedSourceAmount,      sourceCurrency: resolvedSourceCurrency,      targetAmount: resolvedTargetAmount,      targetCurrency: resolvedTargetCurrency,      exchangeRate: exchangeRate,      fee: fee,      provider: provider,      expiresAt: expiresAt,    );  }   String get feeLabel => fee == null      ? 'Not returned'      : '${sourceCurrency.trim()} ${fee!.toStringAsFixed(2)}';   String get rateLabel {    if (exchangeRate <= 0) {      return 'No exchange';    }    return '1 $sourceCurrency = ${exchangeRate.toStringAsFixed(6)} $targetCurrency';  }} double? _feeFromList(Object? value) {  if (value is! List || value.isEmpty) {    return null;  }   var total = 0.0;  var foundFee = false;  for (final item in value) {    if (item is Map) {      final amount = _parseNullableDecimal(item['Amount'] ?? item['amount']);      if (amount != null) {        total += amount;        foundFee = true;      }    }  }   return foundFee ? total : null;} class PayoutCheck {  const PayoutCheck({    required this.id,    required this.pass,    this.message = '',  });   factory PayoutCheck.fromJson(Map<String, dynamic> json) {    return PayoutCheck(      id: (json['checkId'] ?? json['CheckId'])?.toString() ?? '',      pass: (json['pass'] ?? json['Pass']) as bool? ?? false,      message:          (json['detailMessage'] ?? json['DetailMessage'])?.toString() ?? '',    );  }   final String id;  final bool pass;  final String message;} class PayoutOtpInitiation {  const PayoutOtpInitiation({    required this.success,    this.verificationToken = '',    this.message = '',    this.expiresAt,    this.verificationResult,    this.paymentSummary,  });   factory PayoutOtpInitiation.fromJson(Map<String, dynamic> json) {    return PayoutOtpInitiation(      verificationResult:          (json['verificationResult'] ?? json['VerificationResult']) is Map              ? Map<String, dynamic>.from(                  json['verificationResult'] ?? json['VerificationResult'])              : null,      paymentSummary: (json['paymentSummary'] ?? json['PaymentSummary']) is Map          ? Map<String, dynamic>.from(              json['paymentSummary'] ?? json['PaymentSummary'])          : null,      success: (json['success'] ?? json['Success']) as bool? ?? false,      verificationToken:          (json['verificationToken'] ?? json['VerificationToken'])                  ?.toString() ??              '',      message: (json['message'] ?? json['Message'])?.toString() ?? '',      expiresAt: DateTime.tryParse(        (json['expiresAt'] ?? json['ExpiresAt'])?.toString() ?? '',      ),    );  }   final Map<String, dynamic>? verificationResult;  final Map<String, dynamic>? paymentSummary;  final bool success;  final String verificationToken;  final String message;  final DateTime? expiresAt;} class OtpVerification {  const OtpVerification({    required this.success,    this.message = '',    this.verificationToken = '',    this.remainingAttempts,    this.isExpired = false,    this.isLocked = false,  });   factory OtpVerification.fromJson(Map<String, dynamic> json) {    return OtpVerification(      success: (json['success'] ?? json['Success']) as bool? ?? false,      message: (json['message'] ?? json['Message'])?.toString() ?? '',      verificationToken:          (json['verificationToken'] ?? json['VerificationToken'])                  ?.toString() ??              '',      remainingAttempts: _parseNullableInt(        json['remainingAttempts'] ?? json['RemainingAttempts'],      ),      isExpired: (json['isExpired'] ?? json['IsExpired']) as bool? ?? false,      isLocked: (json['isLocked'] ?? json['IsLocked']) as bool? ?? false,    );  }   final bool success;  final String message;  final String verificationToken;  final int? remainingAttempts;  final bool isExpired;  final bool isLocked;} class OnboardingTask {  const OnboardingTask({    required this.id,    required this.title,    required this.description,    required this.status,    required this.route,  });   factory OnboardingTask.fromJson(Map<String, dynamic> json) {    return OnboardingTask(      id: json['id']?.toString() ?? json['Id']?.toString() ?? 'task',      title: json['title']?.toString() ?? json['Title']?.toString() ?? 'Task',      description: json['description']?.toString() ??          json['Description']?.toString() ??          '',      status: _taskStatus(        json['status']?.toString() ?? json['Status']?.toString() ?? 'pending',      ),      route: json['route']?.toString() ?? json['Route']?.toString() ?? '/home',    );  }   final String id;  final String title;  final String description;  final OnboardingTaskStatus status;  final String route;   bool get isComplete => status == OnboardingTaskStatus.complete;} class DashboardSnapshot {  const DashboardSnapshot({    required this.profile,    required this.accounts,    required this.cards,    required this.transactions,    required this.onboarding,  });   final UserProfile profile;  final List<AccountBalance> accounts;  final List<PaymentCard> cards;  final List<LedgerTransaction> transactions;  final List<OnboardingTask> onboarding;   Money get totalBalance {    final total = accounts.fold<int>(        0, (sum, account) => sum + account.balance.minorUnits);    final currency = accounts.isEmpty ? 'EUR' : accounts.first.balance.currency;     return Money(currency: currency, minorUnits: total);  }} Map<String, dynamic> _moneyJson(Object? value, {String currency = 'USD'}) {  if (value is Map<String, dynamic>) {    return {      'currency': currency,      ...value,    };  }  if (value is num || value is String) {    return {'currency': currency, 'amount': value};  }   return {'currency': currency, 'minorUnits': 0};} List<Money> _currencyBalanceList(Map<String, dynamic> json) {  final rows = <Money>[];  final seen = <String>{};   void addMoney(Money money) {    final currency = money.currency.trim().toUpperCase();    if (currency.isEmpty) {      return;    }    final key = '$currency-${money.minorUnits}';    if (!seen.add(key)) {      return;    }    rows.add(Money(currency: currency, minorUnits: money.minorUnits));  }   void addRow(Map<String, dynamic> row) {    final currency = (row['currency'] ??                row['Currency'] ??                row['currencyCode'] ??                row['CurrencyCode'])            ?.toString()            .trim()            .toUpperCase() ??        '';    final value = row['available'] ??        row['Available'] ??        row['availableBalance'] ??        row['AvailableBalance'] ??        row['balance'] ??        row['Balance'] ??        row['amount'] ??        row['Amount'] ??        row['value'] ??        row['Value'];     addMoney(Money.fromJson(_moneyJson(value, currency: currency)));  }   void addValue(Object? value, {String? impliedCurrency}) {    if (value is List) {      for (final item in value) {        addValue(item);      }      return;    }    if (value is! Map) return;     final map = value.map((key, item) => MapEntry(key.toString(), item));    final hasExplicitCurrency = const [      'currency',      'Currency',      'currencyCode',      'CurrencyCode',    ].any(map.containsKey);    if (hasExplicitCurrency) {      addRow(map);      return;    }     for (final entry in map.entries) {      final key = entry.key.trim();      final nested = entry.value;      if (nested is num || nested is String) {        addMoney(Money.fromJson(_moneyJson(nested, currency: key)));      } else if (nested is Map) {        final nestedMap = nested.map(          (nestedKey, item) => MapEntry(nestedKey.toString(), item),        );        addRow({          'currency': impliedCurrency ?? key,          ...nestedMap,        });      } else {        addValue(nested, impliedCurrency: impliedCurrency ?? key);      }    }  }   for (final key in const [    'balances',    'Balances',    'currencyBalances',    'CurrencyBalances',    'supportedCurrencyBalances',    'SupportedCurrencyBalances',    'availableBalances',    'AvailableBalances',  ]) {    addValue(json[key]);  }   return rows;} Map<String, dynamic>? _cardBalanceJson(  Map<String, dynamic> json, {  required String currency,}) {  // The ledger balance may include pending/held funds. Prefer the provider's  // spendable balance, including an explicit zero, on every card surface.  final available = json['availableBalance'] ??      json['AvailableBalance'] ??      json['available'] ??      json['Available'];  if (available != null) {    return _moneyJson(available, currency: currency);  }   final direct = json['balance'] ?? json['Balance'];  if (direct is Map) {    final nestedAvailable = direct['availableBalance'] ??        direct['AvailableBalance'] ??        direct['available'] ??        direct['Available'];    if (nestedAvailable != null) {      return _moneyJson(nestedAvailable,          currency: (direct['currency'] ?? direct['Currency'] ?? currency)              .toString());    }  }  if (direct != null) {    return _moneyJson(direct, currency: currency);  }   final total = json['totalBalance'] ??      json['TotalBalance'] ??      json['currentBalance'] ??      json['CurrentBalance'];  if (total != null) {    return _moneyJson(total, currency: currency);  }   final balances = json['balances'] ?? json['Balances'];  if (balances is List && balances.isNotEmpty) {    final first = balances.first;    if (first is Map<String, dynamic>) {      return _moneyJson(first, currency: currency);    }  }   return null;} String _cardCurrencyFromJson(Map<String, dynamic> json) {  final direct = json['currency'] ?? json['Currency'];  if (direct != null && direct.toString().trim().isNotEmpty) {    return direct.toString().trim().toUpperCase();  }   for (final key in const ['balance', 'Balance', 'limit', 'Limit']) {    final value = json[key];    if (value is Map) {      final currency = value['currency'] ?? value['Currency'];      if (currency != null && currency.toString().trim().isNotEmpty) {        return currency.toString().trim().toUpperCase();      }    }  }   return 'USD';} CardStatus _cardStatus(String value) {  final normalized = value.toLowerCase().replaceAll('_', '');  return CardStatus.values.firstWhere(    (status) => status.name == normalized,    orElse: () => CardStatus.pending,  );} /// Buckets the provider's transaction type names into the app's filterable/// kinds, following the names Hoppa actually emits: card_payment, refund,/// cashback and chargeback are card activity; anything with "fee" is a fee;/// card_topup / card_loading / deposit are top-ups; transfer_*, p2p_*,/// withdrawals, payouts, exchanges and card_unload are transfers; merchant/// payments and mandates are payments.TransactionType _transactionType(String value) {  final normalized = value.toLowerCase().replaceAll(RegExp(r'[\s_-]+'), '');  for (final type in TransactionType.values) {    if (type.name.toLowerCase() == normalized) return type;  }  if (normalized.contains('fee') || normalized.contains('subscription')) {    return TransactionType.fee;  }  if (normalized.startsWith('card') &&      (normalized.contains('payment') ||          normalized.contains('purchase') ||          normalized.contains('withdraw') ||          normalized.contains('reversal') ||          normalized.contains('settlement') ||          normalized.contains('authorization') ||          normalized.contains('decline'))) {    return TransactionType.card;  }  // "pos" and "atm" only as prefixes: "deposit" contains "pos".  if (normalized.startsWith('pos') ||      normalized.startsWith('atm') ||      const [        'purchase',        'authorization',        'settlement',        'refund',        'cashback',        'chargeback',        'reversal',        'merchant',      ].any(normalized.contains)) {    return TransactionType.card;  }  if (normalized.contains('unload')) return TransactionType.transfer;  if (normalized.contains('topup') ||      normalized.contains('loading') ||      normalized.contains('load') ||      normalized.contains('funding') ||      normalized.contains('deposit')) {    return TransactionType.topUp;  }  if (normalized.contains('transfer') ||      normalized.contains('p2p') ||      normalized.contains('peer') ||      normalized.contains('withdraw') ||      normalized.contains('payout') ||      normalized.contains('exchange') ||      normalized.contains('quantum') ||      normalized.contains('swap') ||      normalized.contains('send') ||      normalized.contains('receive')) {    return TransactionType.transfer;  }  if (normalized.contains('payment') ||      normalized.contains('mandate') ||      normalized.contains('bill')) {    return TransactionType.payment;  }  return TransactionType.card;} OnboardingTaskStatus _taskStatus(String value) {  final normalized = value.toLowerCase().replaceAll('_', '');  return OnboardingTaskStatus.values.firstWhere(    (status) => status.name.toLowerCase() == normalized,    orElse: () => OnboardingTaskStatus.pending,  );}
HOPPA DEVELOPER ASSISTANT

What are you building?

From a question to an implementation.

Get a step-by-step flow, example code and the docs behind it. Choose a starting point or describe your feature.

Answers use the public API guide and example app code. Check the linked reference before shipping. Explore code sources ↗

Your question and recent conversation are sent to our AI provider. Don’t include API keys, personal data or card details.