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,
})
override

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

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,
}) 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'}');

  // 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;

  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 {
      // Bring user back to the original app tab.
      web.window.focus();
    } catch (_) {}
    completer.complete(map);
  }

  Map<dynamic, dynamic> normalize(Map<dynamic, dynamic> map) {
    final data = map['data'];
    if (data == null) return map;
    try {
      if (data is String) {
        final s = data.trim();
        if (!(s.startsWith('{') || s.startsWith('['))) return map;
        final decoded = jsonDecode(s);
        final normalized = _stringifyLeafValues(decoded);
        return <dynamic, dynamic>{...map, 'data': jsonEncode(normalized)};
      }
      final normalized = _stringifyLeafValues(data);
      return <dynamic, dynamic>{...map, 'data': jsonEncode(normalized)};
    } catch (_) {
      return map;
    }
  }

  _trace(devLogs, 'opening payment tab for direct POST');
  paymentTab = web.window.open('about:blank', '_blank');
  if (paymentTab == null) {
    _logErr('window.open returned null (popup blocked or not allowed)');
    return <dynamic, dynamic>{
      'success': false,
      'cancelled': true,
      'data': 'POPUP_BLOCKED',
    };
  }
  if (!_openPaymentTabAndSubmit(
    paymentTab: paymentTab,
    html: html,
    devLogs: devLogs,
  )) {
    try {
      paymentTab.close();
    } catch (_) {}
    return <dynamic, dynamic>{
      'success': false,
      'cancelled': false,
      'data': 'PAYMENT_TAB_ERROR',
    };
  }
  try {
    paymentTab.focus();
  } catch (e) {
    _trace(devLogs, 'paymentTab.focus() failed: $e');
  }

  // Listen for return messages (works even when gateway can't be iframed).
  messageListener = ((web.Event e) {
    final me = e as web.MessageEvent;
    // Security: only accept messages from our own origin.
    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 payload accepted');
      complete(normalize(<dynamic, dynamic>{
        'success': true,
        'cancelled': false,
        'data': jsonEncode(obj),
      }));
    } catch (err) {
      _trace(devLogs, 'postMessage parse error: $err');
    }
  }).toJS;
  web.window.addEventListener('message', messageListener);

  // BroadcastChannel fallback when opener is null (or in same-tab returns).
  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 payload accepted');
        complete(normalize(<dynamic, dynamic>{
          'success': true,
          'cancelled': false,
          'data': jsonEncode(obj),
        }));
      } catch (err) {
        _trace(devLogs, 'BroadcastChannel parse error: $err');
      }
    }).toJS;
  } catch (e) {
    _trace(devLogs, 'BroadcastChannel unavailable: $e');
    _bc = null;
  }

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

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

  return completer.future;
}