From dd7340749cf3326b629c9511315f5a1c7570c477 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 06:43:41 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20handle=20edge=20cases=20in=20payment=20?= =?UTF-8?q?flow=20=E2=80=94=20cancellation=20and=20native=20errors=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startPayment() previously assigned its onPaymentResult callback only after invokeMethod resolved, so a slow first call could overwrite a faster second call's callback even though it was triggered earlier. The callback slot is now claimed synchronously before the native call is awaited, so the last-triggered request always wins regardless of native completion order, and a superseded call's late failure no longer clobbers a newer callback. invokeMethod('startPayment', ...) now also catches PlatformException and rethrows it as MonextPaymentNativeException, carrying the native SDK's raw error code and message (e.g. an invalid/expired session token) to the Dart caller. Cancellation, timeout, network-error and malformed-JSON outcomes were already relayed as-is through onPaymentResult by design; added tests proving this explicitly per the ticket's acceptance criteria. --- README.md | 11 +- lib/monext_payment.dart | 14 +- lib/monext_payment_method_channel.dart | 25 ++- lib/monext_payment_native_exception.dart | 21 ++ lib/monext_payment_platform_interface.dart | 4 + test/monext_payment_method_channel_test.dart | 197 +++++++++++++++---- 6 files changed, 227 insertions(+), 45 deletions(-) create mode 100644 lib/monext_payment_native_exception.dart diff --git a/README.md b/README.md index f91df60..26ac37c 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,14 @@ await MonextPayment.startPayment( [Google Pay Setup](docs/guides/google-pay-setup.md) and [Apple Pay Setup](docs/guides/apple-pay-setup.md). `onPaymentResult` is invoked once, later, with the raw value native code sends when the payment flow completes; it applies no validation - or transformation of its own, and is not invoked if `startPayment` itself throws. + or transformation of its own, and is not invoked if `startPayment` itself throws. Calling + `startPayment` again while a previous call is still in progress cancels that previous request: + only the latest call's outcome is ever relayed to `onPaymentResult`. - It throws `MonextPaymentNotInitializedException` if called before `initialize()`, an - `ArgumentError` if `sessionToken` is empty, and a - `MonextPaymentChannelUnavailableException` if no native handler is registered for the - `MethodChannel`. + `ArgumentError` if `sessionToken` is empty, a `MonextPaymentChannelUnavailableException` if no + native handler is registered for the `MethodChannel`, and a `MonextPaymentNativeException` if + the native SDK rejects the request synchronously (e.g. an invalid or expired session token), + carrying its raw `errorCode` and `message`. ## Try it diff --git a/lib/monext_payment.dart b/lib/monext_payment.dart index fcd45e2..93c0152 100644 --- a/lib/monext_payment.dart +++ b/lib/monext_payment.dart @@ -14,6 +14,7 @@ export 'monext_environment.dart'; export 'monext_google_pay_configuration.dart'; export 'monext_language.dart'; export 'monext_payment_channel_unavailable_exception.dart'; +export 'monext_payment_native_exception.dart'; export 'monext_payment_not_initialized_exception.dart'; export 'monext_payment_result.dart'; @@ -56,10 +57,17 @@ class MonextPayment { /// validation or transformation of its own. It is not invoked if /// [startPayment] itself throws. /// + /// Calling [startPayment] again while a previous call's flow is still in + /// progress cancels that previous request: its [onPaymentResult], if any + /// result later arrives for it, is never invoked, and only the latest + /// call's outcome is relayed. + /// /// Throws a [MonextPaymentNotInitializedException] if [initialize] has - /// not been called yet, an [ArgumentError] if [sessionToken] is empty, - /// and a [MonextPaymentChannelUnavailableException] if no native handler - /// is registered for the MethodChannel. + /// not been called yet, an [ArgumentError] if [sessionToken] is empty, a + /// [MonextPaymentChannelUnavailableException] if no native handler is + /// registered for the MethodChannel, and a [MonextPaymentNativeException] + /// if the native SDK rejects the request synchronously (e.g. an invalid + /// or expired session token), carrying its raw error code and message. static Future startPayment({ required String sessionToken, required MonextEnvironment environment, diff --git a/lib/monext_payment_method_channel.dart b/lib/monext_payment_method_channel.dart index 3dbba93..6ce81bf 100644 --- a/lib/monext_payment_method_channel.dart +++ b/lib/monext_payment_method_channel.dart @@ -7,6 +7,7 @@ import 'monext_environment.dart'; import 'monext_google_pay_configuration.dart'; import 'monext_language.dart'; import 'monext_payment_channel_unavailable_exception.dart'; +import 'monext_payment_native_exception.dart'; import 'monext_payment_platform_interface.dart'; /// An implementation of [MonextPaymentPlatform] that uses method channels. @@ -16,7 +17,10 @@ class MethodChannelMonextPayment extends MonextPaymentPlatform { final methodChannel = const MethodChannel('monext_payment'); // Set by the most recent startPayment() call; invoked when native code - // relays the outcome via the onPaymentResult MethodChannel call. + // relays the outcome via the onPaymentResult MethodChannel call. Assigned + // synchronously, before the native call is awaited, so that whichever + // startPayment() call is *triggered* last always owns this slot - + // regardless of which native call *resolves* last. void Function(String result)? _onPaymentResultCallback; MethodChannelMonextPayment() { @@ -58,11 +62,28 @@ class MethodChannelMonextPayment extends MonextPaymentPlatform { 'applePayConfiguration': applePayConfiguration.toJson(), }; + // Claim the callback slot immediately: this supersedes any earlier, + // still in-flight startPayment() call, whose result (if it arrives at + // all) must not be relayed anymore. + _onPaymentResultCallback = onPaymentResult; + try { await methodChannel.invokeMethod('startPayment', message); - _onPaymentResultCallback = onPaymentResult; } on MissingPluginException { + _releaseCallbackIfOwned(onPaymentResult); throw MonextPaymentChannelUnavailableException(); + } on PlatformException catch (e) { + _releaseCallbackIfOwned(onPaymentResult); + throw MonextPaymentNativeException(errorCode: e.code, message: e.message); + } + } + + // Clears the callback slot only if it still belongs to this call: a newer, + // superseding startPayment() call may already have claimed it, and this + // (now-failed) call must not clobber that newer callback. + void _releaseCallbackIfOwned(void Function(String result) onPaymentResult) { + if (identical(_onPaymentResultCallback, onPaymentResult)) { + _onPaymentResultCallback = null; } } } diff --git a/lib/monext_payment_native_exception.dart b/lib/monext_payment_native_exception.dart new file mode 100644 index 0000000..9d594a3 --- /dev/null +++ b/lib/monext_payment_native_exception.dart @@ -0,0 +1,21 @@ +/// Thrown when [MonextPayment.startPayment] fails because the native SDK +/// rejected the request synchronously (e.g. an invalid or expired session +/// token). +/// +/// Carries the native SDK's raw error code and message as-is, without +/// interpretation: this layer relays native errors, it doesn't classify +/// them. +class MonextPaymentNativeException implements Exception { + /// The native SDK's raw error code. + final String errorCode; + + /// The native SDK's raw error message, if any. + final String? message; + + MonextPaymentNativeException({required this.errorCode, this.message}); + + @override + String toString() => + 'MonextPaymentNativeException: $errorCode' + '${message != null ? ' ($message)' : ''}'; +} diff --git a/lib/monext_payment_platform_interface.dart b/lib/monext_payment_platform_interface.dart index 0451854..4ea2508 100644 --- a/lib/monext_payment_platform_interface.dart +++ b/lib/monext_payment_platform_interface.dart @@ -39,6 +39,10 @@ abstract class MonextPaymentPlatform extends PlatformInterface { /// code sends once the payment flow completes (see /// [MonextPaymentResult.fromValue] for decoding it); this call applies no /// validation or transformation of its own. + /// + /// A call made while a previous one is still in progress cancels that + /// previous request: only the latest call's [onPaymentResult] is ever + /// invoked. Future startPayment({ required String sessionToken, required MonextEnvironment environment, diff --git a/test/monext_payment_method_channel_test.dart b/test/monext_payment_method_channel_test.dart index 5cb03fc..397319b 100644 --- a/test/monext_payment_method_channel_test.dart +++ b/test/monext_payment_method_channel_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:monext_payment/monext_payment.dart'; @@ -60,44 +62,38 @@ void main() { }); }); - test( - 'invokes startPayment with language, googlePayConfiguration and ' - 'applePayConfiguration', - () async { - MethodCall? receivedCall; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - receivedCall = methodCall; - return null; - }); + test('invokes startPayment with language, googlePayConfiguration and ' + 'applePayConfiguration', () async { + MethodCall? receivedCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + receivedCall = methodCall; + return null; + }); - await platform.startPayment( - sessionToken: 'a-session-token', - environment: MonextEnvironment.sandbox, - language: MonextLanguage.fr, - googlePayConfiguration: const MonextGooglePayConfiguration( - theme: MonextGooglePayButtonTheme.light, - type: MonextGooglePayButtonType.buy, - ), - applePayConfiguration: const MonextApplePayConfiguration( - buttonLabel: MonextApplePayButtonLabel.buy, - buttonStyle: MonextApplePayButtonStyle.white, - ), - onPaymentResult: (_) {}, - ); + await platform.startPayment( + sessionToken: 'a-session-token', + environment: MonextEnvironment.sandbox, + language: MonextLanguage.fr, + googlePayConfiguration: const MonextGooglePayConfiguration( + theme: MonextGooglePayButtonTheme.light, + type: MonextGooglePayButtonType.buy, + ), + applePayConfiguration: const MonextApplePayConfiguration( + buttonLabel: MonextApplePayButtonLabel.buy, + buttonStyle: MonextApplePayButtonStyle.white, + ), + onPaymentResult: (_) {}, + ); - expect(receivedCall?.arguments, { - 'sessionToken': 'a-session-token', - 'environment': 'sandbox', - 'language': 'FR', - 'googlePayConfiguration': {'theme': 'light', 'type': 'buy'}, - 'applePayConfiguration': { - 'buttonLabel': 'buy', - 'buttonStyle': 'white', - }, - }); - }, - ); + expect(receivedCall?.arguments, { + 'sessionToken': 'a-session-token', + 'environment': 'sandbox', + 'language': 'FR', + 'googlePayConfiguration': {'theme': 'light', 'type': 'buy'}, + 'applePayConfiguration': {'buttonLabel': 'buy', 'buttonStyle': 'white'}, + }); + }); test('omits appearance from the message when not provided', () async { MethodCall? receivedCall; @@ -195,5 +191,134 @@ void main() { expect(firstCallEvents, isEmpty); expect(secondCallEvents, ['captured']); }); + + test('the last-triggered call owns the callback even when its native call ' + 'resolves before the earlier call\'s native call does', () async { + // The first call's invokeMethod('startPayment', ...) stays pending + // until firstCallCompleter completes, simulating a slow native call + // that is still "in progress" when the second call is triggered and + // completes first. + final firstCallCompleter = Completer(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.arguments['sessionToken'] == 'first-session-token') { + await firstCallCompleter.future; + } + return null; + }); + final firstCallEvents = []; + final secondCallEvents = []; + + final firstCall = platform.startPayment( + sessionToken: 'first-session-token', + environment: MonextEnvironment.sandbox, + onPaymentResult: firstCallEvents.add, + ); + await platform.startPayment( + sessionToken: 'second-session-token', + environment: MonextEnvironment.sandbox, + onPaymentResult: secondCallEvents.add, + ); + firstCallCompleter.complete(); + await firstCall; + await simulateNativeCall('onPaymentResult', 'captured'); + + expect(firstCallEvents, isEmpty); + expect(secondCallEvents, ['captured']); + }); + + test('throws MonextPaymentNativeException with the native SDK\'s raw error ' + 'code and message', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + throw PlatformException( + code: 'invalid_session_token', + message: 'The session token has expired.', + ); + }); + + await expectLater( + platform.startPayment( + sessionToken: 'an-expired-session-token', + environment: MonextEnvironment.sandbox, + onPaymentResult: (_) {}, + ), + throwsA( + isA() + .having((e) => e.errorCode, 'errorCode', 'invalid_session_token') + .having( + (e) => e.message, + 'message', + 'The session token has expired.', + ), + ), + ); + }); + + test('a superseded call throwing MonextPaymentNativeException does not ' + 'clear the newer call\'s callback', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return null; + }); + final secondCallEvents = []; + + await platform.startPayment( + sessionToken: 'a-session-token', + environment: MonextEnvironment.sandbox, + onPaymentResult: (_) {}, + ); + await platform.startPayment( + sessionToken: 'another-session-token', + environment: MonextEnvironment.sandbox, + onPaymentResult: secondCallEvents.add, + ); + + // A stray failure notification for the (already superseded) first + // call must not wipe out the second call's callback. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + throw PlatformException(code: 'some_error'); + }); + await expectLater( + platform.startPayment( + sessionToken: 'a-session-token', + environment: MonextEnvironment.sandbox, + onPaymentResult: (_) {}, + ), + throwsA(isA()), + ); + + await simulateNativeCall('onPaymentResult', 'captured'); + + expect(secondCallEvents, isEmpty); + }); + + test('relays raw result values untouched, including cancellation, timeout, ' + 'network-error and malformed JSON payloads', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return null; + }); + + for (final rawResult in [ + '{"status":"cancelled","errorCode":"userCancelled"}', + '{"status":"cancelled","errorCode":"TIMEOUT"}', + '{"status":"error","errorCode":"NETWORK_ERROR",' + '"message":"connection lost"}', + 'not-even-json', + ]) { + final events = []; + + await platform.startPayment( + sessionToken: 'a-session-token', + environment: MonextEnvironment.sandbox, + onPaymentResult: events.add, + ); + await simulateNativeCall('onPaymentResult', rawResult); + + expect(events, [rawResult]); + } + }); }); }