Verifone.js

import SignatureTablet from './SignatureTablet.js'
import { BASE_URL, checkResponse } from './Common.js'

/**
 * ControlProperty
 * @classdesc Form Control Properties
 * @see {@link Verifone#renderForm} Render a form and update control properties
 */
export class ControlProperty {
  id
  name
  value

  /**
   * Instantiate a Control Property
   * @constructor
   * @param {number} id - ID of the Control on the Form
   * @param {string} name - Name of the property to update
   * @param {number|string|boolean} value - property value
   * @example
   * // A property to make a control hidden
   * const prop = new ControlProperty(1, "visible", false)
   *
   * // A property to change the text of a control
   * const prop = new ControlProperty(1, "caption", "Cancel Operation")
   *
   * // A property to change the background color of a control
   * const prop = new ControlProperty(1, "bg_color", "#EB9534")
   */
  constructor (id, name, value) {
    this.id = id
    this.name = name
    this.value = value
  }

  toJSON () {
    return {
      id: this.id,
      name: this.name,
      value: this.value
    }
  }
}

/**
 * DeviceForm
 * @classdesc Form to be rendered on a tablet device.
 * @see {@link Verifone#renderForm} Render a form
 */
export class DeviceForm {
  id
  waitForEvents
  timeout

  /**
   * Instantiate a Form to be Rendered
   * @constructor
   * @param {string} id - ID of the Form
   * @param {bool} [waitForEvents=false] - If `true` will wait for event data to
   * be received on the form.
   * @param {number} [timeout=15] - How long, in seconds, to wait for events from
   * the form.
   * @example
   * // Form with no input data
   * const form = new DeviceForm("FP_DISPLAY")
   *
   * // Form with buttons, wait 15 seconds for event data
   * const form = new DeviceForm("FP_GEN_DISPLAY", true, 25)
   */
  constructor (id, waitForEvents = false, timeout = 15) {
    this.id = id
    this.waitForEvents = waitForEvents
    this.timeout = timeout
  }

  toJSON () {
    return {
      id: this.id,
      wait_for_events: this.waitForEvents,
      timeout: this.timeout
    }
  }
}

/**
 * Verifone
 * @classdesc Captures a signature and renders forms on Verifone devices.
 * @extends SignatureTablet
 * @see {@link SignatureTablet}
 */
export class Verifone extends SignatureTablet {
  /**
   * Instantiate a Verifone Class
   * @constructor
   * @param {string} [baseURL] - Protocol, domain, and port for the service.
   * @example
   * const tablet = new Verifone()
   *
   */
  constructor (baseUrl = BASE_URL) {
    super('capture_signature_verifone', baseUrl)
  }

  /**
   * Not currently supported for this plugin.
   * @throws Not implemented error
   * @example
   * // Not currently supported for this plugin
   * @override
   */
  async streamUrl () {
    throw new Error('streamUrl() is not implemented for the Verifone Plugin')
  }

  /**
   * Not currently supported for this plugin.
   * @throws Not implemented error
   * @example
   * // Not currently supported for this plugin
   * @override
   */
  async getMostRecentFrame () {
    throw new Error('getMostRecentFrame() is not implemented for the Verifone Plugin')
  }

  /**
   * Not currently supported for this plugin.
   * @throws Not implemented error
   * @see {@link Verifone#cancel}
   * @example
   * // Not currently supported for this plugin
   * @override
   */
  async stopFeed () {
    throw new Error('stopFeed() is not implemented for the Verifone Plugin')
  }

  /**
   * Get detailed information about a device
   *
   * @description Returns detailed information about a device.
   *
   * @async
   * @method
   * @returns {object} Device info
   * @example
   * const info = await device.info()
   */
  async info (deviceOpt) {
    const device = await this.setupDevice(deviceOpt)
    return device.info()
  }

  /**
   * Cancel a pending device operation
   *
   * @description Will attempt to cancel any active long-running operation
   *
   * @param {Number?} [timeout] If provided, will attempt to
   * wait at least this long (in seconds) for the operation to be cancelled
   * before returning a response. By default this endpoint will request a
   * cancellation and return immediately, however if you'd like to block, waiting
   * for the device to be ready before attempting another operation you may supply
   * this timeout.
   *
   * @param {string|object} [deviceOpt] - Cancel the operation for either a
   * specific Device ID or a Device Object. The default is the first available
   * device.
   *
   * @async
   * @method
   * @returns {object} Cancellation status
   * @example
   * const {status} = await device.cancel()
   */
  async cancel (timeout = null, deviceOpt) {
    const device = await this.setupDevice(deviceOpt)
    return device.cancel(timeout)
  }

  /**
   * Restart the device or reset state.
   *
   * @description Reset the Verifone's internal state, reboot the device, or
   * Forms Processor application.
   *
   * @param {Number?} [depth=3] 0 = Reboot the device, 1 = Restart Forms
   * Processor, 2 = Reset device state, 3 = Reset device state and drain serial
   * port.
   *
   * @param {string|object} [deviceOpt] - Display the objects on either a
   * specific Device ID or a Device Object. The default is the first available
   * device.
   *
   * @async
   * @method
   * @returns {object} Reset status
   * @example
   * const {status} = await device.reset()
   */
  async reset (depth = 3, deviceOpt) {
    const device = await this.setupDevice(deviceOpt)
    return device.reset(depth)
  }

