@fire-spark/payments-clisitef-client 0.0.0-stage → 0.7.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.
- package/android/build.gradle +20 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/dev/firespark/payments/clisitef/BoundedRunner.kt +141 -0
- package/android/src/main/java/dev/firespark/payments/clisitef/CliSiTefBridge.kt +164 -0
- package/android/src/main/java/dev/firespark/payments/clisitef/CliSiTefModule.kt +73 -0
- package/android/src/main/java/dev/firespark/payments/clisitef/CliSiTefRecords.kt +47 -0
- package/app.plugin.js +1 -0
- package/dist/index.d.mts +199 -0
- package/dist/index.mjs +312 -0
- package/expo-module.config.json +6 -0
- package/package.json +61 -3
- package/plugin/CLSIT +21 -0
- package/plugin/withClisitef.cjs +94 -0
- package/README.md +0 -3
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
plugins {
|
|
2
|
+
id 'com.android.library'
|
|
3
|
+
id 'expo-module-gradle-plugin'
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
group = 'dev.firespark.payments.clisitef'
|
|
7
|
+
version = '0.1.0'
|
|
8
|
+
|
|
9
|
+
android {
|
|
10
|
+
namespace "dev.firespark.payments.clisitef"
|
|
11
|
+
defaultConfig {
|
|
12
|
+
versionCode 1
|
|
13
|
+
versionName "0.1.0"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
dependencies {
|
|
18
|
+
// The app module ships the JAR (copied by the config plugin); this module only compiles against it.
|
|
19
|
+
compileOnly files("${rootProject.projectDir}/app/libs/clisitef-android.jar")
|
|
20
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
package dev.firespark.payments.clisitef
|
|
2
|
+
|
|
3
|
+
import android.util.Log
|
|
4
|
+
import expo.modules.kotlin.Promise
|
|
5
|
+
import java.util.concurrent.ExecutorService
|
|
6
|
+
import java.util.concurrent.Executors
|
|
7
|
+
import java.util.concurrent.RejectedExecutionException
|
|
8
|
+
import java.util.concurrent.ThreadFactory
|
|
9
|
+
import java.util.concurrent.TimeUnit
|
|
10
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
11
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
12
|
+
import org.json.JSONObject
|
|
13
|
+
|
|
14
|
+
const val TIMEOUT_CODE = "CLISITEF_TIMEOUT"
|
|
15
|
+
const val ERROR_CODE = "CLISITEF_ERROR"
|
|
16
|
+
|
|
17
|
+
internal enum class Lane {
|
|
18
|
+
/** The library is stateful and not thread-safe: one ordered thread, replaced when it hangs. */
|
|
19
|
+
SERIAL,
|
|
20
|
+
/** `abortTransaction` is `synchronized` in the JAR: if the hung call holds the monitor, the next abort needs another thread. */
|
|
21
|
+
ABORT,
|
|
22
|
+
/** `closePinPad` / `disconnectSitef` are not `synchronized` and make a hung call's I/O fail. */
|
|
23
|
+
RECOVERY
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Runs each CliSiTef call off the JS thread with a ceiling. A hung JNI call cannot be killed; on
|
|
28
|
+
* timeout the promise is rejected and the serial thread is replaced so later calls do not queue
|
|
29
|
+
* behind it (legacy incident 2026-09-12/13: kiosks dead for hours behind a stuck thread).
|
|
30
|
+
*/
|
|
31
|
+
internal class BoundedRunner {
|
|
32
|
+
@Volatile private var serial: ExecutorService = Executors.newSingleThreadExecutor(daemon("clisitef-native"))
|
|
33
|
+
private val abort: ExecutorService = Executors.newCachedThreadPool(daemon("clisitef-abort"))
|
|
34
|
+
private val recovery: ExecutorService = Executors.newCachedThreadPool(daemon("clisitef-recovery"))
|
|
35
|
+
private val watchdog = Executors.newSingleThreadScheduledExecutor(daemon("clisitef-watchdog"))
|
|
36
|
+
private val consecutiveTimeouts = AtomicInteger(0)
|
|
37
|
+
private val executorReplacements = AtomicInteger(0)
|
|
38
|
+
@Volatile private var lastTimeoutOperation: String? = null
|
|
39
|
+
@Volatile private var lastCallOperation: String? = null
|
|
40
|
+
@Volatile private var lastCallDurationMs = 0L
|
|
41
|
+
|
|
42
|
+
fun run(operation: String, timeoutMs: Long, promise: Promise, lane: Lane = Lane.SERIAL, block: () -> Any?) {
|
|
43
|
+
val settled = AtomicBoolean(false)
|
|
44
|
+
val startedAt = System.currentTimeMillis()
|
|
45
|
+
val target = when (lane) {
|
|
46
|
+
Lane.SERIAL -> serial
|
|
47
|
+
Lane.ABORT -> abort
|
|
48
|
+
Lane.RECOVERY -> recovery
|
|
49
|
+
}
|
|
50
|
+
// TODO(debug-clisitef): remove.
|
|
51
|
+
Log.d(DEBUG_TAG, "runner.$operation › queued lane=$lane timeoutMs=$timeoutMs")
|
|
52
|
+
val timer = if (timeoutMs > 0) {
|
|
53
|
+
watchdog.schedule({
|
|
54
|
+
if (settled.compareAndSet(false, true)) {
|
|
55
|
+
val count = consecutiveTimeouts.incrementAndGet()
|
|
56
|
+
lastTimeoutOperation = operation
|
|
57
|
+
val replaced = lane == Lane.SERIAL && replaceSerial(target)
|
|
58
|
+
Log.w(TAG, "$operation timed out after ${timeoutMs}ms consecutive=$count executorReplaced=$replaced")
|
|
59
|
+
promise.reject(TIMEOUT_CODE, timeoutJson(operation, timeoutMs, count, replaced), null)
|
|
60
|
+
}
|
|
61
|
+
}, timeoutMs, TimeUnit.MILLISECONDS)
|
|
62
|
+
} else {
|
|
63
|
+
null
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
target.execute {
|
|
67
|
+
// TODO(debug-clisitef): remove.
|
|
68
|
+
Log.d(DEBUG_TAG, "runner.$operation › running thread=${Thread.currentThread().name} waited=${System.currentTimeMillis() - startedAt}ms")
|
|
69
|
+
val outcome = runCatching(block)
|
|
70
|
+
timer?.cancel(false)
|
|
71
|
+
if (!settled.compareAndSet(false, true)) {
|
|
72
|
+
Log.w(TAG, "$operation returned late after ${System.currentTimeMillis() - startedAt}ms; result discarded")
|
|
73
|
+
return@execute
|
|
74
|
+
}
|
|
75
|
+
lastCallOperation = operation
|
|
76
|
+
lastCallDurationMs = System.currentTimeMillis() - startedAt
|
|
77
|
+
// TODO(debug-clisitef): remove.
|
|
78
|
+
Log.d(DEBUG_TAG, "runner.$operation › settled ok=${outcome.isSuccess} value=${outcome.getOrNull()} error=${outcome.exceptionOrNull()?.message} ms=$lastCallDurationMs")
|
|
79
|
+
outcome
|
|
80
|
+
.onSuccess {
|
|
81
|
+
consecutiveTimeouts.set(0)
|
|
82
|
+
promise.resolve(it)
|
|
83
|
+
}
|
|
84
|
+
.onFailure { promise.reject(ERROR_CODE, it.message ?: "$operation failed", it) }
|
|
85
|
+
}
|
|
86
|
+
} catch (error: RejectedExecutionException) {
|
|
87
|
+
timer?.cancel(false)
|
|
88
|
+
if (settled.compareAndSet(false, true)) {
|
|
89
|
+
promise.reject(ERROR_CODE, "executor rejected $operation: ${error.message}", error)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fun health(appDirectory: String?): Map<String, Any?> = mapOf(
|
|
95
|
+
"consecutiveTimeouts" to consecutiveTimeouts.get(),
|
|
96
|
+
"executorReplacements" to executorReplacements.get(),
|
|
97
|
+
"lastTimeoutOperation" to lastTimeoutOperation,
|
|
98
|
+
"lastCallOperation" to lastCallOperation,
|
|
99
|
+
"lastCallDurationMs" to lastCallDurationMs,
|
|
100
|
+
"appDirectory" to appDirectory
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
fun shutdown() {
|
|
104
|
+
serial.shutdown()
|
|
105
|
+
abort.shutdown()
|
|
106
|
+
recovery.shutdown()
|
|
107
|
+
watchdog.shutdownNow()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The old thread is not interrupted: an interrupt does not free a blocking JNI call. */
|
|
111
|
+
@Synchronized
|
|
112
|
+
private fun replaceSerial(stuck: ExecutorService): Boolean {
|
|
113
|
+
if (serial !== stuck) return false
|
|
114
|
+
serial = Executors.newSingleThreadExecutor(daemon("clisitef-native"))
|
|
115
|
+
executorReplacements.incrementAndGet()
|
|
116
|
+
stuck.shutdown()
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private fun timeoutJson(operation: String, timeoutMs: Long, count: Int, replaced: Boolean): String =
|
|
121
|
+
JSONObject()
|
|
122
|
+
.put("operation", operation)
|
|
123
|
+
.put("timeoutMs", timeoutMs)
|
|
124
|
+
.put("consecutiveTimeouts", count)
|
|
125
|
+
.put("executorReplaced", replaced)
|
|
126
|
+
.put("executorReplacements", executorReplacements.get())
|
|
127
|
+
.toString()
|
|
128
|
+
|
|
129
|
+
private companion object {
|
|
130
|
+
const val TAG = "CliSiTef"
|
|
131
|
+
// TODO(debug-clisitef): remove.
|
|
132
|
+
const val DEBUG_TAG = "CLISITEF_DEBUG"
|
|
133
|
+
|
|
134
|
+
fun daemon(name: String): ThreadFactory {
|
|
135
|
+
val sequence = AtomicInteger(0)
|
|
136
|
+
return ThreadFactory { runnable ->
|
|
137
|
+
Thread(runnable, "$name-${sequence.incrementAndGet()}").apply { isDaemon = true }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
package dev.firespark.payments.clisitef
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import br.com.softwareexpress.sitef.android.CliSiTef
|
|
6
|
+
import br.com.softwareexpress.sitef.android.ICliSiTefListener
|
|
7
|
+
import java.io.File
|
|
8
|
+
|
|
9
|
+
/** The only class that touches `clisitef-android.jar`; signatures match the legacy kiosk bridge. */
|
|
10
|
+
internal class CliSiTefBridge(
|
|
11
|
+
private val context: Context,
|
|
12
|
+
private val onData: (DataEvent) -> Unit,
|
|
13
|
+
private val onResult: (stage: Int, resultCode: Int) -> Unit
|
|
14
|
+
) {
|
|
15
|
+
private val clisitef: CliSiTef
|
|
16
|
+
|
|
17
|
+
private val listener = object : ICliSiTefListener {
|
|
18
|
+
override fun onData(stage: Int, command: Int, fieldId: Int, minLength: Int, maxLength: Int, input: ByteArray?) {
|
|
19
|
+
val text = input?.let { String(it) } ?: ""
|
|
20
|
+
// TODO(debug-clisitef): remove.
|
|
21
|
+
debug("listener.onData", "stage=$stage command=$command fieldId=$fieldId min=$minLength max=$maxLength input=\"$text\"")
|
|
22
|
+
onData(DataEvent(stage, command, fieldId, minLength, maxLength, text))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
override fun onTransactionResult(stage: Int, resultCode: Int) {
|
|
26
|
+
// TODO(debug-clisitef): remove.
|
|
27
|
+
debug("listener.onTransactionResult", "stage=$stage resultCode=$resultCode")
|
|
28
|
+
onResult(stage, resultCode)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// TODO(debug-clisitef): remove with every `traced` / `debug` call.
|
|
33
|
+
private fun debug(scope: String, message: String) {
|
|
34
|
+
Log.d(DEBUG_TAG, "$scope › $message [thread=${Thread.currentThread().name}]")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// TODO(debug-clisitef): remove; call `block()` directly.
|
|
38
|
+
private fun <T> traced(scope: String, input: String, block: () -> T): T {
|
|
39
|
+
val startedAt = System.currentTimeMillis()
|
|
40
|
+
debug(scope, "→ jar in $input")
|
|
41
|
+
try {
|
|
42
|
+
val output = block()
|
|
43
|
+
debug(scope, "← jar out $output (${System.currentTimeMillis() - startedAt}ms)")
|
|
44
|
+
return output
|
|
45
|
+
} catch (error: Throwable) {
|
|
46
|
+
debug(scope, "✕ jar error ${error.javaClass.simpleName}: ${error.message} (${System.currentTimeMillis() - startedAt}ms)")
|
|
47
|
+
throw error
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
init {
|
|
52
|
+
// CliSiTef copies CLSIT on instantiation but does not overwrite an existing copy, and files/
|
|
53
|
+
// survives app updates; sync first so this APK's CLSIT wins.
|
|
54
|
+
syncClsit(context)
|
|
55
|
+
clisitef = CliSiTef(context)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
val appDirectory: String
|
|
59
|
+
get() = context.applicationInfo.dataDir ?: context.filesDir.parent ?: ""
|
|
60
|
+
|
|
61
|
+
fun configure(params: ConfigureParams): Int {
|
|
62
|
+
val additional = withAppDir(params.additionalParams, appDirectory)
|
|
63
|
+
val input = "serverIp=${params.serverIp} storeCode=${params.storeCode} terminal=${params.terminalNumber} additional=$additional"
|
|
64
|
+
return traced("configure", input) {
|
|
65
|
+
clisitef.configure(params.serverIp, params.storeCode, params.terminalNumber, additional)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fun start(params: StartParams): Int {
|
|
70
|
+
val input = "modality=${params.modality} value=${params.value} coupon=${params.fiscalCoupon} " +
|
|
71
|
+
"date=${params.fiscalDate} time=${params.time} operator=${params.operator} restrictions=${params.restrictions}"
|
|
72
|
+
return traced("startTransaction", input) {
|
|
73
|
+
clisitef.startTransaction(
|
|
74
|
+
listener,
|
|
75
|
+
params.modality,
|
|
76
|
+
params.value,
|
|
77
|
+
params.fiscalCoupon,
|
|
78
|
+
params.fiscalDate,
|
|
79
|
+
params.time,
|
|
80
|
+
params.operator,
|
|
81
|
+
params.restrictions
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fun continueTransaction(data: String): Int =
|
|
87
|
+
traced("continueTransaction", "data=\"$data\"") { clisitef.continueTransaction(data) }
|
|
88
|
+
|
|
89
|
+
/** Without coupon, date and time the library reuses the ones from `startTransaction`. */
|
|
90
|
+
fun finish(params: FinishParams): Int {
|
|
91
|
+
val full = params.fiscalCoupon.isNotEmpty() && params.fiscalDate.isNotEmpty() && params.time.isNotEmpty()
|
|
92
|
+
val input = "confirm=${params.confirm} full=$full coupon=${params.fiscalCoupon} " +
|
|
93
|
+
"date=${params.fiscalDate} time=${params.time} additional=${params.additionalParams}"
|
|
94
|
+
return traced("finishTransaction", input) {
|
|
95
|
+
if (!full) return@traced clisitef.finishTransaction(params.confirm)
|
|
96
|
+
clisitef.finishTransaction(params.confirm, params.fiscalCoupon, params.fiscalDate, params.time, params.additionalParams)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fun abort(continueFlag: Int): Int =
|
|
101
|
+
traced("abortTransaction", "continueFlag=$continueFlag") { clisitef.abortTransaction(continueFlag) }
|
|
102
|
+
|
|
103
|
+
fun pendingCount(fiscalDate: String, fiscalCoupon: String): Int =
|
|
104
|
+
traced("getQttPendingTransactions", "date=$fiscalDate coupon=$fiscalCoupon") {
|
|
105
|
+
clisitef.getQttPendingTransactions(fiscalDate, fiscalCoupon)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fun submitPending(): Int = traced("submitPendingMessages", "") { clisitef.submitPendingMessages() }
|
|
109
|
+
|
|
110
|
+
fun version(): String = traced("getVersion", "") { clisitef.getVersion() }
|
|
111
|
+
|
|
112
|
+
fun closePinPad(): Int = traced("closePinPad", "") { clisitef.closePinPad() }
|
|
113
|
+
|
|
114
|
+
fun disconnectSitef(): Int {
|
|
115
|
+
traced("disconnectSitef", "") { clisitef.disconnectSitef() }
|
|
116
|
+
return 0
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
companion object {
|
|
120
|
+
private const val TAG = "CliSiTef"
|
|
121
|
+
// TODO(debug-clisitef): remove.
|
|
122
|
+
private const val DEBUG_TAG = "CLISITEF_DEBUG"
|
|
123
|
+
private const val CLSIT = "CLSIT"
|
|
124
|
+
private val APP_DIR = Regex("DiretorioApp=[^;\\]]*")
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The library keeps pending confirmations and tables under `DiretorioApp`; a wrong
|
|
128
|
+
* applicationId there loses pending undo messages on restart (seen in the legacy staging APK).
|
|
129
|
+
*/
|
|
130
|
+
fun withAppDir(params: String, dataDir: String): String {
|
|
131
|
+
if (dataDir.isEmpty()) return params
|
|
132
|
+
val entry = "DiretorioApp=$dataDir"
|
|
133
|
+
if (APP_DIR.containsMatchIn(params)) return params.replace(APP_DIR, Regex.escapeReplacement(entry))
|
|
134
|
+
val trimmed = params.trim()
|
|
135
|
+
if (trimmed.isEmpty()) return "[$entry]"
|
|
136
|
+
if (trimmed.endsWith("]")) return "${trimmed.dropLast(1)};$entry]"
|
|
137
|
+
return "$trimmed;$entry"
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Idempotent and atomic; a missing or empty asset keeps the current file. */
|
|
141
|
+
private fun syncClsit(context: Context) {
|
|
142
|
+
val target = File(context.filesDir, CLSIT)
|
|
143
|
+
val temp = File(context.filesDir, "$CLSIT.new")
|
|
144
|
+
try {
|
|
145
|
+
val shipped = context.assets.open(CLSIT).use { it.readBytes() }
|
|
146
|
+
// TODO(debug-clisitef): remove.
|
|
147
|
+
Log.d(DEBUG_TAG, "syncClsit › shipped=${shipped.size}B target=${target.absolutePath} exists=${target.isFile}")
|
|
148
|
+
if (shipped.isEmpty()) return
|
|
149
|
+
if (target.isFile && target.readBytes().contentEquals(shipped)) return
|
|
150
|
+
temp.outputStream().use { out ->
|
|
151
|
+
out.write(shipped)
|
|
152
|
+
out.flush()
|
|
153
|
+
out.fd.sync()
|
|
154
|
+
}
|
|
155
|
+
if (!temp.renameTo(target) && !(target.delete() && temp.renameTo(target))) {
|
|
156
|
+
temp.delete()
|
|
157
|
+
}
|
|
158
|
+
} catch (error: Exception) {
|
|
159
|
+
Log.w(TAG, "CLSIT sync skipped: ${error.message}")
|
|
160
|
+
temp.delete()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
package dev.firespark.payments.clisitef
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.Promise
|
|
4
|
+
import expo.modules.kotlin.exception.Exceptions
|
|
5
|
+
import expo.modules.kotlin.modules.Module
|
|
6
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
7
|
+
|
|
8
|
+
class CliSiTefModule : Module() {
|
|
9
|
+
private val runner = BoundedRunner()
|
|
10
|
+
private var bridge: CliSiTefBridge? = null
|
|
11
|
+
|
|
12
|
+
private fun bridge(): CliSiTefBridge = synchronized(this) {
|
|
13
|
+
bridge?.let { return it }
|
|
14
|
+
val context = appContext.reactContext?.applicationContext ?: throw Exceptions.ReactContextLost()
|
|
15
|
+
// TODO(debug-clisitef): remove.
|
|
16
|
+
android.util.Log.d("CLISITEF_DEBUG", "module › creating bridge dataDir=${context.applicationInfo.dataDir}")
|
|
17
|
+
CliSiTefBridge(
|
|
18
|
+
context,
|
|
19
|
+
onData = { event -> sendEvent("onData", event.toMap()) },
|
|
20
|
+
onResult = { stage, code -> sendEvent("onTransactionResult", mapOf("stage" to stage, "resultCode" to code)) }
|
|
21
|
+
).also { bridge = it }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
override fun definition() = ModuleDefinition {
|
|
25
|
+
Name("CliSiTef")
|
|
26
|
+
|
|
27
|
+
Events("onData", "onTransactionResult")
|
|
28
|
+
|
|
29
|
+
AsyncFunction("configure") { params: ConfigureParams, promise: Promise ->
|
|
30
|
+
runner.run("configure", 15_000, promise) { bridge().configure(params) }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
AsyncFunction("startTransaction") { params: StartParams, promise: Promise ->
|
|
34
|
+
runner.run("startTransaction", 30_000, promise) { bridge().start(params) }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
AsyncFunction("continueTransaction") { data: String, promise: Promise ->
|
|
38
|
+
runner.run("continueTransaction", 0, promise) { bridge().continueTransaction(data) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
AsyncFunction("finishTransaction") { params: FinishParams, promise: Promise ->
|
|
42
|
+
runner.run("finishTransaction", 15_000, promise) { bridge().finish(params) }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
AsyncFunction("abortTransaction") { continueFlag: Int, promise: Promise ->
|
|
46
|
+
runner.run("abortTransaction", 15_000, promise, Lane.ABORT) { bridge().abort(continueFlag) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
AsyncFunction("getQttPendingTransactions") { fiscalDate: String, fiscalCoupon: String, promise: Promise ->
|
|
50
|
+
runner.run("getQttPendingTransactions", 15_000, promise) { bridge().pendingCount(fiscalDate, fiscalCoupon) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
AsyncFunction("submitPendingMessages") { promise: Promise ->
|
|
54
|
+
runner.run("submitPendingMessages", 15_000, promise) { bridge().submitPending() }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
AsyncFunction("getVersion") { promise: Promise ->
|
|
58
|
+
runner.run("getVersion", 15_000, promise) { bridge().version() }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
AsyncFunction("closePinPad") { promise: Promise ->
|
|
62
|
+
runner.run("closePinPad", 10_000, promise, Lane.RECOVERY) { bridge().closePinPad() }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
AsyncFunction("disconnectSitef") { promise: Promise ->
|
|
66
|
+
runner.run("disconnectSitef", 10_000, promise, Lane.RECOVERY) { bridge().disconnectSitef() }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
Function("getHealth") { runner.health(bridge?.appDirectory) }
|
|
70
|
+
|
|
71
|
+
OnDestroy { runner.shutdown() }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
package dev.firespark.payments.clisitef
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.records.Field
|
|
4
|
+
import expo.modules.kotlin.records.Record
|
|
5
|
+
|
|
6
|
+
class ConfigureParams : Record {
|
|
7
|
+
@Field val serverIp: String = ""
|
|
8
|
+
@Field val storeCode: String = ""
|
|
9
|
+
@Field val terminalNumber: String = ""
|
|
10
|
+
@Field val additionalParams: String = ""
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class StartParams : Record {
|
|
14
|
+
@Field val modality: Int = 0
|
|
15
|
+
@Field val value: String = ""
|
|
16
|
+
@Field val fiscalCoupon: String = ""
|
|
17
|
+
@Field val fiscalDate: String = ""
|
|
18
|
+
@Field val time: String = ""
|
|
19
|
+
@Field val operator: String = ""
|
|
20
|
+
@Field val restrictions: String = ""
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class FinishParams : Record {
|
|
24
|
+
@Field val confirm: Int = 0
|
|
25
|
+
@Field val fiscalCoupon: String = ""
|
|
26
|
+
@Field val fiscalDate: String = ""
|
|
27
|
+
@Field val time: String = ""
|
|
28
|
+
@Field val additionalParams: String = ""
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
data class DataEvent(
|
|
32
|
+
val stage: Int,
|
|
33
|
+
val command: Int,
|
|
34
|
+
val fieldId: Int,
|
|
35
|
+
val minLength: Int,
|
|
36
|
+
val maxLength: Int,
|
|
37
|
+
val input: String
|
|
38
|
+
) {
|
|
39
|
+
fun toMap(): Map<String, Any> = mapOf(
|
|
40
|
+
"stage" to stage,
|
|
41
|
+
"command" to command,
|
|
42
|
+
"fieldId" to fieldId,
|
|
43
|
+
"minLength" to minLength,
|
|
44
|
+
"maxLength" to maxLength,
|
|
45
|
+
"input" to input
|
|
46
|
+
)
|
|
47
|
+
}
|
package/app.plugin.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require("./plugin/withClisitef.cjs");
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
type ClisitefConfigureParams = {
|
|
3
|
+
serverIp: string;
|
|
4
|
+
storeCode: string;
|
|
5
|
+
terminalNumber: string;
|
|
6
|
+
/** `[TipoPinPad=...;ParmsClient=...]`; the native side sets `DiretorioApp`. */
|
|
7
|
+
additionalParams: string;
|
|
8
|
+
};
|
|
9
|
+
type ClisitefStartParams = {
|
|
10
|
+
modality: number;
|
|
11
|
+
/** Amount as SiTef expects it: `"123,45"`. */
|
|
12
|
+
value: string;
|
|
13
|
+
fiscalCoupon: string;
|
|
14
|
+
/** `YYYYMMDD` in the store time zone. */
|
|
15
|
+
fiscalDate: string;
|
|
16
|
+
/** `HHMMSS` in the store time zone. */
|
|
17
|
+
time: string;
|
|
18
|
+
operator: string;
|
|
19
|
+
restrictions: string;
|
|
20
|
+
};
|
|
21
|
+
type ClisitefFinishParams = {
|
|
22
|
+
/** 1 confirms the transaction, 0 undoes it. */
|
|
23
|
+
confirm: 0 | 1;
|
|
24
|
+
fiscalCoupon: string;
|
|
25
|
+
fiscalDate: string;
|
|
26
|
+
time: string;
|
|
27
|
+
additionalParams?: string;
|
|
28
|
+
};
|
|
29
|
+
/** One step of the interactive loop: a menu, message or data request. */
|
|
30
|
+
type ClisitefDataEvent = {
|
|
31
|
+
stage: number;
|
|
32
|
+
command: number;
|
|
33
|
+
fieldId: number;
|
|
34
|
+
minLength: number;
|
|
35
|
+
maxLength: number;
|
|
36
|
+
input: string;
|
|
37
|
+
};
|
|
38
|
+
type ClisitefResultEvent = {
|
|
39
|
+
stage: number;
|
|
40
|
+
resultCode: number;
|
|
41
|
+
};
|
|
42
|
+
type ClisitefHealth = {
|
|
43
|
+
consecutiveTimeouts: number;
|
|
44
|
+
executorReplacements: number;
|
|
45
|
+
lastTimeoutOperation: string | null;
|
|
46
|
+
lastCallOperation: string | null;
|
|
47
|
+
lastCallDurationMs: number;
|
|
48
|
+
/** `DiretorioApp` the library writes to; `null` until the bridge starts. */
|
|
49
|
+
appDirectory: string | null;
|
|
50
|
+
};
|
|
51
|
+
type ClisitefEvents = {
|
|
52
|
+
onData: (event: ClisitefDataEvent) => void;
|
|
53
|
+
onTransactionResult: (event: ClisitefResultEvent) => void;
|
|
54
|
+
};
|
|
55
|
+
type ClisitefSubscription = {
|
|
56
|
+
remove: () => void;
|
|
57
|
+
};
|
|
58
|
+
/** Shape of the Expo module registered as `CliSiTef` on Android. */
|
|
59
|
+
type ClisitefNativeModule = {
|
|
60
|
+
configure: (params: ClisitefConfigureParams) => Promise<number>;
|
|
61
|
+
startTransaction: (params: ClisitefStartParams) => Promise<number>;
|
|
62
|
+
continueTransaction: (data: string) => Promise<number>;
|
|
63
|
+
finishTransaction: (params: ClisitefFinishParams) => Promise<number>;
|
|
64
|
+
abortTransaction: (continueFlag: number) => Promise<number>;
|
|
65
|
+
getQttPendingTransactions: (fiscalDate: string, fiscalCoupon: string) => Promise<number>;
|
|
66
|
+
submitPendingMessages: () => Promise<number>;
|
|
67
|
+
getVersion: () => Promise<string>;
|
|
68
|
+
closePinPad: () => Promise<number>;
|
|
69
|
+
disconnectSitef: () => Promise<number>;
|
|
70
|
+
getHealth: () => ClisitefHealth;
|
|
71
|
+
addListener: <E extends keyof ClisitefEvents>(event: E, listener: ClisitefEvents[E]) => ClisitefSubscription;
|
|
72
|
+
};
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/client.d.ts
|
|
75
|
+
type ClisitefClientOptions = {
|
|
76
|
+
/** Extra wait after the native ceiling before the JS side gives up. */
|
|
77
|
+
graceMs?: number;
|
|
78
|
+
};
|
|
79
|
+
type ClisitefClient = {
|
|
80
|
+
readonly available: boolean;
|
|
81
|
+
/** Skips the native call when the config did not change since the last success. */
|
|
82
|
+
configure: (config: ClisitefConfigureParams) => Promise<number>;
|
|
83
|
+
/** Forces the next `configure` to reach the native side. */
|
|
84
|
+
invalidate: () => void;
|
|
85
|
+
startTransaction: (params: ClisitefStartParams) => Promise<number>;
|
|
86
|
+
continueTransaction: (data: string) => Promise<number>;
|
|
87
|
+
finishTransaction: (params: ClisitefFinishParams) => Promise<number>;
|
|
88
|
+
abortTransaction: (continueFlag: number) => Promise<number>;
|
|
89
|
+
getQttPendingTransactions: (fiscalDate: string, fiscalCoupon: string) => Promise<number>;
|
|
90
|
+
submitPendingMessages: () => Promise<number>;
|
|
91
|
+
getVersion: () => Promise<string>;
|
|
92
|
+
/** Recovery for a hung library: releases the pinpad without restarting the app. */
|
|
93
|
+
closePinPad: () => Promise<number>;
|
|
94
|
+
/** Recovery for a hung library: drops the SiTef connection so a blocked call fails. */
|
|
95
|
+
disconnectSitef: () => Promise<number>;
|
|
96
|
+
getHealth: () => ClisitefHealth | null;
|
|
97
|
+
onData: (listener: ClisitefEvents["onData"]) => ClisitefSubscription;
|
|
98
|
+
onTransactionResult: (listener: ClisitefEvents["onTransactionResult"]) => ClisitefSubscription;
|
|
99
|
+
};
|
|
100
|
+
declare const createClisitefClient: (native: ClisitefNativeModule | null, options?: ClisitefClientOptions) => ClisitefClient;
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region src/constants.d.ts
|
|
103
|
+
/** `startTransaction` modalities used by the Brazil kiosk. Voucher shares 2 with debit and differs by restrictions. */
|
|
104
|
+
declare const CLISITEF_MODALITY: {
|
|
105
|
+
readonly DEBIT: 2;
|
|
106
|
+
readonly CREDIT: 3;
|
|
107
|
+
readonly VOUCHER: 2;
|
|
108
|
+
readonly PIX: 122;
|
|
109
|
+
};
|
|
110
|
+
declare const CLISITEF_RESULT: {
|
|
111
|
+
readonly OK: 0;
|
|
112
|
+
readonly CONTINUE: 10000;
|
|
113
|
+
};
|
|
114
|
+
declare const CLISITEF_CONFIRM: {
|
|
115
|
+
readonly CONFIRM: 1;
|
|
116
|
+
readonly UNDO: 0;
|
|
117
|
+
};
|
|
118
|
+
/** `abortTransaction` flag that closes an unfinished iteration. */
|
|
119
|
+
declare const CLISITEF_ABORT_FLAG = -1;
|
|
120
|
+
declare const CLISITEF_TIMEOUT_CODE = "CLISITEF_TIMEOUT";
|
|
121
|
+
/** Native ceilings per operation; 0 = no ceiling (the loop waits on the customer). */
|
|
122
|
+
declare const CLISITEF_TIMEOUT_MS: {
|
|
123
|
+
readonly configure: 15000;
|
|
124
|
+
readonly startTransaction: 30000;
|
|
125
|
+
readonly continueTransaction: 0;
|
|
126
|
+
readonly finishTransaction: 15000;
|
|
127
|
+
readonly abortTransaction: 15000;
|
|
128
|
+
readonly getQttPendingTransactions: 15000;
|
|
129
|
+
readonly submitPendingMessages: 15000;
|
|
130
|
+
readonly getVersion: 15000;
|
|
131
|
+
readonly closePinPad: 10000;
|
|
132
|
+
readonly disconnectSitef: 10000;
|
|
133
|
+
};
|
|
134
|
+
type ClisitefOperation = keyof typeof CLISITEF_TIMEOUT_MS;
|
|
135
|
+
/** Extra time the JS backstop waits after the native ceiling before giving up. */
|
|
136
|
+
declare const CLISITEF_JS_GRACE_MS = 5000;
|
|
137
|
+
declare const CLISITEF_DEFAULT_TIME_ZONE = "America/Sao_Paulo";
|
|
138
|
+
declare const CLISITEF_DEFAULT_PINPAD = "ANDROID_USB";
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/debug.d.ts
|
|
141
|
+
/** Logs one step: `[CLISITEF_DEBUG] scope › event { data }`. */
|
|
142
|
+
declare const clisitefDebug: (scope: string, event: string, data?: unknown) => void;
|
|
143
|
+
/** Logs the input, the output or the error, and the duration of a call. */
|
|
144
|
+
declare const clisitefDebugCall: <T>(scope: string, input: unknown, call: () => Promise<T>) => Promise<T>;
|
|
145
|
+
//#endregion
|
|
146
|
+
//#region src/errors.d.ts
|
|
147
|
+
declare class ClisitefUnavailableError extends Error {
|
|
148
|
+
readonly code = "CLISITEF_UNAVAILABLE";
|
|
149
|
+
constructor();
|
|
150
|
+
}
|
|
151
|
+
type ClisitefTimeoutInfo = {
|
|
152
|
+
operation: string;
|
|
153
|
+
timeoutMs: number;
|
|
154
|
+
consecutiveTimeouts: number | null;
|
|
155
|
+
executorReplaced: boolean;
|
|
156
|
+
/** `native`: the Kotlin ceiling fired; `js`: the native call never settled. */
|
|
157
|
+
source: "native" | "js";
|
|
158
|
+
};
|
|
159
|
+
declare class ClisitefTimeoutError extends Error {
|
|
160
|
+
readonly code = "CLISITEF_TIMEOUT";
|
|
161
|
+
readonly info: ClisitefTimeoutInfo;
|
|
162
|
+
constructor(info: ClisitefTimeoutInfo);
|
|
163
|
+
}
|
|
164
|
+
/** Turns the native `CLISITEF_TIMEOUT` rejection (JSON message) into a typed error. */
|
|
165
|
+
declare const toClisitefTimeout: (error: unknown) => ClisitefTimeoutError | null;
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region src/helpers.d.ts
|
|
168
|
+
/** `123.4` → `"123,40"`. */
|
|
169
|
+
declare const formatClisitefAmount: (amount: number) => string;
|
|
170
|
+
type ClisitefAdditionalParamsInput = {
|
|
171
|
+
cnpjStore: string;
|
|
172
|
+
cnpjEnterprise: string;
|
|
173
|
+
pinpadType?: string;
|
|
174
|
+
};
|
|
175
|
+
/** `[TipoPinPad=ANDROID_USB;ParmsClient=1={cnpjStore};2={cnpjEnterprise}]`. */
|
|
176
|
+
declare const clisitefAdditionalParams: (input: ClisitefAdditionalParamsInput) => string;
|
|
177
|
+
/** Same config ⇒ same signature, so `configure` only runs when something changed. */
|
|
178
|
+
declare const clisitefConfigSignature: (config: ClisitefConfigureParams) => string;
|
|
179
|
+
type ClisitefFiscalDateTime = {
|
|
180
|
+
fiscalDate: string;
|
|
181
|
+
time: string;
|
|
182
|
+
};
|
|
183
|
+
/** `YYYYMMDD` / `HHMMSS` in the store time zone (not the device one). */
|
|
184
|
+
declare const clisitefFiscalDateTime: (date?: Date, timeZone?: string) => ClisitefFiscalDateTime;
|
|
185
|
+
type ClisitefMenuOption = {
|
|
186
|
+
value: string;
|
|
187
|
+
label: string;
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* Command 21 buffer, documented as `"1:Débito;2:Crédito;"`. Falls back to
|
|
191
|
+
* alternating `"1;Débito;2;Crédito;"` tokens.
|
|
192
|
+
*/
|
|
193
|
+
declare const parseClisitefMenu: (input: string) => ClisitefMenuOption[];
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/index.d.ts
|
|
196
|
+
/** Shared client bound to the device's native module; one per app. */
|
|
197
|
+
declare const clisitef: ClisitefClient;
|
|
198
|
+
//#endregion
|
|
199
|
+
export { CLISITEF_ABORT_FLAG, CLISITEF_CONFIRM, CLISITEF_DEFAULT_PINPAD, CLISITEF_DEFAULT_TIME_ZONE, CLISITEF_JS_GRACE_MS, CLISITEF_MODALITY, CLISITEF_RESULT, CLISITEF_TIMEOUT_CODE, CLISITEF_TIMEOUT_MS, ClisitefAdditionalParamsInput, ClisitefClient, ClisitefClientOptions, ClisitefConfigureParams, ClisitefDataEvent, ClisitefEvents, ClisitefFinishParams, ClisitefFiscalDateTime, ClisitefHealth, ClisitefMenuOption, ClisitefNativeModule, ClisitefOperation, ClisitefResultEvent, ClisitefStartParams, ClisitefSubscription, ClisitefTimeoutError, ClisitefTimeoutInfo, ClisitefUnavailableError, clisitef, clisitefAdditionalParams, clisitefConfigSignature, clisitefDebug, clisitefDebugCall, clisitefFiscalDateTime, createClisitefClient, formatClisitefAmount, parseClisitefMenu, toClisitefTimeout };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { requireOptionalNativeModule } from "expo";
|
|
2
|
+
//#region src/constants.ts
|
|
3
|
+
/** `startTransaction` modalities used by the Brazil kiosk. Voucher shares 2 with debit and differs by restrictions. */
|
|
4
|
+
const CLISITEF_MODALITY = {
|
|
5
|
+
DEBIT: 2,
|
|
6
|
+
CREDIT: 3,
|
|
7
|
+
VOUCHER: 2,
|
|
8
|
+
PIX: 122
|
|
9
|
+
};
|
|
10
|
+
const CLISITEF_RESULT = {
|
|
11
|
+
OK: 0,
|
|
12
|
+
CONTINUE: 1e4
|
|
13
|
+
};
|
|
14
|
+
const CLISITEF_CONFIRM = {
|
|
15
|
+
CONFIRM: 1,
|
|
16
|
+
UNDO: 0
|
|
17
|
+
};
|
|
18
|
+
/** `abortTransaction` flag that closes an unfinished iteration. */
|
|
19
|
+
const CLISITEF_ABORT_FLAG = -1;
|
|
20
|
+
const CLISITEF_TIMEOUT_CODE = "CLISITEF_TIMEOUT";
|
|
21
|
+
/** Native ceilings per operation; 0 = no ceiling (the loop waits on the customer). */
|
|
22
|
+
const CLISITEF_TIMEOUT_MS = {
|
|
23
|
+
configure: 15e3,
|
|
24
|
+
startTransaction: 3e4,
|
|
25
|
+
continueTransaction: 0,
|
|
26
|
+
finishTransaction: 15e3,
|
|
27
|
+
abortTransaction: 15e3,
|
|
28
|
+
getQttPendingTransactions: 15e3,
|
|
29
|
+
submitPendingMessages: 15e3,
|
|
30
|
+
getVersion: 15e3,
|
|
31
|
+
closePinPad: 1e4,
|
|
32
|
+
disconnectSitef: 1e4
|
|
33
|
+
};
|
|
34
|
+
/** Extra time the JS backstop waits after the native ceiling before giving up. */
|
|
35
|
+
const CLISITEF_JS_GRACE_MS = 5e3;
|
|
36
|
+
const CLISITEF_DEFAULT_TIME_ZONE = "America/Sao_Paulo";
|
|
37
|
+
const CLISITEF_DEFAULT_PINPAD = "ANDROID_USB";
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/debug.ts
|
|
40
|
+
const TAG = "[CLISITEF_DEBUG]";
|
|
41
|
+
const elapsed = (startedAt) => Date.now() - startedAt;
|
|
42
|
+
const safe = (value) => {
|
|
43
|
+
if (value instanceof Map) return Object.fromEntries(value);
|
|
44
|
+
if (value instanceof Error) {
|
|
45
|
+
const code = value.code;
|
|
46
|
+
return {
|
|
47
|
+
name: value.name,
|
|
48
|
+
message: value.message,
|
|
49
|
+
code
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
};
|
|
54
|
+
/** Logs one step: `[CLISITEF_DEBUG] scope › event { data }`. */
|
|
55
|
+
const clisitefDebug = (scope, event, data) => {
|
|
56
|
+
const time = (/* @__PURE__ */ new Date()).toISOString();
|
|
57
|
+
if (data === void 0) return console.log(TAG, time, `${scope} › ${event}`);
|
|
58
|
+
console.log(TAG, time, `${scope} › ${event}`, safe(data));
|
|
59
|
+
};
|
|
60
|
+
/** Logs the input, the output or the error, and the duration of a call. */
|
|
61
|
+
const clisitefDebugCall = async (scope, input, call) => {
|
|
62
|
+
const startedAt = Date.now();
|
|
63
|
+
clisitefDebug(scope, "→ in", input);
|
|
64
|
+
try {
|
|
65
|
+
const output = await call();
|
|
66
|
+
const ms = elapsed(startedAt);
|
|
67
|
+
clisitefDebug(scope, "← out", {
|
|
68
|
+
output: safe(output),
|
|
69
|
+
ms
|
|
70
|
+
});
|
|
71
|
+
return output;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const ms = elapsed(startedAt);
|
|
74
|
+
clisitefDebug(scope, "✕ error", {
|
|
75
|
+
error: safe(error),
|
|
76
|
+
ms
|
|
77
|
+
});
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/errors.ts
|
|
83
|
+
var ClisitefUnavailableError = class extends Error {
|
|
84
|
+
code = "CLISITEF_UNAVAILABLE";
|
|
85
|
+
constructor() {
|
|
86
|
+
super("CliSiTef native module is not available (Android build with the CliSiTef plugin required)");
|
|
87
|
+
this.name = "ClisitefUnavailableError";
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
var ClisitefTimeoutError = class extends Error {
|
|
91
|
+
code = CLISITEF_TIMEOUT_CODE;
|
|
92
|
+
info;
|
|
93
|
+
constructor(info) {
|
|
94
|
+
super(`CliSiTef ${info.operation} timed out after ${info.timeoutMs} ms`);
|
|
95
|
+
this.name = "ClisitefTimeoutError";
|
|
96
|
+
this.info = info;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const asRecord = (value) => {
|
|
100
|
+
if (typeof value !== "object" || value === null) return null;
|
|
101
|
+
return value;
|
|
102
|
+
};
|
|
103
|
+
/** Turns the native `CLISITEF_TIMEOUT` rejection (JSON message) into a typed error. */
|
|
104
|
+
const toClisitefTimeout = (error) => {
|
|
105
|
+
const record = asRecord(error);
|
|
106
|
+
if (!record || record.code !== "CLISITEF_TIMEOUT") return null;
|
|
107
|
+
let payload = {};
|
|
108
|
+
try {
|
|
109
|
+
payload = asRecord(JSON.parse(String(record.message))) ?? {};
|
|
110
|
+
} catch {
|
|
111
|
+
payload = {};
|
|
112
|
+
}
|
|
113
|
+
return new ClisitefTimeoutError({
|
|
114
|
+
operation: typeof payload.operation === "string" ? payload.operation : "unknown",
|
|
115
|
+
timeoutMs: typeof payload.timeoutMs === "number" ? payload.timeoutMs : 0,
|
|
116
|
+
consecutiveTimeouts: typeof payload.consecutiveTimeouts === "number" ? payload.consecutiveTimeouts : null,
|
|
117
|
+
executorReplaced: payload.executorReplaced === true,
|
|
118
|
+
source: "native"
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/helpers.ts
|
|
123
|
+
/** `123.4` → `"123,40"`. */
|
|
124
|
+
const formatClisitefAmount = (amount) => {
|
|
125
|
+
if (!Number.isFinite(amount) || amount < 0) throw new RangeError(`Invalid CliSiTef amount: ${amount}`);
|
|
126
|
+
return amount.toFixed(2).replace(".", ",");
|
|
127
|
+
};
|
|
128
|
+
const digits = (value) => value.replace(/\D/g, "");
|
|
129
|
+
/** `[TipoPinPad=ANDROID_USB;ParmsClient=1={cnpjStore};2={cnpjEnterprise}]`. */
|
|
130
|
+
const clisitefAdditionalParams = (input) => {
|
|
131
|
+
return `[TipoPinPad=${input.pinpadType ?? "ANDROID_USB"};ParmsClient=1=${digits(input.cnpjStore)};2=${digits(input.cnpjEnterprise)}]`;
|
|
132
|
+
};
|
|
133
|
+
/** Same config ⇒ same signature, so `configure` only runs when something changed. */
|
|
134
|
+
const clisitefConfigSignature = (config) => {
|
|
135
|
+
const { serverIp, storeCode, terminalNumber, additionalParams } = config;
|
|
136
|
+
return [
|
|
137
|
+
serverIp,
|
|
138
|
+
storeCode,
|
|
139
|
+
terminalNumber,
|
|
140
|
+
additionalParams
|
|
141
|
+
].map((value) => value.trim()).join("|");
|
|
142
|
+
};
|
|
143
|
+
const formatters = /* @__PURE__ */ new Map();
|
|
144
|
+
const formatterFor = (timeZone) => {
|
|
145
|
+
const cached = formatters.get(timeZone);
|
|
146
|
+
if (cached) return cached;
|
|
147
|
+
const options = {
|
|
148
|
+
timeZone,
|
|
149
|
+
year: "numeric",
|
|
150
|
+
month: "2-digit",
|
|
151
|
+
day: "2-digit",
|
|
152
|
+
hour: "2-digit",
|
|
153
|
+
minute: "2-digit",
|
|
154
|
+
second: "2-digit",
|
|
155
|
+
hourCycle: "h23"
|
|
156
|
+
};
|
|
157
|
+
const formatter = new Intl.DateTimeFormat("en-US", options);
|
|
158
|
+
formatters.set(timeZone, formatter);
|
|
159
|
+
return formatter;
|
|
160
|
+
};
|
|
161
|
+
/** `YYYYMMDD` / `HHMMSS` in the store time zone (not the device one). */
|
|
162
|
+
const clisitefFiscalDateTime = (date = /* @__PURE__ */ new Date(), timeZone = CLISITEF_DEFAULT_TIME_ZONE) => {
|
|
163
|
+
const parts = Object.fromEntries(formatterFor(timeZone).formatToParts(date).map((part) => [part.type, part.value]));
|
|
164
|
+
return {
|
|
165
|
+
fiscalDate: `${parts.year}${parts.month}${parts.day}`,
|
|
166
|
+
time: `${parts.hour}${parts.minute}${parts.second}`
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
const MENU_ITEM = /^(\d+):(.+)$/;
|
|
170
|
+
/**
|
|
171
|
+
* Command 21 buffer, documented as `"1:Débito;2:Crédito;"`. Falls back to
|
|
172
|
+
* alternating `"1;Débito;2;Crédito;"` tokens.
|
|
173
|
+
*/
|
|
174
|
+
const parseClisitefMenu = (input) => {
|
|
175
|
+
const tokens = input.split(";").map((token) => token.trim());
|
|
176
|
+
const options = [];
|
|
177
|
+
for (const token of tokens) {
|
|
178
|
+
const match = MENU_ITEM.exec(token);
|
|
179
|
+
const value = match?.[1];
|
|
180
|
+
const label = match?.[2]?.trim();
|
|
181
|
+
if (value && label) options.push({
|
|
182
|
+
value,
|
|
183
|
+
label
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (options.length > 0) return options;
|
|
187
|
+
for (let i = 0; i + 1 < tokens.length; i += 2) {
|
|
188
|
+
const value = tokens[i];
|
|
189
|
+
const label = tokens[i + 1];
|
|
190
|
+
if (value && label) options.push({
|
|
191
|
+
value,
|
|
192
|
+
label
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return options;
|
|
196
|
+
};
|
|
197
|
+
//#endregion
|
|
198
|
+
//#region src/client.ts
|
|
199
|
+
const createClisitefClient = (native, options = {}) => {
|
|
200
|
+
const graceMs = options.graceMs ?? 5e3;
|
|
201
|
+
let signature = null;
|
|
202
|
+
const requireNative = () => {
|
|
203
|
+
if (!native) throw new ClisitefUnavailableError();
|
|
204
|
+
return native;
|
|
205
|
+
};
|
|
206
|
+
const bounded = async (operation, call, input) => {
|
|
207
|
+
return clisitefDebugCall(`client.${operation}`, input, () => boundedCall(operation, call));
|
|
208
|
+
};
|
|
209
|
+
const boundedCall = async (operation, call) => {
|
|
210
|
+
const module = requireNative();
|
|
211
|
+
const ceiling = CLISITEF_TIMEOUT_MS[operation];
|
|
212
|
+
let timer;
|
|
213
|
+
const backstop = new Promise((_, reject) => {
|
|
214
|
+
if (ceiling === 0) return;
|
|
215
|
+
const timeoutMs = ceiling + graceMs;
|
|
216
|
+
const info = {
|
|
217
|
+
operation,
|
|
218
|
+
timeoutMs,
|
|
219
|
+
consecutiveTimeouts: null,
|
|
220
|
+
executorReplaced: false,
|
|
221
|
+
source: "js"
|
|
222
|
+
};
|
|
223
|
+
timer = setTimeout(() => reject(new ClisitefTimeoutError(info)), timeoutMs);
|
|
224
|
+
});
|
|
225
|
+
try {
|
|
226
|
+
return await Promise.race([call(module), backstop]);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
const timeout = error instanceof ClisitefTimeoutError ? error : toClisitefTimeout(error);
|
|
229
|
+
if (!timeout) throw error;
|
|
230
|
+
clisitefDebug(`client.${operation}`, "timeout", timeout.info);
|
|
231
|
+
signature = null;
|
|
232
|
+
throw timeout;
|
|
233
|
+
} finally {
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
const configure = async (config) => {
|
|
238
|
+
const next = clisitefConfigSignature(config);
|
|
239
|
+
if (next === signature) {
|
|
240
|
+
clisitefDebug("client.configure", "skipped (same config)", config);
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
const call = (module) => module.configure(config);
|
|
244
|
+
const code = await bounded("configure", call, config);
|
|
245
|
+
signature = code === 0 ? next : null;
|
|
246
|
+
return code;
|
|
247
|
+
};
|
|
248
|
+
const listen = (event, listener) => {
|
|
249
|
+
const logged = ((payload) => {
|
|
250
|
+
clisitefDebug(`client.event.${event}`, "← native", payload);
|
|
251
|
+
listener(payload);
|
|
252
|
+
});
|
|
253
|
+
clisitefDebug(`client.event.${event}`, "subscribe");
|
|
254
|
+
const subscription = requireNative().addListener(event, logged);
|
|
255
|
+
return { remove: () => {
|
|
256
|
+
clisitefDebug(`client.event.${event}`, "unsubscribe");
|
|
257
|
+
subscription.remove();
|
|
258
|
+
} };
|
|
259
|
+
};
|
|
260
|
+
return {
|
|
261
|
+
available: native !== null,
|
|
262
|
+
configure,
|
|
263
|
+
invalidate: () => {
|
|
264
|
+
clisitefDebug("client.invalidate", "signature cleared");
|
|
265
|
+
signature = null;
|
|
266
|
+
},
|
|
267
|
+
startTransaction: (params) => {
|
|
268
|
+
const call = (m) => m.startTransaction(params);
|
|
269
|
+
return bounded("startTransaction", call, params);
|
|
270
|
+
},
|
|
271
|
+
continueTransaction: (data) => {
|
|
272
|
+
const call = (m) => m.continueTransaction(data);
|
|
273
|
+
return bounded("continueTransaction", call, { data });
|
|
274
|
+
},
|
|
275
|
+
finishTransaction: (params) => {
|
|
276
|
+
const call = (m) => m.finishTransaction(params);
|
|
277
|
+
return bounded("finishTransaction", call, params);
|
|
278
|
+
},
|
|
279
|
+
abortTransaction: (flag) => {
|
|
280
|
+
const call = (m) => m.abortTransaction(flag);
|
|
281
|
+
return bounded("abortTransaction", call, { continueFlag: flag });
|
|
282
|
+
},
|
|
283
|
+
getQttPendingTransactions: (date, coupon) => {
|
|
284
|
+
const call = (m) => {
|
|
285
|
+
return m.getQttPendingTransactions(date, coupon);
|
|
286
|
+
};
|
|
287
|
+
return bounded("getQttPendingTransactions", call, {
|
|
288
|
+
fiscalDate: date,
|
|
289
|
+
fiscalCoupon: coupon
|
|
290
|
+
});
|
|
291
|
+
},
|
|
292
|
+
submitPendingMessages: () => {
|
|
293
|
+
return bounded("submitPendingMessages", (m) => m.submitPendingMessages());
|
|
294
|
+
},
|
|
295
|
+
getVersion: () => bounded("getVersion", (module) => module.getVersion()),
|
|
296
|
+
closePinPad: () => bounded("closePinPad", (module) => module.closePinPad()),
|
|
297
|
+
disconnectSitef: () => bounded("disconnectSitef", (module) => module.disconnectSitef()),
|
|
298
|
+
getHealth: () => {
|
|
299
|
+
const health = native?.getHealth() ?? null;
|
|
300
|
+
clisitefDebug("client.getHealth", "← out", health);
|
|
301
|
+
return health;
|
|
302
|
+
},
|
|
303
|
+
onData: (listener) => listen("onData", listener),
|
|
304
|
+
onTransactionResult: (listener) => listen("onTransactionResult", listener)
|
|
305
|
+
};
|
|
306
|
+
};
|
|
307
|
+
//#endregion
|
|
308
|
+
//#region src/index.ts
|
|
309
|
+
/** Shared client bound to the device's native module; one per app. */
|
|
310
|
+
const clisitef = createClisitefClient(requireOptionalNativeModule("CliSiTef"));
|
|
311
|
+
//#endregion
|
|
312
|
+
export { CLISITEF_ABORT_FLAG, CLISITEF_CONFIRM, CLISITEF_DEFAULT_PINPAD, CLISITEF_DEFAULT_TIME_ZONE, CLISITEF_JS_GRACE_MS, CLISITEF_MODALITY, CLISITEF_RESULT, CLISITEF_TIMEOUT_CODE, CLISITEF_TIMEOUT_MS, ClisitefTimeoutError, ClisitefUnavailableError, clisitef, clisitefAdditionalParams, clisitefConfigSignature, clisitefDebug, clisitefDebugCall, clisitefFiscalDateTime, createClisitefClient, formatClisitefAmount, parseClisitefMenu, toClisitefTimeout };
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fire-spark/payments-clisitef-client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.7.1",
|
|
4
|
+
"description": "Fire Spark native client for CliSiTef (Software Express) on Android: SiTef server and pinpad bridge shared by the CliSiTef adapters",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"android/build.gradle",
|
|
9
|
+
"android/src",
|
|
10
|
+
"plugin",
|
|
11
|
+
"app.plugin.js",
|
|
12
|
+
"expo-module.config.json"
|
|
13
|
+
],
|
|
14
|
+
"main": "./dist/index.mjs",
|
|
15
|
+
"types": "./dist/index.d.mts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.mts",
|
|
19
|
+
"import": "./dist/index.mjs",
|
|
20
|
+
"default": "./dist/index.mjs"
|
|
21
|
+
},
|
|
22
|
+
"./app.plugin": "./app.plugin.js",
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"expo": ">=57"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"expo": "~57.0.25",
|
|
30
|
+
"tsdown": "^0.22.14",
|
|
31
|
+
"typescript": "^7.0.2",
|
|
32
|
+
"vitest": "^2.1.8"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"tsdown": {
|
|
38
|
+
"entry": [
|
|
39
|
+
"src/index.ts"
|
|
40
|
+
],
|
|
41
|
+
"format": [
|
|
42
|
+
"esm"
|
|
43
|
+
],
|
|
44
|
+
"platform": "neutral",
|
|
45
|
+
"dts": {
|
|
46
|
+
"tsconfig": "tsconfig.build.json"
|
|
47
|
+
},
|
|
48
|
+
"clean": true,
|
|
49
|
+
"deps": {
|
|
50
|
+
"neverBundle": [
|
|
51
|
+
"expo"
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsdown",
|
|
57
|
+
"dev": "tsdown --watch",
|
|
58
|
+
"check-types": "tsc --noEmit",
|
|
59
|
+
"test:utils": "vitest run",
|
|
60
|
+
"lint": "oxlint ./src --max-warnings=0",
|
|
61
|
+
"format": "oxfmt ./src",
|
|
62
|
+
"check-format": "oxfmt --check ./src"
|
|
63
|
+
}
|
|
6
64
|
}
|
package/plugin/CLSIT
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[CliSiTef]
|
|
2
|
+
HabilitaTrace=1
|
|
3
|
+
TraceRotativo=20
|
|
4
|
+
DiretorioTrace=./files
|
|
5
|
+
|
|
6
|
+
[CliSiTefI]
|
|
7
|
+
HabilitaTrace=1
|
|
8
|
+
TraceRotativo=20
|
|
9
|
+
DiretorioTrace=./files
|
|
10
|
+
|
|
11
|
+
[PinPad]
|
|
12
|
+
HabilitaTrace=1
|
|
13
|
+
|
|
14
|
+
[Geral]
|
|
15
|
+
TransacoesAdicionaisHabilitadas = 7;8;3919;20;21;22
|
|
16
|
+
|
|
17
|
+
[SiTef]
|
|
18
|
+
TempoEsperaConexao=300
|
|
19
|
+
TempoAdicionalEspera=1200
|
|
20
|
+
TempoEsperaDesfazimento=30
|
|
21
|
+
TempoEsperaConfirmacao=30
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const {
|
|
2
|
+
AndroidConfig,
|
|
3
|
+
withAppBuildGradle,
|
|
4
|
+
withDangerousMod
|
|
5
|
+
} = require("expo/config-plugins");
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
|
|
9
|
+
const JAR = "clisitef-android.jar";
|
|
10
|
+
const CLSIT = "CLSIT";
|
|
11
|
+
const DEFAULT_CLSIT = path.join(__dirname, CLSIT);
|
|
12
|
+
const MARKER = "// @fire-spark/payments-clisitef-client";
|
|
13
|
+
|
|
14
|
+
const copy = (from, to) => {
|
|
15
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
16
|
+
fs.copyFileSync(from, to);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Expected layout of `binariesDir` (per environment, never committed):
|
|
21
|
+
* clisitef-android.jar
|
|
22
|
+
* jniLibs/<abi>/*.so (libclisitef, libcrypto, libgsurf_rsa, libjSerialComm, libssl, libtlswnb)
|
|
23
|
+
* CLSIT (optional; defaults to the one shipped with this package)
|
|
24
|
+
*/
|
|
25
|
+
const withClisitefBinaries = (config, { binariesDir }) =>
|
|
26
|
+
withDangerousMod(config, [
|
|
27
|
+
"android",
|
|
28
|
+
async cfg => {
|
|
29
|
+
const source = path.resolve(cfg.modRequest.projectRoot, binariesDir);
|
|
30
|
+
const app = path.join(cfg.modRequest.platformProjectRoot, "app");
|
|
31
|
+
const jar = path.join(source, JAR);
|
|
32
|
+
if (!fs.existsSync(jar)) {
|
|
33
|
+
throw new Error(`[clisitef] missing ${jar}; set binariesDir to the CliSiTef files of this environment`);
|
|
34
|
+
}
|
|
35
|
+
copy(jar, path.join(app, "libs", JAR));
|
|
36
|
+
const clsit = path.join(source, CLSIT);
|
|
37
|
+
copy(fs.existsSync(clsit) ? clsit : DEFAULT_CLSIT, path.join(app, "src/main/assets", CLSIT));
|
|
38
|
+
const jniLibs = path.join(source, "jniLibs");
|
|
39
|
+
const libs = fs.existsSync(jniLibs)
|
|
40
|
+
? fs.readdirSync(jniLibs, { recursive: true }).filter(file => String(file).endsWith(".so"))
|
|
41
|
+
: [];
|
|
42
|
+
if (!libs.some(file => path.basename(String(file)) === "libclisitef.so")) {
|
|
43
|
+
throw new Error(`[clisitef] no libclisitef.so found under ${jniLibs}/<abi>/`);
|
|
44
|
+
}
|
|
45
|
+
for (const file of libs) {
|
|
46
|
+
copy(path.join(jniLibs, String(file)), path.join(app, "src/main/jniLibs", String(file)));
|
|
47
|
+
}
|
|
48
|
+
return cfg;
|
|
49
|
+
}
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
// useLegacyPackaging: Android 14+ does not load libclisitef.so from a compressed APK.
|
|
53
|
+
const gradleBlock = `
|
|
54
|
+
${MARKER}
|
|
55
|
+
android {
|
|
56
|
+
packagingOptions {
|
|
57
|
+
jniLibs {
|
|
58
|
+
useLegacyPackaging true
|
|
59
|
+
keepDebugSymbols += ["**/libclisitef.so"]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
dependencies {
|
|
64
|
+
implementation files("libs/${JAR}")
|
|
65
|
+
}
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
const withClisitefGradle = config =>
|
|
69
|
+
withAppBuildGradle(config, cfg => {
|
|
70
|
+
if (!cfg.modResults.contents.includes(MARKER)) {
|
|
71
|
+
cfg.modResults.contents += gradleBlock;
|
|
72
|
+
}
|
|
73
|
+
return cfg;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const PERMISSIONS = [
|
|
77
|
+
"android.permission.INTERNET",
|
|
78
|
+
"android.permission.BLUETOOTH",
|
|
79
|
+
"android.permission.BLUETOOTH_ADMIN",
|
|
80
|
+
"android.permission.BLUETOOTH_SCAN",
|
|
81
|
+
"android.permission.BLUETOOTH_CONNECT"
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
/** @param {{ binariesDir: string }} options */
|
|
85
|
+
const withClisitef = (config, options = {}) => {
|
|
86
|
+
if (!options.binariesDir) {
|
|
87
|
+
throw new Error("[clisitef] the plugin needs { binariesDir }");
|
|
88
|
+
}
|
|
89
|
+
config = AndroidConfig.Permissions.withPermissions(config, PERMISSIONS);
|
|
90
|
+
config = withClisitefBinaries(config, options);
|
|
91
|
+
return withClisitefGradle(config);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
module.exports = withClisitef;
|
package/README.md
DELETED