@fire-spark/payments-medianet-client 0.0.0-stage → 0.13.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.medianet'
7
+ version = '0.1.0'
8
+
9
+ android {
10
+ namespace "dev.firespark.payments.medianet"
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/CajaPinPadLibrary_V3.jar")
20
+ }
@@ -0,0 +1,3 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.INTERNET" />
3
+ </manifest>
@@ -0,0 +1,116 @@
1
+ package dev.firespark.payments.medianet
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 = "MEDIANET_TIMEOUT"
15
+ const val ERROR_CODE = "MEDIANET_ERROR"
16
+
17
+ /**
18
+ * Runs each pinpad call off the JS thread, one at a time (the pinpad serves one frame per
19
+ * connection), with a ceiling. A socket stuck past its own timeout cannot be killed; on timeout
20
+ * the promise is rejected and the thread is replaced so later calls do not queue behind it.
21
+ */
22
+ internal class BoundedRunner {
23
+ @Volatile private var serial: ExecutorService = Executors.newSingleThreadExecutor(daemon("medianet-native"))
24
+ private val watchdog = Executors.newSingleThreadScheduledExecutor(daemon("medianet-watchdog"))
25
+ private val consecutiveTimeouts = AtomicInteger(0)
26
+ private val executorReplacements = AtomicInteger(0)
27
+ @Volatile private var lastTimeoutOperation: String? = null
28
+ @Volatile private var lastCallOperation: String? = null
29
+ @Volatile private var lastCallDurationMs = 0L
30
+
31
+ fun run(operation: String, timeoutMs: Long, promise: Promise, block: () -> Any?) {
32
+ val settled = AtomicBoolean(false)
33
+ val startedAt = System.currentTimeMillis()
34
+ val target = serial
35
+ MedianetDebug.log("runner.$operation › queued timeoutMs=$timeoutMs")
36
+ val timer = watchdog.schedule({
37
+ if (settled.compareAndSet(false, true)) {
38
+ val count = consecutiveTimeouts.incrementAndGet()
39
+ lastTimeoutOperation = operation
40
+ val replaced = replaceSerial(target)
41
+ Log.w(TAG, "$operation timed out after ${timeoutMs}ms consecutive=$count executorReplaced=$replaced")
42
+ promise.reject(TIMEOUT_CODE, timeoutJson(operation, timeoutMs, count, replaced), null)
43
+ }
44
+ }, timeoutMs, TimeUnit.MILLISECONDS)
45
+ try {
46
+ target.execute {
47
+ val thread = Thread.currentThread().name
48
+ val waited = System.currentTimeMillis() - startedAt
49
+ MedianetDebug.log("runner.$operation › running thread=$thread waited=${waited}ms")
50
+ val outcome = runCatching(block)
51
+ timer.cancel(false)
52
+ if (!settled.compareAndSet(false, true)) {
53
+ Log.w(TAG, "$operation returned late after ${System.currentTimeMillis() - startedAt}ms; result discarded")
54
+ return@execute
55
+ }
56
+ lastCallOperation = operation
57
+ lastCallDurationMs = System.currentTimeMillis() - startedAt
58
+ val ok = outcome.isSuccess
59
+ val error = outcome.exceptionOrNull()?.message
60
+ MedianetDebug.log("runner.$operation › settled ok=$ok error=$error ms=$lastCallDurationMs")
61
+ outcome
62
+ .onSuccess {
63
+ consecutiveTimeouts.set(0)
64
+ promise.resolve(it)
65
+ }
66
+ .onFailure { promise.reject(ERROR_CODE, it.message ?: "$operation failed", it) }
67
+ }
68
+ } catch (error: RejectedExecutionException) {
69
+ timer.cancel(false)
70
+ if (settled.compareAndSet(false, true)) {
71
+ promise.reject(ERROR_CODE, "executor rejected $operation: ${error.message}", error)
72
+ }
73
+ }
74
+ }
75
+
76
+ fun health(): Map<String, Any?> = mapOf(
77
+ "consecutiveTimeouts" to consecutiveTimeouts.get(),
78
+ "executorReplacements" to executorReplacements.get(),
79
+ "lastTimeoutOperation" to lastTimeoutOperation,
80
+ "lastCallOperation" to lastCallOperation,
81
+ "lastCallDurationMs" to lastCallDurationMs
82
+ )
83
+
84
+ fun shutdown() {
85
+ serial.shutdown()
86
+ watchdog.shutdownNow()
87
+ }
88
+
89
+ @Synchronized
90
+ private fun replaceSerial(stuck: ExecutorService): Boolean {
91
+ if (serial !== stuck) return false
92
+ serial = Executors.newSingleThreadExecutor(daemon("medianet-native"))
93
+ executorReplacements.incrementAndGet()
94
+ stuck.shutdown()
95
+ return true
96
+ }
97
+
98
+ private fun timeoutJson(operation: String, timeoutMs: Long, count: Int, replaced: Boolean): String =
99
+ JSONObject()
100
+ .put("operation", operation)
101
+ .put("timeoutMs", timeoutMs)
102
+ .put("consecutiveTimeouts", count)
103
+ .put("executorReplaced", replaced)
104
+ .toString()
105
+
106
+ private companion object {
107
+ const val TAG = "Medianet"
108
+
109
+ fun daemon(name: String): ThreadFactory {
110
+ val sequence = AtomicInteger(0)
111
+ return ThreadFactory { runnable ->
112
+ Thread(runnable, "$name-${sequence.incrementAndGet()}").apply { isDaemon = true }
113
+ }
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,102 @@
1
+ package dev.firespark.payments.medianet
2
+
3
+ import cajapinpad.CifradoTramas
4
+ import cajapinpad.Conexion
5
+ import cajapinpad.ProccessData
6
+
7
+ private const val HASH_LENGTH = 32
8
+ private const val NETWORK_MEDIANET = "2"
9
+
10
+ /**
11
+ * The only class that touches `CajaPinPadLibrary_V3.jar`. Field order and fillers match the legacy
12
+ * Ecuador kiosk module (Medianet frames `LT`, `CP`, `PC`, `PP`); the library frames and hashes them.
13
+ */
14
+ internal class MedianetBridge {
15
+ private val data: ProccessData = ProccessData.getInstance()
16
+
17
+ fun linkTest(params: ConnectionParams): FrameResponse =
18
+ send(params.host, params.port, params.timeoutMs, arrayOf("LT"))
19
+
20
+ fun setup(params: SetupParams): FrameResponse {
21
+ val host = " ".repeat(15)
22
+ val port = " ".repeat(6)
23
+ val fields = arrayOf(
24
+ "CP",
25
+ params.ip,
26
+ params.mask,
27
+ params.gateway,
28
+ host, port, // switch main host
29
+ host, port, // switch alternate host
30
+ host, port, // network 2 main host
31
+ host, port, // network 2 alternate host
32
+ params.listenPort
33
+ )
34
+ return send(params.host, params.port, params.timeoutMs, fields)
35
+ }
36
+
37
+ fun control(params: ControlParams): FrameResponse {
38
+ val fields = arrayOf(
39
+ "PC",
40
+ params.batch,
41
+ params.sequential,
42
+ " ".repeat(12),
43
+ params.mid,
44
+ params.tid,
45
+ " ".repeat(23),
46
+ params.cid,
47
+ NETWORK_MEDIANET
48
+ )
49
+ return send(params.host, params.port, params.timeoutMs, fields)
50
+ }
51
+
52
+ /** `PP` with transaction type `01` (sale), `03` (void) or `04` (reverse). */
53
+ fun authorize(type: String, params: AuthorizationParams): FrameResponse {
54
+ val blank = " ".repeat(12)
55
+ val fields = arrayOf(
56
+ "PP",
57
+ type,
58
+ NETWORK_MEDIANET,
59
+ "00", // deferred code
60
+ " ", // deferred term
61
+ " ", // grace months
62
+ " ",
63
+ params.total,
64
+ params.subtotal,
65
+ params.baseWithoutTax,
66
+ params.tax,
67
+ blank, // service tax
68
+ blank, // tip
69
+ blank, // fixed amount
70
+ params.sequential,
71
+ params.hour,
72
+ params.date,
73
+ params.authorization,
74
+ params.mid,
75
+ params.tid,
76
+ params.cid,
77
+ " ".repeat(20)
78
+ )
79
+ return send(params.host, params.port, params.timeoutMs, fields)
80
+ }
81
+
82
+ private fun send(host: String, port: Int, timeoutMs: Int, fields: Array<String>): FrameResponse {
83
+ val type = fields.firstOrNull() ?: "?"
84
+ MedianetDebug.log("bridge › $type $host:$port timeoutMs=$timeoutMs")
85
+ val cipher = CifradoTramas()
86
+ val frame = fields + data.padright(cipher.getHash(), HASH_LENGTH, ' ')
87
+ val connection = Conexion()
88
+ connection.sendData(host, port, data.getFinalData(frame), timeoutMs)
89
+ val received = connection.getDataRecived()
90
+ if (received == null || received.isEmpty()) {
91
+ throw IllegalStateException("Medianet pinpad did not answer ($host:$port)")
92
+ }
93
+ val response = data.hex2AsciiStr(data.byte2hex(received))
94
+ if (response.length < HASH_LENGTH) {
95
+ throw IllegalStateException("Medianet incomplete response (len=${response.length})")
96
+ }
97
+ val hash = response.substring(response.length - HASH_LENGTH)
98
+ val valid = cipher.validateHash(hash)
99
+ MedianetDebug.log("bridge › $type len=${response.length} hash=$valid")
100
+ return FrameResponse(response, valid)
101
+ }
102
+ }
@@ -0,0 +1,16 @@
1
+ package dev.firespark.payments.medianet
2
+
3
+ import android.util.Log
4
+
5
+ /** Process-wide gate for `MEDIANET_DEBUG` logcat lines. Off until `setLogs`. */
6
+ internal object MedianetDebug {
7
+ const val TAG = "MEDIANET_DEBUG"
8
+
9
+ @Volatile
10
+ var enabled: Boolean = false
11
+
12
+ fun log(message: String) {
13
+ if (!enabled) return
14
+ Log.d(TAG, message)
15
+ }
16
+ }
@@ -0,0 +1,51 @@
1
+ package dev.firespark.payments.medianet
2
+
3
+ import expo.modules.kotlin.Promise
4
+ import expo.modules.kotlin.modules.Module
5
+ import expo.modules.kotlin.modules.ModuleDefinition
6
+
7
+ /** Grace over the socket timeout before the runner gives up on a stuck call. */
8
+ private const val CEILING_GRACE_MS = 5_000L
9
+
10
+ class MedianetModule : Module() {
11
+ private val runner = BoundedRunner()
12
+ private val bridge = MedianetBridge()
13
+
14
+ private fun ceiling(timeoutMs: Int) = timeoutMs.toLong() + CEILING_GRACE_MS
15
+
16
+ override fun definition() = ModuleDefinition {
17
+ Name("Medianet")
18
+
19
+ AsyncFunction("linkTest") { params: ConnectionParams, promise: Promise ->
20
+ runner.run("linkTest", ceiling(params.timeoutMs), promise) { bridge.linkTest(params).toMap() }
21
+ }
22
+
23
+ AsyncFunction("setup") { params: SetupParams, promise: Promise ->
24
+ runner.run("setup", ceiling(params.timeoutMs), promise) { bridge.setup(params).toMap() }
25
+ }
26
+
27
+ AsyncFunction("control") { params: ControlParams, promise: Promise ->
28
+ runner.run("control", ceiling(params.timeoutMs), promise) { bridge.control(params).toMap() }
29
+ }
30
+
31
+ AsyncFunction("sale") { params: AuthorizationParams, promise: Promise ->
32
+ runner.run("sale", ceiling(params.timeoutMs), promise) { bridge.authorize("01", params).toMap() }
33
+ }
34
+
35
+ AsyncFunction("void") { params: AuthorizationParams, promise: Promise ->
36
+ runner.run("void", ceiling(params.timeoutMs), promise) { bridge.authorize("03", params).toMap() }
37
+ }
38
+
39
+ AsyncFunction("reverse") { params: AuthorizationParams, promise: Promise ->
40
+ runner.run("reverse", ceiling(params.timeoutMs), promise) { bridge.authorize("04", params).toMap() }
41
+ }
42
+
43
+ Function("setLogs") { enabled: Boolean ->
44
+ MedianetDebug.enabled = enabled
45
+ }
46
+
47
+ Function("getHealth") { runner.health() }
48
+
49
+ OnDestroy { runner.shutdown() }
50
+ }
51
+ }
@@ -0,0 +1,53 @@
1
+ package dev.firespark.payments.medianet
2
+
3
+ import expo.modules.kotlin.records.Field
4
+ import expo.modules.kotlin.records.Record
5
+
6
+ /** Every frame field arrives already padded by the JS client (`frames.ts`). */
7
+ class ConnectionParams : Record {
8
+ @Field val host: String = ""
9
+ @Field val port: Int = 0
10
+ @Field val timeoutMs: Int = 0
11
+ }
12
+
13
+ class SetupParams : Record {
14
+ @Field val host: String = ""
15
+ @Field val port: Int = 0
16
+ @Field val timeoutMs: Int = 0
17
+ @Field val ip: String = ""
18
+ @Field val mask: String = ""
19
+ @Field val gateway: String = ""
20
+ @Field val listenPort: String = ""
21
+ }
22
+
23
+ class ControlParams : Record {
24
+ @Field val host: String = ""
25
+ @Field val port: Int = 0
26
+ @Field val timeoutMs: Int = 0
27
+ @Field val batch: String = ""
28
+ @Field val sequential: String = ""
29
+ @Field val mid: String = ""
30
+ @Field val tid: String = ""
31
+ @Field val cid: String = ""
32
+ }
33
+
34
+ class AuthorizationParams : Record {
35
+ @Field val host: String = ""
36
+ @Field val port: Int = 0
37
+ @Field val timeoutMs: Int = 0
38
+ @Field val total: String = ""
39
+ @Field val subtotal: String = ""
40
+ @Field val baseWithoutTax: String = ""
41
+ @Field val tax: String = ""
42
+ @Field val sequential: String = ""
43
+ @Field val hour: String = ""
44
+ @Field val date: String = ""
45
+ @Field val authorization: String = ""
46
+ @Field val mid: String = ""
47
+ @Field val tid: String = ""
48
+ @Field val cid: String = ""
49
+ }
50
+
51
+ data class FrameResponse(val response: String, val hashValid: Boolean) {
52
+ fun toMap(): Map<String, Any> = mapOf("response" to response, "hashValid" to hashValid)
53
+ }
package/app.plugin.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = require("./plugin/withMedianet.cjs");
@@ -0,0 +1,225 @@
1
+ //#region src/types.d.ts
2
+ /** Where the pinpad listens on the LAN; `timeoutMs` is the socket timeout. */
3
+ type MedianetConnection = {
4
+ host: string;
5
+ port: number;
6
+ timeoutMs: number;
7
+ };
8
+ /** `CP`: network settings of the pinpad. Fields already padded. */
9
+ type MedianetSetupParams = MedianetConnection & {
10
+ ip: string;
11
+ mask: string;
12
+ gateway: string;
13
+ listenPort: string;
14
+ };
15
+ /** `PC`: commercial control (batch, sequential, MID, TID, CID). Padded. */
16
+ type MedianetControlParams = MedianetConnection & {
17
+ batch: string;
18
+ sequential: string;
19
+ mid: string;
20
+ tid: string;
21
+ cid: string;
22
+ };
23
+ /** `PP` sale, void or reverse. Amounts are 12-digit cents, all padded. */
24
+ type MedianetAuthorizationParams = MedianetConnection & {
25
+ total: string;
26
+ subtotal: string;
27
+ baseWithoutTax: string;
28
+ tax: string;
29
+ /** Sequential of the original sale; blanks on a sale. */
30
+ sequential: string;
31
+ /** `HHmmss`. */
32
+ hour: string;
33
+ /** `YYYYMMDD`. */
34
+ date: string;
35
+ /** Authorization number of the original sale; blanks on a sale. */
36
+ authorization: string;
37
+ mid: string;
38
+ tid: string;
39
+ cid: string;
40
+ };
41
+ /** Raw ASCII answer of the pinpad and whether its hash checked out. */
42
+ type MedianetFrameResponse = {
43
+ response: string;
44
+ hashValid: boolean;
45
+ };
46
+ type MedianetHealth = {
47
+ consecutiveTimeouts: number;
48
+ executorReplacements: number;
49
+ lastTimeoutOperation: string | null;
50
+ lastCallOperation: string | null;
51
+ lastCallDurationMs: number;
52
+ };
53
+ /** Shape of the Expo module registered as `Medianet` on Android. */
54
+ type MedianetNativeModule = {
55
+ linkTest: (params: MedianetConnection) => Promise<MedianetFrameResponse>;
56
+ setup: (params: MedianetSetupParams) => Promise<MedianetFrameResponse>;
57
+ control: (params: MedianetControlParams) => Promise<MedianetFrameResponse>;
58
+ sale: (params: MedianetAuthorizationParams) => Promise<MedianetFrameResponse>;
59
+ void: (params: MedianetAuthorizationParams) => Promise<MedianetFrameResponse>;
60
+ reverse: (params: MedianetAuthorizationParams) => Promise<MedianetFrameResponse>;
61
+ getHealth: () => MedianetHealth;
62
+ /** Gates `MEDIANET_DEBUG` logcat lines. Off until the adapter enables it. */
63
+ setLogs: (enabled: boolean) => void;
64
+ };
65
+ //#endregion
66
+ //#region src/client.d.ts
67
+ type MedianetClientOptions = {
68
+ /** Extra wait after the native ceiling before the JS side gives up. */
69
+ graceMs?: number;
70
+ };
71
+ type Frame = Promise<MedianetFrameResponse>;
72
+ type MedianetClient = {
73
+ readonly available: boolean;
74
+ /** `LT`: link test. */
75
+ linkTest: (params: MedianetConnection) => Frame;
76
+ /** `CP`: network settings of the pinpad. */
77
+ setup: (params: MedianetSetupParams) => Frame;
78
+ /** `PC`: opens the batch of the day with MID, TID and CID. */
79
+ control: (params: MedianetControlParams) => Frame;
80
+ /** `PP` / `01`. */
81
+ sale: (params: MedianetAuthorizationParams) => Frame;
82
+ /** `PP` / `03`: void of an approved sale. */
83
+ void: (params: MedianetAuthorizationParams) => Frame;
84
+ /** `PP` / `04`: reverse of a sale with an unknown result. */
85
+ reverse: (params: MedianetAuthorizationParams) => Frame;
86
+ getHealth: () => MedianetHealth | null;
87
+ };
88
+ declare const createMedianetClient: (native: MedianetNativeModule | null, options?: MedianetClientOptions) => MedianetClient;
89
+ //#endregion
90
+ //#region src/constants.d.ts
91
+ /** Pinpad result codes of every frame (legacy kiosk; ficha §11). */
92
+ declare const MEDIANET_PINPAD_CODE: {
93
+ readonly OK: "00";
94
+ readonly FRAME_ERROR: "01";
95
+ readonly CONNECTION_ERROR: "02";
96
+ readonly PROCESS_ERROR: "20";
97
+ readonly TIMEOUT: "TO";
98
+ readonly PINPAD_ERROR: "ER";
99
+ };
100
+ /** Authorizer response code of an approved `PP`. */
101
+ declare const MEDIANET_APPROVED = "00";
102
+ declare const MEDIANET_TIMEOUT_CODE = "MEDIANET_TIMEOUT";
103
+ /**
104
+ * Socket timeout floor: the pinpad waits for card and PIN inside one
105
+ * frame, so shorter values abort real payments (legacy kiosk).
106
+ */
107
+ declare const MEDIANET_MIN_TIMEOUT_MS = 60000;
108
+ /** Native ceiling = socket timeout + 5 s (`MedianetModule.kt`). */
109
+ declare const MEDIANET_NATIVE_GRACE_MS = 5000;
110
+ /** Extra wait of the JS backstop after the native ceiling. */
111
+ declare const MEDIANET_JS_GRACE_MS = 5000;
112
+ declare const MEDIANET_DEFAULT_TIME_ZONE = "America/Guayaquil";
113
+ type MedianetOperation = "linkTest" | "setup" | "control" | "sale" | "void" | "reverse";
114
+ //#endregion
115
+ //#region src/debug.d.ts
116
+ /** Turns console and Android logcat traces on or off. Default is off. */
117
+ declare const setMedianetLogs: (value: boolean) => void;
118
+ /** Logs one step: `[MEDIANET_DEBUG] scope › event { data }`. */
119
+ declare const medianetDebug: (scope: string, event: string, data?: unknown) => void;
120
+ /** Logs the input, the output or the error, and the duration of a call. */
121
+ declare const medianetDebugCall: <T>(scope: string, input: unknown, call: () => Promise<T>) => Promise<T>;
122
+ //#endregion
123
+ //#region src/errors.d.ts
124
+ declare class MedianetUnavailableError extends Error {
125
+ readonly code = "MEDIANET_UNAVAILABLE";
126
+ constructor();
127
+ }
128
+ type MedianetTimeoutInfo = {
129
+ operation: string;
130
+ timeoutMs: number;
131
+ /** `native`: the Kotlin ceiling fired; `js`: the native call never settled. */
132
+ source: "native" | "js";
133
+ };
134
+ declare class MedianetTimeoutError extends Error {
135
+ readonly code = "MEDIANET_TIMEOUT";
136
+ readonly info: MedianetTimeoutInfo;
137
+ constructor(info: MedianetTimeoutInfo);
138
+ }
139
+ /** Turns the native `MEDIANET_TIMEOUT` rejection (JSON message) into a typed error. */
140
+ declare const toMedianetTimeout: (error: unknown) => MedianetTimeoutError | null;
141
+ //#endregion
142
+ //#region src/frames.d.ts
143
+ /** Merchant id: 15, space-padded right. */
144
+ declare const medianetMid: (mid: string) => string;
145
+ /** Terminal id: 8, zero-padded left. */
146
+ declare const medianetTid: (tid: string) => string;
147
+ /** Cashier id: 15, space-padded right. */
148
+ declare const medianetCid: (cid: string) => string;
149
+ /** Amount in cents: 12, zero-padded left. */
150
+ declare const medianetAmount: (cents: number) => string;
151
+ /** `10.0.0.5` → `010.000.000.005`. */
152
+ declare const medianetIp: (ip: string) => string;
153
+ /** Network mask: 15, space-padded right (legacy kiosk). */
154
+ declare const medianetMask: (mask: string) => string;
155
+ /** Listen port of the pinpad: 6, zero-padded left. */
156
+ declare const medianetListenPort: (port: number) => string;
157
+ /** Original sequential / authorization of a void: 6, as returned. */
158
+ declare const medianetReference: (value: string | null | undefined) => string;
159
+ /** Socket timeout in ms, never under the 60 s floor. */
160
+ declare const medianetTimeoutMs: (timeoutMs?: number | null) => number;
161
+ type MedianetDateTime = {
162
+ /** `YYYYMMDD`. */
163
+ date: string;
164
+ /** `HHmmss`. */
165
+ hour: string;
166
+ /** `DDMMYY`: batch of the day sent in `PC`. */
167
+ batch: string;
168
+ };
169
+ /** Store-local date and time; Ecuador by default. */
170
+ declare const medianetDateTime: (date?: Date, timeZone?: string) => MedianetDateTime;
171
+ /** `PC` sequential: last 2 chars of the cashier id + `0001` (legacy kiosk). */
172
+ declare const medianetControlSequential: (cid: string) => string;
173
+ //#endregion
174
+ //#region src/parse.d.ts
175
+ /** Answer of `CP` / `PC`: message type, pinpad code and text. */
176
+ type MedianetStatusResponse = {
177
+ type: string;
178
+ code: string;
179
+ message: string;
180
+ };
181
+ /** Answer of `PP` (sale, void, reverse). Fields trimmed; blanks → `""`. */
182
+ type MedianetAuthorizationResponse = MedianetStatusResponse & {
183
+ /** `02` Medianet (`01` Datafast, `03` Austro). */
184
+ network: string;
185
+ /** Authorizer response code; `00` approved. */
186
+ responseCode: string;
187
+ sequential: string;
188
+ batch: string;
189
+ /** `HHmmss`. */
190
+ hour: string;
191
+ /** `YYYYMMDD`. */
192
+ date: string;
193
+ authorization: string;
194
+ terminalId: string;
195
+ merchantId: string;
196
+ interest: string;
197
+ award: string;
198
+ acquirerCode: string;
199
+ acquirerName: string;
200
+ cardGroup: string;
201
+ /** Reading mode as returned by the pinpad. */
202
+ entryMode: string;
203
+ cardholder: string;
204
+ fixedAmount: string;
205
+ applicationLabel: string;
206
+ aid: string;
207
+ cryptogram: string;
208
+ pinVerification: string;
209
+ arqc: string;
210
+ tvr: string;
211
+ tsi: string;
212
+ /** Masked PAN, e.g. `41668200XXXX6319`. */
213
+ maskedPan: string;
214
+ };
215
+ /** Offsets from the legacy kiosk (`medianet.utils.ts`; ficha §8). */
216
+ declare const parseMedianetAuthorization: (response: string) => MedianetAuthorizationResponse;
217
+ /** `PC`: 2 filler chars between the code and the message. */
218
+ declare const parseMedianetControl: (response: string) => MedianetStatusResponse;
219
+ declare const parseMedianetSetup: (response: string) => MedianetStatusResponse;
220
+ //#endregion
221
+ //#region src/index.d.ts
222
+ /** Shared client bound to the device's native module; one per app. */
223
+ declare const medianet: MedianetClient;
224
+ //#endregion
225
+ export { MEDIANET_APPROVED, MEDIANET_DEFAULT_TIME_ZONE, MEDIANET_JS_GRACE_MS, MEDIANET_MIN_TIMEOUT_MS, MEDIANET_NATIVE_GRACE_MS, MEDIANET_PINPAD_CODE, MEDIANET_TIMEOUT_CODE, MedianetAuthorizationParams, MedianetAuthorizationResponse, MedianetClient, MedianetClientOptions, MedianetConnection, MedianetControlParams, MedianetDateTime, MedianetFrameResponse, MedianetHealth, MedianetNativeModule, MedianetOperation, MedianetSetupParams, MedianetStatusResponse, MedianetTimeoutError, MedianetTimeoutInfo, MedianetUnavailableError, createMedianetClient, medianet, medianetAmount, medianetCid, medianetControlSequential, medianetDateTime, medianetDebug, medianetDebugCall, medianetIp, medianetListenPort, medianetMask, medianetMid, medianetReference, medianetTid, medianetTimeoutMs, parseMedianetAuthorization, parseMedianetControl, parseMedianetSetup, setMedianetLogs, toMedianetTimeout };
package/dist/index.mjs ADDED
@@ -0,0 +1,325 @@
1
+ import { requireOptionalNativeModule } from "expo";
2
+ //#region src/constants.ts
3
+ /** Pinpad result codes of every frame (legacy kiosk; ficha §11). */
4
+ const MEDIANET_PINPAD_CODE = {
5
+ OK: "00",
6
+ FRAME_ERROR: "01",
7
+ CONNECTION_ERROR: "02",
8
+ PROCESS_ERROR: "20",
9
+ TIMEOUT: "TO",
10
+ PINPAD_ERROR: "ER"
11
+ };
12
+ /** Authorizer response code of an approved `PP`. */
13
+ const MEDIANET_APPROVED = "00";
14
+ const MEDIANET_TIMEOUT_CODE = "MEDIANET_TIMEOUT";
15
+ /**
16
+ * Socket timeout floor: the pinpad waits for card and PIN inside one
17
+ * frame, so shorter values abort real payments (legacy kiosk).
18
+ */
19
+ const MEDIANET_MIN_TIMEOUT_MS = 6e4;
20
+ /** Native ceiling = socket timeout + 5 s (`MedianetModule.kt`). */
21
+ const MEDIANET_NATIVE_GRACE_MS = 5e3;
22
+ /** Extra wait of the JS backstop after the native ceiling. */
23
+ const MEDIANET_JS_GRACE_MS = 5e3;
24
+ const MEDIANET_DEFAULT_TIME_ZONE = "America/Guayaquil";
25
+ //#endregion
26
+ //#region src/native.ts
27
+ /** `null` on iOS, web, or an Android build without the Medianet plugin. */
28
+ const medianetNative = requireOptionalNativeModule("Medianet");
29
+ //#endregion
30
+ //#region src/debug.ts
31
+ const TAG = "[MEDIANET_DEBUG]";
32
+ let enabled = false;
33
+ const elapsed = (startedAt) => Date.now() - startedAt;
34
+ /** The pinpad frame carries the cardholder, the cryptogram and a PAN. */
35
+ const frameOf = (value) => {
36
+ if (!("response" in value) || !("hashValid" in value)) return null;
37
+ const frame = value;
38
+ if (typeof frame.response !== "string") return null;
39
+ if (typeof frame.hashValid !== "boolean") return null;
40
+ return {
41
+ len: frame.response.length,
42
+ hashValid: frame.hashValid
43
+ };
44
+ };
45
+ const safe = (value) => {
46
+ if (value instanceof Map) return Object.fromEntries(value);
47
+ if (value instanceof Error) {
48
+ const code = value.code;
49
+ return {
50
+ name: value.name,
51
+ message: value.message,
52
+ code
53
+ };
54
+ }
55
+ if (value && typeof value === "object") {
56
+ const frame = frameOf(value);
57
+ if (frame) return frame;
58
+ }
59
+ return value;
60
+ };
61
+ /** Turns console and Android logcat traces on or off. Default is off. */
62
+ const setMedianetLogs = (value) => {
63
+ enabled = value;
64
+ medianetNative?.setLogs(value);
65
+ };
66
+ /** Logs one step: `[MEDIANET_DEBUG] scope › event { data }`. */
67
+ const medianetDebug = (scope, event, data) => {
68
+ if (!enabled) return;
69
+ const time = (/* @__PURE__ */ new Date()).toISOString();
70
+ if (data === void 0) return console.log(TAG, time, `${scope} › ${event}`);
71
+ console.log(TAG, time, `${scope} › ${event}`, safe(data));
72
+ };
73
+ /** Logs the input, the output or the error, and the duration of a call. */
74
+ const medianetDebugCall = async (scope, input, call) => {
75
+ const startedAt = Date.now();
76
+ medianetDebug(scope, "→ in", input);
77
+ try {
78
+ const output = await call();
79
+ const ms = elapsed(startedAt);
80
+ medianetDebug(scope, "← out", {
81
+ output: safe(output),
82
+ ms
83
+ });
84
+ return output;
85
+ } catch (error) {
86
+ const ms = elapsed(startedAt);
87
+ medianetDebug(scope, "✕ error", {
88
+ error: safe(error),
89
+ ms
90
+ });
91
+ throw error;
92
+ }
93
+ };
94
+ //#endregion
95
+ //#region src/errors.ts
96
+ var MedianetUnavailableError = class extends Error {
97
+ code = "MEDIANET_UNAVAILABLE";
98
+ constructor() {
99
+ super("Medianet native module is not available (Android build with the Medianet plugin required)");
100
+ this.name = "MedianetUnavailableError";
101
+ }
102
+ };
103
+ var MedianetTimeoutError = class extends Error {
104
+ code = MEDIANET_TIMEOUT_CODE;
105
+ info;
106
+ constructor(info) {
107
+ super(`Medianet ${info.operation} timed out after ${info.timeoutMs} ms`);
108
+ this.name = "MedianetTimeoutError";
109
+ this.info = info;
110
+ }
111
+ };
112
+ const asRecord = (value) => {
113
+ if (typeof value !== "object" || value === null) return null;
114
+ return value;
115
+ };
116
+ const parseJson = (value) => {
117
+ try {
118
+ return asRecord(JSON.parse(String(value))) ?? {};
119
+ } catch {
120
+ return {};
121
+ }
122
+ };
123
+ /** Turns the native `MEDIANET_TIMEOUT` rejection (JSON message) into a typed error. */
124
+ const toMedianetTimeout = (error) => {
125
+ const record = asRecord(error);
126
+ if (!record || record.code !== "MEDIANET_TIMEOUT") return null;
127
+ const payload = parseJson(record.message);
128
+ return new MedianetTimeoutError({
129
+ operation: typeof payload.operation === "string" ? payload.operation : "unknown",
130
+ timeoutMs: typeof payload.timeoutMs === "number" ? payload.timeoutMs : 0,
131
+ source: "native"
132
+ });
133
+ };
134
+ //#endregion
135
+ //#region src/client.ts
136
+ const createMedianetClient = (native, options = {}) => {
137
+ const graceMs = options.graceMs ?? 5e3;
138
+ const boundedCall = async (operation, params, call) => {
139
+ if (!native) throw new MedianetUnavailableError();
140
+ const timeoutMs = params.timeoutMs + MEDIANET_NATIVE_GRACE_MS + graceMs;
141
+ let timer;
142
+ const backstop = new Promise((_, reject) => {
143
+ const info = {
144
+ operation,
145
+ timeoutMs,
146
+ source: "js"
147
+ };
148
+ const fire = () => reject(new MedianetTimeoutError(info));
149
+ timer = setTimeout(fire, timeoutMs);
150
+ });
151
+ try {
152
+ return await Promise.race([call(native, params), backstop]);
153
+ } catch (error) {
154
+ let timeout = null;
155
+ if (error instanceof MedianetTimeoutError) timeout = error;
156
+ else timeout = toMedianetTimeout(error);
157
+ if (!timeout) throw error;
158
+ medianetDebug(`client.${operation}`, "timeout", timeout.info);
159
+ throw timeout;
160
+ } finally {
161
+ clearTimeout(timer);
162
+ }
163
+ };
164
+ const bounded = async (operation, params, call) => {
165
+ const run = () => boundedCall(operation, params, call);
166
+ return medianetDebugCall(`client.${operation}`, params, run);
167
+ };
168
+ return {
169
+ available: native !== null,
170
+ linkTest: (params) => bounded("linkTest", params, (m, p) => m.linkTest(p)),
171
+ setup: (params) => bounded("setup", params, (m, p) => m.setup(p)),
172
+ control: (params) => bounded("control", params, (m, p) => m.control(p)),
173
+ sale: (params) => bounded("sale", params, (m, p) => m.sale(p)),
174
+ void: (params) => bounded("void", params, (m, p) => m.void(p)),
175
+ reverse: (params) => bounded("reverse", params, (m, p) => m.reverse(p)),
176
+ getHealth: () => {
177
+ const health = native?.getHealth() ?? null;
178
+ medianetDebug("client.getHealth", "← out", health);
179
+ return health;
180
+ }
181
+ };
182
+ };
183
+ //#endregion
184
+ //#region src/frames.ts
185
+ const fit = (value, length, field) => {
186
+ if (value.length > length) throw new RangeError(`Medianet ${field} longer than ${length}: ${value}`);
187
+ return value;
188
+ };
189
+ const padEnd = (value, length, field) => {
190
+ return fit(value.trim(), length, field).padEnd(length, " ");
191
+ };
192
+ const padStart = (value, length, field, fill = "0") => {
193
+ return fit(value.trim(), length, field).padStart(length, fill);
194
+ };
195
+ /** Merchant id: 15, space-padded right. */
196
+ const medianetMid = (mid) => padEnd(mid, 15, "MID");
197
+ /** Terminal id: 8, zero-padded left. */
198
+ const medianetTid = (tid) => padStart(tid, 8, "TID");
199
+ /** Cashier id: 15, space-padded right. */
200
+ const medianetCid = (cid) => padEnd(cid, 15, "CID");
201
+ /** Amount in cents: 12, zero-padded left. */
202
+ const medianetAmount = (cents) => {
203
+ if (!Number.isInteger(cents) || cents < 0) throw new RangeError(`Invalid Medianet amount: ${cents}`);
204
+ return padStart(String(cents), 12, "amount");
205
+ };
206
+ /** `10.0.0.5` → `010.000.000.005`. */
207
+ const medianetIp = (ip) => {
208
+ const padded = ip.trim().split(".").map((octet) => octet.padStart(3, "0")).join(".");
209
+ return fit(padded, 15, "IP");
210
+ };
211
+ /** Network mask: 15, space-padded right (legacy kiosk). */
212
+ const medianetMask = (mask) => padEnd(mask, 15, "mask");
213
+ /** Listen port of the pinpad: 6, zero-padded left. */
214
+ const medianetListenPort = (port) => {
215
+ return padStart(String(port), 6, "port");
216
+ };
217
+ /** Original sequential / authorization of a void: 6, as returned. */
218
+ const medianetReference = (value) => {
219
+ return padStart(value ?? "", 6, "reference", " ");
220
+ };
221
+ /** Socket timeout in ms, never under the 60 s floor. */
222
+ const medianetTimeoutMs = (timeoutMs) => {
223
+ const value = Number(timeoutMs);
224
+ if (!Number.isFinite(value) || value <= 0) return MEDIANET_MIN_TIMEOUT_MS;
225
+ return Math.max(Math.round(value), MEDIANET_MIN_TIMEOUT_MS);
226
+ };
227
+ const formatters = /* @__PURE__ */ new Map();
228
+ const formatterFor = (timeZone) => {
229
+ const cached = formatters.get(timeZone);
230
+ if (cached) return cached;
231
+ const options = {
232
+ timeZone,
233
+ year: "numeric",
234
+ month: "2-digit",
235
+ day: "2-digit",
236
+ hour: "2-digit",
237
+ minute: "2-digit",
238
+ second: "2-digit",
239
+ hourCycle: "h23"
240
+ };
241
+ const formatter = new Intl.DateTimeFormat("en-US", options);
242
+ formatters.set(timeZone, formatter);
243
+ return formatter;
244
+ };
245
+ /** Store-local date and time; Ecuador by default. */
246
+ const medianetDateTime = (date = /* @__PURE__ */ new Date(), timeZone = MEDIANET_DEFAULT_TIME_ZONE) => {
247
+ const parts = Object.fromEntries(formatterFor(timeZone).formatToParts(date).map((part) => [part.type, part.value]));
248
+ const { year = "", month = "", day = "" } = parts;
249
+ return {
250
+ date: `${year}${month}${day}`,
251
+ hour: `${parts.hour}${parts.minute}${parts.second}`,
252
+ batch: `${day}${month}${year.slice(-2)}`
253
+ };
254
+ };
255
+ /** `PC` sequential: last 2 chars of the cashier id + `0001` (legacy kiosk). */
256
+ const medianetControlSequential = (cid) => {
257
+ return `${cid.trim().slice(-2).padStart(2, "0")}0001`;
258
+ };
259
+ //#endregion
260
+ //#region src/parse.ts
261
+ /** Drops the 4-digit length prefix the library puts before the frame. */
262
+ const payloadOf = (response) => response.slice(4);
263
+ const slice = (data, from, to) => {
264
+ return data.slice(from, to).trim();
265
+ };
266
+ /** Offsets from the legacy kiosk (`medianet.utils.ts`; ficha §8). */
267
+ const parseMedianetAuthorization = (response) => {
268
+ const data = payloadOf(response);
269
+ const at = (from, to) => slice(data, from, to);
270
+ return {
271
+ type: at(0, 2),
272
+ code: at(2, 4),
273
+ network: at(4, 6),
274
+ responseCode: at(6, 8),
275
+ message: at(8, 28),
276
+ sequential: at(28, 34),
277
+ batch: at(34, 40),
278
+ hour: at(40, 46),
279
+ date: at(46, 54),
280
+ authorization: at(54, 60),
281
+ terminalId: at(60, 68),
282
+ merchantId: at(68, 83),
283
+ interest: at(83, 95),
284
+ award: at(95, 175),
285
+ acquirerCode: at(175, 178),
286
+ acquirerName: at(178, 208),
287
+ cardGroup: at(208, 233),
288
+ entryMode: at(233, 235),
289
+ cardholder: at(235, 275),
290
+ fixedAmount: at(275, 287),
291
+ applicationLabel: at(287, 307),
292
+ aid: at(307, 327),
293
+ cryptogram: at(327, 349),
294
+ pinVerification: at(349, 364),
295
+ arqc: at(364, 380),
296
+ tvr: at(380, 390),
297
+ tsi: at(390, 394),
298
+ maskedPan: at(394, 419)
299
+ };
300
+ };
301
+ /** `PC`: 2 filler chars between the code and the message. */
302
+ const parseMedianetControl = (response) => {
303
+ const data = payloadOf(response);
304
+ const at = (from, to) => slice(data, from, to);
305
+ return {
306
+ type: at(0, 2),
307
+ code: at(2, 4),
308
+ message: at(6, 26)
309
+ };
310
+ };
311
+ const parseMedianetSetup = (response) => {
312
+ const data = payloadOf(response);
313
+ const at = (from, to) => slice(data, from, to);
314
+ return {
315
+ type: at(0, 2),
316
+ code: at(2, 4),
317
+ message: at(4, 24)
318
+ };
319
+ };
320
+ //#endregion
321
+ //#region src/index.ts
322
+ /** Shared client bound to the device's native module; one per app. */
323
+ const medianet = createMedianetClient(medianetNative);
324
+ //#endregion
325
+ export { MEDIANET_APPROVED, MEDIANET_DEFAULT_TIME_ZONE, MEDIANET_JS_GRACE_MS, MEDIANET_MIN_TIMEOUT_MS, MEDIANET_NATIVE_GRACE_MS, MEDIANET_PINPAD_CODE, MEDIANET_TIMEOUT_CODE, MedianetTimeoutError, MedianetUnavailableError, createMedianetClient, medianet, medianetAmount, medianetCid, medianetControlSequential, medianetDateTime, medianetDebug, medianetDebugCall, medianetIp, medianetListenPort, medianetMask, medianetMid, medianetReference, medianetTid, medianetTimeoutMs, parseMedianetAuthorization, parseMedianetControl, parseMedianetSetup, setMedianetLogs, toMedianetTimeout };
@@ -0,0 +1,6 @@
1
+ {
2
+ "platforms": ["android"],
3
+ "android": {
4
+ "modules": ["dev.firespark.payments.medianet.MedianetModule"]
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "@fire-spark/payments-medianet-client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.13.0",
4
+ "description": "Fire Spark native client for the Medianet pinpad (Ecuador) on Android: CajaPinPad V3 TCP bridge shared by the Medianet 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
  }
@@ -0,0 +1,57 @@
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 = "CajaPinPadLibrary_V3.jar";
10
+ const MARKER = "// @fire-spark/payments-medianet-client";
11
+
12
+ /**
13
+ * Expected layout of `binariesDir` (per environment, never committed):
14
+ * CajaPinPadLibrary_V3.jar (Medianet / WPOSS `cajapinpad` library)
15
+ */
16
+ const withMedianetBinaries = (config, { binariesDir }) =>
17
+ withDangerousMod(config, [
18
+ "android",
19
+ async cfg => {
20
+ const source = path.resolve(cfg.modRequest.projectRoot, binariesDir);
21
+ const jar = path.join(source, JAR);
22
+ if (!fs.existsSync(jar)) {
23
+ throw new Error(`[medianet] missing ${jar}; set binariesDir to the Medianet files of this environment`);
24
+ }
25
+ const target = path.join(cfg.modRequest.platformProjectRoot, "app", "libs", JAR);
26
+ fs.mkdirSync(path.dirname(target), { recursive: true });
27
+ fs.copyFileSync(jar, target);
28
+ return cfg;
29
+ }
30
+ ]);
31
+
32
+ const gradleBlock = `
33
+ ${MARKER}
34
+ dependencies {
35
+ implementation files("libs/${JAR}")
36
+ }
37
+ `;
38
+
39
+ const withMedianetGradle = config =>
40
+ withAppBuildGradle(config, cfg => {
41
+ if (!cfg.modResults.contents.includes(MARKER)) {
42
+ cfg.modResults.contents += gradleBlock;
43
+ }
44
+ return cfg;
45
+ });
46
+
47
+ /** @param {{ binariesDir: string }} options */
48
+ const withMedianet = (config, options = {}) => {
49
+ if (!options.binariesDir) {
50
+ throw new Error("[medianet] the plugin needs { binariesDir }");
51
+ }
52
+ config = AndroidConfig.Permissions.withPermissions(config, ["android.permission.INTERNET"]);
53
+ config = withMedianetBinaries(config, options);
54
+ return withMedianetGradle(config);
55
+ };
56
+
57
+ module.exports = withMedianet;
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.