@fire-spark/payments-clisitef-client 0.0.0-stage → 0.7.0

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.
@@ -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,4 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.INTERNET" />
3
+ <uses-feature android:name="android.hardware.usb.host" android:required="false" />
4
+ </manifest>
@@ -0,0 +1,133 @@
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
+ val timer = if (timeoutMs > 0) {
51
+ watchdog.schedule({
52
+ if (settled.compareAndSet(false, true)) {
53
+ val count = consecutiveTimeouts.incrementAndGet()
54
+ lastTimeoutOperation = operation
55
+ val replaced = lane == Lane.SERIAL && replaceSerial(target)
56
+ Log.w(TAG, "$operation timed out after ${timeoutMs}ms consecutive=$count executorReplaced=$replaced")
57
+ promise.reject(TIMEOUT_CODE, timeoutJson(operation, timeoutMs, count, replaced), null)
58
+ }
59
+ }, timeoutMs, TimeUnit.MILLISECONDS)
60
+ } else {
61
+ null
62
+ }
63
+ try {
64
+ target.execute {
65
+ val outcome = runCatching(block)
66
+ timer?.cancel(false)
67
+ if (!settled.compareAndSet(false, true)) {
68
+ Log.w(TAG, "$operation returned late after ${System.currentTimeMillis() - startedAt}ms; result discarded")
69
+ return@execute
70
+ }
71
+ lastCallOperation = operation
72
+ lastCallDurationMs = System.currentTimeMillis() - startedAt
73
+ outcome
74
+ .onSuccess {
75
+ consecutiveTimeouts.set(0)
76
+ promise.resolve(it)
77
+ }
78
+ .onFailure { promise.reject(ERROR_CODE, it.message ?: "$operation failed", it) }
79
+ }
80
+ } catch (error: RejectedExecutionException) {
81
+ timer?.cancel(false)
82
+ if (settled.compareAndSet(false, true)) {
83
+ promise.reject(ERROR_CODE, "executor rejected $operation: ${error.message}", error)
84
+ }
85
+ }
86
+ }
87
+
88
+ fun health(appDirectory: String?): Map<String, Any?> = mapOf(
89
+ "consecutiveTimeouts" to consecutiveTimeouts.get(),
90
+ "executorReplacements" to executorReplacements.get(),
91
+ "lastTimeoutOperation" to lastTimeoutOperation,
92
+ "lastCallOperation" to lastCallOperation,
93
+ "lastCallDurationMs" to lastCallDurationMs,
94
+ "appDirectory" to appDirectory
95
+ )
96
+
97
+ fun shutdown() {
98
+ serial.shutdown()
99
+ abort.shutdown()
100
+ recovery.shutdown()
101
+ watchdog.shutdownNow()
102
+ }
103
+
104
+ /** The old thread is not interrupted: an interrupt does not free a blocking JNI call. */
105
+ @Synchronized
106
+ private fun replaceSerial(stuck: ExecutorService): Boolean {
107
+ if (serial !== stuck) return false
108
+ serial = Executors.newSingleThreadExecutor(daemon("clisitef-native"))
109
+ executorReplacements.incrementAndGet()
110
+ stuck.shutdown()
111
+ return true
112
+ }
113
+
114
+ private fun timeoutJson(operation: String, timeoutMs: Long, count: Int, replaced: Boolean): String =
115
+ JSONObject()
116
+ .put("operation", operation)
117
+ .put("timeoutMs", timeoutMs)
118
+ .put("consecutiveTimeouts", count)
119
+ .put("executorReplaced", replaced)
120
+ .put("executorReplacements", executorReplacements.get())
121
+ .toString()
122
+
123
+ private companion object {
124
+ const val TAG = "CliSiTef"
125
+
126
+ fun daemon(name: String): ThreadFactory {
127
+ val sequence = AtomicInteger(0)
128
+ return ThreadFactory { runnable ->
129
+ Thread(runnable, "$name-${sequence.incrementAndGet()}").apply { isDaemon = true }
130
+ }
131
+ }
132
+ }
133
+ }
@@ -0,0 +1,120 @@
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
+ onData(DataEvent(stage, command, fieldId, minLength, maxLength, text))
21
+ }
22
+
23
+ override fun onTransactionResult(stage: Int, resultCode: Int) {
24
+ onResult(stage, resultCode)
25
+ }
26
+ }
27
+
28
+ init {
29
+ // CliSiTef copies CLSIT on instantiation but does not overwrite an existing copy, and files/
30
+ // survives app updates; sync first so this APK's CLSIT wins.
31
+ syncClsit(context)
32
+ clisitef = CliSiTef(context)
33
+ }
34
+
35
+ val appDirectory: String
36
+ get() = context.applicationInfo.dataDir ?: context.filesDir.parent ?: ""
37
+
38
+ fun configure(params: ConfigureParams): Int {
39
+ val additional = withAppDir(params.additionalParams, appDirectory)
40
+ return clisitef.configure(params.serverIp, params.storeCode, params.terminalNumber, additional)
41
+ }
42
+
43
+ fun start(params: StartParams): Int = clisitef.startTransaction(
44
+ listener,
45
+ params.modality,
46
+ params.value,
47
+ params.fiscalCoupon,
48
+ params.fiscalDate,
49
+ params.time,
50
+ params.operator,
51
+ params.restrictions
52
+ )
53
+
54
+ fun continueTransaction(data: String): Int = clisitef.continueTransaction(data)
55
+
56
+ /** Without coupon, date and time the library reuses the ones from `startTransaction`. */
57
+ fun finish(params: FinishParams): Int {
58
+ val full = params.fiscalCoupon.isNotEmpty() && params.fiscalDate.isNotEmpty() && params.time.isNotEmpty()
59
+ if (!full) return clisitef.finishTransaction(params.confirm)
60
+ return clisitef.finishTransaction(params.confirm, params.fiscalCoupon, params.fiscalDate, params.time, params.additionalParams)
61
+ }
62
+
63
+ fun abort(continueFlag: Int): Int = clisitef.abortTransaction(continueFlag)
64
+
65
+ fun pendingCount(fiscalDate: String, fiscalCoupon: String): Int =
66
+ clisitef.getQttPendingTransactions(fiscalDate, fiscalCoupon)
67
+
68
+ fun submitPending(): Int = clisitef.submitPendingMessages()
69
+
70
+ fun version(): String = clisitef.getVersion()
71
+
72
+ fun closePinPad(): Int = clisitef.closePinPad()
73
+
74
+ fun disconnectSitef(): Int {
75
+ clisitef.disconnectSitef()
76
+ return 0
77
+ }
78
+
79
+ companion object {
80
+ private const val TAG = "CliSiTef"
81
+ private const val CLSIT = "CLSIT"
82
+ private val APP_DIR = Regex("DiretorioApp=[^;\\]]*")
83
+
84
+ /**
85
+ * The library keeps pending confirmations and tables under `DiretorioApp`; a wrong
86
+ * applicationId there loses pending undo messages on restart (seen in the legacy staging APK).
87
+ */
88
+ fun withAppDir(params: String, dataDir: String): String {
89
+ if (dataDir.isEmpty()) return params
90
+ val entry = "DiretorioApp=$dataDir"
91
+ if (APP_DIR.containsMatchIn(params)) return params.replace(APP_DIR, Regex.escapeReplacement(entry))
92
+ val trimmed = params.trim()
93
+ if (trimmed.isEmpty()) return "[$entry]"
94
+ if (trimmed.endsWith("]")) return "${trimmed.dropLast(1)};$entry]"
95
+ return "$trimmed;$entry"
96
+ }
97
+
98
+ /** Idempotent and atomic; a missing or empty asset keeps the current file. */
99
+ private fun syncClsit(context: Context) {
100
+ val target = File(context.filesDir, CLSIT)
101
+ val temp = File(context.filesDir, "$CLSIT.new")
102
+ try {
103
+ val shipped = context.assets.open(CLSIT).use { it.readBytes() }
104
+ if (shipped.isEmpty()) return
105
+ if (target.isFile && target.readBytes().contentEquals(shipped)) return
106
+ temp.outputStream().use { out ->
107
+ out.write(shipped)
108
+ out.flush()
109
+ out.fd.sync()
110
+ }
111
+ if (!temp.renameTo(target) && !(target.delete() && temp.renameTo(target))) {
112
+ temp.delete()
113
+ }
114
+ } catch (error: Exception) {
115
+ Log.w(TAG, "CLSIT sync skipped: ${error.message}")
116
+ temp.delete()
117
+ }
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,71 @@
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
+ CliSiTefBridge(
16
+ context,
17
+ onData = { event -> sendEvent("onData", event.toMap()) },
18
+ onResult = { stage, code -> sendEvent("onTransactionResult", mapOf("stage" to stage, "resultCode" to code)) }
19
+ ).also { bridge = it }
20
+ }
21
+
22
+ override fun definition() = ModuleDefinition {
23
+ Name("CliSiTef")
24
+
25
+ Events("onData", "onTransactionResult")
26
+
27
+ AsyncFunction("configure") { params: ConfigureParams, promise: Promise ->
28
+ runner.run("configure", 15_000, promise) { bridge().configure(params) }
29
+ }
30
+
31
+ AsyncFunction("startTransaction") { params: StartParams, promise: Promise ->
32
+ runner.run("startTransaction", 30_000, promise) { bridge().start(params) }
33
+ }
34
+
35
+ AsyncFunction("continueTransaction") { data: String, promise: Promise ->
36
+ runner.run("continueTransaction", 0, promise) { bridge().continueTransaction(data) }
37
+ }
38
+
39
+ AsyncFunction("finishTransaction") { params: FinishParams, promise: Promise ->
40
+ runner.run("finishTransaction", 15_000, promise) { bridge().finish(params) }
41
+ }
42
+
43
+ AsyncFunction("abortTransaction") { continueFlag: Int, promise: Promise ->
44
+ runner.run("abortTransaction", 15_000, promise, Lane.ABORT) { bridge().abort(continueFlag) }
45
+ }
46
+
47
+ AsyncFunction("getQttPendingTransactions") { fiscalDate: String, fiscalCoupon: String, promise: Promise ->
48
+ runner.run("getQttPendingTransactions", 15_000, promise) { bridge().pendingCount(fiscalDate, fiscalCoupon) }
49
+ }
50
+
51
+ AsyncFunction("submitPendingMessages") { promise: Promise ->
52
+ runner.run("submitPendingMessages", 15_000, promise) { bridge().submitPending() }
53
+ }
54
+
55
+ AsyncFunction("getVersion") { promise: Promise ->
56
+ runner.run("getVersion", 15_000, promise) { bridge().version() }
57
+ }
58
+
59
+ AsyncFunction("closePinPad") { promise: Promise ->
60
+ runner.run("closePinPad", 10_000, promise, Lane.RECOVERY) { bridge().closePinPad() }
61
+ }
62
+
63
+ AsyncFunction("disconnectSitef") { promise: Promise ->
64
+ runner.run("disconnectSitef", 10_000, promise, Lane.RECOVERY) { bridge().disconnectSitef() }
65
+ }
66
+
67
+ Function("getHealth") { runner.health(bridge?.appDirectory) }
68
+
69
+ OnDestroy { runner.shutdown() }
70
+ }
71
+ }
@@ -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");
@@ -0,0 +1,193 @@
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/errors.d.ts
141
+ declare class ClisitefUnavailableError extends Error {
142
+ readonly code = "CLISITEF_UNAVAILABLE";
143
+ constructor();
144
+ }
145
+ type ClisitefTimeoutInfo = {
146
+ operation: string;
147
+ timeoutMs: number;
148
+ consecutiveTimeouts: number | null;
149
+ executorReplaced: boolean;
150
+ /** `native`: the Kotlin ceiling fired; `js`: the native call never settled. */
151
+ source: "native" | "js";
152
+ };
153
+ declare class ClisitefTimeoutError extends Error {
154
+ readonly code = "CLISITEF_TIMEOUT";
155
+ readonly info: ClisitefTimeoutInfo;
156
+ constructor(info: ClisitefTimeoutInfo);
157
+ }
158
+ /** Turns the native `CLISITEF_TIMEOUT` rejection (JSON message) into a typed error. */
159
+ declare const toClisitefTimeout: (error: unknown) => ClisitefTimeoutError | null;
160
+ //#endregion
161
+ //#region src/helpers.d.ts
162
+ /** `123.4` → `"123,40"`. */
163
+ declare const formatClisitefAmount: (amount: number) => string;
164
+ type ClisitefAdditionalParamsInput = {
165
+ cnpjStore: string;
166
+ cnpjEnterprise: string;
167
+ pinpadType?: string;
168
+ };
169
+ /** `[TipoPinPad=ANDROID_USB;ParmsClient=1={cnpjStore};2={cnpjEnterprise}]`. */
170
+ declare const clisitefAdditionalParams: (input: ClisitefAdditionalParamsInput) => string;
171
+ /** Same config ⇒ same signature, so `configure` only runs when something changed. */
172
+ declare const clisitefConfigSignature: (config: ClisitefConfigureParams) => string;
173
+ type ClisitefFiscalDateTime = {
174
+ fiscalDate: string;
175
+ time: string;
176
+ };
177
+ /** `YYYYMMDD` / `HHMMSS` in the store time zone (not the device one). */
178
+ declare const clisitefFiscalDateTime: (date?: Date, timeZone?: string) => ClisitefFiscalDateTime;
179
+ type ClisitefMenuOption = {
180
+ value: string;
181
+ label: string;
182
+ };
183
+ /**
184
+ * Command 21 buffer, documented as `"1:Débito;2:Crédito;"`. Falls back to
185
+ * alternating `"1;Débito;2;Crédito;"` tokens.
186
+ */
187
+ declare const parseClisitefMenu: (input: string) => ClisitefMenuOption[];
188
+ //#endregion
189
+ //#region src/index.d.ts
190
+ /** Shared client bound to the device's native module; one per app. */
191
+ declare const clisitef: ClisitefClient;
192
+ //#endregion
193
+ 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, clisitefFiscalDateTime, createClisitefClient, formatClisitefAmount, parseClisitefMenu, toClisitefTimeout };
package/dist/index.mjs ADDED
@@ -0,0 +1,231 @@
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/errors.ts
40
+ var ClisitefUnavailableError = class extends Error {
41
+ code = "CLISITEF_UNAVAILABLE";
42
+ constructor() {
43
+ super("CliSiTef native module is not available (Android build with the CliSiTef plugin required)");
44
+ this.name = "ClisitefUnavailableError";
45
+ }
46
+ };
47
+ var ClisitefTimeoutError = class extends Error {
48
+ code = CLISITEF_TIMEOUT_CODE;
49
+ info;
50
+ constructor(info) {
51
+ super(`CliSiTef ${info.operation} timed out after ${info.timeoutMs} ms`);
52
+ this.name = "ClisitefTimeoutError";
53
+ this.info = info;
54
+ }
55
+ };
56
+ const asRecord = (value) => {
57
+ if (typeof value !== "object" || value === null) return null;
58
+ return value;
59
+ };
60
+ /** Turns the native `CLISITEF_TIMEOUT` rejection (JSON message) into a typed error. */
61
+ const toClisitefTimeout = (error) => {
62
+ const record = asRecord(error);
63
+ if (!record || record.code !== "CLISITEF_TIMEOUT") return null;
64
+ let payload = {};
65
+ try {
66
+ payload = asRecord(JSON.parse(String(record.message))) ?? {};
67
+ } catch {
68
+ payload = {};
69
+ }
70
+ return new ClisitefTimeoutError({
71
+ operation: typeof payload.operation === "string" ? payload.operation : "unknown",
72
+ timeoutMs: typeof payload.timeoutMs === "number" ? payload.timeoutMs : 0,
73
+ consecutiveTimeouts: typeof payload.consecutiveTimeouts === "number" ? payload.consecutiveTimeouts : null,
74
+ executorReplaced: payload.executorReplaced === true,
75
+ source: "native"
76
+ });
77
+ };
78
+ //#endregion
79
+ //#region src/helpers.ts
80
+ /** `123.4` → `"123,40"`. */
81
+ const formatClisitefAmount = (amount) => {
82
+ if (!Number.isFinite(amount) || amount < 0) throw new RangeError(`Invalid CliSiTef amount: ${amount}`);
83
+ return amount.toFixed(2).replace(".", ",");
84
+ };
85
+ const digits = (value) => value.replace(/\D/g, "");
86
+ /** `[TipoPinPad=ANDROID_USB;ParmsClient=1={cnpjStore};2={cnpjEnterprise}]`. */
87
+ const clisitefAdditionalParams = (input) => {
88
+ return `[TipoPinPad=${input.pinpadType ?? "ANDROID_USB"};ParmsClient=1=${digits(input.cnpjStore)};2=${digits(input.cnpjEnterprise)}]`;
89
+ };
90
+ /** Same config ⇒ same signature, so `configure` only runs when something changed. */
91
+ const clisitefConfigSignature = (config) => {
92
+ const { serverIp, storeCode, terminalNumber, additionalParams } = config;
93
+ return [
94
+ serverIp,
95
+ storeCode,
96
+ terminalNumber,
97
+ additionalParams
98
+ ].map((value) => value.trim()).join("|");
99
+ };
100
+ const formatters = /* @__PURE__ */ new Map();
101
+ const formatterFor = (timeZone) => {
102
+ const cached = formatters.get(timeZone);
103
+ if (cached) return cached;
104
+ const options = {
105
+ timeZone,
106
+ year: "numeric",
107
+ month: "2-digit",
108
+ day: "2-digit",
109
+ hour: "2-digit",
110
+ minute: "2-digit",
111
+ second: "2-digit",
112
+ hourCycle: "h23"
113
+ };
114
+ const formatter = new Intl.DateTimeFormat("en-US", options);
115
+ formatters.set(timeZone, formatter);
116
+ return formatter;
117
+ };
118
+ /** `YYYYMMDD` / `HHMMSS` in the store time zone (not the device one). */
119
+ const clisitefFiscalDateTime = (date = /* @__PURE__ */ new Date(), timeZone = CLISITEF_DEFAULT_TIME_ZONE) => {
120
+ const parts = Object.fromEntries(formatterFor(timeZone).formatToParts(date).map((part) => [part.type, part.value]));
121
+ return {
122
+ fiscalDate: `${parts.year}${parts.month}${parts.day}`,
123
+ time: `${parts.hour}${parts.minute}${parts.second}`
124
+ };
125
+ };
126
+ const MENU_ITEM = /^(\d+):(.+)$/;
127
+ /**
128
+ * Command 21 buffer, documented as `"1:Débito;2:Crédito;"`. Falls back to
129
+ * alternating `"1;Débito;2;Crédito;"` tokens.
130
+ */
131
+ const parseClisitefMenu = (input) => {
132
+ const tokens = input.split(";").map((token) => token.trim());
133
+ const options = [];
134
+ for (const token of tokens) {
135
+ const match = MENU_ITEM.exec(token);
136
+ const value = match?.[1];
137
+ const label = match?.[2]?.trim();
138
+ if (value && label) options.push({
139
+ value,
140
+ label
141
+ });
142
+ }
143
+ if (options.length > 0) return options;
144
+ for (let i = 0; i + 1 < tokens.length; i += 2) {
145
+ const value = tokens[i];
146
+ const label = tokens[i + 1];
147
+ if (value && label) options.push({
148
+ value,
149
+ label
150
+ });
151
+ }
152
+ return options;
153
+ };
154
+ //#endregion
155
+ //#region src/client.ts
156
+ const createClisitefClient = (native, options = {}) => {
157
+ const graceMs = options.graceMs ?? 5e3;
158
+ let signature = null;
159
+ const requireNative = () => {
160
+ if (!native) throw new ClisitefUnavailableError();
161
+ return native;
162
+ };
163
+ const bounded = async (operation, call) => {
164
+ const module = requireNative();
165
+ const ceiling = CLISITEF_TIMEOUT_MS[operation];
166
+ let timer;
167
+ const backstop = new Promise((_, reject) => {
168
+ if (ceiling === 0) return;
169
+ const timeoutMs = ceiling + graceMs;
170
+ const info = {
171
+ operation,
172
+ timeoutMs,
173
+ consecutiveTimeouts: null,
174
+ executorReplaced: false,
175
+ source: "js"
176
+ };
177
+ timer = setTimeout(() => reject(new ClisitefTimeoutError(info)), timeoutMs);
178
+ });
179
+ try {
180
+ return await Promise.race([call(module), backstop]);
181
+ } catch (error) {
182
+ const timeout = error instanceof ClisitefTimeoutError ? error : toClisitefTimeout(error);
183
+ if (!timeout) throw error;
184
+ signature = null;
185
+ throw timeout;
186
+ } finally {
187
+ clearTimeout(timer);
188
+ }
189
+ };
190
+ const configure = async (config) => {
191
+ const next = clisitefConfigSignature(config);
192
+ if (next === signature) return 0;
193
+ const code = await bounded("configure", (module) => module.configure(config));
194
+ signature = code === 0 ? next : null;
195
+ return code;
196
+ };
197
+ return {
198
+ available: native !== null,
199
+ configure,
200
+ invalidate: () => {
201
+ signature = null;
202
+ },
203
+ startTransaction: (params) => bounded("startTransaction", (module) => module.startTransaction(params)),
204
+ continueTransaction: (data) => {
205
+ return bounded("continueTransaction", (m) => m.continueTransaction(data));
206
+ },
207
+ finishTransaction: (params) => bounded("finishTransaction", (module) => module.finishTransaction(params)),
208
+ abortTransaction: (flag) => bounded("abortTransaction", (module) => module.abortTransaction(flag)),
209
+ getQttPendingTransactions: (date, coupon) => {
210
+ const call = (m) => {
211
+ return m.getQttPendingTransactions(date, coupon);
212
+ };
213
+ return bounded("getQttPendingTransactions", call);
214
+ },
215
+ submitPendingMessages: () => {
216
+ return bounded("submitPendingMessages", (m) => m.submitPendingMessages());
217
+ },
218
+ getVersion: () => bounded("getVersion", (module) => module.getVersion()),
219
+ closePinPad: () => bounded("closePinPad", (module) => module.closePinPad()),
220
+ disconnectSitef: () => bounded("disconnectSitef", (module) => module.disconnectSitef()),
221
+ getHealth: () => native?.getHealth() ?? null,
222
+ onData: (listener) => requireNative().addListener("onData", listener),
223
+ onTransactionResult: (listener) => requireNative().addListener("onTransactionResult", listener)
224
+ };
225
+ };
226
+ //#endregion
227
+ //#region src/index.ts
228
+ /** Shared client bound to the device's native module; one per app. */
229
+ const clisitef = createClisitefClient(requireOptionalNativeModule("CliSiTef"));
230
+ //#endregion
231
+ 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, clisitefFiscalDateTime, createClisitefClient, formatClisitefAmount, parseClisitefMenu, toClisitefTimeout };
@@ -0,0 +1,6 @@
1
+ {
2
+ "platforms": ["android"],
3
+ "android": {
4
+ "modules": ["dev.firespark.payments.clisitef.CliSiTefModule"]
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "@fire-spark/payments-clisitef-client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.7.0",
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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.