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();
}
}