Lesson 57 of 77 advanced

System Design II: Fintech Claims/Payment App

Designing a secure, compliant payment and claims processing system — auth, encryption, document handling, and error cascades

Open interactive version (quiz + challenge)

Real-world analogy

Designing a fintech app is like designing a bank vault inside a shopping mall. The mall needs to be accessible and fast, but the vault needs multiple locks, audit logs of every access, and fail-safe mechanisms — if one lock breaks, the vault stays locked, not open.

What is it?

A fintech claims and payment system design covers the security architecture, authentication patterns, encrypted storage, idempotent API design, document handling, and compliance requirements needed to build a production-grade financial application in Flutter.

Real-world relevance

Payback and TapMeHome are fintech apps requiring claims processing with document upload, BankID authentication, and strict audit trails. The Flutter client uses flutter_secure_storage for JWT storage, certificate pinning via dio, and the saga pattern on the backend to handle multi-step claim payouts with automatic compensation on failure.

Key points

Code example

// Fintech Flutter: Secure auth + payment flow
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:dio/dio.dart';

// Secure token storage
class SecureTokenStore {
  static const _storage = FlutterSecureStorage(
    aOptions: AndroidOptions(encryptedSharedPreferences: true),
    iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
  );

  static Future<void> saveTokens({
    required String accessToken,
    required String refreshToken,
  }) async {
    await Future.wait([
      _storage.write(key: 'access_token', value: accessToken),
      _storage.write(key: 'refresh_token', value: refreshToken),
    ]);
  }

  static Future<String?> getAccessToken() =>
      _storage.read(key: 'access_token');

  static Future<void> clearAll() async {
    await _storage.deleteAll();
  }
}

// Dio with auth interceptor + certificate pinning
class ApiClient {
  static Dio create() {
    final dio = Dio(BaseOptions(
      baseUrl: 'https://api.payback-app.com',
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 30),
    ));

    // Certificate pinning (simplified — use ssl_pinning_plugin for production)
    (dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
      final client = HttpClient();
      client.badCertificateCallback = (cert, host, port) {
        // Validate against pinned certificate SHA256 fingerprint
        return CertificatePinner.isValid(cert, host);
      };
      return client;
    };

    // Auth interceptor with token refresh
    dio.interceptors.add(InterceptorsWrapper(
      onRequest: (options, handler) async {
        final token = await SecureTokenStore.getAccessToken();
        if (token != null) {
          options.headers['Authorization'] = 'Bearer $token';
        }
        options.headers['X-Idempotency-Key'] = const Uuid().v4();
        handler.next(options);
      },
      onError: (error, handler) async {
        if (error.response?.statusCode == 401) {
          // Attempt token refresh
          final refreshed = await _refreshToken();
          if (refreshed) {
            // Retry original request
            handler.resolve(await dio.fetch(error.requestOptions));
            return;
          }
          // Refresh failed — clear tokens and redirect to login
          await SecureTokenStore.clearAll();
          GetIt.I<AuthRouter>().navigateToLogin();
        }
        handler.next(error);
      },
    ));

    return dio;
  }
}

// Payment state machine — enforced both client and server
enum PaymentStatus { initiated, processing, completed, failed, reversed }

class PaymentStateMachine {
  static const Map<PaymentStatus, Set<PaymentStatus>> _validTransitions = {
    PaymentStatus.initiated: {PaymentStatus.processing},
    PaymentStatus.processing: {PaymentStatus.completed, PaymentStatus.failed},
    PaymentStatus.failed: {PaymentStatus.reversed},
    PaymentStatus.completed: {PaymentStatus.reversed},
    PaymentStatus.reversed: {},
  };

  static bool canTransition(PaymentStatus from, PaymentStatus to) =>
      _validTransitions[from]?.contains(to) ?? false;
}

// Claim document upload with presigned URL
class ClaimDocumentService {
  final Dio _dio;

