openPayment method
Future<Map?>
openPayment({
- required String url,
- required Map<
String, String> params, - String? title,
- bool javaScript = true,
- bool devLogs = false,
- String? actionUrl,
- 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;
}