@padosoft/react-native-ecr17 0.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/Ecr17.podspec +39 -39
  2. package/README.md +348 -348
  3. package/android/CMakeLists.txt +41 -41
  4. package/android/build.gradle +148 -148
  5. package/android/fix-prefab.gradle +50 -50
  6. package/android/gradle.properties +5 -5
  7. package/android/src/main/AndroidManifest.xml +2 -2
  8. package/android/src/main/cpp/cpp-adapter.cpp +8 -8
  9. package/android/src/main/java/com/margelo/nitro/ecr17/HybridEcr17Transport.kt +233 -233
  10. package/android/src/main/java/com/padosoft/ecr17/Ecr17Package.kt +30 -30
  11. package/cpp/Ecr17.hpp +1 -1
  12. package/cpp/Ecr17Client/HybridEcr17Client.cpp +598 -598
  13. package/cpp/Ecr17Client/HybridEcr17Client.hpp +85 -85
  14. package/cpp/Ecr17Protocol/Ecr17Protocol.cpp +277 -277
  15. package/cpp/Ecr17Protocol/Ecr17Protocol.hpp +103 -103
  16. package/cpp/Ecr17Response/Ecr17Response.cpp +155 -155
  17. package/cpp/Ecr17Response/Ecr17Response.hpp +113 -113
  18. package/cpp/Lcr/Lcr.cpp +42 -42
  19. package/cpp/Lcr/Lcr.hpp +21 -21
  20. package/cpp/PacketCodec/PacketCodec.cpp +145 -145
  21. package/cpp/PacketCodec/PacketCodec.hpp +47 -47
  22. package/cpp/Session/Ecr17Session.cpp +260 -260
  23. package/cpp/Session/Ecr17Session.hpp +97 -97
  24. package/cpp/Session/RetryPolicy.hpp +23 -23
  25. package/cpp/Transport/FakeTransport.hpp +95 -95
  26. package/cpp/Transport/NativeTransportAdapter.cpp +42 -42
  27. package/cpp/Transport/NativeTransportAdapter.hpp +32 -32
  28. package/cpp/Transport/Transport.hpp +30 -30
  29. package/cpp/tests/CMakeLists.txt +55 -55
  30. package/cpp/tests/PosixTcpTransport.hpp +105 -105
  31. package/cpp/tests/stubs/LrcMode.hpp +25 -25
  32. package/cpp/tests/test_flows.cpp +148 -148
  33. package/cpp/tests/test_integration_terminal.cpp +72 -72
  34. package/cpp/tests/test_lrc.cpp +66 -66
  35. package/cpp/tests/test_packet_codec.cpp +164 -164
  36. package/cpp/tests/test_protocol.cpp +102 -102
  37. package/cpp/tests/test_protocol_commands.cpp +190 -190
  38. package/cpp/tests/test_response.cpp +164 -164
  39. package/cpp/tests/test_retry_policy.cpp +28 -28
  40. package/cpp/tests/test_session.cpp +262 -262
  41. package/ios/HybridEcr17Transport.swift +103 -103
  42. package/lib/commonjs/index.js +50 -0
  43. package/lib/commonjs/index.js.map +1 -0
  44. package/lib/commonjs/package.json +1 -0
  45. package/lib/commonjs/specs/client.nitro.js +17 -0
  46. package/lib/commonjs/specs/client.nitro.js.map +1 -0
  47. package/lib/commonjs/specs/transport.nitro.js +6 -0
  48. package/lib/commonjs/specs/transport.nitro.js.map +1 -0
  49. package/lib/commonjs/types/client.js +2 -0
  50. package/lib/commonjs/types/client.js.map +1 -0
  51. package/lib/commonjs/utils/client.js +13 -0
  52. package/lib/commonjs/utils/client.js.map +1 -0
  53. package/lib/module/index.js +7 -0
  54. package/lib/module/index.js.map +1 -0
  55. package/lib/module/specs/client.nitro.js +13 -0
  56. package/lib/module/specs/client.nitro.js.map +1 -0
  57. package/lib/module/specs/transport.nitro.js +4 -0
  58. package/lib/module/specs/transport.nitro.js.map +1 -0
  59. package/lib/module/types/client.js +2 -0
  60. package/lib/module/types/client.js.map +1 -0
  61. package/lib/module/utils/client.js +9 -0
  62. package/lib/module/utils/client.js.map +1 -0
  63. package/lib/typescript/src/index.d.ts +5 -0
  64. package/lib/typescript/src/index.d.ts.map +1 -0
  65. package/lib/typescript/src/specs/client.nitro.d.ts +63 -0
  66. package/lib/typescript/src/specs/client.nitro.d.ts.map +1 -0
  67. package/lib/typescript/src/specs/transport.nitro.d.ts +13 -0
  68. package/lib/typescript/src/specs/transport.nitro.d.ts.map +1 -0
  69. package/lib/typescript/src/types/client.d.ts +138 -0
  70. package/lib/typescript/src/types/client.d.ts.map +1 -0
  71. package/lib/typescript/src/utils/client.d.ts +3 -0
  72. package/lib/typescript/src/utils/client.d.ts.map +1 -0
  73. package/nitro.json +30 -30
  74. package/package.json +4 -4
  75. package/react-native.config.js +18 -18
  76. package/src/index.ts +4 -4
  77. package/src/specs/client.nitro.ts +102 -102
  78. package/src/specs/transport.nitro.ts +25 -25
  79. package/src/types/client.ts +196 -196
  80. package/src/utils/client.ts +10 -10