  Future<String> uploadDocument(File imageFile) async {
    // 1. Compress image
    final compressed = await FlutterImageCompress.compressAndGetFile(
      imageFile.path,
      '${imageFile.path}_compressed.jpg',
      quality: 80,
      minWidth: 1920,
    );

    // 2. Get presigned URL
    final presignedResponse = await _dio.post('/claims/documents/presign', data: {
      'contentType': 'image/jpeg',
      'fileSize': compressed!.lengthSync(),
    });
    final uploadUrl = presignedResponse.data['uploadUrl'] as String;
    final documentId = presignedResponse.data['documentId'] as String;

    // 3. Upload directly to S3
    await Dio().put(
      uploadUrl,
      data: compressed.openRead(),
      options: Options(
        headers: {
          'Content-Type': 'image/jpeg',
          'Content-Length': compressed.lengthSync(),
        },
      ),
    );

    return documentId; // Return ID for claim association
  }
}

Line-by-line walkthrough

  1. 1. FlutterSecureStorage with AndroidOptions(encryptedSharedPreferences: true) uses Android's EncryptedSharedPreferences backed by the Android Keystore — hardware-backed on devices with a secure element.
  2. 2. IOSOptions with first_unlock_this_device means tokens are accessible after the first device unlock but not while the device is locked — balancing security with background refresh capability.
  3. 3. Future.wait saves both tokens atomically — avoids a state where access token is saved but refresh token write fails.
  4. 4. The Dio interceptor adds X-Idempotency-Key on every mutating request — generated fresh per request so retries use the same key (stored in a retry handler, not shown here for brevity).
  5. 5. onError intercept for 401 attempts a token refresh before failing — this handles silent token refresh without the user seeing a login screen for normal session continuation.
  6. 6. After refresh, handler.resolve(await dio.fetch(error.requestOptions)) retries the original request with the new token — the original caller's Future receives the retried response transparently.
  7. 7. PaymentStateMachine encodes valid state transitions as a const Map — invalid transitions are caught before the API call, preventing invalid state mutation requests.
  8. 8. ClaimDocumentService uploads via presigned URL: the app server issues a time-limited, scope-limited S3 URL; the file never passes through the app server, reducing load and keeping PII data out of application logs.

Spot the bug

class AuthInterceptor extends Interceptor {
  bool _isRefreshing = false;

  @override
  Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401) {
      final newToken = await _refreshAccessToken();
      if (newToken != null) {
        err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
        final response = await Dio().fetch(err.requestOptions);
        handler.resolve(response);
        return;
      }
    }
    handler.next(err);
  }
}
Need a hint?
Multiple concurrent API calls all return 401 simultaneously. What happens and how do you fix it?
Show answer
Bug: Multiple concurrent 401 errors trigger _refreshAccessToken() simultaneously — resulting in multiple parallel refresh token requests to the server. Most servers invalidate the refresh token after first use (rotation), so only the first refresh succeeds; the rest fail with invalid_token, logging the user out incorrectly. Fix: use the _isRefreshing flag and a queue. When _isRefreshing is true, queue subsequent 401 retries and wait for the single in-flight refresh to complete, then retry all queued requests with the new token. Pattern: use a Completer<String> stored as a field — first 401 starts the refresh and sets the completer; subsequent 401s await the same completer. Also: new Dio() inside the interceptor creates a new instance without interceptors — use the original dio instance reference (passed to the interceptor constructor) to avoid infinite loops when retrying.

Explain like I'm 5

Building a fintech app is like being a bank manager. You need super-strong locks on every door (encryption and secure storage), cameras recording everything (audit logs), guards checking IDs carefully (strong auth with BankID), and emergency plans for when things go wrong (saga pattern). If the electricity goes out mid-transaction, you need to know exactly what happened and put everything back exactly as it was.

Fun fact

The average fintech data breach costs 5.9 million USD — nearly double the cross-industry average. This is why fintech apps undergo the most rigorous security architecture reviews, and why senior fintech interviews always include security design questions.

Hands-on challenge

Design the complete auth and payment flow for a claims app: (1) BankID authentication flow with JWT issuance. (2) Secure token storage implementation. (3) Document upload flow with presigned URLs and client-side compression. (4) Payment state machine (define all states and valid transitions). (5) Error handling for: network timeout mid-payment, session expiry during document upload, payment gateway failure. (6) What audit data would you log for a claim submission?

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery