@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.
Files changed (3) hide show
  1. package/dist/index.d.ts +131 -10
  2. package/dist/index.js +795 -204
  3. 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), 42 (identified menu, only with
8
- * `ItemMenuIdentificado`) and 50/51 (QR, PIX only).
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": invalid("Digital wallet not enabled"),
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
- const clisitefMetadata = (input) => {
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
- const brand = clisitefBrand(fields);
172
- const last4 = field(fields, CLISITEF_FIELD.LAST4)?.slice(-4) ?? null;
173
- const entry = field(fields, CLISITEF_FIELD.ENTRY_MODE);
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
- card_type: CARD_TYPES[input.modality] ?? null,
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 receipt = customer !== null || merchant !== null ? {
202
- customer,
203
- merchant,
204
- format: "text"
205
- } : void 0;
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/settings.ts
229
- const CLISITEF_PINPAD_TYPES = [
230
- "ANDROID_USB",
231
- "ANDROID_BT",
232
- "ANDROID_AUTO"
233
- ];
234
- /**
235
- * Ficha §6: filled per kiosk in the device settings. SiTef IP, store code
236
- * and store CNPJ come from the payment's store settings (`config.stores`);
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 text = (value) => {
258
- return typeof value === "string" ? value.trim() : "";
297
+ const readPendingReversals = async (storage) => {
298
+ const list = await readJson(storage, CLISITEF_REVERSALS_KEY);
299
+ return Array.isArray(list) ? list : [];
259
300
  };
260
- const notConfigured = (missing) => {
261
- return new PaymentError(createPaymentError("TERMINAL_NOT_CONFIGURED", { message: `CliSiTef is missing ${missing.join(", ")}` }));
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
- const isModality = (value) => {
264
- return value === "DEBIT" || value === "CREDIT" || value === "VOUCHER";
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
- /** `modalities`, or the legacy single `modality` of older configs. */
267
- const listModalities = (config) => {
268
- const listed = Array.isArray(config.modalities) ? config.modalities : [];
269
- return (listed.length ? listed : [config.modality]).map(text).filter(Boolean);
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
- /** Per modality; the legacy `restrictions` applies to all of them. */
272
- const readRestrictions = (config, modalities) => {
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
- /** `payments.config` (merchant) + device settings; throws when incomplete. */
282
- const readClisitefPinPadSettings = (config, device) => {
283
- const listed = listModalities(config);
284
- const cnpjEnterprise = text(config.cnpj_enterprise);
285
- const store = storeSettingsOf(config);
286
- const serverIp = text(device.sitef_ip) || text(store.sitef_ip);
287
- const storeCode = text(device.store_code) || text(store.store_code);
288
- const terminalNumber = text(device.terminal_number);
289
- const cnpjStore = (text(device.cnpj_store) || text(store.cnpj_store)).replace(/\D/g, "");
290
- const pinpadType = text(device.pinpad_type) || void 0;
291
- const required = {
292
- modalities: listed.join(","),
293
- cnpj_enterprise: cnpjEnterprise
294
- };
295
- const fromDevice = {
296
- sitef_ip: serverIp,
297
- store_code: storeCode
298
- };
299
- const more = {
300
- terminal_number: terminalNumber,
301
- cnpj_store: cnpjStore
302
- };
303
- const values = {
304
- ...required,
305
- ...fromDevice
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
- Object.assign(values, more);
308
- const missing = Object.keys(values).filter((key) => !values[key]);
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 (missing.length) throw notConfigured(missing);
315
- if (!listed.every(isModality)) throw notConfigured(["a valid modality"]);
316
- const configure = {
317
- serverIp,
318
- storeCode,
319
- terminalNumber,
320
- additionalParams: clisitefAdditionalParams({
321
- cnpjStore,
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
- configure,
330
- modalities: unique,
331
- operator,
332
- restrictions: readRestrictions(config, unique),
333
- terminalNumber
373
+ kind: "ask",
374
+ options: rest
334
375
  };
335
376
  };
336
- //#endregion
337
- //#region src/store.ts
338
- const isRecord = (value) => {
339
- if (!value || typeof value !== "object") return false;
340
- const record = value;
341
- return typeof record.fiscalCoupon === "string" && typeof record.attemptReference === "string";
342
- };
343
- const readTransaction = async (storage) => {
344
- const raw = await storage.get(CLISITEF_STORAGE_KEY);
345
- clisitefDebug("pinpad.store", "read", { raw });
346
- if (!raw) return null;
347
- try {
348
- const parsed = JSON.parse(raw);
349
- return isRecord(parsed) ? parsed : null;
350
- } catch {
351
- return null;
352
- }
353
- };
354
- const writeTransaction = async (storage, record) => {
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
- fields: Object.fromEntries(fields)
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 (MESSAGES.has(command)) {
493
- if (text.trim()) ui.message({
494
- text: text.trim(),
495
- kind: "waiting"
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, deviceSettings } = ctx;
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 record of the device, if any. */
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
- let record = await readTransaction(storage);
869
- if (!error && record && isOpen(record)) record = await recover(storage, record);
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 && isOpen(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 unresolved = outcome.status === "unresolved";
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, unresolved);
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: ctx.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
- key: "submit_pending_messages",
1083
- label: "Send pending messages",
1084
- run: operation(async () => {
1085
- return `Pending messages sent (${await client.submitPendingMessages()})`;
1086
- }, "submit_pending_messages")
1087
- }, {
1088
- key: "release_pinpad",
1089
- label: "Release pinpad",
1090
- run: operation(async () => {
1091
- await client.closePinPad();
1092
- await client.disconnectSitef();
1093
- client.invalidate();
1094
- return "Pinpad released";
1095
- }, "release_pinpad")
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
- reversalFor: (result) => result.status === "PAID" ? "manual" : "cancel",
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 };