@@ -1,598 +1,598 @@
1
- #include "HybridEcr17Client.hpp"
2
-
3
- #include <NitroModules/HybridObjectRegistry.hpp>
4
-
5
- #include <chrono>
6
- #include <ctime>
7
- #include <exception>
8
- #include <optional>
9
- #include <stdexcept>
10
- #include <string>
11
- #include <type_traits>
12
- #include <utility>
13
-
14
- #include "Ecr17Protocol/Ecr17Protocol.hpp"
15
- #include "Ecr17Response/Ecr17Response.hpp"
16
- #include "Session/RetryPolicy.hpp"
17
-
18
- // Commands run on Nitro's C++ thread pool. On Android, those worker threads are
19
- // NOT attached to the JVM, and — even once attached — JNI `FindClass` on an
20
- // attached worker thread resolves against the *system* class loader, which can't
21
- // see app/NitroModules classes (e.g. com.margelo.nitro.core.ArrayBuffer, looked
22
- // up lazily by the generated transport bridge in `send()` via JArrayBuffer::wrap).
23
- // That yields "Unable to retrieve jni environment" (no JNIEnv) or
24
- // "ClassNotFoundException ... DexPathList[... /system/lib64 ...]" (wrong loader).
25
- #ifdef __ANDROID__
26
- #include <fbjni/fbjni.h>
27
- #endif
28
-
29
- namespace margelo::nitro::ecr17 {
30
-
31
- namespace {
32
-
33
- // Runs `fn` on Android under fbjni's ThreadScope::WithClassLoader, which attaches
34
- // the current thread to the JVM AND installs fbjni's cached app class loader for
35
- // the duration — so every JNI FindClass inside (including NitroModules' lazy
36
- // ArrayBuffer lookup) resolves app classes, not the system loader. On iOS (no JVM)
37
- // `fn` is just called directly. Returns whatever `fn` returns (incl. void) and
38
- // propagates exceptions, so the caller's try/catch and return-value logic is
39
- // unchanged. WithClassLoader takes a `std::function<void()>`, so on Android the
40
- // result is captured in a local and any exception via std::exception_ptr, then
41
- // rethrown after the scope. `fn` is a lambda, so a `return` inside it returns from
42
- // the lambda (not the caller) on BOTH platforms — safe in value-returning callers.
43
- template <typename Fn>
44
- auto runOnJvmThread(Fn&& fn) -> decltype(fn()) {
45
- #ifdef __ANDROID__
46
- using Ret = decltype(fn());
47
- std::exception_ptr err;
48
- if constexpr (std::is_void_v<Ret>) {
49
- ::facebook::jni::ThreadScope::WithClassLoader([&]() {
50
- try {
51
- fn();
52
- } catch (...) {
53
- err = std::current_exception();
54
- }
55
- });
56
- if (err) std::rethrow_exception(err);
57
- } else {
58
- std::optional<Ret> result;
59
- ::facebook::jni::ThreadScope::WithClassLoader([&]() {
60
- try {
61
- result.emplace(fn());
62
- } catch (...) {
63
- err = std::current_exception();
64
- }
65
- });
66
- if (err) std::rethrow_exception(err);
67
- return std::move(*result);
68
- }
69
- #else
70
- return fn();
71
- #endif
72
- }
73
-
74
- } // namespace
75
-
76
- using margelo::nitro::HybridObjectRegistry;
77
- using margelo::nitro::Promise;
78
-
79
- namespace {
80
-
81
- std::optional<std::string> optStr(const std::string& s) {
82
- return s.empty() ? std::nullopt : std::optional<std::string>(s);
83
- }
84
-
85
- std::optional<double> optNum(const std::string& s) {
86
- if (s.empty()) {
87
- return std::nullopt;
88
- }
89
- try {
90
- return std::stod(s);
91
- } catch (...) {
92
- return std::nullopt;
93
- }
94
- }
95
-
96
- TransactionOutcome mapOutcome(Outcome o) {
97
- switch (o) {
98
- case Outcome::Ok: return TransactionOutcome::OK;
99
- case Outcome::Ko: return TransactionOutcome::KO;
100
- case Outcome::CardNotPresent: return TransactionOutcome::CARDNOTPRESENT;
101
- case Outcome::UnknownTag: return TransactionOutcome::UNKNOWNTAG;
102
- default: return TransactionOutcome::UNKNOWN;
103
- }
104
- }
105
-
106
- std::optional<CardType> mapCardType(const std::string& raw) {
107
- if (raw == "1") return CardType::DEBIT;
108
- if (raw == "2") return CardType::CREDIT;
109
- if (raw == "3") return CardType::OTHER;
110
- return std::nullopt;
111
- }
112
-
113
- std::optional<TransactionEntryMode> mapEntryMode(const std::string& raw) {
114
- if (raw == "ICC") return TransactionEntryMode::ICC;
115
- if (raw == "MAG") return TransactionEntryMode::MAG;
116
- if (raw == "MAN") return TransactionEntryMode::MANUAL;
117
- if (raw == "CLM") return TransactionEntryMode::CLESSMAG;
118
- if (raw == "CLI") return TransactionEntryMode::CLESSICC;
119
- return std::nullopt;
120
- }
121
-
122
- char mapPaymentType(const std::optional<PaymentCardType>& t) {
123
- if (!t.has_value()) return '0';
124
- switch (*t) {
125
- case PaymentCardType::DEBIT: return '1';
126
- case PaymentCardType::CREDIT: return '2';
127
- case PaymentCardType::OTHER: return '3';
128
- default: return '0'; // AUTO
129
- }
130
- }
131
-
132
- PaymentResult mapPayment(const PaymentResponse& p) {
133
- PaymentResult r;
134
- r.outcome = mapOutcome(p.outcome);
135
- r.resultCode = p.resultCode;
136
- r.pan = optStr(p.pan);
137
- r.entryMode = mapEntryMode(p.transactionType);
138
- r.authCode = optStr(p.authCode);
139
- r.hostDateTime = optStr(p.hostDateTime);
140
- r.cardType = mapCardType(p.cardType);
141
- r.acquirerId = optStr(p.acquirerId);
142
- r.stan = optStr(p.stan);
143
- r.onlineId = optStr(p.onlineId);
144
- r.errorDescription = optStr(p.errorDescription);
145
- if (p.currency.applied) {
146
- CurrencyExchange ce; // the Nitro-generated struct
147
- ce.applied = true;
148
- ce.rate = optNum(p.currency.rate);
149
- ce.currencyCode = optStr(p.currency.currencyCode);
150
- ce.amountCents = optNum(p.currency.amount);
151
- ce.precision = optNum(p.currency.precision);
152
- r.currencyExchange = ce;
153
- }
154
- return r;
155
- }
156
-
157
- ReversalResult mapReversal(const PaymentResponse& p) {
158
- ReversalResult r;
159
- r.outcome = mapOutcome(p.outcome);
160
- r.resultCode = p.resultCode;
161
- r.pan = optStr(p.pan);
162
- r.entryMode = mapEntryMode(p.transactionType);
163
- r.hostDateTime = optStr(p.hostDateTime);
164
- r.cardType = mapCardType(p.cardType);
165
- r.acquirerId = optStr(p.acquirerId);
166
- r.stan = optStr(p.stan);
167
- r.onlineId = optStr(p.onlineId);
168
- r.errorDescription = optStr(p.errorDescription);
169
- return r;
170
- }
171
-
172
- CardVerificationResult mapCardVerify(const PaymentResponse& p) {
173
- CardVerificationResult r;
174
- r.outcome = mapOutcome(p.outcome);
175
- r.resultCode = p.resultCode;
176
- r.pan = optStr(p.pan);
177
- r.entryMode = mapEntryMode(p.transactionType);
178
- r.authCode = optStr(p.authCode);
179
- r.hostDateTime = optStr(p.hostDateTime);
180
- r.cardType = mapCardType(p.cardType);
181
- r.acquirerId = optStr(p.acquirerId);
182
- r.stan = optStr(p.stan);
183
- r.onlineId = optStr(p.onlineId);
184
- r.errorDescription = optStr(p.errorDescription);
185
- return r;
186
- }
187
-
188
- PreAuthResult mapPreAuth(const PreAuthResponse& p) {
189
- PreAuthResult r;
190
- r.outcome = mapOutcome(p.outcome);
191
- r.resultCode = p.resultCode;
192
- r.pan = optStr(p.pan);
193
- r.entryMode = mapEntryMode(p.transactionType);
194
- r.authCode = optStr(p.authCode);
195
- r.preAuthorizedAmountCents = optNum(p.preAuthorizedAmount);
196
- r.preAuthCode = optStr(p.preAuthCode);
197
- r.actionCode = optStr(p.actionCode);
198
- r.hostDateTime = optStr(p.hostDateTime);
199
- r.cardType = mapCardType(p.cardType);
200
- r.acquirerId = optStr(p.acquirerId);
201
- r.stan = optStr(p.stan);
202
- r.onlineId = optStr(p.onlineId);
203
- r.errorDescription = optStr(p.errorDescription);
204
- return r;
205
- }
206
-
207
- PosStatusResponse mapStatus(const StatusResponse& s) {
208
- PosStatusResponse r;
209
- r.terminalId = s.terminalId;
210
- r.status = static_cast<double>(s.status);
211
- r.softwareRelease = s.softwareRelease;
212
- // Parse "DDMMYYhhmm" into a time_point; fall back to epoch on bad input.
213
- std::chrono::system_clock::time_point tp{};
214
- if (s.dateTimeRaw.size() >= 10) {
215
- try {
216
- std::tm tm{};
217
- tm.tm_mday = std::stoi(s.dateTimeRaw.substr(0, 2));
218
- tm.tm_mon = std::stoi(s.dateTimeRaw.substr(2, 2)) - 1;
219
- tm.tm_year = 100 + std::stoi(s.dateTimeRaw.substr(4, 2)); // 20YY
220
- tm.tm_hour = std::stoi(s.dateTimeRaw.substr(6, 2));
221
- tm.tm_min = std::stoi(s.dateTimeRaw.substr(8, 2));
222
- tm.tm_isdst = -1;
223
- std::time_t t = std::mktime(&tm);
224
- if (t != -1) {
225
- tp = std::chrono::system_clock::from_time_t(t);
226
- }
227
- } catch (...) {
228
- // keep epoch
229
- }
230
- }
231
- r.terminalDateTime = tp;
232
- return r;
233
- }
234
-
235
- TotalsResult mapTotals(const TotalsResponse& t) {
236
- TotalsResult r;
237
- r.outcome = mapOutcome(t.outcome);
238
- r.resultCode = t.resultCode;
239
- r.posTotalCents = optNum(t.posTotal).value_or(0.0);
240
- return r;
241
- }
242
-
243
- CloseSessionResult mapClose(const CloseResponse& c) {
244
- CloseSessionResult r;
245
- r.outcome = mapOutcome(c.outcome);
246
- r.resultCode = c.resultCode;
247
- r.posTotalCents = optNum(c.posTotal);
248
- r.hostTotalCents = optNum(c.hostTotal);
249
- r.actionCode = optStr(c.actionCode);
250
- r.errorDescription = optStr(c.errorDescription);
251
- return r;
252
- }
253
-
254
- VasResult mapVas(const VasResponse& v) {
255
- VasResult r;
256
- r.responseId = v.responseId;
257
- r.responseMessage = v.responseMessage;
258
- r.orderId = optStr(v.orderId);
259
- r.rawXml = v.rawXml;
260
- return r;
261
- }
262
-
263
- } // namespace
264
-
265
- void HybridEcr17Client::configure(const Ecr17Config& config) {
266
- config_ = config;
267
- // Close any open socket before tearing down the old transport, otherwise the
268
- // prior native connection leaks until the HybridObject is collected.
269
- if (transport_) {
270
- transport_->disconnect();
271
- }
272
- // Force re-init so a new configuration rebuilds the session/timeouts.
273
- session_.reset();
274
- adapter_.reset();
275
- transport_.reset();
276
- // Create the transport HybridObject NOW, on this (JS) thread. createHybridObject
277
- // does a JNI FindClass for the Kotlin transport, which resolves only against the
278
- // app class loader — and the JS thread has it. Doing it lazily on a Nitro worker
279
- // thread (attached via ThreadScope) would use the system class loader and throw
280
- // ClassNotFoundException. fbjni caches the resolved jclass globally, so later
281
- // method calls from worker threads work (they only need a JNIEnv, see the guards).
282
- ensureInit();
283
- }
284
-
285
- Ecr17Config HybridEcr17Client::configuration() { return config_; }
286
-
287
- void HybridEcr17Client::ensureInit() {
288
- if (session_) {
289
- return;
290
- }
291
- auto obj = HybridObjectRegistry::createHybridObject("Ecr17Transport");
292
- // HybridObject is a *virtual* base, so static_pointer_cast can't downcast
293
- // from it — must use dynamic_pointer_cast.
294
- transport_ = std::dynamic_pointer_cast<HybridEcr17TransportSpec>(obj);
295
- if (!transport_) {
296
- throw std::runtime_error("ECR17: registry returned an incompatible Ecr17Transport object");
297
- }
298
- adapter_ = std::make_shared<NativeTransportAdapter>(transport_);
299
-
300
- SessionConfig sc;
301
- sc.lrcMode = config_.lrcMode.value_or(LrcMode::STD);
302
- sc.ackTimeoutMs = static_cast<int>(config_.ackTimeoutMs.value_or(2000));
303
- sc.responseTimeoutMs = static_cast<int>(config_.responseTimeoutMs.value_or(60000));
304
- sc.retryCount = static_cast<int>(config_.retryCount.value_or(3));
305
- sc.retryDelayMs = static_cast<int>(config_.retryDelayMs.value_or(200));
306
- sc.receiptDrainMs = static_cast<int>(config_.receiptDrainMs.value_or(0));
307
- session_ = std::make_unique<Ecr17Session>(*adapter_, sc);
308
-
309
- session_->setOnProgress([this](const std::string& message) {
310
- if (onProgress_) onProgress_(ProgressEvent{message});
311
- });
312
- session_->setOnReceiptLine([this](const std::string& line) {
313
- if (onReceiptLine_) onReceiptLine_(ReceiptLine{line});
314
- });
315
- }
316
-
317
- void HybridEcr17Client::ensureConnected() {
318
- // Run the transport JNI work under the app class loader (Android); inline on iOS.
319
- runOnJvmThread([&]() {
320
- ensureInit();
321
- // PROACTIVE reconnect: isConnected() performs a synchronous, non-destructive
322
- // liveness probe (Android: a 1-byte peek-with-pushback that detects a peer
323
- // FIN without writing to or consuming from the stream) so a peer-closed/
324
- // half-open socket — common because ECR17/Nexi terminals close TCP between
325
- // transactions — is detected HERE, before any command is sent. This is what
326
- // stops a financial command from being sent on a stale socket and then
327
- // hitting the (correct) money-safety "never replay" path with a FALSE
328
- // "transport disconnected".
329
- if (transport_->isConnected()) {
330
- return; // verified live — returns from this lambda only, no further JNI
331
- }
332
- // Auto-connect: block this worker thread until the native transport connects
333
- // (or throw on failure). keepAlive leaves the socket open for reuse.
334
- if (onConnectionStateChange_) onConnectionStateChange_(ConnectionState::CONNECTING);
335
- const double port = config_.port.value_or(10000);
336
- const double timeout = config_.connectionTimeoutMs.value_or(5000);
337
- try {
338
- transport_->connect(config_.host, port, timeout)->await().get();
339
- } catch (...) {
340
- // Don't leave listeners stuck on CONNECTING when the connection fails.
341
- if (onConnectionStateChange_) onConnectionStateChange_(ConnectionState::DISCONNECTED);
342
- throw;
343
- }
344
- if (onConnectionStateChange_) onConnectionStateChange_(ConnectionState::CONNECTED);
345
- });
346
- }
347
-
348
- std::string HybridEcr17Client::cashRegisterIdOr(const std::optional<std::string>& override) const {
349
- return override.value_or(config_.cashRegisterId);
350
- }
351
-
352
- DecodedPacket HybridEcr17Client::runTransaction(
353
- const std::string& mainPayload, const std::optional<TokenizationRequest>& tokenization,
354
- bool safeToRetry) {
355
- std::lock_guard<std::mutex> txLock(txMutex_); // serialize exchanges on the shared session
356
- auto doExchange = [&]() -> DecodedPacket {
357
- if (tokenization.has_value()) {
358
- const bool recurring = tokenization->service == TokenizationService::RECURRING;
359
- const std::string tag =
360
- Ecr17Protocol::formatTokenizationTag(recurring, tokenization->contractCode);
361
- const std::string additional =
362
- Ecr17Protocol::buildAdditionalTagsMessage(config_.terminalId, tag);
363
- return session_->exchangeWithAdditionalData(mainPayload, additional);
364
- }
365
- return session_->exchange(mainPayload);
366
- };
367
-
368
- // All exchange JNI (session_ -> adapter_ -> Kotlin transport, incl. the
369
- // ArrayBuffer lookup in send()) must run under the app class loader on Android.
370
- return runOnJvmThread([&]() -> DecodedPacket {
371
- try {
372
- return doExchange();
373
- } catch (const std::exception&) {
374
- const auto originalError = std::current_exception();
375
- const bool autoReconnect = config_.autoReconnect.value_or(false);
376
- const bool dropped = !transport_ || !transport_->isConnected();
377
- if (autoReconnect && dropped) {
378
- try {
379
- ensureConnected(); // restore the socket for subsequent commands
380
- } catch (...) {
381
- // Reconnect failed: surface the original exchange error, not the
382
- // reconnect failure (the former is what the caller needs to see).
383
- std::rethrow_exception(originalError);
384
- }
385
- }
386
- if (shouldRetryAfterReconnect(autoReconnect, dropped, safeToRetry)) {
387
- return doExchange(); // only read-only/idempotent ops may be replayed
388
- }
389
- throw; // financial op: surface the error (recover via sendLastResult / 'G')
390
- }
391
- });
392
- }
393
-
394
- void HybridEcr17Client::runAckOnly(const std::string& payload, bool safeToRetry) {
395
- std::lock_guard<std::mutex> txLock(txMutex_); // serialize exchanges on the shared session
396
- // Send under the app class loader on Android (ArrayBuffer lookup in send()).
397
- runOnJvmThread([&]() {
398
- try {
399
- session_->sendAckOnly(payload);
400
- } catch (const std::exception&) {
401
- const auto originalError = std::current_exception();
402
- const bool autoReconnect = config_.autoReconnect.value_or(false);
403
- const bool dropped = !transport_ || !transport_->isConnected();
404
- if (autoReconnect && dropped) {
405
- try {
406
- ensureConnected();
407
- } catch (...) {
408
- std::rethrow_exception(originalError); // surface the original error
409
- }
410
- }
411
- if (!shouldRetryAfterReconnect(autoReconnect, dropped, safeToRetry)) {
412
- throw; // not retryable: surface the original error
413
- }
414
- session_->sendAckOnly(payload); // read-only/idempotent op: safe to replay
415
- }
416
- });
417
- }
418
-
419
- std::shared_ptr<Promise<void>> HybridEcr17Client::connect() {
420
- // Delegate to ensureConnected so the explicit Connect path emits CONNECTING
421
- // and then CONNECTED on success (consistent with command auto-connect);
422
- // returning the raw transport promise would leave listeners stuck on
423
- // CONNECTING. Runs on a worker thread (ensureConnected blocks until ready).
424
- return Promise<void>::async([this]() { ensureConnected(); });
425
- }
426
-
427
- void HybridEcr17Client::disconnect() {
428
- if (transport_) {
429
- transport_->disconnect();
430
- }
431
- if (onConnectionStateChange_) onConnectionStateChange_(ConnectionState::DISCONNECTED);
432
- }
433
-
434
- bool HybridEcr17Client::isConnected() { return transport_ && transport_->isConnected(); }
435
-
436
- std::shared_ptr<Promise<PosStatusResponse>> HybridEcr17Client::status() {
437
- return Promise<PosStatusResponse>::async([this]() -> PosStatusResponse {
438
- ensureConnected();
439
- auto pkt = runTransaction(Ecr17Protocol::buildStatusMessage(config_.terminalId),
440
- std::nullopt, /*safeToRetry=*/true);
441
- return mapStatus(Ecr17Response::parseStatus(pkt.payload));
442
- });
443
- }
444
-
445
- std::shared_ptr<Promise<PaymentResult>> HybridEcr17Client::pay(const PaymentRequest& request) {
446
- return Promise<PaymentResult>::async([this, request]() -> PaymentResult {
447
- ensureConnected();
448
- const bool tok = request.tokenization.has_value();
449
- auto payload = Ecr17Protocol::buildPaymentMessage(
450
- config_.terminalId, cashRegisterIdOr(request.cashRegisterId),
451
- static_cast<int>(request.amountCents), mapPaymentType(request.paymentType),
452
- request.cardAlreadyPresent.value_or(false), tok, request.receiptText.value_or(""));
453
- auto pkt = runTransaction(payload, request.tokenization, false);
454
- return mapPayment(Ecr17Response::parsePayment(pkt.payload));
455
- });
456
- }
457
-
458
- std::shared_ptr<Promise<PaymentResult>> HybridEcr17Client::payExtended(const PaymentRequest& request) {
459
- return Promise<PaymentResult>::async([this, request]() -> PaymentResult {
460
- ensureConnected();
461
- const bool tok = request.tokenization.has_value();
462
- auto payload = Ecr17Protocol::buildExtendedPaymentMessage(
463
- config_.terminalId, cashRegisterIdOr(request.cashRegisterId),
464
- static_cast<int>(request.amountCents), mapPaymentType(request.paymentType),
465
- request.cardAlreadyPresent.value_or(false), tok, request.receiptText.value_or(""));
466
- auto pkt = runTransaction(payload, request.tokenization, false);
467
- return mapPayment(Ecr17Response::parsePayment(pkt.payload));
468
- });
469
- }
470
-
471
- std::shared_ptr<Promise<ReversalResult>> HybridEcr17Client::reverse(const ReversalRequest& request) {
472
- return Promise<ReversalResult>::async([this, request]() -> ReversalResult {
473
- ensureConnected();
474
- auto payload = Ecr17Protocol::buildReversalMessage(
475
- config_.terminalId, cashRegisterIdOr(request.cashRegisterId),
476
- request.stan.value_or("000000"));
477
- auto pkt = runTransaction(payload, std::nullopt, /*safeToRetry=*/false);
478
- return mapReversal(Ecr17Response::parsePayment(pkt.payload));
479
- });
480
- }
481
-
482
- std::shared_ptr<Promise<PreAuthResult>> HybridEcr17Client::preAuth(const PreAuthRequest& request) {
483
- return Promise<PreAuthResult>::async([this, request]() -> PreAuthResult {
484
- ensureConnected();
485
- const bool tok = request.tokenization.has_value();
486
- auto payload = Ecr17Protocol::buildPreAuthMessage(
487
- config_.terminalId, cashRegisterIdOr(request.cashRegisterId),
488
- static_cast<int>(request.amountCents), mapPaymentType(request.paymentType),
489
- request.cardAlreadyPresent.value_or(false), tok, request.receiptText.value_or(""));
490
- auto pkt = runTransaction(payload, request.tokenization, false);
491
- return mapPreAuth(Ecr17Response::parsePreAuth(pkt.payload));
492
- });
493
- }
494
-
495
- std::shared_ptr<Promise<PreAuthResult>> HybridEcr17Client::incrementalAuth(
496
- const IncrementalAuthRequest& request) {
497
- return Promise<PreAuthResult>::async([this, request]() -> PreAuthResult {
498
- ensureConnected();
499
- auto payload = Ecr17Protocol::buildIncrementalMessage(
500
- config_.terminalId, cashRegisterIdOr(request.cashRegisterId),
501
- static_cast<int>(request.amountCents), request.originalPreAuthCode, false,
502
- request.receiptText.value_or(""));
503
- auto pkt = runTransaction(payload, std::nullopt, /*safeToRetry=*/false);
504
- return mapPreAuth(Ecr17Response::parsePreAuth(pkt.payload));
505
- });
506
- }
507
-
508
- std::shared_ptr<Promise<PaymentResult>> HybridEcr17Client::preAuthClosure(
509
- const PreAuthClosureRequest& request) {
510
- return Promise<PaymentResult>::async([this, request]() -> PaymentResult {
511
- ensureConnected();
512
- auto payload = Ecr17Protocol::buildPreAuthClosureMessage(
513
- config_.terminalId, cashRegisterIdOr(request.cashRegisterId),
514
- static_cast<int>(request.amountCents), request.originalPreAuthCode, false,
515
- request.receiptText.value_or(""));
516
- auto pkt = runTransaction(payload, std::nullopt, /*safeToRetry=*/false);
517
- return mapPayment(Ecr17Response::parsePayment(pkt.payload));
518
- });
519
- }
520
-
521
- std::shared_ptr<Promise<CardVerificationResult>> HybridEcr17Client::verifyCard(
522
- const CardVerificationRequest& request) {
523
- return Promise<CardVerificationResult>::async([this, request]() -> CardVerificationResult {
524
- ensureConnected();
525
- const bool tok = request.tokenization.has_value();
526
- auto payload = Ecr17Protocol::buildCardVerificationMessage(
527
- config_.terminalId, cashRegisterIdOr(request.cashRegisterId),
528
- mapPaymentType(request.paymentType), tok);
529
- auto pkt = runTransaction(payload, request.tokenization, false);
530
- return mapCardVerify(Ecr17Response::parsePayment(pkt.payload));
531
- });
532
- }
533
-
534
- std::shared_ptr<Promise<CloseSessionResult>> HybridEcr17Client::closeSession() {
535
- return Promise<CloseSessionResult>::async([this]() -> CloseSessionResult {
536
- ensureConnected();
537
- auto payload = Ecr17Protocol::buildCloseSessionMessage(config_.terminalId, config_.cashRegisterId);
538
- auto pkt = runTransaction(payload, std::nullopt, /*safeToRetry=*/false);
539
- return mapClose(Ecr17Response::parseClose(pkt.payload));
540
- });
541
- }
542
-
543
- std::shared_ptr<Promise<TotalsResult>> HybridEcr17Client::totals() {
544
- return Promise<TotalsResult>::async([this]() -> TotalsResult {
545
- ensureConnected();
546
- auto payload = Ecr17Protocol::buildTotalsMessage(config_.terminalId, config_.cashRegisterId);
547
- auto pkt = runTransaction(payload, std::nullopt, /*safeToRetry=*/true);
548
- return mapTotals(Ecr17Response::parseTotals(pkt.payload));
549
- });
550
- }
551
-
552
- std::shared_ptr<Promise<PaymentResult>> HybridEcr17Client::sendLastResult() {
553
- return Promise<PaymentResult>::async([this]() -> PaymentResult {
554
- ensureConnected();
555
- auto payload = Ecr17Protocol::buildSendLastResultMessage(config_.terminalId, config_.cashRegisterId);
556
- auto pkt = runTransaction(payload, std::nullopt, /*safeToRetry=*/true);
557
- return mapPayment(Ecr17Response::parsePayment(pkt.payload));
558
- });
559
- }
560
-
561
- std::shared_ptr<Promise<void>> HybridEcr17Client::enableEcrPrinting(bool enabled) {
562
- return Promise<void>::async([this, enabled]() {
563
- ensureConnected();
564
- runAckOnly(Ecr17Protocol::buildEnableEcrPrintMessage(config_.terminalId, enabled),
565
- /*safeToRetry=*/true);
566
- });
567
- }
568
-
569
- std::shared_ptr<Promise<void>> HybridEcr17Client::reprint(bool toEcr) {
570
- return Promise<void>::async([this, toEcr]() {
571
- ensureConnected();
572
- runAckOnly(Ecr17Protocol::buildReprintMessage(config_.terminalId, toEcr),
573
- /*safeToRetry=*/false);
574
- });
575
- }
576
-
577
- std::shared_ptr<Promise<VasResult>> HybridEcr17Client::vas(const std::string& xmlRequest) {
578
- return Promise<VasResult>::async([this, xmlRequest]() -> VasResult {
579
- ensureConnected();
580
- auto payload = Ecr17Protocol::buildVasMessage(config_.terminalId, config_.cashRegisterId, xmlRequest);
581
- auto pkt = runTransaction(payload, std::nullopt, /*safeToRetry=*/false);
582
- return mapVas(Ecr17Response::parseVas(pkt.payload));
583
- });
584
- }
585
-
586
- void HybridEcr17Client::setOnProgress(const std::function<void(const ProgressEvent&)>& callback) {
587
- onProgress_ = callback;
588
- }
589
-
590
- void HybridEcr17Client::setOnReceiptLine(const std::function<void(const ReceiptLine&)>& callback) {
591
- onReceiptLine_ = callback;
592
- }
593
-
594
- void HybridEcr17Client::setOnConnectionStateChange(const std::function<void(ConnectionState)>& callback) {
595
- onConnectionStateChange_ = callback;
596
- }
597
-
598
- } // namespace margelo::nitro::ecr17
1
+ #include "HybridEcr17Client.hpp"
2
+
3
+ #include <NitroModules/HybridObjectRegistry.hpp>
4
+
5
+ #include <chrono>
6
+ #include <ctime>
7
+ #include <exception>
8
+ #include <optional>
9
+ #include <stdexcept>
10
+ #include <string>
11
+ #include <type_traits>
12
+ #include <utility>
13
+
14
+ #include "Ecr17Protocol/Ecr17Protocol.hpp"
15
+ #include "Ecr17Response/Ecr17Response.hpp"
16
+ #include "Session/RetryPolicy.hpp"
17
+
18
+ // Commands run on Nitro's C++ thread pool. On Android, those worker threads are
19
+ // NOT attached to the JVM, and — even once attached — JNI `FindClass` on an
20
+ // attached worker thread resolves against the *system* class loader, which can't
21
+ // see app/NitroModules classes (e.g. com.margelo.nitro.core.ArrayBuffer, looked
22
+ // up lazily by the generated transport bridge in `send()` via JArrayBuffer::wrap).
23
+ // That yields "Unable to retrieve jni environment" (no JNIEnv) or
24
+ // "ClassNotFoundException ... DexPathList[... /system/lib64 ...]" (wrong loader).
25
+ #ifdef __ANDROID__
26
+ #include <fbjni/fbjni.h>
27
+ #endif
28
+
29
+ namespace margelo::nitro::ecr17 {
30
+
31
+ namespace {
32
+
33
+ // Runs `fn` on Android under fbjni's ThreadScope::WithClassLoader, which attaches
34
+ // the current thread to the JVM AND installs fbjni's cached app class loader for
35
+ // the duration — so every JNI FindClass inside (including NitroModules' lazy
36
+ // ArrayBuffer lookup) resolves app classes, not the system loader. On iOS (no JVM)
37
+ // `fn` is just called directly. Returns whatever `fn` returns (incl. void) and
38
+ // propagates exceptions, so the caller's try/catch and return-value logic is
39
+ // unchanged. WithClassLoader takes a `std::function<void()>`, so on Android the
40
+ // result is captured in a local and any exception via std::exception_ptr, then
41
+ // rethrown after the scope. `fn` is a lambda, so a `return` inside it returns from
42
+ // the lambda (not the caller) on BOTH platforms — safe in value-returning callers.
43
+ template <typename Fn>
44
+ auto runOnJvmThread(Fn&& fn) -> decltype(fn()) {
45
+ #ifdef __ANDROID__
46
+ using Ret = decltype(fn());
47
+ std::exception_ptr err;
48
+ if constexpr (std::is_void_v<Ret>) {
49
+ ::facebook::jni::ThreadScope::WithClassLoader([&]() {
50
+ try {
51
+ fn();
52
+ } catch (...) {
53
+ err = std::current_exception();
54
+ }
55
+ });
56
+ if (err) std::rethrow_exception(err);
57
+ } else {
58
+ std::optional<Ret> result;
59
+ ::facebook::jni::ThreadScope::WithClassLoader([&]() {
60
+ try {
61
+ result.emplace(fn());
62
+ } catch (...) {
63
+ err = std::current_exception();
64
+ }
65
+ });
66
+ if (err) std::rethrow_exception(err);
67
+ return std::move(*result);
68
+ }
69
+ #else
70
+ return fn();
71
+ #endif
72
+ }
73
+
74
+ } // namespace
75
+
76
+ using margelo::nitro::HybridObjectRegistry;
77
+ using margelo::nitro::Promise;
78
+
79
+ namespace {
80
+
81
+ std::optional<std::string> optStr(const std::string& s) {
82
+ return s.empty() ? std::nullopt : std::optional<std::string>(s);
83
+ }
84
+
85
+ std::optional<double> optNum(const std::string& s) {
86
+ if (s.empty()) {
87
+ return std::nullopt;
88
+ }
89
+ try {
90
+ return std::stod(s);
91
+ } catch (...) {
92
+ return std::nullopt;
93
+ }
94
+ }
95
+
96
+ TransactionOutcome mapOutcome(Outcome o) {
97
+ switch (o) {
98
+ case Outcome::Ok: return TransactionOutcome::OK;
99
+ case Outcome::Ko: return TransactionOutcome::KO;
100
+ case Outcome::CardNotPresent: return TransactionOutcome::CARDNOTPRESENT;
101
+ case Outcome::UnknownTag: return TransactionOutcome::UNKNOWNTAG;
102
+ default: return TransactionOutcome::UNKNOWN;
103
+ }
104
+ }
105
+
106
+ std::optional<CardType> mapCardType(const std::string& raw) {
107
+ if (raw == "1") return CardType::DEBIT;
108
+ if (raw == "2") return CardType::CREDIT;
109
+ if (raw == "3") return CardType::OTHER;
110
+ return std::nullopt;
111
+ }
112
+
113
+ std::optional<TransactionEntryMode> mapEntryMode(const std::string& raw) {
114
+ if (raw == "ICC") return TransactionEntryMode::ICC;
115
+ if (raw == "MAG") return TransactionEntryMode::MAG;
116
+ if (raw == "MAN") return TransactionEntryMode::MANUAL;
117
+ if (raw == "CLM") return TransactionEntryMode::CLESSMAG;
118
+ if (raw == "CLI") return TransactionEntryMode::CLESSICC;
119
+ return std::nullopt;
120
+ }
121
+
122
+ char mapPaymentType(const std::optional<PaymentCardType>& t) {
123
+ if (!t.has_value()) return '0';
124
+ switch (*t) {
125
+ case PaymentCardType::DEBIT: return '1';
126
+ case PaymentCardType::CREDIT: return '2';
127
+ case PaymentCardType::OTHER: return '3';
128
+ default: return '0'; // AUTO
129
+ }
130
+ }
131
+
132
+ PaymentResult mapPayment(const PaymentResponse& p) {
133
+ PaymentResult r;
134
+ r.outcome = mapOutcome(p.outcome);
135
+ r.resultCode = p.resultCode;
136
+ r.pan = optStr(p.pan);
137
+ r.entryMode = mapEntryMode(p.transactionType);
138
+ r.authCode = optStr(p.authCode);
139
+ r.hostDateTime = optStr(p.hostDateTime);
140
+ r.cardType = mapCardType(p.cardType);
141
+ r.acquirerId = optStr(p.acquirerId);
142
+ r.stan = optStr(p.stan);
143
+ r.onlineId = optStr(p.onlineId);
144
+ r.errorDescription = optStr(p.errorDescription);
145
+ if (p.currency.applied) {
146
+ CurrencyExchange ce; // the Nitro-generated struct
147
+ ce.applied = true;
148
+ ce.rate = optNum(p.currency.rate);
149
+ ce.currencyCode = optStr(p.currency.currencyCode);
150
+ ce.amountCents = optNum(p.currency.amount);
151
+ ce.precision = optNum(p.currency.precision);
152
+ r.currencyExchange = ce;
153
+ }
154
+ return r;
155
+ }
156
+
157
+ ReversalResult mapReversal(const PaymentResponse& p) {
158
+ ReversalResult r;
159
+ r.outcome = mapOutcome(p.outcome);
160
+ r.resultCode = p.resultCode;
161
+ r.pan = optStr(p.pan);
162
+ r.entryMode = mapEntryMode(p.transactionType);
163
+ r.hostDateTime = optStr(p.hostDateTime);
164
+ r.cardType = mapCardType(p.cardType);
165
+ r.acquirerId = optStr(p.acquirerId);
166
+ r.stan = optStr(p.stan);
167
+ r.onlineId = optStr(p.onlineId);
168
+ r.errorDescription = optStr(p.errorDescription);
169
+ return r;
170
+ }
171
+
172
+ CardVerificationResult mapCardVerify(const PaymentResponse& p) {
173
+ CardVerificationResult r;
174
+ r.outcome = mapOutcome(p.outcome);
175
+ r.resultCode = p.resultCode;
176
+ r.pan = optStr(p.pan);
177
+ r.entryMode = mapEntryMode(p.transactionType);
178
+ r.authCode = optStr(p.authCode);
179
+ r.hostDateTime = optStr(p.hostDateTime);
180
+ r.cardType = mapCardType(p.cardType);
181
+ r.acquirerId = optStr(p.acquirerId);
182
+ r.stan = optStr(p.stan);
183
+ r.onlineId = optStr(p.onlineId);
184
+ r.errorDescription = optStr(p.errorDescription);
185
+ return r;
186
+ }
187
+
188
+ PreAuthResult mapPreAuth(const PreAuthResponse& p) {
189
+ PreAuthResult r;
190
+ r.outcome = mapOutcome(p.outcome);
191
+ r.resultCode = p.resultCode;
192
+ r.pan = optStr(p.pan);
193
+ r.entryMode = mapEntryMode(p.transactionType);
194
+ r.authCode = optStr(p.authCode);
195
+ r.preAuthorizedAmountCents = optNum(p.preAuthorizedAmount);
196
+ r.preAuthCode = optStr(p.preAuthCode);
197
+ r.actionCode = optStr(p.actionCode);
198
+ r.hostDateTime = optStr(p.hostDateTime);
199
+ r.cardType = mapCardType(p.cardType);
200
+ r.acquirerId = optStr(p.acquirerId);
201
+ r.stan = optStr(p.stan);
202
+ r.onlineId = optStr(p.onlineId);
203
+ r.errorDescription = optStr(p.errorDescription);
204
+ return r;
205
+ }
206
+
207
+ PosStatusResponse mapStatus(const StatusResponse& s) {
208
+ PosStatusResponse r;
209
+ r.terminalId = s.terminalId;
210
+ r.status = static_cast<double>(s.status);
211
+ r.softwareRelease = s.softwareRelease;
212
+ // Parse "DDMMYYhhmm" into a time_point; fall back to epoch on bad input.
213
+ std::chrono::system_clock::time_point tp{};
214
+ if (s.dateTimeRaw.size() >= 10) {
215
+ try {
216
+ std::tm tm{};
217
+ tm.tm_mday = std::stoi(s.dateTimeRaw.substr(0, 2));
218
+ tm.tm_mon = std::stoi(s.dateTimeRaw.substr(2, 2)) - 1;
219
+ tm.tm_year = 100 + std::stoi(s.dateTimeRaw.substr(4, 2)); // 20YY
220
+ tm.tm_hour = std::stoi(s.dateTimeRaw.substr(6, 2));
221
+ tm.tm_min = std::stoi(s.dateTimeRaw.substr(8, 2));
222
+ tm.tm_isdst = -1;
223
+ std::time_t t = std::mktime(&tm);
224
+ if (t != -1) {
225
+ tp = std::chrono::system_clock::from_time_t(t);
226
+ }
227
+ } catch (...) {
228
+ // keep epoch
229
+ }
230
+ }
231
+ r.terminalDateTime = tp;
232
+ return r;
233
+ }
234
+
235
+ TotalsResult mapTotals(const TotalsResponse& t) {
236
+ TotalsResult r;
237
+ r.outcome = mapOutcome(t.outcome);
238
+ r.resultCode = t.resultCode;
239
+ r.posTotalCents = optNum(t.posTotal).value_or(0.0);
240
+ return r;
241
+ }
242
+
243
+ CloseSessionResult mapClose(const CloseResponse& c) {
244
+ CloseSessionResult r;
245
+ r.outcome = mapOutcome(c.outcome);
246
+ r.resultCode = c.resultCode;
247
+ r.posTotalCents = optNum(c.posTotal);
248
+ r.hostTotalCents = optNum(c.hostTotal);
249
+ r.actionCode = optStr(c.actionCode);
250
+ r.errorDescription = optStr(c.errorDescription);
251
+ return r;
252
+ }
253
+
254
+ VasResult mapVas(const VasResponse& v) {
255
+ VasResult r;
256
+ r.responseId = v.responseId;
257
+ r.responseMessage = v.responseMessage;
258
+ r.orderId = optStr(v.orderId);
259
+ r.rawXml = v.rawXml;
260
+ return r;
261
+ }
262
+
263
+ } // namespace
264
+
265
+ void HybridEcr17Client::configure(const Ecr17Config& config) {
266
+ config_ = config;
267
+ // Close any open socket before tearing down the old transport, otherwise the
268
+ // prior native connection leaks until the HybridObject is collected.
269
+ if (transport_) {
270
+ transport_->disconnect();
271
+ }
272
+ // Force re-init so a new configuration rebuilds the session/timeouts.
273
+ session_.reset();
274
+ adapter_.reset();
275
+ transport_.reset();
276
+ // Create the transport HybridObject NOW, on this (JS) thread. createHybridObject
277
+ // does a JNI FindClass for the Kotlin transport, which resolves only against the
278
+ // app class loader — and the JS thread has it. Doing it lazily on a Nitro worker
279
+ // thread (attached via ThreadScope) would use the system class loader and throw
280
+ // ClassNotFoundException. fbjni caches the resolved jclass globally, so later
281
+ // method calls from worker threads work (they only need a JNIEnv, see the guards).
282
+ ensureInit();
283
+ }
284
+
285
+ Ecr17Config HybridEcr17Client::configuration() { return config_; }
286
+
287
+ void HybridEcr17Client::ensureInit() {
288
+ if (session_) {
289
+ return;
290
+ }
291
+ auto obj = HybridObjectRegistry::createHybridObject("Ecr17Transport");
292
+ // HybridObject is a *virtual* base, so static_pointer_cast can't downcast
293
+ // from it — must use dynamic_pointer_cast.
294
+ transport_ = std::dynamic_pointer_cast<HybridEcr17TransportSpec>(obj);
295
+ if (!transport_) {
296
+ throw std::runtime_error("ECR17: registry returned an incompatible Ecr17Transport object");
297
+ }
298
+ adapter_ = std::make_shared<NativeTransportAdapter>(transport_);
299
+
300
+ SessionConfig sc;
301
+ sc.lrcMode = config_.lrcMode.value_or(LrcMode::STD);
302
+ sc.ackTimeoutMs = static_cast<int>(config_.ackTimeoutMs.value_or(2000));
303
+ sc.responseTimeoutMs = static_cast<int>(config_.responseTimeoutMs.value_or(60000));
304
+ sc.retryCount = static_cast<int>(config_.retryCount.value_or(3));
305
+ sc.retryDelayMs = static_cast<int>(config_.retryDelayMs.value_or(200));
306
+ sc.receiptDrainMs = static_cast<int>(config_.receiptDrainMs.value_or(0));
307
+ session_ = std::make_unique<Ecr17Session>(*adapter_, sc);
308
+
309
+ session_->setOnProgress([this](const std::string& message) {
310
+ if (onProgress_) onProgress_(ProgressEvent{message});
311
+ });
312
+ session_->setOnReceiptLine([this](const std::string& line) {
313
+ if (onReceiptLine_) onReceiptLine_(ReceiptLine{line});
314
+ });
315
+ }
316
+
317
+ void HybridEcr17Client::ensureConnected() {
318
+ // Run the transport JNI work under the app class loader (Android); inline on iOS.
319
+ runOnJvmThread([&]() {
320
+ ensureInit();
321
+ // PROACTIVE reconnect: isConnected() performs a synchronous, non-destructive
322
+ // liveness probe (Android: a 1-byte peek-with-pushback that detects a peer
323
+ // FIN without writing to or consuming from the stream) so a peer-closed/
324
+ // half-open socket — common because ECR17/Nexi terminals close TCP between
325
+ // transactions — is detected HERE, before any command is sent. This is what
326
+ // stops a financial command from being sent on a stale socket and then
327
+ // hitting the (correct) money-safety "never replay" path with a FALSE
328
+ // "transport disconnected".
329
+ if (transport_->isConnected()) {
330
+ return; // verified live — returns from this lambda only, no further JNI
331
+ }
332
+ // Auto-connect: block this worker thread until the native transport connects
333
+ // (or throw on failure). keepAlive leaves the socket open for reuse.
334
+ if (onConnectionStateChange_) onConnectionStateChange_(ConnectionState::CONNECTING);
335
+ const double port = config_.port.value_or(10000);
336
+ const double timeout = config_.connectionTimeoutMs.value_or(5000);
337
+ try {
338
+ transport_->connect(config_.host, port, timeout)->await().get();
339
+ } catch (...) {
340
+ // Don't leave listeners stuck on CONNECTING when the connection fails.
341
+ if (onConnectionStateChange_) onConnectionStateChange_(ConnectionState::DISCONNECTED);
342
+ throw;
343
+ }
344
+ if (onConnectionStateChange_) onConnectionStateChange_(ConnectionState::CONNECTED);
345
+ });
346
+ }
347
+
348
+ std::string HybridEcr17Client::cashRegisterIdOr(const std::optional<std::string>& override) const {
349
+ return override.value_or(config_.cashRegisterId);
350
+ }
351
+
352
+ DecodedPacket HybridEcr17Client::runTransaction(
353
+ const std::string& mainPayload, const std::optional<TokenizationRequest>& tokenization,
354
+ bool safeToRetry) {
355
+ std::lock_guard<std::mutex> txLock(txMutex_); // serialize exchanges on the shared session
356
+ auto doExchange = [&]() -> DecodedPacket {
357
+ if (tokenization.has_value()) {
358
+ const bool recurring = tokenization->service == TokenizationService::RECURRING;
359
+ const std::string tag =
360
+ Ecr17Protocol::formatTokenizationTag(recurring, tokenization->contractCode);
361
+ const std::string additional =
362
+ Ecr17Protocol::buildAdditionalTagsMessage(config_.terminalId, tag);
363
+ return session_->exchangeWithAdditionalData(mainPayload, additional);
364
+ }
365
+ return session_->exchange(mainPayload);
366
+ };
367
+
368
+ // All exchange JNI (session_ -> adapter_ -> Kotlin transport, incl. the
369
+ // ArrayBuffer lookup in send()) must run under the app class loader on Android.
370
+ return runOnJvmThread([&]() -> DecodedPacket {
371
+ try {
372
+ return doExchange();
373
+ } catch (const std::exception&) {
374
+ const auto originalError = std::current_exception();
375
+ const bool autoReconnect = config_.autoReconnect.value_or(false);
376
+ const bool dropped = !transport_ || !transport_->isConnected();
377
+ if (autoReconnect && dropped) {
378
+ try {
379
+ ensureConnected(); // restore the socket for subsequent commands
380
+ } catch (...) {
381
+ // Reconnect failed: surface the original exchange error, not the
382
+ // reconnect failure (the former is what the caller needs to see).
383
+ std::rethrow_exception(originalError);
384
+ }
385
+ }
386
+ if (shouldRetryAfterReconnect(autoReconnect, dropped, safeToRetry)) {
387
+ return doExchange(); // only read-only/idempotent ops may be replayed
388
+ }
389
+ throw; // financial op: surface the error (recover via sendLastResult / 'G')
390
+ }
391
+ });
392
+ }
393
+
394
+ void HybridEcr17Client::runAckOnly(const std::string& payload, bool safeToRetry) {
395
+ std::lock_guard<std::mutex> txLock(txMutex_); // serialize exchanges on the shared session
396
+ // Send under the app class loader on Android (ArrayBuffer lookup in send()).
397
+ runOnJvmThread([&]() {
398
+ try {
399
+ session_->sendAckOnly(payload);
400
+ } catch (const std::exception&) {
401
+ const auto originalError = std::current_exception();
402
+ const bool autoReconnect = config_.autoReconnect.value_or(false);
403
+ const bool dropped = !transport_ || !transport_->isConnected();
404
+ if (autoReconnect && dropped) {
405
+ try {
406
+ ensureConnected();
407
+ } catch (...) {
408
+ std::rethrow_exception(originalError); // surface the original error
409
+ }
410
+ }
411
+ if (!shouldRetryAfterReconnect(autoReconnect, dropped, safeToRetry)) {
412
+ throw; // not retryable: surface the original error
413
+ }
414
+ session_->sendAckOnly(payload); // read-only/idempotent op: safe to replay
415
+ }
416
+ });
417
+ }
418
+
419
+ std::shared_ptr<Promise<void>> HybridEcr17Client::connect() {
420
+ // Delegate to ensureConnected so the explicit Connect path emits CONNECTING
421
+ // and then CONNECTED on success (consistent with command auto-connect);
422
+ // returning the raw transport promise would leave listeners stuck on
423
+ // CONNECTING. Runs on a worker thread (ensureConnected blocks until ready).
424
+ return Promise<void>::async([this]() { ensureConnected(); });
425
+ }
426
+
427
+ void HybridEcr17Client::disconnect() {
428
+ if (transport_) {
429
+ transport_->disconnect();
430
+ }
431
+ if (onConnectionStateChange_) onConnectionStateChange_(ConnectionState::DISCONNECTED);
432
+ }
433
+
434
+ bool HybridEcr17Client::isConnected() { return transport_ && transport_->isConnected(); }
435
+
436
+ std::shared_ptr<Promise<PosStatusResponse>> HybridEcr17Client::status() {
437
+ return Promise<PosStatusResponse>::async([this]() -> PosStatusResponse {
438
+ ensureConnected();
439
+ auto pkt = runTransaction(Ecr17Protocol::buildStatusMessage(config_.terminalId),
440
+ std::nullopt, /*safeToRetry=*/true);
441
+ return mapStatus(Ecr17Response::parseStatus(pkt.payload));
442
+ });
443
+ }
444
+
445
+ std::shared_ptr<Promise<PaymentResult>> HybridEcr17Client::pay(const PaymentRequest& request) {
446
+ return Promise<PaymentResult>::async([this, request]() -> PaymentResult {
447
+ ensureConnected();
448
+ const bool tok = request.tokenization.has_value();
449
+ auto payload = Ecr17Protocol::buildPaymentMessage(
450
+ config_.terminalId, cashRegisterIdOr(request.cashRegisterId),
451
+ static_cast<int>(request.amountCents), mapPaymentType(request.paymentType),
452
+ request.cardAlreadyPresent.value_or(false), tok, request.receiptText.value_or(""));
453
+ auto pkt = runTransaction(payload, request.tokenization, false);
454
+ return mapPayment(Ecr17Response::parsePayment(pkt.payload));
455
+ });
456
+ }
457
+
458
+ std::shared_ptr<Promise<PaymentResult>> HybridEcr17Client::payExtended(const PaymentRequest& request) {
459
+ return Promise<PaymentResult>::async([this, request]() -> PaymentResult {
460
+ ensureConnected();
461
+ const bool tok = request.tokenization.has_value();
462
+ auto payload = Ecr17Protocol::buildExtendedPaymentMessage(
463
+ config_.terminalId, cashRegisterIdOr(request.cashRegisterId),
464
+ static_cast<int>(request.amountCents), mapPaymentType(request.paymentType),
465
+ request.cardAlreadyPresent.value_or(false), tok, request.receiptText.value_or(""));
466
+ auto pkt = runTransaction(payload, request.tokenization, false);
467
+ return mapPayment(Ecr17Response::parsePayment(pkt.payload));
468
+ });
469
+ }
470
+
471
+ std::shared_ptr<Promise<ReversalResult>> HybridEcr17Client::reverse(const ReversalRequest& request) {
472
+ return Promise<ReversalResult>::async([this, request]() -> ReversalResult {
473
+ ensureConnected();
474
+ auto payload = Ecr17Protocol::buildReversalMessage(
475
+ config_.terminalId, cashRegisterIdOr(request.cashRegisterId),
476
+ request.stan.value_or("000000"));
477
+ auto pkt = runTransaction(payload, std::nullopt, /*safeToRetry=*/false);
478
+ return mapReversal(Ecr17Response::parsePayment(pkt.payload));
479
+ });
480
+ }
481
+
482
+ std::shared_ptr<Promise<PreAuthResult>> HybridEcr17Client::preAuth(const PreAuthRequest& request) {
483
+ return Promise<PreAuthResult>::async([this, request]() -> PreAuthResult {
484
+ ensureConnected();
485
+ const bool tok = request.tokenization.has_value();
486
+ auto payload = Ecr17Protocol::buildPreAuthMessage(
487
+ config_.terminalId, cashRegisterIdOr(request.cashRegisterId),
488
+ static_cast<int>(request.amountCents), mapPaymentType(request.paymentType),
489
+ request.cardAlreadyPresent.value_or(false), tok, request.receiptText.value_or(""));
490
+ auto pkt = runTransaction(payload, request.tokenization, false);
491
+ return mapPreAuth(Ecr17Response::parsePreAuth(pkt.payload));
492
+ });
493
+ }
494
+
495
+ std::shared_ptr<Promise<PreAuthResult>> HybridEcr17Client::incrementalAuth(
496
+ const IncrementalAuthRequest& request) {
497
+ return Promise<PreAuthResult>::async([this, request]() -> PreAuthResult {
498
+ ensureConnected();
499
+ auto payload = Ecr17Protocol::buildIncrementalMessage(
500
+ config_.terminalId, cashRegisterIdOr(request.cashRegisterId),
501
+ static_cast<int>(request.amountCents), request.originalPreAuthCode, false,
502
+ request.receiptText.value_or(""));
503
+ auto pkt = runTransaction(payload, std::nullopt, /*safeToRetry=*/false);
504
+ return mapPreAuth(Ecr17Response::parsePreAuth(pkt.payload));
505
+ });
506
+ }
507
+
508
+ std::shared_ptr<Promise<PaymentResult>> HybridEcr17Client::preAuthClosure(
509
+ const PreAuthClosureRequest& request) {
510
+ return Promise<PaymentResult>::async([this, request]() -> PaymentResult {
511
+ ensureConnected();
512
+ auto payload = Ecr17Protocol::buildPreAuthClosureMessage(
513
+ config_.terminalId, cashRegisterIdOr(request.cashRegisterId),
514
+ static_cast<int>(request.amountCents), request.originalPreAuthCode, false,
515
+ request.receiptText.value_or(""));
516
+ auto pkt = runTransaction(payload, std::nullopt, /*safeToRetry=*/false);
517
+ return mapPayment(Ecr17Response::parsePayment(pkt.payload));
518
+ });
519
+ }
520
+
521
+ std::shared_ptr<Promise<CardVerificationResult>> HybridEcr17Client::verifyCard(
522
+ const CardVerificationRequest& request) {
523
+ return Promise<CardVerificationResult>::async([this, request]() -> CardVerificationResult {
524
+ ensureConnected();
525
+ const bool tok = request.tokenization.has_value();
526
+ auto payload = Ecr17Protocol::buildCardVerificationMessage(
527
+ config_.terminalId, cashRegisterIdOr(request.cashRegisterId),
528
+ mapPaymentType(request.paymentType), tok);
529
+ auto pkt = runTransaction(payload, request.tokenization, false);
530
+ return mapCardVerify(Ecr17Response::parsePayment(pkt.payload));
531
+ });
532
+ }
533
+
534
+ std::shared_ptr<Promise<CloseSessionResult>> HybridEcr17Client::closeSession() {
535
+ return Promise<CloseSessionResult>::async([this]() -> CloseSessionResult {
536
+ ensureConnected();
537
+ auto payload = Ecr17Protocol::buildCloseSessionMessage(config_.terminalId, config_.cashRegisterId);
538
+ auto pkt = runTransaction(payload, std::nullopt, /*safeToRetry=*/false);
539
+ return mapClose(Ecr17Response::parseClose(pkt.payload));
540
+ });
541
+ }
542
+
543
+ std::shared_ptr<Promise<TotalsResult>> HybridEcr17Client::totals() {
544
+ return Promise<TotalsResult>::async([this]() -> TotalsResult {
545
+ ensureConnected();
546
+ auto payload = Ecr17Protocol::buildTotalsMessage(config_.terminalId, config_.cashRegisterId);
547
+ auto pkt = runTransaction(payload, std::nullopt, /*safeToRetry=*/true);
548
+ return mapTotals(Ecr17Response::parseTotals(pkt.payload));
549
+ });
550
+ }
551
+
552
+ std::shared_ptr<Promise<PaymentResult>> HybridEcr17Client::sendLastResult() {
553
+ return Promise<PaymentResult>::async([this]() -> PaymentResult {
554
+ ensureConnected();
555
+ auto payload = Ecr17Protocol::buildSendLastResultMessage(config_.terminalId, config_.cashRegisterId);
556
+ auto pkt = runTransaction(payload, std::nullopt, /*safeToRetry=*/true);
557
+ return mapPayment(Ecr17Response::parsePayment(pkt.payload));
558
+ });
559
+ }
560
+
561
+ std::shared_ptr<Promise<void>> HybridEcr17Client::enableEcrPrinting(bool enabled) {
562
+ return Promise<void>::async([this, enabled]() {
563
+ ensureConnected();
564
+ runAckOnly(Ecr17Protocol::buildEnableEcrPrintMessage(config_.terminalId, enabled),
565
+ /*safeToRetry=*/true);
566
+ });
567
+ }
568
+
569
+ std::shared_ptr<Promise<void>> HybridEcr17Client::reprint(bool toEcr) {
570
+ return Promise<void>::async([this, toEcr]() {
571
+ ensureConnected();
572
+ runAckOnly(Ecr17Protocol::buildReprintMessage(config_.terminalId, toEcr),
573
+ /*safeToRetry=*/false);
574
+ });
575
+ }
576
+
577
+ std::shared_ptr<Promise<VasResult>> HybridEcr17Client::vas(const std::string& xmlRequest) {
578
+ return Promise<VasResult>::async([this, xmlRequest]() -> VasResult {
579
+ ensureConnected();
580
+ auto payload = Ecr17Protocol::buildVasMessage(config_.terminalId, config_.cashRegisterId, xmlRequest);
581
+ auto pkt = runTransaction(payload, std::nullopt, /*safeToRetry=*/false);
582
+ return mapVas(Ecr17Response::parseVas(pkt.payload));
583
+ });
584
+ }
585
+
586
+ void HybridEcr17Client::setOnProgress(const std::function<void(const ProgressEvent&)>& callback) {
587
+ onProgress_ = callback;
588
+ }
589
+
590
+ void HybridEcr17Client::setOnReceiptLine(const std::function<void(const ReceiptLine&)>& callback) {
591
+ onReceiptLine_ = callback;
592
+ }
593
+
594
+ void HybridEcr17Client::setOnConnectionStateChange(const std::function<void(ConnectionState)>& callback) {
595
+ onConnectionStateChange_ = callback;
596
+ }
597
+
598
+ } // namespace margelo::nitro::ecr17