@fire-spark/payments-clisitef-pinpad-rn 0.7.2 → 0.8.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.
- package/dist/index.d.ts +131 -10
- package/dist/index.js +795 -204
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { PaymentError, createPaymentError, definePaymentErrors, toPaymentError } from "@fire-spark/payments";
|
|
2
|
-
import { CLISITEF_ABORT_FLAG, CLISITEF_CONFIRM, CLISITEF_MODALITY, CLISITEF_RESULT, CLISITEF_TIMEOUT_CODE, clisitef, clisitefAdditionalParams, clisitefDebug, clisitefDebugCall, clisitefFiscalDateTime, formatClisitefAmount, parseClisitefMenu } from "@fire-spark/payments-clisitef-client";
|
|
2
|
+
import { CLISITEF_ABORT_FLAG, CLISITEF_CONFIRM, CLISITEF_MODALITY, CLISITEF_RESULT, CLISITEF_TIMEOUT_CODE, CLISITEF_VOID_FUNCTION, clisitef, clisitefAdditionalParams, clisitefDebug, clisitefDebugCall, clisitefFiscalDateTime, formatClisitefAmount, parseClisitefMenu } from "@fire-spark/payments-clisitef-client";
|
|
3
3
|
//#region src/constants.ts
|
|
4
4
|
/**
|
|
5
5
|
* `onData` commands handled by the adapter (Fiserv "Tabela de códigos de
|
|
6
6
|
* comando"). Not handled, so they abort the transaction: 29 (silent field),
|
|
7
|
-
* 31 (check), 35 (barcode)
|
|
8
|
-
* `ItemMenuIdentificado`)
|
|
7
|
+
* 31 (check), 35 (barcode) and 42 (identified menu, only with
|
|
8
|
+
* `ItemMenuIdentificado`).
|
|
9
9
|
*/
|
|
10
10
|
const CLISITEF_COMMAND = {
|
|
11
11
|
STORE_FIELD: 0,
|
|
@@ -26,6 +26,9 @@ const CLISITEF_COMMAND = {
|
|
|
26
26
|
READ_FIELD: 30,
|
|
27
27
|
READ_AMOUNT: 34,
|
|
28
28
|
READ_MASKED: 41,
|
|
29
|
+
/** PIX: show the QR (`fieldId` 584 carries the copy-and-paste payload). */
|
|
30
|
+
QR_SHOW: 50,
|
|
31
|
+
QR_HIDE: 51,
|
|
29
32
|
FOOTER: 52
|
|
30
33
|
};
|
|
31
34
|
/** `continueTransaction` answers to `CONFIRM` (20). */
|
|
@@ -43,9 +46,17 @@ const CLISITEF_FIELD = {
|
|
|
43
46
|
AUTHORIZATION_CODE: 135,
|
|
44
47
|
CARD_BIN: 136,
|
|
45
48
|
INSTITUTION_NAME: 156,
|
|
49
|
+
QR_PAYLOAD: 584,
|
|
46
50
|
LAST4: 1190,
|
|
47
51
|
ENTRY_MODE: 2090
|
|
48
52
|
};
|
|
53
|
+
/** Fields a void asks for (`READ_*` commands), answered without the UI. */
|
|
54
|
+
const CLISITEF_VOID_FIELD = {
|
|
55
|
+
AMOUNT: 146,
|
|
56
|
+
SUPERVISOR_PASSWORD: 500,
|
|
57
|
+
ORIGINAL_DATE: 515,
|
|
58
|
+
ORIGINAL_HOST_NSU: 516
|
|
59
|
+
};
|
|
49
60
|
/** Fields that prove the card was read: an error after them is ambiguous. */
|
|
50
61
|
const CLISITEF_CAPTURE_FIELDS = /* @__PURE__ */ new Set([
|
|
51
62
|
CLISITEF_FIELD.CUSTOMER_RECEIPT,
|
|
@@ -80,6 +91,12 @@ const CLISITEF_PAYMENT_TIMEOUT_MS = 18e4;
|
|
|
80
91
|
const CLISITEF_DEFAULT_OPERATOR = "KIOSKO_BR";
|
|
81
92
|
/** Device-wide record of the last transaction, to undo it after a crash. */
|
|
82
93
|
const CLISITEF_STORAGE_KEY = "clisitef:transaction:v1";
|
|
94
|
+
/** Device-wide record of the last refund, apart from the charge one. */
|
|
95
|
+
const CLISITEF_REFUND_STORAGE_KEY = "clisitef:refund:v1";
|
|
96
|
+
/** Device-wide queue of voids that did not complete, retried later. */
|
|
97
|
+
const CLISITEF_REVERSALS_KEY = "clisitef:reversals:v1";
|
|
98
|
+
/** Device-wide record of completed voids, by attempt reference. */
|
|
99
|
+
const CLISITEF_REFUNDS_KEY = "clisitef:refunds:v1";
|
|
83
100
|
//#endregion
|
|
84
101
|
//#region src/errors.ts
|
|
85
102
|
const source = "docs/payments/clisitef/pinpad.md#11-errores";
|
|
@@ -110,14 +127,18 @@ const clisitefPinPadErrors = definePaymentErrors({
|
|
|
110
127
|
"-15": entry("CANCELLED_BY_USER"),
|
|
111
128
|
"-20": invalid("Invalid CliSiTef parameter"),
|
|
112
129
|
"-21": invalid("Prohibited word in pinpad collection"),
|
|
113
|
-
"-24":
|
|
130
|
+
"-24": entry("WALLET_REJECTED", {
|
|
131
|
+
message: "Digital wallet not enabled",
|
|
132
|
+
retryable: false
|
|
133
|
+
}),
|
|
114
134
|
"-30": entry("TERMINAL_UNAVAILABLE", { message: "File access error" }),
|
|
115
135
|
"-40": entry("CARD_DECLINED", { message: "Declined by the SiTef server" }),
|
|
116
136
|
"-41": invalid("Invalid data"),
|
|
117
137
|
"-43": entry("TERMINAL_UNAVAILABLE", { message: "Pinpad routine failed" }),
|
|
118
138
|
"-50": entry("FRAUD_SUSPECTED", { message: "Unsecured transaction" }),
|
|
119
139
|
"-100": entry("UNKNOWN", { message: "CliSiTef internal error" }),
|
|
120
|
-
[CLISITEF_TIMEOUT_CODE]: entry("TIMEOUT")
|
|
140
|
+
[CLISITEF_TIMEOUT_CODE]: entry("TIMEOUT"),
|
|
141
|
+
FINANCING_ONLY: invalid("Only deferred payment offered by SiTef")
|
|
121
142
|
});
|
|
122
143
|
//#endregion
|
|
123
144
|
//#region src/metadata.ts
|
|
@@ -164,27 +185,43 @@ const CARD_TYPES = {
|
|
|
164
185
|
VOUCHER: "PREPAID",
|
|
165
186
|
PIX: null
|
|
166
187
|
};
|
|
167
|
-
|
|
188
|
+
/** A PIX charge has no card: never take brand or last 4 from its receipt. */
|
|
189
|
+
const cardOf = (input, receipt) => {
|
|
168
190
|
const { fields } = input;
|
|
191
|
+
const entry = field(fields, CLISITEF_FIELD.ENTRY_MODE);
|
|
192
|
+
if (input.modality === "PIX") return {
|
|
193
|
+
brand: null,
|
|
194
|
+
last4: null,
|
|
195
|
+
entry_mode: "QR"
|
|
196
|
+
};
|
|
197
|
+
const last4 = field(fields, CLISITEF_FIELD.LAST4)?.slice(-4) ?? null;
|
|
198
|
+
return {
|
|
199
|
+
brand: clisitefBrand(fields),
|
|
200
|
+
last4: last4 ?? clisitefLast4(receipt),
|
|
201
|
+
entry_mode: entry ? ENTRY_MODES[entry] ?? null : null
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
const receiptOf = (fields) => {
|
|
169
205
|
const customer = field(fields, CLISITEF_FIELD.CUSTOMER_RECEIPT);
|
|
170
206
|
const merchant = field(fields, CLISITEF_FIELD.MERCHANT_RECEIPT);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
207
|
+
if (customer === null && merchant === null) return void 0;
|
|
208
|
+
return {
|
|
209
|
+
customer,
|
|
210
|
+
merchant,
|
|
211
|
+
format: "text"
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
const clisitefMetadata = (input) => {
|
|
215
|
+
const { fields } = input;
|
|
216
|
+
const receipt = receiptOf(fields);
|
|
174
217
|
const institution = field(fields, CLISITEF_FIELD.INSTITUTION_NAME);
|
|
175
218
|
const p1 = {
|
|
176
219
|
provider_transaction_id: field(fields, CLISITEF_FIELD.SITEF_NSU),
|
|
177
220
|
authorization_code: field(fields, CLISITEF_FIELD.AUTHORIZATION_CODE),
|
|
178
221
|
reference: field(fields, CLISITEF_FIELD.HOST_NSU)
|
|
179
222
|
};
|
|
180
|
-
const p2 = {
|
|
181
|
-
|
|
182
|
-
brand
|
|
183
|
-
};
|
|
184
|
-
const p3 = {
|
|
185
|
-
last4: last4 ?? clisitefLast4(customer ?? merchant),
|
|
186
|
-
entry_mode: entry ? ENTRY_MODES[entry] ?? null : null
|
|
187
|
-
};
|
|
223
|
+
const p2 = { card_type: CARD_TYPES[input.modality] ?? null };
|
|
224
|
+
const p3 = cardOf(input, receipt?.customer ?? receipt?.merchant ?? null);
|
|
188
225
|
const p4 = { acquirer: institution ?? field(fields, CLISITEF_FIELD.INSTITUTION_CODE) };
|
|
189
226
|
const p5 = { terminal_id: input.terminalNumber };
|
|
190
227
|
const p6 = { ticket_number: input.fiscalCoupon };
|
|
@@ -198,17 +235,39 @@ const clisitefMetadata = (input) => {
|
|
|
198
235
|
...p6,
|
|
199
236
|
...p7
|
|
200
237
|
};
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
238
|
+
const fn = CLISITEF_MODALITY[input.modality];
|
|
239
|
+
const provider = {
|
|
240
|
+
modality: input.modality,
|
|
241
|
+
function: fn
|
|
242
|
+
};
|
|
206
243
|
return {
|
|
207
244
|
attempt_reference: input.attemptReference,
|
|
208
245
|
payment,
|
|
209
|
-
receipt
|
|
246
|
+
receipt,
|
|
247
|
+
provider
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
/** Void data kept under `provider.void`, next to the charge's metadata. */
|
|
251
|
+
const clisitefVoidMetadata = (input) => {
|
|
252
|
+
const { fields } = input;
|
|
253
|
+
return {
|
|
254
|
+
function: input.function,
|
|
255
|
+
provider_transaction_id: field(fields, CLISITEF_FIELD.SITEF_NSU),
|
|
256
|
+
reference: field(fields, CLISITEF_FIELD.HOST_NSU),
|
|
257
|
+
ticket_number: input.fiscalCoupon,
|
|
258
|
+
transaction_date: input.fiscalDate,
|
|
259
|
+
receipt: receiptOf(fields) ?? null
|
|
210
260
|
};
|
|
211
261
|
};
|
|
262
|
+
/** Spark modality of a charge; older charges only kept `card_type`. */
|
|
263
|
+
const clisitefModalityOf = (metadata) => {
|
|
264
|
+
const stored = metadata.provider?.modality;
|
|
265
|
+
if (typeof stored === "string" && stored in CLISITEF_MODALITY) return stored;
|
|
266
|
+
const cardType = metadata.payment?.card_type;
|
|
267
|
+
if (cardType === "PREPAID") return "VOUCHER";
|
|
268
|
+
if (cardType === "DEBIT" || cardType === "CREDIT") return cardType;
|
|
269
|
+
return null;
|
|
270
|
+
};
|
|
212
271
|
/** PIN_PAD tender details; `null` without brand and last 4 (schema minimum). */
|
|
213
272
|
const clisitefDetails = (metadata) => {
|
|
214
273
|
const payment = metadata.payment;
|
|
@@ -225,138 +284,115 @@ const clisitefDetails = (metadata) => {
|
|
|
225
284
|
};
|
|
226
285
|
};
|
|
227
286
|
//#endregion
|
|
228
|
-
//#region src/
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
* the device values only override them.
|
|
238
|
-
*/
|
|
239
|
-
const clisitefPinPadDeviceFields = [{
|
|
240
|
-
key: "terminal_number",
|
|
241
|
-
label: "SiTef terminal number",
|
|
242
|
-
type: "text",
|
|
243
|
-
required: true
|
|
244
|
-
}, {
|
|
245
|
-
key: "pinpad_type",
|
|
246
|
-
label: "Pinpad connection",
|
|
247
|
-
type: "select",
|
|
248
|
-
options: [...CLISITEF_PINPAD_TYPES]
|
|
249
|
-
}];
|
|
250
|
-
/** The API only sends the entry of the kiosk's store. */
|
|
251
|
-
const storeSettingsOf = (config) => {
|
|
252
|
-
const stores = config.stores;
|
|
253
|
-
if (!stores || typeof stores !== "object") return {};
|
|
254
|
-
const [first] = Object.values(stores);
|
|
255
|
-
return first && typeof first === "object" ? first : {};
|
|
287
|
+
//#region src/reversals.ts
|
|
288
|
+
const readJson = async (storage, key) => {
|
|
289
|
+
const raw = await storage.get(key);
|
|
290
|
+
if (!raw) return null;
|
|
291
|
+
try {
|
|
292
|
+
return JSON.parse(raw);
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
256
296
|
};
|
|
257
|
-
const
|
|
258
|
-
|
|
297
|
+
const readPendingReversals = async (storage) => {
|
|
298
|
+
const list = await readJson(storage, CLISITEF_REVERSALS_KEY);
|
|
299
|
+
return Array.isArray(list) ? list : [];
|
|
259
300
|
};
|
|
260
|
-
const
|
|
261
|
-
|
|
301
|
+
const writePendingReversals = async (storage, list) => {
|
|
302
|
+
if (!list.length) return storage.remove(CLISITEF_REVERSALS_KEY);
|
|
303
|
+
await storage.set(CLISITEF_REVERSALS_KEY, JSON.stringify(list));
|
|
262
304
|
};
|
|
263
|
-
|
|
264
|
-
|
|
305
|
+
/** Adds the void or bumps its attempts. */
|
|
306
|
+
const queueReversal = async (storage, entry, now) => {
|
|
307
|
+
const list = await readPendingReversals(storage);
|
|
308
|
+
const reference = entry.attemptReference;
|
|
309
|
+
const previous = list.find((item) => item.attemptReference === reference);
|
|
310
|
+
const rest = list.filter((item) => item.attemptReference !== reference);
|
|
311
|
+
const attempts = (previous?.attempts ?? 0) + 1;
|
|
312
|
+
const createdAt = previous?.createdAt ?? now;
|
|
313
|
+
await writePendingReversals(storage, [...rest, {
|
|
314
|
+
...entry,
|
|
315
|
+
attempts,
|
|
316
|
+
createdAt
|
|
317
|
+
}]);
|
|
265
318
|
};
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
const
|
|
269
|
-
|
|
319
|
+
const dropReversal = async (storage, attemptReference) => {
|
|
320
|
+
const list = await readPendingReversals(storage);
|
|
321
|
+
const rest = list.filter((item) => item.attemptReference !== attemptReference);
|
|
322
|
+
if (rest.length !== list.length) await writePendingReversals(storage, rest);
|
|
270
323
|
};
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const raw = config.restrictions_by_modality;
|
|
274
|
-
const byModality = raw && typeof raw === "object" ? raw : {};
|
|
275
|
-
const legacy = text(config.restrictions);
|
|
276
|
-
const entries = modalities.map((modality) => {
|
|
277
|
-
return [modality, text(byModality[modality]) || legacy];
|
|
278
|
-
});
|
|
279
|
-
return Object.fromEntries(entries.filter(([, value]) => value));
|
|
324
|
+
const readRefund = async (storage, attemptReference) => {
|
|
325
|
+
return (await readJson(storage, "clisitef:refunds:v1"))?.[attemptReference] ?? null;
|
|
280
326
|
};
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
327
|
+
const writeRefund = async (storage, attemptReference, result) => {
|
|
328
|
+
const refunds = await readJson(storage, "clisitef:refunds:v1") ?? {};
|
|
329
|
+
const kept = Object.entries(refunds).filter(([key]) => {
|
|
330
|
+
return key !== attemptReference;
|
|
331
|
+
}).slice(-49);
|
|
332
|
+
const next = Object.fromEntries([...kept, [attemptReference, result]]);
|
|
333
|
+
await storage.set(CLISITEF_REFUNDS_KEY, JSON.stringify(next));
|
|
334
|
+
};
|
|
335
|
+
//#endregion
|
|
336
|
+
//#region src/menu.ts
|
|
337
|
+
const normalize = (label) => {
|
|
338
|
+
return label.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toUpperCase();
|
|
339
|
+
};
|
|
340
|
+
/** No voucher matcher: its brand menu is always shown. */
|
|
341
|
+
const MATCHERS = {
|
|
342
|
+
DEBIT: /DEBITO/,
|
|
343
|
+
CREDIT: /CREDITO/,
|
|
344
|
+
PIX: /PIX|QR|DIGITAL/
|
|
345
|
+
};
|
|
346
|
+
/** Debit and credit are already picked in the kiosk UI before the charge. */
|
|
347
|
+
const PRESELECTED = /* @__PURE__ */ new Set(["DEBIT", "CREDIT"]);
|
|
348
|
+
const SPOT = /A VISTA/;
|
|
349
|
+
const FINANCING = /PARCEL|PRE[\s-]?DATAD|CREDIARIO|JUROS|DIFERID/;
|
|
350
|
+
const matching = (options, pattern) => {
|
|
351
|
+
return options.filter((option) => pattern.test(normalize(option.label)));
|
|
352
|
+
};
|
|
353
|
+
/** The kiosk has no deferred payment: spot over financing, never financing. */
|
|
354
|
+
const chargeMenu = (options) => {
|
|
355
|
+
const financing = matching(options, FINANCING);
|
|
356
|
+
if (!financing.length) return {
|
|
357
|
+
kind: "ask",
|
|
358
|
+
options
|
|
306
359
|
};
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
clisitefDebug("pinpad.settings", "read", {
|
|
310
|
-
config,
|
|
311
|
-
device,
|
|
312
|
-
missing
|
|
360
|
+
const [spot] = matching(options, SPOT).filter((option) => {
|
|
361
|
+
return !financing.includes(option);
|
|
313
362
|
});
|
|
314
|
-
if (
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
cnpjEnterprise,
|
|
323
|
-
pinpadType
|
|
324
|
-
})
|
|
363
|
+
if (spot) return {
|
|
364
|
+
kind: "pick",
|
|
365
|
+
value: spot.value
|
|
366
|
+
};
|
|
367
|
+
const rest = options.filter((option) => !financing.includes(option));
|
|
368
|
+
if (!rest.length) return {
|
|
369
|
+
kind: "abort",
|
|
370
|
+
message: "Only deferred payment offered"
|
|
325
371
|
};
|
|
326
|
-
const operator = text(config.operator) || "KIOSKO_BR";
|
|
327
|
-
const unique = [...new Set(listed)];
|
|
328
372
|
return {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
operator,
|
|
332
|
-
restrictions: readRestrictions(config, unique),
|
|
333
|
-
terminalNumber
|
|
373
|
+
kind: "ask",
|
|
374
|
+
options: rest
|
|
334
375
|
};
|
|
335
376
|
};
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
if (
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
};
|
|
354
|
-
|
|
355
|
-
clisitefDebug("pinpad.store", `write ${record.state}`, record);
|
|
356
|
-
await storage.set(CLISITEF_STORAGE_KEY, JSON.stringify(record));
|
|
357
|
-
};
|
|
358
|
-
const isOpen = (record) => {
|
|
359
|
-
return record.state === "started" || record.state === "approved";
|
|
377
|
+
/**
|
|
378
|
+
* Command 21. Labels compare without accents and in upper case. A void
|
|
379
|
+
* picks the original modality when one option matches it; a charge does it
|
|
380
|
+
* only for debit and credit, then drops the financing options.
|
|
381
|
+
*/
|
|
382
|
+
const resolveClisitefMenu = (options, modality, purpose) => {
|
|
383
|
+
const matcher = MATCHERS[modality];
|
|
384
|
+
const preselected = purpose === "void" || PRESELECTED.has(modality);
|
|
385
|
+
const matched = matcher && preselected ? matching(options, matcher) : [];
|
|
386
|
+
const [only] = matched;
|
|
387
|
+
if (matched.length === 1 && only) return {
|
|
388
|
+
kind: "pick",
|
|
389
|
+
value: only.value
|
|
390
|
+
};
|
|
391
|
+
if (purpose === "void") return {
|
|
392
|
+
kind: "ask",
|
|
393
|
+
options
|
|
394
|
+
};
|
|
395
|
+
return chargeMenu(matched.length ? matched : options);
|
|
360
396
|
};
|
|
361
397
|
//#endregion
|
|
362
398
|
//#region src/transaction.ts
|
|
@@ -373,6 +409,11 @@ const inputType = (command) => {
|
|
|
373
409
|
if (command === CLISITEF_COMMAND.READ_AMOUNT) return "number";
|
|
374
410
|
return "text";
|
|
375
411
|
};
|
|
412
|
+
/** `"AGUARDE, EM PROCESSAMENTO (3)"` → `"AGUARDE, EM PROCESSAMENTO"`. */
|
|
413
|
+
const COUNTER = /\s*\(\s*\d+\s*\)\s*$/;
|
|
414
|
+
const collapseCounter = (text) => {
|
|
415
|
+
return text.replace(COUNTER, "").trim() || text;
|
|
416
|
+
};
|
|
376
417
|
const MESSAGES = /* @__PURE__ */ new Set([
|
|
377
418
|
CLISITEF_COMMAND.MESSAGE_OPERATOR,
|
|
378
419
|
CLISITEF_COMMAND.MESSAGE_CUSTOMER,
|
|
@@ -393,6 +434,16 @@ const READS = /* @__PURE__ */ new Set([
|
|
|
393
434
|
CLISITEF_COMMAND.READ_AMOUNT,
|
|
394
435
|
CLISITEF_COMMAND.READ_MASKED
|
|
395
436
|
]);
|
|
437
|
+
/** The PIX payload is the charge code and the supervisor password a secret. */
|
|
438
|
+
const redact = (event) => {
|
|
439
|
+
const qr = event.command === CLISITEF_COMMAND.QR_SHOW;
|
|
440
|
+
const masked = event.command === CLISITEF_COMMAND.READ_MASKED;
|
|
441
|
+
if (!qr && !masked && event.fieldId !== CLISITEF_FIELD.QR_PAYLOAD) return event;
|
|
442
|
+
return {
|
|
443
|
+
...event,
|
|
444
|
+
input: `<${event.input.length} chars>`
|
|
445
|
+
};
|
|
446
|
+
};
|
|
396
447
|
/**
|
|
397
448
|
* One CliSiTef transaction: `startTransaction`, the `onData` loop answered
|
|
398
449
|
* through `ui`, `finishTransaction(1)` on approval (`stage 1` = 0) and
|
|
@@ -403,6 +454,7 @@ const runClisitefTransaction = (input) => {
|
|
|
403
454
|
const fields = /* @__PURE__ */ new Map();
|
|
404
455
|
const subscriptions = [];
|
|
405
456
|
let title = "";
|
|
457
|
+
let lastMessage = "";
|
|
406
458
|
let captured = false;
|
|
407
459
|
let approved = false;
|
|
408
460
|
let closing = false;
|
|
@@ -426,7 +478,7 @@ const runClisitefTransaction = (input) => {
|
|
|
426
478
|
const settle = (status, reason) => {
|
|
427
479
|
log(`settle ${status}`, {
|
|
428
480
|
...reason,
|
|
429
|
-
|
|
481
|
+
fieldIds: [...fields.keys()]
|
|
430
482
|
});
|
|
431
483
|
clearTimeout(watchdog);
|
|
432
484
|
for (const subscription of subscriptions) subscription.remove();
|
|
@@ -435,7 +487,8 @@ const runClisitefTransaction = (input) => {
|
|
|
435
487
|
status,
|
|
436
488
|
...reason,
|
|
437
489
|
fields,
|
|
438
|
-
aborted
|
|
490
|
+
aborted,
|
|
491
|
+
evidence: captured
|
|
439
492
|
});
|
|
440
493
|
};
|
|
441
494
|
const undo = async (reason) => {
|
|
@@ -470,17 +523,73 @@ const runClisitefTransaction = (input) => {
|
|
|
470
523
|
message: "Aborted by the payment deadline"
|
|
471
524
|
});
|
|
472
525
|
}
|
|
473
|
-
const reply = async (data) => {
|
|
526
|
+
const reply = async (data, hidden = false) => {
|
|
474
527
|
log("reply", {
|
|
475
|
-
data,
|
|
528
|
+
data: hidden ? "********" : data,
|
|
529
|
+
length: data.length,
|
|
476
530
|
skipped: closing
|
|
477
531
|
});
|
|
478
532
|
if (closing) return;
|
|
479
533
|
await client.continueTransaction(data);
|
|
480
534
|
};
|
|
535
|
+
const showMessage = (text) => {
|
|
536
|
+
const collapsed = collapseCounter(text.trim());
|
|
537
|
+
if (!collapsed || collapsed === lastMessage) return;
|
|
538
|
+
lastMessage = collapsed;
|
|
539
|
+
ui.message({
|
|
540
|
+
text: collapsed,
|
|
541
|
+
kind: "waiting"
|
|
542
|
+
});
|
|
543
|
+
};
|
|
544
|
+
const onMenu = async (text) => {
|
|
545
|
+
const options = parseClisitefMenu(text);
|
|
546
|
+
const label = title || "Selecione uma opção";
|
|
547
|
+
const decision = resolveClisitefMenu(...[
|
|
548
|
+
options,
|
|
549
|
+
input.modality,
|
|
550
|
+
input.purpose
|
|
551
|
+
]);
|
|
552
|
+
log("menu", {
|
|
553
|
+
title: label,
|
|
554
|
+
options,
|
|
555
|
+
decision
|
|
556
|
+
});
|
|
557
|
+
if (decision.kind === "abort") {
|
|
558
|
+
const reason = {
|
|
559
|
+
code: "FINANCING_ONLY",
|
|
560
|
+
message: decision.message
|
|
561
|
+
};
|
|
562
|
+
return close(reason);
|
|
563
|
+
}
|
|
564
|
+
if (decision.kind === "pick") return reply(decision.value);
|
|
565
|
+
const p2 = {
|
|
566
|
+
title: label,
|
|
567
|
+
options: decision.options
|
|
568
|
+
};
|
|
569
|
+
const picked = await ui.select(p2);
|
|
570
|
+
log("ui.select ← out", { picked });
|
|
571
|
+
return reply(picked);
|
|
572
|
+
};
|
|
573
|
+
const onQr = (event) => {
|
|
574
|
+
const payload = event.input.trim();
|
|
575
|
+
const isPayload = event.fieldId === CLISITEF_FIELD.QR_PAYLOAD;
|
|
576
|
+
log("qr shown", {
|
|
577
|
+
fieldId: event.fieldId,
|
|
578
|
+
length: payload.length
|
|
579
|
+
});
|
|
580
|
+
if (!isPayload || !payload) return reply("");
|
|
581
|
+
captured = true;
|
|
582
|
+
ui.action({
|
|
583
|
+
type: "qr",
|
|
584
|
+
payload,
|
|
585
|
+
format: "text",
|
|
586
|
+
expiresAt: null
|
|
587
|
+
});
|
|
588
|
+
return reply("");
|
|
589
|
+
};
|
|
481
590
|
const onData = async (event) => {
|
|
482
591
|
log("onData", {
|
|
483
|
-
...event,
|
|
592
|
+
...redact(event),
|
|
484
593
|
title
|
|
485
594
|
});
|
|
486
595
|
const { command, input: text } = event;
|
|
@@ -489,13 +598,20 @@ const runClisitefTransaction = (input) => {
|
|
|
489
598
|
if (CLISITEF_CAPTURE_FIELDS.has(event.fieldId)) captured = true;
|
|
490
599
|
return reply("");
|
|
491
600
|
}
|
|
492
|
-
if (
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
601
|
+
if (command === CLISITEF_COMMAND.QR_SHOW) return onQr(event);
|
|
602
|
+
if (command === CLISITEF_COMMAND.QR_HIDE) {
|
|
603
|
+
ui.action({
|
|
604
|
+
type: "qr",
|
|
605
|
+
payload: "",
|
|
606
|
+
format: "text",
|
|
607
|
+
expiresAt: null
|
|
496
608
|
});
|
|
497
609
|
return reply("");
|
|
498
610
|
}
|
|
611
|
+
if (MESSAGES.has(command)) {
|
|
612
|
+
showMessage(text);
|
|
613
|
+
return reply("");
|
|
614
|
+
}
|
|
499
615
|
if (command === CLISITEF_COMMAND.MENU_TITLE) {
|
|
500
616
|
title = text.trim();
|
|
501
617
|
return reply("");
|
|
@@ -511,21 +627,10 @@ const runClisitefTransaction = (input) => {
|
|
|
511
627
|
log("ui.confirm ← out", { yes });
|
|
512
628
|
return reply(yes ? "0" : "1");
|
|
513
629
|
}
|
|
514
|
-
if (command === CLISITEF_COMMAND.MENU)
|
|
515
|
-
const options = parseClisitefMenu(text);
|
|
516
|
-
const label = title || "Selecione uma opção";
|
|
517
|
-
log("ui.select → in", {
|
|
518
|
-
title: label,
|
|
519
|
-
options
|
|
520
|
-
});
|
|
521
|
-
const picked = await ui.select({
|
|
522
|
-
title: label,
|
|
523
|
-
options
|
|
524
|
-
});
|
|
525
|
-
log("ui.select ← out", { picked });
|
|
526
|
-
return reply(picked);
|
|
527
|
-
}
|
|
630
|
+
if (command === CLISITEF_COMMAND.MENU) return onMenu(text);
|
|
528
631
|
if (READS.has(command)) {
|
|
632
|
+
const known = input.answerField?.(event.fieldId) ?? null;
|
|
633
|
+
if (known !== null) return reply(known, true);
|
|
529
634
|
const label = text.trim() || title || "Informe o dado";
|
|
530
635
|
const max = event.maxLength > 0 ? event.maxLength : void 0;
|
|
531
636
|
const p1 = {
|
|
@@ -535,7 +640,6 @@ const runClisitefTransaction = (input) => {
|
|
|
535
640
|
};
|
|
536
641
|
log("ui.input → in", p1);
|
|
537
642
|
const value = await ui.input(p1);
|
|
538
|
-
log("ui.input ← out", { value });
|
|
539
643
|
return reply(value);
|
|
540
644
|
}
|
|
541
645
|
const message = `Unsupported CliSiTef command ${command}`;
|
|
@@ -672,6 +776,393 @@ const runClisitefTransaction = (input) => {
|
|
|
672
776
|
return done;
|
|
673
777
|
};
|
|
674
778
|
//#endregion
|
|
779
|
+
//#region src/refund.ts
|
|
780
|
+
/**
|
|
781
|
+
* The acquirer or SiTef answered: the transaction is not there, already
|
|
782
|
+
* voided or the void is refused. Retrying does not help.
|
|
783
|
+
*/
|
|
784
|
+
const DEFINITIVE = /* @__PURE__ */ new Set([
|
|
785
|
+
-3,
|
|
786
|
+
-10,
|
|
787
|
+
-13,
|
|
788
|
+
-20,
|
|
789
|
+
-40,
|
|
790
|
+
-41
|
|
791
|
+
]);
|
|
792
|
+
const isDefinitive = (outcome) => {
|
|
793
|
+
if (outcome.aborted || outcome.error) return false;
|
|
794
|
+
if (outcome.status === "unresolved") return false;
|
|
795
|
+
const { code } = outcome;
|
|
796
|
+
if (typeof code !== "number") return false;
|
|
797
|
+
return code > 0 || DEFINITIVE.has(code);
|
|
798
|
+
};
|
|
799
|
+
/** `YYYYMMDD` (fiscal date of the charge) → `DDMMYYYY` (field 515). */
|
|
800
|
+
const toDdMmYyyy = (date) => {
|
|
801
|
+
return `${date.slice(6, 8)}${date.slice(4, 6)}${date.slice(0, 4)}`;
|
|
802
|
+
};
|
|
803
|
+
const targetOf = (result) => {
|
|
804
|
+
const payment = result.metadata.payment;
|
|
805
|
+
const modality = clisitefModalityOf(result.metadata);
|
|
806
|
+
const hostNsu = payment?.reference?.trim();
|
|
807
|
+
const transactionDate = payment?.transaction_date?.trim();
|
|
808
|
+
if (!modality || !hostNsu || !transactionDate) return null;
|
|
809
|
+
if (!/^\d{8}$/.test(transactionDate)) return null;
|
|
810
|
+
return {
|
|
811
|
+
reference: result.metadata.attempt_reference,
|
|
812
|
+
modality,
|
|
813
|
+
hostNsu,
|
|
814
|
+
transactionDate
|
|
815
|
+
};
|
|
816
|
+
};
|
|
817
|
+
/** The charge stays in force: `PAID`, flagged for review. */
|
|
818
|
+
const stillPaid = (result, error) => {
|
|
819
|
+
const metadata = {
|
|
820
|
+
...result.metadata,
|
|
821
|
+
error,
|
|
822
|
+
needs_review: true
|
|
823
|
+
};
|
|
824
|
+
return {
|
|
825
|
+
...result,
|
|
826
|
+
status: "PAID",
|
|
827
|
+
metadata,
|
|
828
|
+
error
|
|
829
|
+
};
|
|
830
|
+
};
|
|
831
|
+
const voidError = (outcome, attempt) => {
|
|
832
|
+
const p1 = {
|
|
833
|
+
provider_message: outcome.message,
|
|
834
|
+
attempt
|
|
835
|
+
};
|
|
836
|
+
const code = outcome.code == null ? null : String(outcome.code);
|
|
837
|
+
const extra = {
|
|
838
|
+
...p1,
|
|
839
|
+
provider_code: code
|
|
840
|
+
};
|
|
841
|
+
if (isDefinitive(outcome)) {
|
|
842
|
+
const message = "CliSiTef refused the void";
|
|
843
|
+
return createPaymentError("REVERSAL_NOT_ALLOWED", {
|
|
844
|
+
...extra,
|
|
845
|
+
message,
|
|
846
|
+
retryable: false
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
if (outcome.aborted || code === null) return createPaymentError("PROVIDER_UNAVAILABLE", {
|
|
850
|
+
...extra,
|
|
851
|
+
retryable: true
|
|
852
|
+
});
|
|
853
|
+
return {
|
|
854
|
+
...toPaymentError(clisitefPinPadErrors, {
|
|
855
|
+
code: outcome.code,
|
|
856
|
+
message: outcome.message,
|
|
857
|
+
attempt,
|
|
858
|
+
fallback: "PROVIDER_UNAVAILABLE"
|
|
859
|
+
}),
|
|
860
|
+
retryable: true
|
|
861
|
+
};
|
|
862
|
+
};
|
|
863
|
+
/**
|
|
864
|
+
* Voids a confirmed charge with its cancellation function (credit 210,
|
|
865
|
+
* debit and voucher 211, PIX 123). `REFUNDED` only after `stage 2` = 0;
|
|
866
|
+
* any other end keeps the charge `PAID` with `needs_review`, and a
|
|
867
|
+
* retryable one goes to the pending queue.
|
|
868
|
+
*/
|
|
869
|
+
const runClisitefVoid = async (deps, ctx, result) => {
|
|
870
|
+
if (result.status === "REFUNDED") return result;
|
|
871
|
+
const reference = result.metadata.attempt_reference;
|
|
872
|
+
const done = await readRefund(ctx.storage, reference);
|
|
873
|
+
if (done) return done;
|
|
874
|
+
const target = targetOf(result);
|
|
875
|
+
if (!target) {
|
|
876
|
+
const error = createPaymentError("MANUAL_REVERSAL_REQUIRED", {
|
|
877
|
+
message: "Missing CliSiTef void context (host NSU, date, modality)",
|
|
878
|
+
retryable: false,
|
|
879
|
+
attempt: ctx.attempt
|
|
880
|
+
});
|
|
881
|
+
return stillPaid(result, error);
|
|
882
|
+
}
|
|
883
|
+
const { settings } = ctx;
|
|
884
|
+
const date = deps.now();
|
|
885
|
+
const queue = async (error, paid) => {
|
|
886
|
+
const r1 = {
|
|
887
|
+
attemptReference: reference,
|
|
888
|
+
amount: result.amount
|
|
889
|
+
};
|
|
890
|
+
const r2 = { transactionDate: target.transactionDate };
|
|
891
|
+
const r3 = {
|
|
892
|
+
hostNsu: target.hostNsu,
|
|
893
|
+
modality: target.modality
|
|
894
|
+
};
|
|
895
|
+
const entry = {
|
|
896
|
+
...r1,
|
|
897
|
+
...r2,
|
|
898
|
+
...r3,
|
|
899
|
+
reason: error.message,
|
|
900
|
+
result
|
|
901
|
+
};
|
|
902
|
+
await queueReversal(ctx.storage, entry, date.getTime());
|
|
903
|
+
return paid;
|
|
904
|
+
};
|
|
905
|
+
if (ctx.unavailable) {
|
|
906
|
+
const error = {
|
|
907
|
+
...ctx.unavailable,
|
|
908
|
+
retryable: true
|
|
909
|
+
};
|
|
910
|
+
return queue(error, stillPaid(result, error));
|
|
911
|
+
}
|
|
912
|
+
const fiscal = clisitefFiscalDateTime(date, deps.timeZone);
|
|
913
|
+
const fiscalCoupon = String(date.getTime());
|
|
914
|
+
const finish = {
|
|
915
|
+
fiscalCoupon,
|
|
916
|
+
...fiscal
|
|
917
|
+
};
|
|
918
|
+
const fn = CLISITEF_VOID_FUNCTION[target.modality];
|
|
919
|
+
const value = formatClisitefAmount(result.amount);
|
|
920
|
+
const s1 = {
|
|
921
|
+
modality: fn,
|
|
922
|
+
value,
|
|
923
|
+
...finish,
|
|
924
|
+
operator: settings.operator
|
|
925
|
+
};
|
|
926
|
+
const answers = {
|
|
927
|
+
[CLISITEF_VOID_FIELD.AMOUNT]: value,
|
|
928
|
+
[CLISITEF_VOID_FIELD.ORIGINAL_DATE]: toDdMmYyyy(target.transactionDate),
|
|
929
|
+
[CLISITEF_VOID_FIELD.ORIGINAL_HOST_NSU]: target.hostNsu
|
|
930
|
+
};
|
|
931
|
+
if (settings.supervisorPassword) answers[CLISITEF_VOID_FIELD.SUPERVISOR_PASSWORD] = settings.supervisorPassword;
|
|
932
|
+
const outcome = await runClisitefTransaction({
|
|
933
|
+
client: deps.client,
|
|
934
|
+
ui: ctx.ui,
|
|
935
|
+
signal: ctx.signal,
|
|
936
|
+
start: {
|
|
937
|
+
...s1,
|
|
938
|
+
restrictions: ""
|
|
939
|
+
},
|
|
940
|
+
finish,
|
|
941
|
+
confirmationTimeoutMs: deps.confirmationTimeoutMs,
|
|
942
|
+
modality: target.modality,
|
|
943
|
+
purpose: "void",
|
|
944
|
+
onApproved: async () => void 0,
|
|
945
|
+
answerField: (fieldId) => answers[fieldId] ?? null
|
|
946
|
+
});
|
|
947
|
+
if (outcome.code === -1) deps.client.invalidate();
|
|
948
|
+
const voided = clisitefVoidMetadata({
|
|
949
|
+
fields: outcome.fields,
|
|
950
|
+
function: fn,
|
|
951
|
+
fiscalCoupon,
|
|
952
|
+
fiscalDate: fiscal.fiscalDate
|
|
953
|
+
});
|
|
954
|
+
const provider = {
|
|
955
|
+
...result.metadata.provider,
|
|
956
|
+
void: voided
|
|
957
|
+
};
|
|
958
|
+
if (outcome.status === "confirmed") {
|
|
959
|
+
const metadata = {
|
|
960
|
+
...result.metadata,
|
|
961
|
+
provider
|
|
962
|
+
};
|
|
963
|
+
delete metadata.error;
|
|
964
|
+
delete metadata.needs_review;
|
|
965
|
+
const refunded = {
|
|
966
|
+
...result,
|
|
967
|
+
status: "REFUNDED",
|
|
968
|
+
metadata,
|
|
969
|
+
error: void 0
|
|
970
|
+
};
|
|
971
|
+
await writeRefund(ctx.storage, reference, refunded);
|
|
972
|
+
await dropReversal(ctx.storage, reference);
|
|
973
|
+
return refunded;
|
|
974
|
+
}
|
|
975
|
+
const error = voidError(outcome, ctx.attempt);
|
|
976
|
+
const withVoid = {
|
|
977
|
+
...result,
|
|
978
|
+
metadata: {
|
|
979
|
+
...result.metadata,
|
|
980
|
+
provider
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
const paid = stillPaid(withVoid, error);
|
|
984
|
+
if (!error.retryable) {
|
|
985
|
+
await dropReversal(ctx.storage, reference);
|
|
986
|
+
return paid;
|
|
987
|
+
}
|
|
988
|
+
return queue(error, paid);
|
|
989
|
+
};
|
|
990
|
+
//#endregion
|
|
991
|
+
//#region src/settings.ts
|
|
992
|
+
const CLISITEF_PINPAD_TYPES = [
|
|
993
|
+
"ANDROID_USB",
|
|
994
|
+
"ANDROID_BT",
|
|
995
|
+
"ANDROID_AUTO"
|
|
996
|
+
];
|
|
997
|
+
/**
|
|
998
|
+
* Ficha §6: filled per kiosk in the device settings. SiTef IP, store code
|
|
999
|
+
* and store CNPJ come from the payment's store settings (`config.stores`);
|
|
1000
|
+
* the device values only override them.
|
|
1001
|
+
*/
|
|
1002
|
+
const clisitefPinPadDeviceFields = [
|
|
1003
|
+
{
|
|
1004
|
+
key: "terminal_number",
|
|
1005
|
+
label: "SiTef terminal number",
|
|
1006
|
+
type: "text",
|
|
1007
|
+
required: true
|
|
1008
|
+
},
|
|
1009
|
+
{
|
|
1010
|
+
key: "pinpad_type",
|
|
1011
|
+
label: "Pinpad connection",
|
|
1012
|
+
type: "select",
|
|
1013
|
+
options: [...CLISITEF_PINPAD_TYPES]
|
|
1014
|
+
},
|
|
1015
|
+
{
|
|
1016
|
+
key: "secret_supervisor_password",
|
|
1017
|
+
label: "SiTef supervisor password (voids)",
|
|
1018
|
+
type: "password",
|
|
1019
|
+
secret: true
|
|
1020
|
+
}
|
|
1021
|
+
];
|
|
1022
|
+
const redactSecrets = (values = {}) => {
|
|
1023
|
+
const entries = Object.entries(values).map(([key, value]) => {
|
|
1024
|
+
return [key, (key.startsWith("secret_") || key === "supervisorPassword") && value ? "********" : value];
|
|
1025
|
+
});
|
|
1026
|
+
return Object.fromEntries(entries);
|
|
1027
|
+
};
|
|
1028
|
+
const redactStores = (stores) => {
|
|
1029
|
+
if (!stores || typeof stores !== "object") return stores;
|
|
1030
|
+
const redacted = Object.entries(stores).map(([id, store]) => {
|
|
1031
|
+
return [id, redactSecrets(store && typeof store === "object" ? store : {})];
|
|
1032
|
+
});
|
|
1033
|
+
return Object.fromEntries(redacted);
|
|
1034
|
+
};
|
|
1035
|
+
/** The API only sends the entry of the kiosk's store. */
|
|
1036
|
+
const storeSettingsOf = (config) => {
|
|
1037
|
+
const stores = config.stores;
|
|
1038
|
+
if (!stores || typeof stores !== "object") return {};
|
|
1039
|
+
const [first] = Object.values(stores);
|
|
1040
|
+
return first && typeof first === "object" ? first : {};
|
|
1041
|
+
};
|
|
1042
|
+
const CLISITEF_MODALITIES = [
|
|
1043
|
+
"DEBIT",
|
|
1044
|
+
"CREDIT",
|
|
1045
|
+
"VOUCHER",
|
|
1046
|
+
"PIX"
|
|
1047
|
+
];
|
|
1048
|
+
const text = (value) => {
|
|
1049
|
+
return typeof value === "string" ? value.trim() : "";
|
|
1050
|
+
};
|
|
1051
|
+
const notConfigured = (missing) => {
|
|
1052
|
+
return new PaymentError(createPaymentError("TERMINAL_NOT_CONFIGURED", { message: `CliSiTef is missing ${missing.join(", ")}` }));
|
|
1053
|
+
};
|
|
1054
|
+
const isClisitefModality = (value) => {
|
|
1055
|
+
return CLISITEF_MODALITIES.includes(value);
|
|
1056
|
+
};
|
|
1057
|
+
/** `modalities`, or the legacy single `modality` of older configs. */
|
|
1058
|
+
const listModalities = (config) => {
|
|
1059
|
+
const listed = Array.isArray(config.modalities) ? config.modalities : [];
|
|
1060
|
+
return (listed.length ? listed : [config.modality]).map(text).filter(Boolean);
|
|
1061
|
+
};
|
|
1062
|
+
/**
|
|
1063
|
+
* Per modality. The legacy single `restrictions` only applies to configs
|
|
1064
|
+
* without `restrictions_by_modality`: one modality never borrows another's
|
|
1065
|
+
* list (the credit one makes SiTef reject vouchers with `-3`).
|
|
1066
|
+
*/
|
|
1067
|
+
const readRestrictions = (config, modalities) => {
|
|
1068
|
+
const raw = config.restrictions_by_modality;
|
|
1069
|
+
const hasByModality = !!raw && typeof raw === "object";
|
|
1070
|
+
const byModality = hasByModality ? raw : {};
|
|
1071
|
+
const legacy = hasByModality ? "" : text(config.restrictions);
|
|
1072
|
+
const entries = modalities.map((modality) => {
|
|
1073
|
+
return [modality, text(byModality[modality]) || legacy];
|
|
1074
|
+
});
|
|
1075
|
+
return Object.fromEntries(entries.filter(([, value]) => value));
|
|
1076
|
+
};
|
|
1077
|
+
/** `payments.config` (merchant) + device settings; throws when incomplete. */
|
|
1078
|
+
const readClisitefPinPadSettings = (config, device) => {
|
|
1079
|
+
const listed = listModalities(config);
|
|
1080
|
+
const cnpjEnterprise = text(config.cnpj_enterprise);
|
|
1081
|
+
const store = storeSettingsOf(config);
|
|
1082
|
+
const serverIp = text(device.sitef_ip) || text(store.sitef_ip);
|
|
1083
|
+
const storeCode = text(device.store_code) || text(store.store_code);
|
|
1084
|
+
const terminalNumber = text(device.terminal_number);
|
|
1085
|
+
const cnpjStore = (text(device.cnpj_store) || text(store.cnpj_store)).replace(/\D/g, "");
|
|
1086
|
+
const pinpadType = text(device.pinpad_type) || void 0;
|
|
1087
|
+
const required = {
|
|
1088
|
+
modalities: listed.join(","),
|
|
1089
|
+
cnpj_enterprise: cnpjEnterprise
|
|
1090
|
+
};
|
|
1091
|
+
const fromDevice = {
|
|
1092
|
+
sitef_ip: serverIp,
|
|
1093
|
+
store_code: storeCode
|
|
1094
|
+
};
|
|
1095
|
+
const more = {
|
|
1096
|
+
terminal_number: terminalNumber,
|
|
1097
|
+
cnpj_store: cnpjStore
|
|
1098
|
+
};
|
|
1099
|
+
const values = {
|
|
1100
|
+
...required,
|
|
1101
|
+
...fromDevice
|
|
1102
|
+
};
|
|
1103
|
+
Object.assign(values, more);
|
|
1104
|
+
const missing = Object.keys(values).filter((key) => !values[key]);
|
|
1105
|
+
const shown = redactSecrets(device);
|
|
1106
|
+
clisitefDebug("pinpad.settings", "read", {
|
|
1107
|
+
config: {
|
|
1108
|
+
...config,
|
|
1109
|
+
stores: redactStores(config.stores)
|
|
1110
|
+
},
|
|
1111
|
+
device: shown,
|
|
1112
|
+
missing
|
|
1113
|
+
});
|
|
1114
|
+
if (missing.length) throw notConfigured(missing);
|
|
1115
|
+
if (!listed.every(isClisitefModality)) throw notConfigured(["a valid modality"]);
|
|
1116
|
+
const configure = {
|
|
1117
|
+
serverIp,
|
|
1118
|
+
storeCode,
|
|
1119
|
+
terminalNumber,
|
|
1120
|
+
additionalParams: clisitefAdditionalParams({
|
|
1121
|
+
cnpjStore,
|
|
1122
|
+
cnpjEnterprise,
|
|
1123
|
+
pinpadType
|
|
1124
|
+
})
|
|
1125
|
+
};
|
|
1126
|
+
const operator = text(config.operator) || "KIOSKO_BR";
|
|
1127
|
+
const unique = [...new Set(listed)];
|
|
1128
|
+
const restrictions = readRestrictions(config, unique);
|
|
1129
|
+
const devicePassword = text(device.secret_supervisor_password);
|
|
1130
|
+
const storePassword = text(store.secret_supervisor_password);
|
|
1131
|
+
return {
|
|
1132
|
+
configure,
|
|
1133
|
+
modalities: unique,
|
|
1134
|
+
operator,
|
|
1135
|
+
restrictions,
|
|
1136
|
+
terminalNumber,
|
|
1137
|
+
supervisorPassword: devicePassword || storePassword || ""
|
|
1138
|
+
};
|
|
1139
|
+
};
|
|
1140
|
+
//#endregion
|
|
1141
|
+
//#region src/store.ts
|
|
1142
|
+
const isRecord = (value) => {
|
|
1143
|
+
if (!value || typeof value !== "object") return false;
|
|
1144
|
+
const record = value;
|
|
1145
|
+
return typeof record.fiscalCoupon === "string" && typeof record.attemptReference === "string";
|
|
1146
|
+
};
|
|
1147
|
+
const readTransaction = async (storage, key = CLISITEF_STORAGE_KEY) => {
|
|
1148
|
+
const raw = await storage.get(key);
|
|
1149
|
+
clisitefDebug("pinpad.store", "read", { raw });
|
|
1150
|
+
if (!raw) return null;
|
|
1151
|
+
try {
|
|
1152
|
+
const parsed = JSON.parse(raw);
|
|
1153
|
+
return isRecord(parsed) ? parsed : null;
|
|
1154
|
+
} catch {
|
|
1155
|
+
return null;
|
|
1156
|
+
}
|
|
1157
|
+
};
|
|
1158
|
+
const writeTransaction = async (storage, record, key = CLISITEF_STORAGE_KEY) => {
|
|
1159
|
+
clisitefDebug("pinpad.store", `write ${record.state}`, record);
|
|
1160
|
+
await storage.set(key, JSON.stringify(record));
|
|
1161
|
+
};
|
|
1162
|
+
const isOpen = (record) => {
|
|
1163
|
+
return record.state === "started" || record.state === "approved";
|
|
1164
|
+
};
|
|
1165
|
+
//#endregion
|
|
675
1166
|
//#region src/adapter.ts
|
|
676
1167
|
/** Confirmed in the act (`finishTransaction(1)`), never captured later. */
|
|
677
1168
|
const clisitefPinPadCapabilities = {
|
|
@@ -713,6 +1204,17 @@ const paid = (record) => {
|
|
|
713
1204
|
details
|
|
714
1205
|
};
|
|
715
1206
|
};
|
|
1207
|
+
/**
|
|
1208
|
+
* Silence, a deadline or an inconclusive code after the card was read, a
|
|
1209
|
+
* receipt arrived or the PIX QR was shown: the customer may have paid.
|
|
1210
|
+
*/
|
|
1211
|
+
const isAmbiguous = (outcome) => {
|
|
1212
|
+
if (outcome.status === "unresolved") return true;
|
|
1213
|
+
if (!outcome.evidence || outcome.status !== "undone") return false;
|
|
1214
|
+
const { code } = outcome;
|
|
1215
|
+
const inconclusive = typeof code === "number" && CLISITEF_INCONCLUSIVE_CODES.has(code);
|
|
1216
|
+
return outcome.aborted || inconclusive || code === CLISITEF_TIMEOUT_CODE;
|
|
1217
|
+
};
|
|
716
1218
|
const outcomeError = (outcome, attempt) => {
|
|
717
1219
|
const provider = {
|
|
718
1220
|
provider_message: outcome.message,
|
|
@@ -723,8 +1225,8 @@ const outcomeError = (outcome, attempt) => {
|
|
|
723
1225
|
...provider,
|
|
724
1226
|
provider_code: code
|
|
725
1227
|
};
|
|
1228
|
+
if (isAmbiguous(outcome)) return createPaymentError("AMBIGUOUS", extra);
|
|
726
1229
|
if (outcome.aborted) return createPaymentError("TIMEOUT", extra);
|
|
727
|
-
if (outcome.status === "unresolved") return createPaymentError("AMBIGUOUS", extra);
|
|
728
1230
|
if (outcome.code === null) {
|
|
729
1231
|
const message = outcome.message ?? void 0;
|
|
730
1232
|
return createPaymentError("PROVIDER_UNAVAILABLE", {
|
|
@@ -744,7 +1246,8 @@ const outcomeError = (outcome, attempt) => {
|
|
|
744
1246
|
const MODALITY_LABELS = {
|
|
745
1247
|
DEBIT: "Débito",
|
|
746
1248
|
CREDIT: "Crédito",
|
|
747
|
-
VOUCHER: "Voucher"
|
|
1249
|
+
VOUCHER: "Voucher",
|
|
1250
|
+
PIX: "Pix"
|
|
748
1251
|
};
|
|
749
1252
|
/** The only modality, or the one the customer picks. */
|
|
750
1253
|
const chooseModality = async (ui, modalities) => {
|
|
@@ -762,15 +1265,20 @@ const chooseModality = async (ui, modalities) => {
|
|
|
762
1265
|
});
|
|
763
1266
|
};
|
|
764
1267
|
const debugContext = (ctx) => {
|
|
765
|
-
const { attemptReference, attempt, amount, config
|
|
1268
|
+
const { attemptReference, attempt, amount, config } = ctx;
|
|
766
1269
|
return {
|
|
767
1270
|
attemptReference,
|
|
768
1271
|
attempt,
|
|
769
1272
|
amount,
|
|
770
1273
|
config,
|
|
771
|
-
deviceSettings
|
|
1274
|
+
deviceSettings: redactSecrets(ctx.deviceSettings)
|
|
772
1275
|
};
|
|
773
1276
|
};
|
|
1277
|
+
/** `review` when the customer may have paid; `approved` when the undo failed. */
|
|
1278
|
+
const closingState = (outcome) => {
|
|
1279
|
+
if (outcome.status === "unresolved") return "approved";
|
|
1280
|
+
return isAmbiguous(outcome) ? "review" : "undone";
|
|
1281
|
+
};
|
|
774
1282
|
const terminalError = (error) => {
|
|
775
1283
|
const message = error instanceof Error ? error.message : String(error);
|
|
776
1284
|
return {
|
|
@@ -799,7 +1307,7 @@ const clisitefPinPadPayments = (options = {}) => {
|
|
|
799
1307
|
const code = await client.configure(settings.configure);
|
|
800
1308
|
clisitefDebug("pinpad.adapter.configure", "result", {
|
|
801
1309
|
code,
|
|
802
|
-
settings
|
|
1310
|
+
settings: redactSecrets(settings)
|
|
803
1311
|
});
|
|
804
1312
|
if (code === 0) return null;
|
|
805
1313
|
client.invalidate();
|
|
@@ -809,7 +1317,7 @@ const clisitefPinPadPayments = (options = {}) => {
|
|
|
809
1317
|
});
|
|
810
1318
|
};
|
|
811
1319
|
/** Undoes a transaction a crash or a lost result left open. */
|
|
812
|
-
const recover = async (storage, record) => {
|
|
1320
|
+
const recover = async (storage, record, key = CLISITEF_STORAGE_KEY) => {
|
|
813
1321
|
const { fiscalCoupon, fiscalDate, time } = record;
|
|
814
1322
|
const count = client.getQttPendingTransactions;
|
|
815
1323
|
const pending = await count(fiscalDate, fiscalCoupon).catch(() => null);
|
|
@@ -857,27 +1365,37 @@ const clisitefPinPadPayments = (options = {}) => {
|
|
|
857
1365
|
};
|
|
858
1366
|
}
|
|
859
1367
|
clisitefDebug("pinpad.adapter.recover", `→ ${next.state}`, next);
|
|
860
|
-
await writeTransaction(storage, next);
|
|
1368
|
+
await writeTransaction(storage, next, key);
|
|
861
1369
|
return next;
|
|
862
1370
|
};
|
|
863
|
-
/** Configures and resolves the open
|
|
1371
|
+
/** Configures and resolves the open records of the device, if any. */
|
|
864
1372
|
const prepare = async (ctx) => {
|
|
865
1373
|
const settings = readClisitefPinPadSettings(ctx.config, ctx.deviceSettings);
|
|
866
1374
|
const error = await configure(settings);
|
|
867
1375
|
const storage = storageOf(ctx);
|
|
868
|
-
|
|
869
|
-
|
|
1376
|
+
const resolve = async (key) => {
|
|
1377
|
+
const found = await readTransaction(storage, key);
|
|
1378
|
+
if (error || !found || !isOpen(found)) return found;
|
|
1379
|
+
return recover(storage, found, key);
|
|
1380
|
+
};
|
|
1381
|
+
const record = await resolve(CLISITEF_STORAGE_KEY);
|
|
1382
|
+
const refund = await resolve(CLISITEF_REFUND_STORAGE_KEY);
|
|
870
1383
|
clisitefDebug("pinpad.adapter.prepare", "done", {
|
|
871
1384
|
error,
|
|
872
|
-
record
|
|
1385
|
+
record,
|
|
1386
|
+
refund
|
|
873
1387
|
});
|
|
874
1388
|
return {
|
|
875
1389
|
settings,
|
|
876
1390
|
error,
|
|
877
1391
|
storage,
|
|
878
|
-
record
|
|
1392
|
+
record,
|
|
1393
|
+
refund
|
|
879
1394
|
};
|
|
880
1395
|
};
|
|
1396
|
+
const busy = (records) => {
|
|
1397
|
+
return records.some((record) => record !== null && isOpen(record));
|
|
1398
|
+
};
|
|
881
1399
|
const fromRecord = (record, ctx) => {
|
|
882
1400
|
const reference = ctx.attemptReference;
|
|
883
1401
|
if (record.state === "confirmed") return paid(record);
|
|
@@ -904,7 +1422,7 @@ const clisitefPinPadPayments = (options = {}) => {
|
|
|
904
1422
|
const { settings, storage, record: previous } = prepared;
|
|
905
1423
|
if (prepared.error) return failed(empty, reference, prepared.error);
|
|
906
1424
|
if (previous?.attemptReference === reference && previous?.state === "confirmed") return paid(previous);
|
|
907
|
-
if (previous
|
|
1425
|
+
if (busy([previous, prepared.refund])) {
|
|
908
1426
|
const error = createPaymentError("TERMINAL_BUSY", { attempt: ctx.attempt });
|
|
909
1427
|
return failed(empty, reference, error);
|
|
910
1428
|
}
|
|
@@ -973,9 +1491,14 @@ const clisitefPinPadPayments = (options = {}) => {
|
|
|
973
1491
|
confirmationTimeoutMs: confirmationMs,
|
|
974
1492
|
onApproved
|
|
975
1493
|
};
|
|
1494
|
+
const t3 = {
|
|
1495
|
+
modality: chosen,
|
|
1496
|
+
purpose: "charge"
|
|
1497
|
+
};
|
|
976
1498
|
const outcome = await runClisitefTransaction({
|
|
977
1499
|
...t1,
|
|
978
|
-
...t2
|
|
1500
|
+
...t2,
|
|
1501
|
+
...t3
|
|
979
1502
|
});
|
|
980
1503
|
const fields = Object.fromEntries(outcome.fields);
|
|
981
1504
|
clisitefDebug("pinpad.adapter.charge", "outcome", {
|
|
@@ -996,17 +1519,68 @@ const clisitefPinPadPayments = (options = {}) => {
|
|
|
996
1519
|
}
|
|
997
1520
|
if (outcome.code === -1) client.invalidate();
|
|
998
1521
|
const error = outcomeError(outcome, ctx.attempt);
|
|
999
|
-
const
|
|
1000
|
-
const state = unresolved ? "approved" : "undone";
|
|
1522
|
+
const ambiguous = error.code === "AMBIGUOUS";
|
|
1001
1523
|
const closed = {
|
|
1002
1524
|
...record,
|
|
1003
|
-
state,
|
|
1525
|
+
state: closingState(outcome),
|
|
1004
1526
|
metadata,
|
|
1005
1527
|
error
|
|
1006
1528
|
};
|
|
1007
1529
|
await writeTransaction(storage, closed);
|
|
1008
1530
|
if (outcome.error) throw outcome.error;
|
|
1009
|
-
return failed(closed, reference, error,
|
|
1531
|
+
return failed(closed, reference, error, ambiguous);
|
|
1532
|
+
};
|
|
1533
|
+
const voidDeps = {
|
|
1534
|
+
client,
|
|
1535
|
+
now,
|
|
1536
|
+
timeZone: options.timeZone,
|
|
1537
|
+
confirmationTimeoutMs: confirmationMs
|
|
1538
|
+
};
|
|
1539
|
+
/** Prepares the pinpad (recovering an open charge) and voids `result`. */
|
|
1540
|
+
const refund = async (ctx, result) => {
|
|
1541
|
+
const prepared = await prepare(ctx);
|
|
1542
|
+
const { settings, storage, record } = prepared;
|
|
1543
|
+
const busy = record && isOpen(record);
|
|
1544
|
+
const busyError = createPaymentError("TERMINAL_BUSY");
|
|
1545
|
+
const unavailable = prepared.error ?? (busy ? busyError : null);
|
|
1546
|
+
const v1 = {
|
|
1547
|
+
ui: ctx.ui,
|
|
1548
|
+
signal: ctx.signal,
|
|
1549
|
+
storage,
|
|
1550
|
+
settings
|
|
1551
|
+
};
|
|
1552
|
+
const v2 = {
|
|
1553
|
+
attempt: ctx.attempt,
|
|
1554
|
+
unavailable
|
|
1555
|
+
};
|
|
1556
|
+
return runClisitefVoid(voidDeps, {
|
|
1557
|
+
...v1,
|
|
1558
|
+
...v2
|
|
1559
|
+
}, result);
|
|
1560
|
+
};
|
|
1561
|
+
const retryReversals = async (ctx) => {
|
|
1562
|
+
const settings = readClisitefPinPadSettings(ctx.config, ctx.deviceSettings);
|
|
1563
|
+
const error = await configure(settings);
|
|
1564
|
+
if (error) throw new Error(error.message);
|
|
1565
|
+
const storage = storageOf(ctx);
|
|
1566
|
+
const pending = await readPendingReversals(storage);
|
|
1567
|
+
let refunded = 0;
|
|
1568
|
+
for (const entry of pending) {
|
|
1569
|
+
const v1 = {
|
|
1570
|
+
ui: ctx.ui,
|
|
1571
|
+
signal: ctx.signal,
|
|
1572
|
+
storage,
|
|
1573
|
+
settings
|
|
1574
|
+
};
|
|
1575
|
+
const v2 = { attempt: entry.attempts + 1 };
|
|
1576
|
+
const ctx1 = {
|
|
1577
|
+
...v1,
|
|
1578
|
+
...v2
|
|
1579
|
+
};
|
|
1580
|
+
if ((await runClisitefVoid(voidDeps, ctx1, entry.result)).status === "REFUNDED") refunded += 1;
|
|
1581
|
+
}
|
|
1582
|
+
const left = (await readPendingReversals(storage)).length;
|
|
1583
|
+
return `Voids completed: ${refunded}, pending: ${left}`;
|
|
1010
1584
|
};
|
|
1011
1585
|
const query = async (ctx, result) => {
|
|
1012
1586
|
const reference = result.metadata.attempt_reference;
|
|
@@ -1046,9 +1620,10 @@ const clisitefPinPadPayments = (options = {}) => {
|
|
|
1046
1620
|
const operation = (run, name = "terminal") => {
|
|
1047
1621
|
return (ctx) => {
|
|
1048
1622
|
const scope = `pinpad.terminal.${name}`;
|
|
1623
|
+
const deviceSettings = redactSecrets(ctx.deviceSettings);
|
|
1049
1624
|
const input = {
|
|
1050
1625
|
config: ctx.config,
|
|
1051
|
-
deviceSettings
|
|
1626
|
+
deviceSettings
|
|
1052
1627
|
};
|
|
1053
1628
|
const traced = () => clisitefDebugCall(scope, input, task);
|
|
1054
1629
|
const task = async () => {
|
|
@@ -1078,22 +1653,30 @@ const clisitefPinPadPayments = (options = {}) => {
|
|
|
1078
1653
|
return `CliSiTef ${await client.getVersion()}`;
|
|
1079
1654
|
}, "test"),
|
|
1080
1655
|
status: operation(async () => client.getHealth() ?? {}, "status"),
|
|
1081
|
-
extra: [
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1656
|
+
extra: [
|
|
1657
|
+
{
|
|
1658
|
+
key: "submit_pending_messages",
|
|
1659
|
+
label: "Send pending messages",
|
|
1660
|
+
run: operation(async () => {
|
|
1661
|
+
return `Pending messages sent (${await client.submitPendingMessages()})`;
|
|
1662
|
+
}, "submit_pending_messages")
|
|
1663
|
+
},
|
|
1664
|
+
{
|
|
1665
|
+
key: "retry_pending_reversals",
|
|
1666
|
+
label: "Retry pending voids",
|
|
1667
|
+
run: operation(retryReversals, "retry_pending_reversals")
|
|
1668
|
+
},
|
|
1669
|
+
{
|
|
1670
|
+
key: "release_pinpad",
|
|
1671
|
+
label: "Release pinpad",
|
|
1672
|
+
run: operation(async () => {
|
|
1673
|
+
await client.closePinPad();
|
|
1674
|
+
await client.disconnectSitef();
|
|
1675
|
+
client.invalidate();
|
|
1676
|
+
return "Pinpad released";
|
|
1677
|
+
}, "release_pinpad")
|
|
1678
|
+
}
|
|
1679
|
+
]
|
|
1097
1680
|
};
|
|
1098
1681
|
return {
|
|
1099
1682
|
provider: "clisitef",
|
|
@@ -1123,9 +1706,17 @@ const clisitefPinPadPayments = (options = {}) => {
|
|
|
1123
1706
|
const run = () => cancel(ctx, result);
|
|
1124
1707
|
return lock(() => clisitefDebugCall("pinpad.cancel", input, run));
|
|
1125
1708
|
},
|
|
1126
|
-
|
|
1709
|
+
refund: (ctx, result) => {
|
|
1710
|
+
const input = {
|
|
1711
|
+
ctx: debugContext(ctx),
|
|
1712
|
+
result
|
|
1713
|
+
};
|
|
1714
|
+
const run = () => refund(ctx, result);
|
|
1715
|
+
return lock(() => clisitefDebugCall("pinpad.refund", input, run));
|
|
1716
|
+
},
|
|
1717
|
+
reversalFor: (result) => result.status === "PAID" ? "refund" : "cancel",
|
|
1127
1718
|
terminal
|
|
1128
1719
|
};
|
|
1129
1720
|
};
|
|
1130
1721
|
//#endregion
|
|
1131
|
-
export { CLISITEF_CANCEL_CODES, CLISITEF_CAPTURE_FIELDS, CLISITEF_COMMAND, CLISITEF_CONFIRMATION_TIMEOUT_MS, CLISITEF_DEFAULT_OPERATOR, CLISITEF_FIELD, CLISITEF_INCONCLUSIVE_CODES, CLISITEF_NO, CLISITEF_PAYMENT_TIMEOUT_MS, CLISITEF_PINPAD_TYPES, CLISITEF_STORAGE_KEY, CLISITEF_YES, clisitefDetails, clisitefLast4, clisitefMetadata, clisitefPinPadCapabilities, clisitefPinPadDeviceFields, clisitefPinPadErrors, clisitefPinPadPayments, isOpen, readClisitefPinPadSettings, readTransaction, runClisitefTransaction, writeTransaction };
|
|
1722
|
+
export { CLISITEF_CANCEL_CODES, CLISITEF_CAPTURE_FIELDS, CLISITEF_COMMAND, CLISITEF_CONFIRMATION_TIMEOUT_MS, CLISITEF_DEFAULT_OPERATOR, CLISITEF_FIELD, CLISITEF_INCONCLUSIVE_CODES, CLISITEF_MODALITIES, CLISITEF_NO, CLISITEF_PAYMENT_TIMEOUT_MS, CLISITEF_PINPAD_TYPES, CLISITEF_REFUNDS_KEY, CLISITEF_REFUND_STORAGE_KEY, CLISITEF_REVERSALS_KEY, CLISITEF_STORAGE_KEY, CLISITEF_VOID_FIELD, CLISITEF_YES, clisitefDetails, clisitefLast4, clisitefMetadata, clisitefModalityOf, clisitefPinPadCapabilities, clisitefPinPadDeviceFields, clisitefPinPadErrors, clisitefPinPadPayments, clisitefVoidMetadata, dropReversal, isClisitefModality, isOpen, queueReversal, readClisitefPinPadSettings, readPendingReversals, readRefund, readTransaction, redactSecrets, resolveClisitefMenu, runClisitefTransaction, runClisitefVoid, writeRefund, writeTransaction };
|