  /**
   * Render an array of form controls.
   *
   * @description If any objects are Button types, the method will wait for one
   * to be clicked.
   *
   * @param {object[]} objects An array of objects to display.
   *
   * @param {object} [formOpt] - Form configuration options.
   * @param {string} [formOpt.background='#ffffff'] - Background color of form (hex or RGB.)
   * @param {number} [formOpt.timeout=45] - Form timeout, in seconds.
   *
   * @param {string|object} [deviceOpt] - Display the objects on either a
   * specific Device ID or a Device Object. The default is the first available
   * device.
   *
   * @param {object} [requestOpt] - Additional options for the request
   *
   * @param {AbortSignal} [requestOpt.signal=null] - An {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal|AbortSignal}
   * obtained from an {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortController|AbortController}
   * allowing the request to be aborted. If the request is aborted or the HTTP
   * connection is closed for any reason before a user has interacted with the
   * display, the device state will be reset and the screen will be cleared.
   * The {@link Verifone#cancel} method is the preferred method for cancelling
   * a displayObjects request and should usually be used instead of an AbortSignal.
   *
   * @param {Number?} [requestOpt.readyTimeout=10] Attempt to wait at least this
   * long (in seconds) for the device to be ready before sending the
   * displayObjects request. This reduces the chance that the request will fail
   * due to the serial port being locked after a previously cancelled or aborted
   * request.
   *
   * @async
   * @method
   * @returns {string?} ID of the clicked object if any
   * @example
   * const result = await tablet.displayObjects([
   *  new Button("OK", 20, 200),
   *  new Button("Cancel", 80, 200)
   * ], { background: 'rgb(10,100,10)'})
   *
   * console.log(result)
   */
  async displayObjects (objects, formOpt = {}, deviceOpt, requestOpt = {}) {
    const device = await this.setupDevice(deviceOpt)
    const defaultRequestOpt = { signal: null, readyTimeout: 10 }
    const mergedOpts = Object.assign({}, defaultRequestOpt, requestOpt)

    if (typeof mergedOpts.readyTimeout === 'number') {
      // Abort any active in-flight requests and wait at most readyTimeout
      // seconds for the device to be ready
      await this.cancel(mergedOpts.readyTimeout)
    }

    return device.displayObjects(objects, formOpt, mergedOpts.signal)
  }

  /**
   * Render a form and get event data.
   *
   * @param {string|DeviceForm} form - ID of the form on the device or a DeviceForm
   * object with addtional configuration details.
   * @param {ControlProperty[]} [controlProps] - Array of {@link ControlProperty}
   * objects.
   * @param {string|object} [deviceOpt] - Display the objects on either a
   * specific Device ID or a Device Object. The default is the first available
   * device.
   *
   * @override
   * @see {@link Verifone#renderForm} Render a form
   */
  async renderForm (form, controlProps, deviceOpt) {
    if (typeof form === 'string') {
      form = new DeviceForm(form)
    }

    const device = await this.setupDevice(deviceOpt)
    device.can('render_form')

    const body = JSON.stringify({
      form: form.id,
      wait_for_events: form.waitForEvents,
      timeout: form.timeout,
      control_properties: controlProps
    })
    const options = {
      method: 'POST',
      mode: 'cors',
      headers: {
        'Content-Type': 'application/json'
      },
      body
    }
    const url = `${this.baseUrl}/plugin/${this.id}/device/${device.id}/form/${form.id}/render`
    const response = await fetch(url, options)
    await checkResponse(response)
    return await response.json()
  }

  /**
   * Read magstripe from a card reader
   *
   * @param {object} [readOpts] - Additional options for reading magstripe.
   * @param {number} [readOpts.timeout=30] - Magstripe read timeout, in seconds.
   *
   * @param {string|object} [deviceOpt] - Read magstrip using a specific Device
   * ID or a Device Object. The default is the first available device.
   *
   * @param {object} [requestOpt] - Additional options for the request
   *
   * @param {AbortSignal} [requestOpt.signal=null] - An {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal|AbortSignal}
   * obtained from an {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortController|AbortController}
   * allowing the request to be aborted. If the request is aborted or the HTTP
   * connection is closed for any reason before a user has interacted with the
   * display, the device state will be reset and the screen will be cleared.
   * The {@link Verifone#cancel} method is the preferred method for cancelling
   * a readMagstripe request and should usually be used instead of an AbortSignal.
   *
   * @param {Number?} [requestOpt.readyTimeout=10] Attempt to wait at least this
   * long (in seconds) for the device to be ready before sending the
   * readMagstripe request. This reduces the chance that the request will fail
   * due to the serial port being locked after a previously cancelled or aborted
   * request.
   */
  async readMagstripe (readOpts = { timeout: 30 }, deviceOpt, requestOpt = {}) {
    const device = await this.setupDevice(deviceOpt)
    const defaultRequestOpt = { signal: null, readyTimeout: 10 }
    const mergedOpts = Object.assign({}, defaultRequestOpt, requestOpt)

    const options = {
      method: 'GET',
      mode: 'cors',
      headers: {
        'Content-Type': 'application/json'
      }
    }

    device.can('read_magstripe')

    if (typeof mergedOpts.readyTimeout === 'number') {
      // Abort any active in-flight requests and wait at most readyTimeout
      // seconds for the device to be ready
      await this.cancel(mergedOpts.readyTimeout)
    }

    if (requestOpt.signal instanceof AbortSignal) {
      options.signal = requestOpt.signal
    }

    const url = `${this.baseUrl}/plugin/${this.id}/device/${device.id}/magstripe?timeout=${readOpts.timeout}`
    const response = await fetch(url, options)
    await checkResponse(response)
    return await response.json()
  }
}

export default Verifone