runTransaction method

Run one transaction end-to-end.

Returns a TransactionCoordinatorOutcome - never throws from this method. Every failure path (native error, gateway failure, timeout, cancellation, mis-configuration) is mapped to a structured outcome so POS UI code can do a single switch on TransactionCoordinatorOutcomeKind.

Implementation

Future<TransactionCoordinatorOutcome> runTransaction(
  TransactionRequest request,
) async {
  SdkLogger.instance.info(SdkLogCategory.transaction, 'Coordinator: transaction started', metadata: {'gatewayMode': _config.gatewayConfig.mode.wire});
  if (_disposed) {
    SdkLogger.instance.warn(SdkLogCategory.transaction, 'Coordinator: rejected, disposed');
    return _outcome(
      kind: TransactionCoordinatorOutcomeKind.readerFailed,
      result: TransactionResult.failed(
        requestIdempotencyKey: request.idempotencyKey,
        amountMinorUnits: request.amountMinorUnits,
        currency: request.currency,
        referenceId: request.referenceId,
        error: const ReaderError(
          code: ReaderErrorCode.internalError,
          message: 'TransactionCoordinator.runTransaction after dispose()',
        ),
      ),
      readerError: const ReaderError(
        code: ReaderErrorCode.internalError,
        message: 'TransactionCoordinator.runTransaction after dispose()',
      ),
    );
  }
  if (_inFlight) {
    SdkLogger.instance.warn(SdkLogCategory.transaction, 'Coordinator: rejected, already in flight');
    return _outcome(
      kind: TransactionCoordinatorOutcomeKind.readerFailed,
      result: TransactionResult.failed(
        requestIdempotencyKey: request.idempotencyKey,
        amountMinorUnits: request.amountMinorUnits,
        currency: request.currency,
        referenceId: request.referenceId,
        error: const ReaderError(
          code: ReaderErrorCode.transactionInProgress,
          message: 'TransactionCoordinator is already running a transaction.',
        ),
      ),
      readerError: const ReaderError(
        code: ReaderErrorCode.transactionInProgress,
        message: 'TransactionCoordinator is already running a transaction.',
      ),
    );
  }
  _inFlight = true;

  // Subscribe to events BEFORE issuing startTransaction so a
  // synchronous encryptedPayloadReady event delivered by the
  // native side is not missed. The subscription is broadcast-
  // safe; multiple coordinator instances on the same SDK
  // observe the same events.
  EncryptedReaderPayload? capturedPayload;
  final paymentReadyCompleter = Completer<EncryptedReaderPayload>();
  final subscription = _sdk.events.listen((event) {
    if (event.type == ReaderEventType.encryptedPayloadReady &&
        event.encryptedPayload != null &&
        !paymentReadyCompleter.isCompleted) {
      capturedPayload = event.encryptedPayload;
      paymentReadyCompleter.complete(event.encryptedPayload!);
    }
  });

  try {
    // Race the native startTransaction future against the
    // coordinator-level run timeout.
    final TransactionResult nativeResult;
    try {
      nativeResult = await _sdk
          .startTransaction(request)
          .timeout(_config.runTimeout);
    } on TimeoutException {
      // Best-effort cancel; some readers honour it, some do
      // not. Either way, the coordinator's terminal outcome
      // is `timedOut` and the POS is unblocked.
      SdkLogger.instance.warn(SdkLogCategory.transaction, 'Coordinator: timed out');
      await _safeCancel();
      return _outcome(
        kind: TransactionCoordinatorOutcomeKind.timedOut,
        result: TransactionResult.timedOut(
          requestIdempotencyKey: request.idempotencyKey,
          amountMinorUnits: request.amountMinorUnits,
          currency: request.currency,
          referenceId: request.referenceId,
          error: ReaderError(
            code: ReaderErrorCode.timeout,
            message: 'TransactionCoordinator run timeout '
                '(${_config.runTimeout.inMilliseconds}ms) elapsed.',
            recoverable: true,
          ),
        ),
        readerError: ReaderError(
          code: ReaderErrorCode.timeout,
          message: 'TransactionCoordinator run timeout '
              '(${_config.runTimeout.inMilliseconds}ms) elapsed.',
        ),
      );
    } on ReaderError catch (err) {
      return _outcome(
        kind: TransactionCoordinatorOutcomeKind.readerFailed,
        result: TransactionResult.failed(
          requestIdempotencyKey: request.idempotencyKey,
          amountMinorUnits: request.amountMinorUnits,
          currency: request.currency,
          referenceId: request.referenceId,
          error: err,
        ),
        readerError: err,
      );
    } catch (e) {
      // Defensive: the platform-channel adapter is supposed
      // to surface ReaderError exclusively. Anything else is
      // wrapped as an internalError so the coordinator's
      // contract holds.
      final wrapped = ReaderError(
        code: ReaderErrorCode.internalError,
        message: 'Unhandled exception from startTransaction: '
            '${e.runtimeType}',
      );
      return _outcome(
        kind: TransactionCoordinatorOutcomeKind.readerFailed,
        result: TransactionResult.failed(
          requestIdempotencyKey: request.idempotencyKey,
          amountMinorUnits: request.amountMinorUnits,
          currency: request.currency,
          referenceId: request.referenceId,
          error: wrapped,
        ),
        readerError: wrapped,
      );
    }

    // The native side resolved. Classify the outcome by the
    // native status BEFORE deciding whether to run a
    // handoff.
    switch (nativeResult.status) {
      case TransactionResultStatus.cancelled:
        return _outcome(
          kind: TransactionCoordinatorOutcomeKind.cancelled,
          result: nativeResult,
          readerError: nativeResult.error,
        );
      case TransactionResultStatus.timedOut:
        return _outcome(
          kind: TransactionCoordinatorOutcomeKind.timedOut,
          result: nativeResult,
          readerError: nativeResult.error,
        );
      case TransactionResultStatus.failed:
        return _outcome(
          kind: TransactionCoordinatorOutcomeKind.readerFailed,
          result: nativeResult,
          readerError: nativeResult.error,
        );
      case TransactionResultStatus.approved:
      case TransactionResultStatus.declined:
        // The native side ran a gateway flow ahead of us
        // (legacy path / future native gateway integration).
        // Surface the result verbatim; do NOT run a second
        // handoff.
        return _outcome(
          kind: nativeResult.status == TransactionResultStatus.approved
              ? TransactionCoordinatorOutcomeKind.gatewayApproved
              : TransactionCoordinatorOutcomeKind.gatewayDeclined,
          result: nativeResult,
          gatewayResponse: nativeResult.gatewayResponse == null
              ? null
              : _legacyToHandoff(nativeResult.gatewayResponse!),
        );
      case TransactionResultStatus.unknown:
        // The native side captured an encrypted payload but
        // never authorized it. This is the path the
        // coordinator was built for: route to payloadOnly
        // or gatewayHandoff.
        break;
    }

    // status == unknown ⇒ payload-capture path.
    // The encrypted payload should now be on the event
    // stream. Wait for it (with a small grace window) so
    // gatewayHandoff has a payload to forward.
    try {
      capturedPayload ??= await paymentReadyCompleter.future
          .timeout(const Duration(seconds: 2));
    } on TimeoutException {
      // No encryptedPayloadReady event observed. This is a
      // contract violation in the native runtime, but
      // surface it as a structured readerFailed rather than
      // crashing.
      const err = ReaderError(
        code: ReaderErrorCode.cardReadFailed,
        message: 'Native runtime reported status=unknown but no '
            'encryptedPayloadReady event arrived within the grace window.',
        nativeCode: 'ENCRYPTED_PAYLOAD_EVENT_MISSING',
      );
      return _outcome(
        kind: TransactionCoordinatorOutcomeKind.readerFailed,
        result: TransactionResult.failed(
          requestIdempotencyKey: request.idempotencyKey,
          amountMinorUnits: request.amountMinorUnits,
          currency: request.currency,
          referenceId: request.referenceId,
          error: err,
        ),
        readerError: err,
      );
    }

    if (_config.gatewayConfig.mode == GatewayMode.payloadOnly) {
      // payloadOnly: the SDK never forwards. The POS
      // observes encryptedPayloadReady on the event stream
      // and uses its own backend client.
      SdkLogger.instance.info(SdkLogCategory.transaction, 'Coordinator: payload captured (payload-only)');
      return _outcome(
        kind: TransactionCoordinatorOutcomeKind.payloadCapturedOnly,
        result: nativeResult,
      );
    }

    // gatewayHandoff: build a request and route through the
    // orchestrator.
    SdkLogger.instance.info(SdkLogCategory.gateway, 'Coordinator: gateway handoff started');
    return _runHandoff(
      request: request,
      nativeResult: nativeResult,
      payload: capturedPayload!,
    );
  } finally {
    _inFlight = false;
    await subscription.cancel();
  }
}