@fire-spark/payments-clisitef-pinpad-rn 0.8.1 → 0.8.2

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 CHANGED
@@ -66,6 +66,13 @@ declare const CLISITEF_COMMAND: {
66
66
  /** `continueTransaction` answers to `CONFIRM` (20). */
67
67
  declare const CLISITEF_YES = "0";
68
68
  declare const CLISITEF_NO = "1";
69
+ /** `continueTransaction` answer to `CHECK_ABORT` (23): interrupt. */
70
+ declare const CLISITEF_INTERRUPT = "1";
71
+ /**
72
+ * Commands answered after `abortTransaction(-1)` without `stage 1`; then the
73
+ * adapter stops answering and leaves the record open (legacy criterion).
74
+ */
75
+ declare const CLISITEF_DRAIN_LIMIT = 10;
69
76
  /** `TipoCampo` ids stored with `STORE_FIELD` (Fiserv "Tabela TipoCampo"). */
70
77
  declare const CLISITEF_FIELD: {
71
78
  readonly TRANSACTION_DATE: 105;
@@ -305,9 +312,10 @@ declare const isOpen: (record: ClisitefTransactionRecord) => boolean;
305
312
  /**
306
313
  * `confirmed`: `stage 2` closed with 0. `declined`: failed before the card
307
314
  * was charged. `undone`: `finishTransaction(0)` sent after a capture or an
308
- * approval. `unresolved`: the undo itself failed; needs review.
315
+ * approval. `unresolved`: the undo itself failed; needs review. `open`: given
316
+ * up, but the routine never returned `stage 1`; the next charge recovers it.
309
317
  */
310
- type ClisitefOutcomeStatus = "confirmed" | "declined" | "undone" | "unresolved";
318
+ type ClisitefOutcomeStatus = "confirmed" | "declined" | "undone" | "unresolved" | "open";
311
319
  type ClisitefOutcome = {
312
320
  status: ClisitefOutcomeStatus;
313
321
  /** Provider code: `resultCode`, a client error code, or `null`. */
@@ -342,4 +350,4 @@ type ClisitefTransactionInput = {
342
350
  */
343
351
  declare const runClisitefTransaction: (input: ClisitefTransactionInput) => Promise<ClisitefOutcome>;
344
352
  //#endregion
345
- 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, ClisitefFields, ClisitefMenuDecision, ClisitefMenuPurpose, ClisitefModality, ClisitefOutcome, ClisitefOutcomeStatus, ClisitefPendingReversal, ClisitefPinPadOptions, ClisitefPinPadSettings, ClisitefProviderMetadata, ClisitefTransactionInput, ClisitefTransactionRecord, ClisitefTransactionState, ClisitefVoidContext, ClisitefVoidDeps, clisitefDetails, clisitefLast4, clisitefMetadata, clisitefModalityOf, clisitefPinPadCapabilities, clisitefPinPadDeviceFields, clisitefPinPadErrors, clisitefPinPadPayments, clisitefVoidMetadata, dropReversal, isClisitefModality, isOpen, queueReversal, readClisitefPinPadSettings, readPendingReversals, readRefund, readTransaction, redactSecrets, resolveClisitefMenu, runClisitefTransaction, runClisitefVoid, writeRefund, writeTransaction };
353
+ export { CLISITEF_CANCEL_CODES, CLISITEF_CAPTURE_FIELDS, CLISITEF_COMMAND, CLISITEF_CONFIRMATION_TIMEOUT_MS, CLISITEF_DEFAULT_OPERATOR, CLISITEF_DRAIN_LIMIT, CLISITEF_FIELD, CLISITEF_INCONCLUSIVE_CODES, CLISITEF_INTERRUPT, 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, ClisitefFields, ClisitefMenuDecision, ClisitefMenuPurpose, ClisitefModality, ClisitefOutcome, ClisitefOutcomeStatus, ClisitefPendingReversal, ClisitefPinPadOptions, ClisitefPinPadSettings, ClisitefProviderMetadata, ClisitefTransactionInput, ClisitefTransactionRecord, ClisitefTransactionState, ClisitefVoidContext, ClisitefVoidDeps, clisitefDetails, clisitefLast4, clisitefMetadata, clisitefModalityOf, clisitefPinPadCapabilities, clisitefPinPadDeviceFields, clisitefPinPadErrors, clisitefPinPadPayments, clisitefVoidMetadata, dropReversal, isClisitefModality, isOpen, queueReversal, readClisitefPinPadSettings, readPendingReversals, readRefund, readTransaction, redactSecrets, resolveClisitefMenu, runClisitefTransaction, runClisitefVoid, writeRefund, writeTransaction };
package/dist/index.js CHANGED
@@ -34,6 +34,13 @@ const CLISITEF_COMMAND = {
34
34
  /** `continueTransaction` answers to `CONFIRM` (20). */
35
35
  const CLISITEF_YES = "0";
36
36
  const CLISITEF_NO = "1";
37
+ /** `continueTransaction` answer to `CHECK_ABORT` (23): interrupt. */
38
+ const CLISITEF_INTERRUPT = "1";
39
+ /**
40
+ * Commands answered after `abortTransaction(-1)` without `stage 1`; then the
41
+ * adapter stops answering and leaves the record open (legacy criterion).
42
+ */
43
+ const CLISITEF_DRAIN_LIMIT = 10;
37
44
  /** `TipoCampo` ids stored with `STORE_FIELD` (Fiserv "Tabela TipoCampo"). */
38
45
  const CLISITEF_FIELD = {
39
46
  TRANSACTION_DATE: 105,
@@ -459,6 +466,9 @@ const runClisitefTransaction = (input) => {
459
466
  let approved = false;
460
467
  let closing = false;
461
468
  let aborted = false;
469
+ /** Why the adapter asked to interrupt; `stage 1` has not arrived yet. */
470
+ let givingUp = null;
471
+ let drained = 0;
462
472
  let watchdog;
463
473
  let queue = Promise.resolve();
464
474
  let resolve = () => void 0;
@@ -507,18 +517,37 @@ const runClisitefTransaction = (input) => {
507
517
  });
508
518
  }
509
519
  };
510
- const close = async (reason) => {
511
- log("close", reason);
512
- if (closing) return;
520
+ /** Stops answering and leaves the record open: `stage 1` never came. */
521
+ const abandon = () => {
522
+ log("abandon", {
523
+ drained,
524
+ skipped: closing
525
+ });
526
+ if (closing || !givingUp) return;
513
527
  closing = true;
514
- if (!approved) await client.abortTransaction(CLISITEF_ABORT_FLAG).catch(() => 0);
515
- if (captured || approved) return undo(reason);
516
- settle("declined", reason);
528
+ settle("open", givingUp);
529
+ };
530
+ /**
531
+ * Asks CliSiTef to interrupt (`Continua = -1`) and keeps the loop running
532
+ * until `stage 1`: dropping it earlier leaves the routine open (`-12`). An
533
+ * approval already confirmed with `finishTransaction(1)` is undone instead.
534
+ */
535
+ const giveUp = (reason) => {
536
+ log("give up", reason);
537
+ if (closing || givingUp) return;
538
+ if (approved) {
539
+ closing = true;
540
+ undo(reason);
541
+ return;
542
+ }
543
+ givingUp = reason;
544
+ watchdog = setTimeout(abandon, input.confirmationTimeoutMs);
545
+ client.abortTransaction(CLISITEF_ABORT_FLAG).catch(() => 0);
517
546
  };
518
547
  function onAbort() {
519
548
  log("signal aborted (payment deadline)");
520
549
  aborted = true;
521
- close({
550
+ giveUp({
522
551
  code: null,
523
552
  message: "Aborted by the payment deadline"
524
553
  });
@@ -532,6 +561,36 @@ const runClisitefTransaction = (input) => {
532
561
  if (closing) return;
533
562
  await client.continueTransaction(data);
534
563
  };
564
+ const store = (event) => {
565
+ fields.set(event.fieldId, event.input);
566
+ if (CLISITEF_CAPTURE_FIELDS.has(event.fieldId)) captured = true;
567
+ };
568
+ const clearQr = () => {
569
+ ui.action({
570
+ type: "qr",
571
+ payload: "",
572
+ format: "text",
573
+ expiresAt: null
574
+ });
575
+ };
576
+ /** Answers without the UI while CliSiTef winds down after the abort. */
577
+ const drain = async (event) => {
578
+ drained += 1;
579
+ log("drain", {
580
+ command: event.command,
581
+ drained
582
+ });
583
+ if (drained > 10) return abandon();
584
+ const { command } = event;
585
+ if (command === CLISITEF_COMMAND.STORE_FIELD) store(event);
586
+ if (command === CLISITEF_COMMAND.QR_HIDE) clearQr();
587
+ const interrupt = command === CLISITEF_COMMAND.CHECK_ABORT;
588
+ return reply(interrupt ? "1" : "");
589
+ };
590
+ /** A UI answer that arrives after giving up is not sent. */
591
+ const respond = (event, data) => {
592
+ return givingUp ? drain(event) : reply(data);
593
+ };
535
594
  const showMessage = (text) => {
536
595
  const collapsed = collapseCounter(text.trim());
537
596
  if (!collapsed || collapsed === lastMessage) return;
@@ -541,8 +600,8 @@ const runClisitefTransaction = (input) => {
541
600
  kind: "waiting"
542
601
  });
543
602
  };
544
- const onMenu = async (text) => {
545
- const options = parseClisitefMenu(text);
603
+ const onMenu = async (event) => {
604
+ const options = parseClisitefMenu(event.input);
546
605
  const label = title || "Selecione uma opção";
547
606
  const decision = resolveClisitefMenu(...[
548
607
  options,
@@ -555,11 +614,11 @@ const runClisitefTransaction = (input) => {
555
614
  decision
556
615
  });
557
616
  if (decision.kind === "abort") {
558
- const reason = {
617
+ giveUp({
559
618
  code: "FINANCING_ONLY",
560
619
  message: decision.message
561
- };
562
- return close(reason);
620
+ });
621
+ return drain(event);
563
622
  }
564
623
  if (decision.kind === "pick") return reply(decision.value);
565
624
  const p2 = {
@@ -568,7 +627,7 @@ const runClisitefTransaction = (input) => {
568
627
  };
569
628
  const picked = await ui.select(p2);
570
629
  log("ui.select ← out", { picked });
571
- return reply(picked);
630
+ return respond(event, picked);
572
631
  };
573
632
  const onQr = (event) => {
574
633
  const payload = event.input.trim();
@@ -592,20 +651,15 @@ const runClisitefTransaction = (input) => {
592
651
  ...redact(event),
593
652
  title
594
653
  });
654
+ if (givingUp) return drain(event);
595
655
  const { command, input: text } = event;
596
656
  if (command === CLISITEF_COMMAND.STORE_FIELD) {
597
- fields.set(event.fieldId, text);
598
- if (CLISITEF_CAPTURE_FIELDS.has(event.fieldId)) captured = true;
657
+ store(event);
599
658
  return reply("");
600
659
  }
601
660
  if (command === CLISITEF_COMMAND.QR_SHOW) return onQr(event);
602
661
  if (command === CLISITEF_COMMAND.QR_HIDE) {
603
- ui.action({
604
- type: "qr",
605
- payload: "",
606
- format: "text",
607
- expiresAt: null
608
- });
662
+ clearQr();
609
663
  return reply("");
610
664
  }
611
665
  if (MESSAGES.has(command)) {
@@ -625,9 +679,9 @@ const runClisitefTransaction = (input) => {
625
679
  log("ui.confirm → in", { text: text.trim() || title });
626
680
  const yes = await ui.confirm({ text: text.trim() || title });
627
681
  log("ui.confirm ← out", { yes });
628
- return reply(yes ? "0" : "1");
682
+ return respond(event, yes ? "0" : "1");
629
683
  }
630
- if (command === CLISITEF_COMMAND.MENU) return onMenu(text);
684
+ if (command === CLISITEF_COMMAND.MENU) return onMenu(event);
631
685
  if (READS.has(command)) {
632
686
  const known = input.answerField?.(event.fieldId) ?? null;
633
687
  if (known !== null) return reply(known, true);
@@ -640,13 +694,14 @@ const runClisitefTransaction = (input) => {
640
694
  };
641
695
  log("ui.input → in", p1);
642
696
  const value = await ui.input(p1);
643
- return reply(value);
697
+ return respond(event, value);
644
698
  }
645
699
  const message = `Unsupported CliSiTef command ${command}`;
646
- return close({
700
+ giveUp({
647
701
  code: `UNSUPPORTED_COMMAND_${command}`,
648
702
  message
649
703
  });
704
+ return drain(event);
650
705
  };
651
706
  const confirm = () => {
652
707
  const message = "CliSiTef did not close the confirmation";
@@ -678,6 +733,21 @@ const runClisitefTransaction = (input) => {
678
733
  });
679
734
  });
680
735
  };
736
+ /**
737
+ * `stage 1` after giving up. A late approval is undone, never confirmed; a
738
+ * cancel (`-2`, `-6`, `-15`) needs no `finishTransaction`: CliSiTef sends
739
+ * the undo itself.
740
+ */
741
+ const windDown = (reason, code) => {
742
+ closing = true;
743
+ const closed = {
744
+ ...reason,
745
+ code: reason.code ?? code
746
+ };
747
+ const inconclusive = captured && CLISITEF_INCONCLUSIVE_CODES.has(code);
748
+ if (code === CLISITEF_RESULT.OK || inconclusive) return undo(closed);
749
+ settle("declined", closed);
750
+ };
681
751
  const onResult = async (event) => {
682
752
  const { stage, resultCode: code } = event;
683
753
  log("onTransactionResult", {
@@ -686,6 +756,7 @@ const runClisitefTransaction = (input) => {
686
756
  skipped: closing
687
757
  });
688
758
  if (closing) return;
759
+ if (givingUp && stage === 1) return windDown(givingUp, code);
689
760
  if (stage === 1 && code === CLISITEF_RESULT.OK) {
690
761
  approved = true;
691
762
  await input.onApproved(fields);
@@ -715,7 +786,8 @@ const runClisitefTransaction = (input) => {
715
786
  message: null
716
787
  });
717
788
  };
718
- const enqueue = (handle) => {
789
+ /** A failed `onData` handler still owes CliSiTef an answer. */
790
+ const enqueue = (handle, event) => {
719
791
  const run = async () => {
720
792
  await handle();
721
793
  };
@@ -729,10 +801,11 @@ const runClisitefTransaction = (input) => {
729
801
  error
730
802
  });
731
803
  if (closing) return;
732
- await close({
804
+ giveUp({
733
805
  ...reason,
734
806
  error
735
807
  });
808
+ if (event && givingUp) await drain(event).catch(abandon);
736
809
  });
737
810
  };
738
811
  const begin = async () => {
@@ -748,7 +821,7 @@ const runClisitefTransaction = (input) => {
748
821
  });
749
822
  }
750
823
  signal.addEventListener("abort", onAbort);
751
- const handleData = (e) => enqueue(() => onData(e));
824
+ const handleData = (e) => enqueue(() => onData(e), e);
752
825
  const handleResult = (e) => {
753
826
  enqueue(() => onResult(e));
754
827
  };
@@ -1210,6 +1283,7 @@ const paid = (record) => {
1210
1283
  */
1211
1284
  const isAmbiguous = (outcome) => {
1212
1285
  if (outcome.status === "unresolved") return true;
1286
+ if (outcome.status === "open") return outcome.evidence;
1213
1287
  if (!outcome.evidence || outcome.status !== "undone") return false;
1214
1288
  const { code } = outcome;
1215
1289
  const inconclusive = typeof code === "number" && CLISITEF_INCONCLUSIVE_CODES.has(code);
@@ -1226,7 +1300,8 @@ const outcomeError = (outcome, attempt) => {
1226
1300
  provider_code: code
1227
1301
  };
1228
1302
  if (isAmbiguous(outcome)) return createPaymentError("AMBIGUOUS", extra);
1229
- if (outcome.aborted) return createPaymentError("TIMEOUT", extra);
1303
+ const cancelled = typeof outcome.code === "number" && CLISITEF_CANCEL_CODES.has(outcome.code);
1304
+ if (outcome.aborted && !cancelled) return createPaymentError("TIMEOUT", extra);
1230
1305
  if (outcome.code === null) {
1231
1306
  const message = outcome.message ?? void 0;
1232
1307
  return createPaymentError("PROVIDER_UNAVAILABLE", {
@@ -1274,9 +1349,13 @@ const debugContext = (ctx) => {
1274
1349
  deviceSettings: redactSecrets(ctx.deviceSettings)
1275
1350
  };
1276
1351
  };
1277
- /** `review` when the customer may have paid; `approved` when the undo failed. */
1352
+ /**
1353
+ * `review` when the customer may have paid; `approved` when the undo failed;
1354
+ * `started` when the routine never closed, so the next charge recovers it.
1355
+ */
1278
1356
  const closingState = (outcome) => {
1279
1357
  if (outcome.status === "unresolved") return "approved";
1358
+ if (outcome.status === "open") return "started";
1280
1359
  return isAmbiguous(outcome) ? "review" : "undone";
1281
1360
  };
1282
1361
  const terminalError = (error) => {
@@ -1316,7 +1395,10 @@ const clisitefPinPadPayments = (options = {}) => {
1316
1395
  fallback: "TERMINAL_NOT_CONFIGURED"
1317
1396
  });
1318
1397
  };
1319
- /** Undoes a transaction a crash or a lost result left open. */
1398
+ /**
1399
+ * Undoes a transaction a crash, a lost result or an interrupted abort left
1400
+ * pending in CliSiTef. A `review` record stays as is when nothing is pending.
1401
+ */
1320
1402
  const recover = async (storage, record, key = CLISITEF_STORAGE_KEY) => {
1321
1403
  const { fiscalCoupon, fiscalDate, time } = record;
1322
1404
  const count = client.getQttPendingTransactions;
@@ -1326,6 +1408,7 @@ const clisitefPinPadPayments = (options = {}) => {
1326
1408
  record
1327
1409
  });
1328
1410
  if (pending === null) return record;
1411
+ if (pending === 0 && record.state === "review") return record;
1329
1412
  let next;
1330
1413
  if (pending > 0) {
1331
1414
  const p1 = {
@@ -1371,12 +1454,17 @@ const clisitefPinPadPayments = (options = {}) => {
1371
1454
  const isConfigureBusy = (error) => {
1372
1455
  return error?.provider_code === "-12";
1373
1456
  };
1374
- /** Drops a leftover native session so `configure` can run again. */
1457
+ /**
1458
+ * Interrupts a leftover native session so `configure` can run again. No
1459
+ * `submitPendingMessages` or `closePinPad` here: right after an abort they
1460
+ * left the native thread hung in production.
1461
+ */
1375
1462
  const releaseNative = async () => {
1376
1463
  await client.abortTransaction(CLISITEF_ABORT_FLAG).catch(() => 0);
1377
- await client.submitPendingMessages().catch(() => -1);
1378
- await client.closePinPad().catch(() => -1);
1379
- client.invalidate();
1464
+ };
1465
+ /** Open, or flagged for review with the coupon maybe still pending. */
1466
+ const recoverable = (record) => {
1467
+ return isOpen(record) || record.state === "review";
1380
1468
  };
1381
1469
  /** Configures and resolves the open records of the device, if any. */
1382
1470
  const prepare = async (ctx) => {
@@ -1390,7 +1478,7 @@ const clisitefPinPadPayments = (options = {}) => {
1390
1478
  const storage = storageOf(ctx);
1391
1479
  const resolve = async (key) => {
1392
1480
  const found = await readTransaction(storage, key);
1393
- if (error || !found || !isOpen(found)) return found;
1481
+ if (error || !found || !recoverable(found)) return found;
1394
1482
  return recover(storage, found, key);
1395
1483
  };
1396
1484
  const record = await resolve(CLISITEF_STORAGE_KEY);
@@ -1734,4 +1822,4 @@ const clisitefPinPadPayments = (options = {}) => {
1734
1822
  };
1735
1823
  };
1736
1824
  //#endregion
1737
- 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 };
1825
+ export { CLISITEF_CANCEL_CODES, CLISITEF_CAPTURE_FIELDS, CLISITEF_COMMAND, CLISITEF_CONFIRMATION_TIMEOUT_MS, CLISITEF_DEFAULT_OPERATOR, CLISITEF_DRAIN_LIMIT, CLISITEF_FIELD, CLISITEF_INCONCLUSIVE_CODES, CLISITEF_INTERRUPT, 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fire-spark/payments-clisitef-pinpad-rn",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Fire Spark CliSiTef (Software Express) pinpad adapter for Brazil: debit, credit, voucher and Pix with automatic voids on Android kiosks",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -17,9 +17,9 @@
17
17
  }
18
18
  },
19
19
  "dependencies": {
20
- "@fire-spark/payments": "^0.8.1",
21
- "@fire-spark/payments-clisitef-client": "^0.8.1",
22
- "@fire-spark/types": "^0.8.1"
20
+ "@fire-spark/payments": "^0.8.2",
21
+ "@fire-spark/payments-clisitef-client": "^0.8.2",
22
+ "@fire-spark/types": "^0.8.2"
23
23
  },
24
24
  "devDependencies": {
25
25
  "tsdown": "^0.22.14",