Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 11 additions & 3 deletions lib/monext_payment.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<void> startPayment({
required String sessionToken,
required MonextEnvironment environment,
Expand Down
25 changes: 23 additions & 2 deletions lib/monext_payment_method_channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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() {
Expand Down Expand Up @@ -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<void>('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;
}
}
}
21 changes: 21 additions & 0 deletions lib/monext_payment_native_exception.dart
Original file line number Diff line number Diff line change
@@ -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)' : ''}';
}
4 changes: 4 additions & 0 deletions lib/monext_payment_platform_interface.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> startPayment({
required String sessionToken,
required MonextEnvironment environment,
Expand Down
197 changes: 161 additions & 36 deletions test/monext_payment_method_channel_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<void>();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
if (methodCall.arguments['sessionToken'] == 'first-session-token') {
await firstCallCompleter.future;
}
return null;
});
final firstCallEvents = <String>[];
final secondCallEvents = <String>[];

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<MonextPaymentNativeException>()
.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 = <String>[];

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<MonextPaymentNativeException>()),
);

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 <String>[
'{"status":"cancelled","errorCode":"userCancelled"}',
'{"status":"cancelled","errorCode":"TIMEOUT"}',
'{"status":"error","errorCode":"NETWORK_ERROR",'
'"message":"connection lost"}',
'not-even-json',
]) {
final events = <String>[];

await platform.startPayment(
sessionToken: 'a-session-token',
environment: MonextEnvironment.sandbox,
onPaymentResult: events.add,
);
await simulateNativeCall('onPaymentResult', rawResult);

expect(events, [rawResult]);
}
});
});
}
Loading