openPayment method

  1. @override
Future<Map?> openPayment({
  1. required String url,
  2. required Map<String, String> params,
  3. String? title,
  4. bool javaScript = true,
  5. bool devLogs = false,
  6. String? actionUrl,
  7. String? actionPath,
  8. bool forceOverlay = false,
})
override

Returns a Map like: { success: bool, data: String?, cancelled: bool }

forceOverlay is used by the web implementation for top-level payment navigation inside embedded WebView / WebKit hosts. Native platforms ignore it.

Implementation

@override
Future<Map<dynamic, dynamic>?> openPayment({
  required String url,
  required Map<String, String> params,
  String? title,
  bool javaScript = true,
  bool devLogs = false,
  String? actionUrl,
  String? actionPath,
  bool forceOverlay = false,
}) async {
  if (url.trim().isEmpty) {
    _logErr('openPayment: missing base url');
    return <dynamic, dynamic>{
      'success': false,
      'cancelled': false,
      'data': 'ARG_ERROR: url is required',
    };
  }

  _trace(devLogs, 'openPayment start base=${_safeUrl(url)} order_id=${params['order_id'] ?? ''} actionPath=${actionPath ?? 'default'} forceOverlay=$forceOverlay');

  // IMPORTANT: Do NOT mutate `return_url` here.
  // The payment gateway's hash typically includes return_url, so any mutation
  // after the client generates the hash will cause a mismatch.
  final effectiveParams = Map<String, String>.from(params);
  final expectedOrderId = (effectiveParams['order_id'] ?? '').trim();

  final action = _buildActionUri(
    baseUrl: url,
    actionUrl: actionUrl,
    actionPath: actionPath,
  );

  final html = _buildAutoSubmitHtml(
    action: action,
    params: effectiveParams,
    devLogs: devLogs,
    title: title,
  );

  _trace(devLogs, 'POST action=${_safeUrl(action.toString())} (html len=${html.length})');

  final completer = Completer<Map<dynamic, dynamic>>();
  Timer? timeout;
  late final web.EventListener messageListener;
  web.Window? paymentTab;
  Timer? closedPoll;
  var useOverlay = forceOverlay || _isEmbeddedWebView();

  void complete(Map<dynamic, dynamic> map) {
    if (completer.isCompleted) return;
    timeout?.cancel();
    closedPoll?.cancel();
    _trace(devLogs, 'openPayment complete success=${map['success']} cancelled=${map['cancelled']} dataLen=${(map['data']?.toString() ?? '').length}');
    try {
      _bc?.close();
    } catch (_) {}
    _bc = null;
    try {
      web.window.removeEventListener('message', messageListener);
    } catch (_) {}
    try {
      paymentTab?.close();
    } catch (_) {}
    _removePaymentOverlay();
    try {
      web.window.focus();
    } catch (_) {}
    completer.complete(map);
  }

  var isFetchingStatus = false;

  Future<void> handleReturnSignal(String source) async {
    if (isFetchingStatus || completer.isCompleted) return;
    isFetchingStatus = true;
    _trace(devLogs, '$source: return URL reached — fetching payment status');
    try {
      paymentTab?.close();
    } catch (_) {}
    _removePaymentOverlay();
    try {
      final statusJson = await _fetchPaymentStatusWithRetry(
        baseUrl: url,
        params: effectiveParams,
        devLogs: devLogs,
      );
      complete(<dynamic, dynamic>{
        'success': true,
        'cancelled': false,
        'data': statusJson,
      });
    } catch (e) {
      _logErr('paymentstatus fetch failed: $e');
      complete(<dynamic, dynamic>{
        'success': false,
        'cancelled': false,
        'data': 'ERROR:$e',
      });
    }
  }

  // Listen for return messages before opening UI.
  messageListener = ((web.Event e) {
    final me = e as web.MessageEvent;
    try {
      if (me.origin != web.window.location.origin) {
        _trace(devLogs, 'postMessage ignored: wrong origin=${me.origin}');
        return;
      }
    } catch (_) {
      return;
    }
    final data = me.data;
    try {
      final raw = data?.toString() ?? '';
      if (raw.isEmpty) return;
      final obj = jsonDecode(raw);
      if (obj is! Map) {
        _trace(devLogs, 'postMessage: JSON is not an object');
        return;
      }
      if (obj['type'] != _messageType) {
        _trace(devLogs, 'postMessage: type=${obj['type']} (ignored)');
        return;
      }
      final q = obj['query'];
      if (expectedOrderId.isNotEmpty && q is Map) {
        final returnedOrderId = (q['order_id'] ??
                q['orderID'] ??
                q['orderId'] ??
                q['ORDER_ID'] ??
                '')
            .toString()
            .trim();
        if (returnedOrderId.isNotEmpty && returnedOrderId != expectedOrderId) {
          _trace(
            devLogs,
            'postMessage: order_id mismatch expected=$expectedOrderId got=$returnedOrderId',
          );
          return;
        }
      }
      _trace(devLogs, 'postMessage: return signal accepted');
      handleReturnSignal('postMessage');
    } catch (err) {
      _trace(devLogs, 'postMessage parse error: $err');
    }
  }).toJS;
  web.window.addEventListener('message', messageListener);

  try {
    _bc = web.BroadcastChannel('flutter_payment_plugin');
    _trace(devLogs, 'BroadcastChannel listening');
    _bc!.onmessage = ((web.MessageEvent e) {
      final s = e.data?.toString() ?? '';
      try {
        final obj = jsonDecode(s);
        if (obj is! Map) return;
        if (obj['type'] != _messageType) return;
        final q = obj['query'];
        if (expectedOrderId.isNotEmpty && q is Map) {
          final returnedOrderId = (q['order_id'] ??
                  q['orderID'] ??
                  q['orderId'] ??
                  q['ORDER_ID'] ??
                  '')
              .toString()
              .trim();
          if (returnedOrderId.isNotEmpty && returnedOrderId != expectedOrderId) {
            _trace(devLogs, 'BroadcastChannel: order_id mismatch');
            return;
          }
        }
        _trace(devLogs, 'BroadcastChannel: return signal accepted');
        handleReturnSignal('BroadcastChannel');
      } catch (err) {
        _trace(devLogs, 'BroadcastChannel parse error: $err');
      }
    }).toJS;
  } catch (e) {
    _trace(devLogs, 'BroadcastChannel unavailable: $e');
    _bc = null;
  }

  if (useOverlay) {
    // Gateways block iframes — navigate the host WebView itself.
    // Result is resumed after payment_return.html redirects back to the app.
    _trace(devLogs, 'WebView mode: top-level POST (forceOverlay=$forceOverlay)');
    _persistPendingPayment(
      baseUrl: url,
      params: effectiveParams,
      devLogs: devLogs,
    );
    if (!_submitPaymentInTopWindow(
      action: action,
      params: effectiveParams,
      devLogs: devLogs,
    )) {
      complete(<dynamic, dynamic>{
        'success': false,
        'cancelled': false,
        'data': 'PAYMENT_TOP_NAV_ERROR',
      });
      return completer.future;
    }
    // Page is navigating away; Future will not complete in this JS context.
    // consumeWebReturnIfAny() handles the result after return.
    return completer.future;
  }

  _trace(devLogs, 'opening payment tab for direct POST');
  paymentTab = web.window.open('about:blank', '_blank');
  if (paymentTab == null) {
    _logErr('window.open returned null — falling back to top-level POST');
    _persistPendingPayment(
      baseUrl: url,
      params: effectiveParams,
      devLogs: devLogs,
    );
    if (!_submitPaymentInTopWindow(
      action: action,
      params: effectiveParams,
      devLogs: devLogs,
    )) {
      complete(<dynamic, dynamic>{
        'success': false,
        'cancelled': true,
        'data': 'POPUP_BLOCKED',
      });
      return completer.future;
    }
    return completer.future;
  }
  if (!_openPaymentTabAndSubmit(
    paymentTab: paymentTab,
    html: html,
    devLogs: devLogs,
  )) {
    try {
      paymentTab.close();
    } catch (_) {}
    complete(<dynamic, dynamic>{
      'success': false,
      'cancelled': false,
      'data': 'PAYMENT_TAB_ERROR',
    });
    return completer.future;
  }
  try {
    paymentTab.focus();
  } catch (e) {
    _trace(devLogs, 'paymentTab.focus() failed: $e');
  }

  closedPoll = Timer.periodic(const Duration(milliseconds: 400), (_) {
    try {
      if (paymentTab?.closed == true) {
        _logErr('payment tab closed before return (user cancelled or navigated away)');
        complete(<dynamic, dynamic>{
          'success': false,
          'cancelled': true,
          'data': null,
        });
      }
    } catch (e) {
      _trace(devLogs, 'closedPoll error: $e');
    }
  });

  timeout = Timer(const Duration(minutes: 10), () {
    _logErr('timeout waiting for payment return (no postMessage from payment_return.html)');
    complete(<dynamic, dynamic>{
      'success': false,
      'cancelled': true,
      'data': 'TIMEOUT',
    });
  });

  return completer.future;
}