Skip to content

Instantly share code, notes, and snippets.

@elbeicktalat
Created May 9, 2026 10:53
Show Gist options
  • Select an option

  • Save elbeicktalat/12880c9a1a375b8beeb85bc17ad4b361 to your computer and use it in GitHub Desktop.

Select an option

Save elbeicktalat/12880c9a1a375b8beeb85bc17ad4b361 to your computer and use it in GitHub Desktop.
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter_terminal_sdk/flutter_terminal_sdk.dart';
import 'package:flutter_terminal_sdk/models/card_reader_callbacks.dart';
import 'package:flutter_terminal_sdk/models/data/TerminalList.dart';
import 'package:flutter_terminal_sdk/models/data/purchase_response.dart';
import 'package:flutter_terminal_sdk/models/data/refund_response.dart';
import 'package:flutter_terminal_sdk/models/nearpay_user_response.dart';
import 'package:flutter_terminal_sdk/models/purchase_callbacks.dart';
import 'package:flutter_terminal_sdk/models/refund_callbacks.dart';
import 'package:flutter_terminal_sdk/models/terminal_connection_response.dart';
import 'package:flutter_terminal_sdk/models/terminal_response.dart';
import 'package:flutter_terminal_sdk/models/terminal_sdk_initialization_listener.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:logger/logger.dart';
import 'package:meta/meta.dart';
import 'package:raas/src/base/utils/env.dart';
import 'package:raas/src/base/utils/logging.dart';
import 'package:raas/src/core/terminal/nearpay/domain/nearpay_error_type.dart';
part 'nearpay_event.dart';
part 'nearpay_state.dart';
class NearpayBloc extends HydratedBloc<NearpayEvent, NearpayState> {
NearpayBloc(this._terminalSdk) : super(const NearpayInitial()) {
on<InitializeNearpayTerminal>(_onInitializeNearpayTerminal);
on<SendNearpayOtp>(_onSendNearpayOtp);
on<VerifyNearpayOtp>(_onVerifyNearpayOtp);
on<NearpayJWTLogin>(_onNearpayJWTLogin);
on<FetchAndConnectToFirstTerminal>(_onFetchAndConnectToFirstTerminal);
on<ConnectToNearpayTerminal>(_onConnectToNearpayTerminal);
on<CreateNearpayPurchase>(_onCreateNearpayPurchase);
on<CreateNearpayRefund>(_onCreateNearpayRefund);
on<LogoutNearpayTerminal>(_onLogoutNearpayTerminal);
}
final FlutterTerminalSdk _terminalSdk;
static final Logger _log = logger('TerminalBloc');
TerminalModel? _connectedTerminal;
// Create a singleton-like static cache to survive BLoC lifecycle re-builds
static TerminalModel? _cachedConnectedTerminal;
FutureOr<void> _onInitializeNearpayTerminal(
InitializeNearpayTerminal event,
Emitter<NearpayState> emit,
) async {
_log.i('Starting terminal initialization...');
emit(
NearpayLoading(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid),
);
final Completer<void> completer = Completer<void>();
try {
if (_terminalSdk.isInitialized) {
_log.i('Terminal is already initialized');
completer.complete();
} else {
_log.i('Calling _terminalSdk.initialize()...');
await _terminalSdk.initialize(
environment: Flavor.nearpayEnv,
googleCloudProjectNumber: Flavor.googleProjectNumber,
huaweiSafetyDetectApiKey: '',
country: Country.sa,
initializationListener: TerminalSDKInitializationListener(
onInitializationSuccess: () {
_log.i('SDK initialized successfully via listener');
if (!completer.isCompleted) completer.complete();
},
onInitializationFailure: (String error) {
_log.e('SDK initialization failed via listener: $error');
if (!completer.isCompleted) completer.completeError(Exception(error));
},
),
);
}
// Add a timeout just in case the callback never fires
await completer.future.timeout(
const Duration(seconds: 10),
onTimeout: () {
_log.w(
'Initialization callback timed out after 10 seconds. Assuming success if no error was thrown.',
);
if (!completer.isCompleted) completer.complete();
},
);
_log.i(
'Terminal initialization completed. Emit NearpaySuccess so SetupBloc can take over the terminal connection.',
);
emit(
NearpaySuccess(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid),
);
} catch (e, stack) {
_log.e('Failed to initialize terminal: $e', error: e, stackTrace: stack);
emit(
NearpayFailure(
errorType: NearpayErrorType.initFailed,
userUUID: state.userUUID,
terminalUUID: state.terminalUUID,
tid: state.tid,
),
);
}
}
FutureOr<void> _onSendNearpayOtp(SendNearpayOtp event, Emitter<NearpayState> emit) async {
emit(
NearpayLoading(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid),
);
try {
await _terminalSdk.sendMobileOtp(event.mobileNumber);
emit(
NearpaySuccess(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid),
);
} catch (e) {
_log.e('Failed to send OTP: $e');
emit(
NearpayFailure(
errorType: NearpayErrorType.otpSendFailed,
userUUID: state.userUUID,
terminalUUID: state.terminalUUID,
tid: state.tid,
),
);
}
}
FutureOr<void> _onVerifyNearpayOtp(VerifyNearpayOtp event, Emitter<NearpayState> emit) async {
emit(
NearpayLoading(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid),
);
try {
final NearpayUser user = await _terminalSdk.verifyMobileOtp(
mobileNumber: event.mobileNumber,
code: event.code,
);
emit(
NearpaySuccess(userUUID: user.userUUID, terminalUUID: state.terminalUUID, tid: state.tid),
);
} catch (e) {
_log.e('Failed to verify OTP: $e');
emit(
NearpayFailure(
errorType: NearpayErrorType.otpVerifyFailed,
userUUID: state.userUUID,
terminalUUID: state.terminalUUID,
tid: state.tid,
),
);
}
}
FutureOr<void> _onNearpayJWTLogin(NearpayJWTLogin event, Emitter<NearpayState> emit) async {
emit(
NearpayLoading(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid),
);
try {
final TerminalModel terminalModel = await _terminalSdk.jwtLogin(jwt: event.jwt);
emit(
NearpaySuccess(
userUUID: state.userUUID,
terminalUUID: terminalModel.terminalUUID,
tid: terminalModel.tid,
),
);
} catch (e) {
_log.e('Failed to login with JWT: $e');
emit(
NearpayFailure(
errorType: NearpayErrorType.loginFailed,
userUUID: state.userUUID,
terminalUUID: state.terminalUUID,
tid: state.tid,
),
);
}
}
FutureOr<void> _onFetchAndConnectToFirstTerminal(
FetchAndConnectToFirstTerminal event,
Emitter<NearpayState> emit,
) async {
emit(
NearpayLoading(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid),
);
if (state.userUUID == null) {
emit(
NearpayFailure(
errorType: NearpayErrorType.missingParameters,
userUUID: state.userUUID,
terminalUUID: state.terminalUUID,
tid: state.tid,
),
);
return;
}
try {
final TerminalList terminalList = await _terminalSdk.getTerminalList(state.userUUID!);
if (terminalList.terminals.isEmpty) {
emit(
NearpayFailure(
errorType: NearpayErrorType.noTerminalsFound,
userUUID: state.userUUID,
terminalUUID: state.terminalUUID,
tid: state.tid,
),
);
return;
}
final TerminalConnectionModel firstTerminal = terminalList.terminals.first;
// Now connect to this terminal
add(
ConnectToNearpayTerminal(
userUUID: state.userUUID,
terminalUUID: firstTerminal.uuid,
tid: firstTerminal.tid,
),
);
} catch (e) {
_log.e('Failed to fetch terminals: $e');
emit(
NearpayFailure(
errorType: NearpayErrorType.noTerminalsFound,
userUUID: state.userUUID,
terminalUUID: state.terminalUUID,
tid: state.tid,
),
);
}
}
FutureOr<void> _onConnectToNearpayTerminal(
ConnectToNearpayTerminal event,
Emitter<NearpayState> emit,
) async {
emit(
NearpayLoading(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid),
);
final String? uUUID = event.userUUID ?? state.userUUID;
final String? tUUID = event.terminalUUID ?? state.terminalUUID;
final String? tid = event.tid ?? state.tid;
if (uUUID == null || tUUID == null || tid == null) {
emit(
NearpayFailure(
errorType: NearpayErrorType.missingParameters,
userUUID: state.userUUID,
terminalUUID: state.terminalUUID,
tid: state.tid,
),
);
return;
}
try {
_log.i(
'Calling _terminalSdk.connectTerminal with tid=$tid, userUUID=$uUUID, terminalUUID=$tUUID',
);
_connectedTerminal = await _terminalSdk
.connectTerminal(tid: tid, userUUID: uUUID, terminalUUID: tUUID)
.timeout(
const Duration(seconds: 10),
onTimeout: () {
_log.w('connectTerminal timed out natively. Falling back to local terminal model.');
return TerminalModel(terminalUUID: tUUID, tid: tid);
},
);
_log.i('Terminal connected successfully: ${_connectedTerminal!.toString()}');
_cachedConnectedTerminal = _connectedTerminal;
emit(
NearpayConnected(
userUUID: uUUID,
terminalUUID: tUUID,
tid: tid,
terminalModel: _connectedTerminal!,
),
);
} catch (e, stack) {
_log.e('Failed to connect terminal: $e', error: e, stackTrace: stack);
emit(
NearpayFailure(
errorType: NearpayErrorType.connectionFailed,
userUUID: state.userUUID,
terminalUUID: state.terminalUUID,
tid: state.tid,
),
);
}
}
FutureOr<void> _onCreateNearpayPurchase(
CreateNearpayPurchase event,
Emitter<NearpayState> emit,
) async {
_log.i('Initiating Nearpay Purchase...');
// Always use the instance attached to the state if available
if (state is NearpayConnected) {
_connectedTerminal = (state as NearpayConnected).terminalModel;
}
// Fallback to static cache if bloc state stripped it (e.g. hydrated bloc json load)
_connectedTerminal ??= _cachedConnectedTerminal;
// Capture the original connected state so we don't wipe it out!
final NearpayState originalState = state;
emit(
NearpayLoading(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid),
);
final Completer<PurchaseResponse> completer = Completer<PurchaseResponse>();
try {
_log.i(
'Calling _connectedTerminal.purchase with intentUUID: ${event.intentUUID} and amount: ${event.amount}',
);
// Do not await the native purchase call directly!
// If the SDK implementation blocks the Future until the UI completes, awaiting it might
// cause issues if the Completer is also used, or if the method never returns properly.
_connectedTerminal!
.purchase(
intentUUID: event.intentUUID,
amount: event.amount.cents,
customerReferenceNumber: event.customerReferenceNumber,
callbacks: PurchaseCallbacks(
onTransactionPurchaseCompleted: (PurchaseResponse response) {
_log.i('Purchase completed: ${response.toJson()}');
if (!completer.isCompleted) {
final status = response.status?.toLowerCase();
if (status == 'approved') {
completer.complete(response);
} else {
// E.g. 'declined' (insufficient funds, etc)
completer.completeError(Exception(status ?? 'declined'));
}
}
},
onSendTransactionFailure: (String error) {
_log.e('Purchase failed via callback: $error');
if (!completer.isCompleted) completer.completeError(Exception(error));
},
cardReaderCallbacks: CardReaderCallbacks(
onReaderClosed: () {
_log.i('Purchase closed by user');
if (!completer.isCompleted) {
completer.completeError(Exception('USER_CANCELLED'));
}
},
onReaderDismissed: () {
_log.i('Purchase dismissed by user');
if (!completer.isCompleted) {
completer.completeError(Exception('USER_CANCELLED'));
}
},
),
),
)
.catchError((e) {
_log.e('Native purchase method threw error immediately: $e');
if (!completer.isCompleted) completer.completeError(e);
return e;
});
_log.i('Waiting for completer.future (callbacks)...');
final PurchaseResponse response = await completer.future;
emit(
NearpayPurchaseSuccess(
response: response,
customerReferenceNumber: event.customerReferenceNumber,
userUUID: originalState.userUUID,
terminalUUID: originalState.terminalUUID,
tid: originalState.tid,
),
);
// RESTORE the connected state so we don't lose the terminal object!
emit(
NearpayConnected(
userUUID: originalState.userUUID!,
terminalUUID: originalState.terminalUUID!,
tid: originalState.tid!,
terminalModel: _connectedTerminal!,
),
);
} catch (e, s) {
_log.e('Purchase failed or threw an exception: $e', error: e, stackTrace: s);
final errorType = e.toString().contains('USER_CANCELLED')
? NearpayErrorType.readerDismissed
: NearpayErrorType.purchaseFailed;
emit(
NearpayFailure(
errorType: errorType,
userUUID: originalState.userUUID,
terminalUUID: originalState.terminalUUID,
tid: originalState.tid,
),
);
// Attempt to restore connected state even on failure
if (_connectedTerminal != null && originalState.userUUID != null) {
emit(
NearpayConnected(
userUUID: originalState.userUUID!,
terminalUUID: originalState.terminalUUID!,
tid: originalState.tid!,
terminalModel: _connectedTerminal!,
),
);
}
unawaited(
FirebaseCrashlytics.instance.recordError(
e,
s,
fatal: true,
reason: 'Nearpay Purchase Failed: $e',
),
);
}
}
FutureOr<void> _onCreateNearpayRefund(
CreateNearpayRefund event,
Emitter<NearpayState> emit,
) async {
// Always use the instance attached to the state if available
if (state is NearpayConnected) {
_connectedTerminal = (state as NearpayConnected).terminalModel;
}
// Fallback to static cache if bloc state stripped it (e.g. hydrated bloc json load)
_connectedTerminal ??= _cachedConnectedTerminal;
// Capture the original connected state so we don't wipe it out!
final NearpayState originalState = state;
emit(
NearpayLoading(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid),
);
final Completer<RefundResponse> completer = Completer();
try {
_connectedTerminal!
.refund(
intentUUID: event.intentUUID,
refundUuid: event.refundUuid,
amount: event.amount.cents,
customerReferenceNumber: event.customerReferenceNumber,
callbacks: RefundCallbacks(
onTransactionRefundCompleted: (RefundResponse response) {
if (!completer.isCompleted) completer.complete(response);
},
onSendTransactionFailure: (String error) {
if (!completer.isCompleted) completer.completeError(Exception(error));
},
cardReaderCallbacks: CardReaderCallbacks(
onReaderClosed: () {
_log.i('Refund closed by user');
if (!completer.isCompleted) {
completer.completeError(Exception('USER_CANCELLED'));
}
},
onReaderDismissed: () {
_log.i('Refund dismissed by user');
if (!completer.isCompleted) {
completer.completeError(Exception('USER_CANCELLED'));
}
},
),
),
)
.then((_) {
_log.i('Native refund method completed execution.');
Future.delayed(const Duration(milliseconds: 500), () {
if (!completer.isCompleted) {
_log.w('Native method finished but no callback fired. Assuming USER_CANCELLED.');
completer.completeError(Exception('USER_CANCELLED'));
}
});
})
.catchError((e) {
if (!completer.isCompleted) completer.completeError(e);
return e;
});
_log.i('Waiting for completer.future (callbacks)...');
final RefundResponse response = await completer.future;
emit(
NearpayRefundSuccess(
response: response,
customerReferenceNumber: event.customerReferenceNumber,
userUUID: originalState.userUUID,
terminalUUID: originalState.terminalUUID,
tid: originalState.tid,
),
);
// RESTORE the connected state so we don't lose the terminal object!
emit(
NearpayConnected(
userUUID: originalState.userUUID!,
terminalUUID: originalState.terminalUUID!,
tid: originalState.tid!,
terminalModel: _connectedTerminal!,
),
);
} catch (e, s) {
_log.e('Refund failed or threw an exception: $e', error: e, stackTrace: s);
final errorType = e.toString().contains('USER_CANCELLED')
? NearpayErrorType.readerDismissed
: NearpayErrorType.refundFailed;
emit(
NearpayFailure(
errorType: errorType,
userUUID: originalState.userUUID,
terminalUUID: originalState.terminalUUID,
tid: originalState.tid,
),
);
// Attempt to restore connected state even on failure
if (_connectedTerminal != null && originalState.userUUID != null) {
emit(
NearpayConnected(
userUUID: originalState.userUUID!,
terminalUUID: originalState.terminalUUID!,
tid: originalState.tid!,
terminalModel: _connectedTerminal!,
),
);
}
unawaited(
FirebaseCrashlytics.instance.recordError(e, s, reason: 'Nearpay Refund Failed: $e'),
);
}
}
FutureOr<void> _onLogoutNearpayTerminal(
LogoutNearpayTerminal event,
Emitter<NearpayState> emit,
) async {
emit(
NearpayLoading(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid),
);
try {
if (state.userUUID != null) {
await _terminalSdk.logout(userUUID: state.userUUID!);
}
_connectedTerminal = null;
_cachedConnectedTerminal = null;
emit(const NearpayInitial());
} catch (e) {
_log.e('Logout failed: $e');
emit(
NearpayFailure(
errorType: NearpayErrorType.logoutFailed,
userUUID: state.userUUID,
terminalUUID: state.terminalUUID,
tid: state.tid,
),
);
}
}
@override
NearpayState? fromJson(Map<String, dynamic> json) {
try {
final userUUID = json['userUUID'] as String?;
final terminalUUID = json['terminalUUID'] as String?;
final tid = json['tid'] as String?;
if (userUUID != null && terminalUUID != null && tid != null) {
// if we have a cached terminal, use it and don't try to connect again
if (_cachedConnectedTerminal != null) {
_connectedTerminal = _cachedConnectedTerminal;
return NearpayConnected(
userUUID: userUUID,
terminalUUID: terminalUUID,
tid: tid,
terminalModel: _connectedTerminal!,
);
}
// This tells the UI: "I know who you are, but I still need to connect to the native driver."
return NearpaySuccess(userUUID: userUUID, terminalUUID: terminalUUID, tid: tid);
}
return const NearpayInitial();
} catch (_) {
return null;
}
}
@override
Map<String, dynamic>? toJson(NearpayState state) {
return {'userUUID': state.userUUID, 'terminalUUID': state.terminalUUID, 'tid': state.tid};
}
}
extension on double {
/// Converts a double to cents.
int get cents => (this * 100).round();
}
part of 'nearpay_bloc.dart';
@immutable
sealed class NearpayEvent {
const NearpayEvent();
}
class InitializeNearpayTerminal extends NearpayEvent {}
class SendNearpayOtp extends NearpayEvent {
const SendNearpayOtp({required this.mobileNumber});
final String mobileNumber;
}
class VerifyNearpayOtp extends NearpayEvent {
const VerifyNearpayOtp({required this.mobileNumber, required this.code});
final String mobileNumber;
final String code;
}
class NearpayJWTLogin extends NearpayEvent {
const NearpayJWTLogin({required this.jwt});
final String jwt;
}
class FetchAndConnectToFirstTerminal extends NearpayEvent {
const FetchAndConnectToFirstTerminal();
}
class ConnectToNearpayTerminal extends NearpayEvent {
const ConnectToNearpayTerminal({this.userUUID, this.terminalUUID, this.tid});
final String? userUUID;
final String? terminalUUID;
final String? tid;
}
class CreateNearpayPurchase extends NearpayEvent {
const CreateNearpayPurchase({
required this.amount,
required this.intentUUID,
required this.customerReferenceNumber,
});
final double amount;
final String intentUUID;
final String customerReferenceNumber;
}
class CreateNearpayRefund extends NearpayEvent {
const CreateNearpayRefund({
required this.intentUUID,
required this.refundUuid,
required this.amount,
required this.customerReferenceNumber,
});
final String intentUUID;
final String refundUuid;
final double amount;
final String customerReferenceNumber;
}
class LogoutNearpayTerminal extends NearpayEvent {}
part of 'nearpay_bloc.dart';
@immutable
sealed class NearpayState extends Equatable {
const NearpayState({this.userUUID, this.terminalUUID, this.tid});
final String? userUUID;
final String? terminalUUID;
final String? tid;
@override
List<Object?> get props => [userUUID, terminalUUID, tid];
}
class NearpayInitial extends NearpayState {
const NearpayInitial({super.userUUID, super.terminalUUID, super.tid});
}
class NearpayLoading extends NearpayState {
const NearpayLoading({super.userUUID, super.terminalUUID, super.tid});
@override
List<Object?> get props => [...super.props];
}
class NearpayConnected extends NearpayState {
const NearpayConnected({
required String userUUID,
required String terminalUUID,
required String tid,
required this.terminalModel,
}) : super(userUUID: userUUID, terminalUUID: terminalUUID, tid: tid);
final TerminalModel terminalModel;
@override
List<Object?> get props => [...super.props, terminalModel];
}
class NearpayFailure extends NearpayState {
const NearpayFailure({required this.errorType, super.userUUID, super.terminalUUID, super.tid});
final NearpayErrorType errorType;
@override
List<Object?> get props => [...super.props, errorType];
}
class NearpaySuccess extends NearpayState {
const NearpaySuccess({super.userUUID, super.terminalUUID, super.tid});
@override
List<Object?> get props => [...super.props];
}
class NearpayPurchaseSuccess extends NearpaySuccess {
const NearpayPurchaseSuccess({
required this.response,
required this.customerReferenceNumber,
super.userUUID,
super.terminalUUID,
super.tid,
});
final PurchaseResponse response;
final String customerReferenceNumber;
@override
List<Object?> get props => [...super.props, response, customerReferenceNumber];
}
class NearpayRefundSuccess extends NearpaySuccess {
const NearpayRefundSuccess({
required this.response,
required this.customerReferenceNumber,
super.userUUID,
super.terminalUUID,
super.tid,
});
final RefundResponse response;
final String customerReferenceNumber;
@override
List<Object?> get props => [...super.props, response, customerReferenceNumber];
}
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_terminal_sdk/models/data/purchase_response.dart';
import 'package:fpdart/fpdart.dart';
import 'package:raas/src/core/error/failures.dart';
import 'package:raas/src/core/terminal/nearpay/bloc/nearpay_bloc.dart';
import 'package:raas/src/core/terminal/nearpay/domain/nearpay_error_type.dart';
import 'package:raas/src/core/terminal/nearpay/domain/nearpay_repository.dart';
import 'package:uuid/uuid.dart';
/// A mixin to cleanly encapsulate Terminal Payment intents without polluting
/// the concrete bloc's state. Uses composition over inheritance to keep the
/// class hierarchy flexible.
mixin TerminalPaymentMixin<Event, State> on Bloc<Event, State> {
String? _intentUUID;
String? _customerReferenceNumber;
double? _lockedAmount;
/// Executes a terminal purchase safely.
/// Generates and persists intent identifiers internally, ensuring they are
/// only created once per transaction lifecycle, unless the amount changes.
Future<Either<Failure, (PurchaseResponse, String)>> executeTerminalPurchase({
required double amount,
required NearpayBloc nearpayBloc,
required NearpayRepository nearpayRepository,
}) async {
// CRITICAL FIX: If the user cancels the terminal, adds another item to the cart,
// and tries to pay again, the amount has changed! We MUST invalidate the old intent
// so the backend and the terminal are perfectly in sync with the new amount.
if (_lockedAmount != null && _lockedAmount != amount) {
clearTerminalIntent();
}
if (_intentUUID == null || _customerReferenceNumber == null) {
_intentUUID = const Uuid().v4();
_lockedAmount = amount;
try {
_customerReferenceNumber = await nearpayRepository.initPurchase(
intentUUID: _intentUUID!,
amount: amount,
);
} catch (e) {
clearTerminalIntent(); // Clear on failure to prevent dangling corrupt state
return Left(
TerminalFailure(
NearpayErrorType.purchaseFailed,
'Failed to initialize terminal purchase on server: $e',
),
);
}
}
nearpayBloc.add(
CreateNearpayPurchase(
amount: amount,
intentUUID: _intentUUID!,
customerReferenceNumber: _customerReferenceNumber!,
),
);
final NearpayState resultState = await nearpayBloc.stream.firstWhere(
(s) => s is NearpayPurchaseSuccess || s is NearpayFailure,
);
if (resultState is NearpayPurchaseSuccess) {
return Right((resultState.response, resultState.customerReferenceNumber));
} else if (resultState is NearpayFailure) {
return Left(TerminalFailure(resultState.errorType));
}
return const Left(
TerminalFailure(NearpayErrorType.purchaseFailed, 'Unexpected terminal state'),
);
}
/// Clears the internal terminal intent.
/// Call this when the cart or transaction is deliberately destroyed or switched.
void clearTerminalIntent() {
_intentUUID = null;
_customerReferenceNumber = null;
_lockedAmount = null;
}
}
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_terminal_sdk/models/data/refund_response.dart';
import 'package:fpdart/fpdart.dart';
import 'package:raas/src/core/error/failures.dart';
import 'package:raas/src/core/terminal/nearpay/bloc/nearpay_bloc.dart';
import 'package:raas/src/core/terminal/nearpay/domain/nearpay_error_type.dart';
import 'package:raas/src/core/terminal/nearpay/domain/nearpay_repository.dart';
import 'package:uuid/uuid.dart';
/// A mixin to cleanly encapsulate Terminal Refund intents without polluting
/// the concrete bloc's state. Uses composition over inheritance to keep the
/// class hierarchy flexible.
mixin TerminalRefundMixin<Event, State> on Bloc<Event, State> {
String? _refundUUID;
String? _originalIntentUUID;
String? _customerReferenceNumber;
double? _lockedAmount;
/// Executes a terminal refund safely.
/// Generates and persists intent identifiers internally, ensuring they are
/// only created once per transaction lifecycle, unless the amount changes.
Future<Either<Failure, (RefundResponse, String)>> executeTerminalRefund({
required double amount,
required String originalIntentUUID,
required NearpayBloc nearpayBloc,
required NearpayRepository nearpayRepository,
}) async {
// CRITICAL FIX: If the user cancels the terminal, changes amount,
// and tries to refund again, the amount has changed! We MUST invalidate the old intent
// so the backend and the terminal are perfectly in sync with the new amount.
if (_lockedAmount != null && _lockedAmount != amount) {
clearTerminalRefundIntent();
}
if (_refundUUID == null ||
_originalIntentUUID != originalIntentUUID ||
_customerReferenceNumber == null) {
_refundUUID = const Uuid().v4();
_originalIntentUUID = originalIntentUUID;
_lockedAmount = amount;
try {
_customerReferenceNumber = await nearpayRepository.initRefund(
refundUUID: _refundUUID!,
originalIntentUUID: _originalIntentUUID!,
amount: amount,
);
} catch (e) {
clearTerminalRefundIntent(); // Clear on failure to prevent dangling corrupt state
return Left(
TerminalFailure(
NearpayErrorType.refundFailed,
'Failed to initialize terminal refund on server: $e',
),
);
}
}
nearpayBloc.add(
CreateNearpayRefund(
amount: amount,
intentUUID: _originalIntentUUID!,
refundUuid: _refundUUID!,
customerReferenceNumber: _customerReferenceNumber!,
),
);
final resultState = await nearpayBloc.stream.firstWhere(
(s) => s is NearpayRefundSuccess || s is NearpayFailure,
);
if (resultState is NearpayRefundSuccess) {
return Right((resultState.response, resultState.customerReferenceNumber));
} else if (resultState is NearpayFailure) {
return Left(TerminalFailure(resultState.errorType));
}
return const Left(TerminalFailure(NearpayErrorType.refundFailed, 'Unexpected terminal state'));
}
/// Clears the internal terminal refund intent.
/// Call this when the refund operation is deliberately destroyed or switched.
void clearTerminalRefundIntent() {
_refundUUID = null;
_originalIntentUUID = null;
_customerReferenceNumber = null;
_lockedAmount = null;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment