From d2e998eafcadc2d7654228412fb1f89d06e1e36a Mon Sep 17 00:00:00 2001 From: Skylar Ittner Date: Thu, 6 Mar 2025 15:02:55 -0700 Subject: [PATCH] Create docs and examples --- README.md | 199 ++++++++++++++ docs/Address.md | 69 +++++ docs/Database.md | 144 ++++++++++ docs/Parcel.md | 394 ++++++++++++++++++++++++++++ docs/Receipt.md | 253 ++++++++++++++++++ docs/ReceiptPrinter.md | 64 +++++ docs/TrackingBarcode.md | 149 +++++++++++ plugins/basic-demo/plugin.js | 62 +++++ plugins/basic-demo/uipluginpage.f7 | 56 ++++ plugins/payment-processor/plugin.js | 236 +++++++++++++++++ plugins/shipping/plugin.js | 77 ++++++ 11 files changed, 1703 insertions(+) create mode 100644 README.md create mode 100644 docs/Address.md create mode 100644 docs/Database.md create mode 100644 docs/Parcel.md create mode 100644 docs/Receipt.md create mode 100644 docs/ReceiptPrinter.md create mode 100644 docs/TrackingBarcode.md create mode 100644 plugins/basic-demo/plugin.js create mode 100644 plugins/basic-demo/uipluginpage.f7 create mode 100644 plugins/payment-processor/plugin.js create mode 100644 plugins/shipping/plugin.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..b1f46bf --- /dev/null +++ b/README.md @@ -0,0 +1,199 @@ +# PostalPoint Plugins + +PostalPoint® supports plugin extensions. Plugins can hook into PostalPoint to add features. + +## What plugins can do + +* Process card payments and handle saved payment methods +* Add additional carriers and provide shipping rates +* Print to label and receipt printers, letting PostalPoint handle hardware support and drivers +* Extend support for prepaid label acceptance, prepaid barcode recognition, and carrier dropoff QR codes +* Install pages in the Tools interface, creating new interfaces and features +* Receive transaction receipts for ingestion into third-party accounting or business software +* Run both Node.JS and browser code. + +## Plugin Package Structure + +A plugin is distributed as a simple ZIP file, containing a folder. That folder's name is the plugin +name. The folder then has at least one file, named `plugin.js`. +he `exports.init` function in `plugin.js` is executed when PostalPoint launches, allowing the plugin +to request involvement with various events in PostalPoint. + +PostalPoint installs plugin packages by unzipping their contents into a plugins folder. +Plugins are uninstalled by deleting their folder. + +## Minimal Plugin Code + +```javascript +// plugin-name/plugin.js +exports.init = function () { + global.apis.alert("This message appears when PostalPoint launches.", "Hello!"); +}; +``` + +Yes, the smallest plugin really is just two lines of code, and accessing PostalPoint features +really is that easy. + +## Example Plugins + +Check out the `plugins` folder for fully-functional plugins. + +## PostalPoint Plugin API + +The PostalPoint plugin API is a globally-available object. + +### API List + +#### Core + +`global.apis.`: + +* `alert(text, title)`: Display an alert dialog. +* `getAppFolder()`: Get the filesystem path to the PostalPoint installation folder. +* `getPluginFolder(pluginName)`: Get the filesystem path to a plugin's folder. + If used without arguments, gets the root plugin storage folder. +* `f7`: The Framework7 app instance for PostalPoint's entire UI, created by `new Framework7()`. See https://framework7.io/docs/app for details. Be very careful. + + +#### Barcode + +`global.apis.barcode.`: + +* `TrackingBarcode`: A class defining a prepaid barcode and related information. See docs/TrackingBarcode.md for details. +* `onPrepaidScan(function (codeString) {})`: The function passed to onPrepaidScan is run +when a barcode is scanned on the Prepaid page. The function is passed one argument, a string +containing the raw barcode data. The function shall return boolean `false` if unable or unwilling +to handle the barcode. If the barcode is handled by this function, it shall return a TrackingBarcode object. +* `addPrepaidBarcode(trackingBarcodeData)`: Add a TrackingBarcode object to the transaction receipt at any time other than `onPrepaidScan`. + +#### Database + +PostalPoint supports multiple SQL databases, currently SQLite and MariaDB. + +`global.apis.database.`: + +* `async getConnection()`: Returns a database driver. See docs/Database.md for details. + + +#### Graphics + +PostalPoint uses the Jimp library version 1.6 for creating and manipulating images and shipping labels. + +`global.apis.graphics.`: + +* `Jimp()`: The [JavaScript Image Manipulation Program](https://jimp-dev.github.io/jimp/). Access like so: `const {Jimp} = global.apis.graphics.Jimp();`. +* `async loadFont(filename)`: Replacement for [Jimp's loadFont](https://jimp-dev.github.io/jimp/api/jimp/functions/loadfont/), which gets very confused about our JS environment and ends up crashing everything. + +#### Kiosk + +`global.apis.kiosk.`: + +* `isKiosk()`: Returns a boolean to indicate if PostalPoint is running in kiosk mode. + +#### Point of Sale + +`global.apis.pos.`: + +* `addReceiptItem(item)`: Add a `ReceiptItem` to the current transaction. +* `addReceiptPayment(item)`: Add a `ReceiptPayment` to the current transaction. +* `addOnscreenPaymentLog(string)`: Append a line of text to the onscreen log displayed during credit card processing. Not shown in kiosk mode. +* `onReceiptChange(function (receipt) {})`: Add a function to be called whenever the transaction data/receipt is changed. +* `onTransactionFinished(function (receipt) {})`: Same as `onReceiptChange` except run when a transaction is completed. +* `registerCardProcessor(...)`: Register the plugin as a credit card processor. See plugins/payment-processor for details. +* `ReceiptItem`: A class representing a sale item in the current transaction. See docs/Receipt.md for details. +* `ReceiptPayment`: A class representing a payment entry for the current transaction. See docs/Receipt.md for details. + +#### Printing + +`global.apis.print.`: + +* `async printLabelImage(image)`: Print a 300 DPI image to the configured shipping label printer. centered on a 4x6 shipping label. + If the printer is 200/203 DPI, the image is automatically scaled down to keep the same real size. + Accepts a Jimp image, or the raw image file data as a Node Buffer, ArrayBuffer, or Uint8Array. + Also accepts an http/https image URL, but it is recommended you fetch the image yourself and handle any network errors. + Recommended image size is 1200x1800, which is 4x6 inches at 300 DPI. 800x1200 images are assumed to be 4x6 but at 200 DPI. + For compatibility reasons, the bottom 200 pixels on an 800x1400 image is cropped off. + Images wider than they are tall will be rotated 90 degrees. Images that are 1200 or 800 pixels + wide and multiples of 1800 or 1200 pixels tall, respectively, are automatically split and printed + onto multiple 1200x1800 or 800x1200 labels. +* `async getReceiptPrinter()`: Get the receipt printer interface. See docs/ReceiptPrinter.md for available printer functions and example code. +* `async printReceiptData(data)`: Print the raw data generated by the receipt printer interface. +* `imageToBitmap(jimpImage, dpiFrom = 300, dpiTo = 300)`: Convert a Jimp image to monochrome 1-bit + pixel data for printing on a receipt printer. Converts color to grayscale, and grayscale is dithered to monochrome. + Optionally changes the image DPI. Use this to get image data for sending to a receipt printer. + Returns an object such as {width: 300, height: 200, img: Uint8Array}. Pass the img value to `drawImage`. + + +#### Settings/Configuration Storage + +PostalPoint provides a UI for user-configurable plugin settings. See `exports.config` in plugins/basic-demo/plugin.js for details. + +Settings are typically very short strings. Do not store data in settings. Non-string settings values +are transparently converted to/from JSON objects. + +Use a unique prefix for your plugin to prevent key name conflicts. + +`global.apis.settings.`: + +* `get(key, defaultValue)`: Get a setting value, or the `defaultValue` if the setting is not set. +* `set(key, value)`: Set a setting value. + + +#### Storing Data + +Behavior is the same as the settings storage. `setBig` stores the data to disk as a JSON file, +while `setSmall` uses the settings storage. +Use `setBig` and `getBig` for storing data except for very short string or number values. + +`global.apis.storage.`: + +* `getBig(key, defaultValue)` +* `getSmall(key, defaultValue)` +* `setBig(key, value)` +* `setSmall(key, value)` + + +#### Shipping and Rates + +`global.apis.shipping.`: + +* `Address`: A class representing an address. See docs/Address.md. +* `getZIPCode(zip)`: Get data for a 5-digit US ZIP Code, such as + `{"city": "HELENA", "state": "MT", "type": "STANDARD"}`. + Returns `false` if the ZIP Code isn't in the database. +* `registerRateEndpoint(getRate, purchase, idPrefix)`: Register the plugin as a shipping rate and label provider. + See plugins/shipping/plugin.js for example usage. + +#### UI + +`global.apis.ui.`: + +* `addToolsPage(page, title, id = "", description = "", cardTitle = "", icon = "")`: Add a page to the Tools screen. See plugins/basic-demo for example usage. +* `showProgressSpinner(title, text = "", subtitle = "")`: Show a Framework7 notification with a loading icon. +* `hideProgressSpinner()`: hide the UI element created by `showProgressSpinner`. +* `openInternalWebBrowser(url)`: Open a web browser UI, navigating to the URL. The browser has forward/back/close buttons. +* `openSystemWebBrowser(url)`: Open the native OS default browser to the URL given. + + +#### Utilities + +Various useful helper functions. + +`global.apis.util.`: + +* `barcode.getBuffer(data, type = "code128", height = 10, scale = 2, includetext = false)`: Get a PNG image buffer of a barcode. Uses library "bwip-js". +* `clipboard.copy(text, showNotification = false)`: Copy a string to the system clipboard, optionally showing a "copied" notification to the user. +* `async delay(ms = 1000)`: Pause execution for some amount of time in an async function, i.e., returns a Promise that resolves in some number of milliseconds. +* `async http.fetch(url, responseType = "text", timeout = 15)`: Fetch a URL. `responseType` can be "text", "blob", "buffer", or "json". Timeout is in seconds. +* `async http.post(url, data, responseType = "text", headers = {"Content-Type": "application/json"}, method = "POST", continueOnBadStatusCode = false)`: POST to a URL. `data` is sent as a JSON body. +* `objectEquals(a, b)`: Compare two objects for equality. +* `string.chunk(str, chunksize)`: Split a string into an array of strings of length `chunksize`. +* `string.split(input, separator, limit)`: Split a string with a RegExp separator an optionally limited number of times. +* `time.diff(compareto)`: Get the number of seconds between now and the given UNIX timestamp. +* `time.format(format, timestamp = time.now())`: Take a UNIX timestamp in seconds and format it to a string. (Mostly) compatible with PHP's date() function. +* `time.now()`: Get the current UNIX timestamp in seconds. +* `time.strtotime(str)`: Parse a string date and return a UNIX timestamp. +* `time.toDateString(timestamp)`: Get a localized date string for a UNIX timestamp. +* `time.toTimeString(timestamp)`: Get a time string for a UNIX timestamp, for example, "2:01 PM". +* `uuid.v4()`: Generate a version 4 UUID string, for example, "fcca5b12-6a11-46eb-96e4-5ed6365de977". +* `uuid.short()`: Generate a 16-character random alphanumeric string, for example, "4210cd8f584e6f6c". \ No newline at end of file diff --git a/docs/Address.md b/docs/Address.md new file mode 100644 index 0000000..a67e424 --- /dev/null +++ b/docs/Address.md @@ -0,0 +1,69 @@ +# Address object + +```javascript +export default class Address { + constructor(uuid = "", name = "", company = "", street1 = "", street2 = "", zip = "", city = "", state = "", country = "", phone = "", email = "") { + this.uuid = uuid; + this.name = name; + this.company = company; + this.street1 = street1; + this.street2 = street2; + this.zip = zip; + this.city = city; + this.state = state; + this.country = country; + this.phone = phone; + this.email = email; + this.residential = null; + } + + static fromObject(address) { + if (address instanceof Address) { + return address; + } + return new Address(address.uuid ?? "", address.name, address.company, address.street1, + address.street2, address.zip, address.city, address.state, address.country, + address.phone, address.email); + } + + toStringArray() { + var citystatezipLine = [this.city, this.state, this.zip].filter(Boolean); + return [this.name, this.company, this.street1, this.street2, `${citystatezipLine.join(" ")}`, (this.country == "US" ? "" : this.country)].filter(Boolean); + } + + /** + * Test if the address provided is the same as this address. + */ + equals(address, checkUUID = false) { + if ( + (checkUUID ? this.uuid == address.uuid : true) + && this.name == address.name + && this.company == address.company + && this.street1 == address.street1 + && this.street2 == address.street2 + && this.city == address.city + && this.state == address.state + && this.zip == address.zip + && this.country == address.country) { + return true; + } + return false; + } + + /** + * Test if an address is the same delivery point as this address. + */ + dpEquals(address) { + if ( + this.street1 == address.street1 + && this.street2 == address.street2 + && this.city == address.city + && this.state == address.state + && this.zip == address.zip + && this.country == address.country) { + return true; + } + return false; + } +} +``` diff --git a/docs/Database.md b/docs/Database.md new file mode 100644 index 0000000..d3d5fa7 --- /dev/null +++ b/docs/Database.md @@ -0,0 +1,144 @@ +# SQL Database Drivers + +`global.apis.database.getConnection()` returns one of these, depending on which database is in use. + +## SQLite + +```javascript +export class SQLiteAdapter { + constructor(db) { + this.type = "sqlite"; + this.db = db; + } + + async query(query, replace) { + if (global.devMode) { + console.info(query, replace); + } + return await this.db.all(query, replace); + } + + async run(statement, replace) { + if (global.devMode) { + console.info(statement, replace); + } + return await this.db.run(statement, replace); + } + + async exec(statement) { + if (global.devMode) { + console.info(statement); + } + return await this.db.exec(statement); + } + + async exists(table, where, replace) { + const q = await this.db.all("SELECT EXISTS(SELECT 1 FROM " + table + " WHERE " + where + ") as n", replace); + if (q[0].n > 0) { + return true; + } + return false; + } + + async close() { + + } + + async tableExists(table) { + return (await this.db.get(`SELECT count(name) AS cnt FROM sqlite_master WHERE type='table' AND name=?`, table)).cnt > 0; + } + + /** + * Get the version code set in the database by setSchemaVersion(). + */ + async getSchemaVersion() { + var res = await this.db.all(`PRAGMA user_version`); + return res[0].user_version; + } + + /** + * Set the database version, using PRAGMA user_version. Must be an integer. + */ + async setSchemaVersion(version) { + await this.db.exec(`PRAGMA user_version = ${version}`); + } + +} +``` + +## MariaDB/MySQL + +```javascript +export class MariaDBAdapter { + constructor(connection) { + this.type = "mariadb"; + this.conn = connection; + } + + async query(query, replace) { + if (global.devMode) { + console.info(query, replace); + } + return await this.conn.query(query, replace); + } + + async run(statement, replace) { + if (global.devMode) { + console.info(statement, replace); + } + return await this.query(statement, replace); + } + + async exec(statement) { + if (global.devMode) { + console.info(statement); + } + return await this.run(statement); + } + + async exists(table, where, replace) { + const q = await this.query("SELECT EXISTS(SELECT 1 FROM " + table + " WHERE " + where + ") as n", replace); + if (q[0].n > 0) { + return true; + } + return false; + } + + async close() { + await this.conn.release(); + } + + async tableExists(table) { + return (await this.query("SHOW TABLES LIKE ?", table)).length > 0; + } + + /** + * Get the version code set in the database by setSchemaVersion(). Returns zero if not set. + */ + async getSchemaVersion() { + if (await this.tableExists("database_metadata")) { + var res = await this.query("SELECT `value` FROM database_metadata WHERE `key`='schema_version' LIMIT 1"); + console.log(res); + console.log(res[0].value); + if (res.length == 1) { + return res[0].value; + } + } + return 0; + } + + /** + * Set a version number for the database schema. + * Must be an integer to maintain code compatibility with SQLite driver. + * Will create a "database_metadata" table if required to store the version number. + */ + async setSchemaVersion(version) { + if (await this.tableExists("database_metadata")) { + await this.query("REPLACE INTO `database_metadata` (`key`, `value`) VALUES (?, ?)", ["schema_version", version]); + } else { + await this.exec("CREATE TABLE IF NOT EXISTS `database_metadata` ( `key` VARCHAR(50) NOT NULL, `value` VARCHAR(255) NOT NULL DEFAULT '', PRIMARY KEY (`key`))"); + await this.setSchemaVersion(version); + } + } +} +``` \ No newline at end of file diff --git a/docs/Parcel.md b/docs/Parcel.md new file mode 100644 index 0000000..7de232f --- /dev/null +++ b/docs/Parcel.md @@ -0,0 +1,394 @@ +# Parcel/Package Object + +This object is supplied a plugin registered with `registerRateEndpoint` when PostalPoint requests +shipping rates from the plugin. + +```javascript +export class Package { + constructor(isPrepaid = false) { + this.prepaid = isPrepaid; + this.packaging = { + type: "Parcel", + service: "", + carrier: "", + length: 999999, + width: 999999, + height: 999999, + weightOz: 999999, + nonmachinable: false, + internalid: 100, + oversizeFlag: false + }; + this.extraServices = { + certifiedMail: false, + barcode3800: "", + registeredMail: false, + registeredMailAmount: false, // can be a number in USD + returnReceipt: false, + returnReceiptElectronic: false, + insurance: false, // can be a number in USD + signature: false, // can be false, "SIGNATURE", or "SIGNATURE_RESTRICTED" + hazmat: false, + liveAnimal: false, // DAY_OLD_POULTRY + cod: false, // Collect on Delivery + codAmount: false, + endorsement: "" // ADDRESS_SERVICE_REQUESTED, CHANGE_SERVICE_REQUESTED, FORWARDING_SERVICE_REQUESTED, LEAVE_IF_NO_RESPONSE, RETURN_SERVICE_REQUESTED + }; + this.specialRateEligibility = false; + this.customs = { + contents: "", + contentsExplanation: "", // needed if contents is "other" + signature: "", + restriction: "", + restrictionComments: "", // needed if restriction is "other" + nonDelivery: "return", // "return" or "abandon", + items: [] // {index: 0, description: "", qty: "", weight: "", value: "", hscode: "", origin: US"} + }; + this.toAddress = new Address(); + this.returnAddress = new Address(); + this.originAddress = new Address(); + this.trackingNumber = ""; + } + + /** + * Format as EasyPost shipment object + * @returns {Package.toEasyPostShipment.shipment} + */ + async toEasyPostShipment() { + // removed + } + + /** + * Format as Endicia shipment object + * @returns {Package.toSERAShipment.shipment} + */ + async toSERAShipment() { + // removed + } + + toJSON() { + return { + prepaid: this.prepaid, + packaging: this.packaging, + extraServices: this.extraServices, + specialRateEligibility: this.specialRateEligibility, + customs: this.customs, + toAddress: this.toAddress, + returnAddress: this.returnAddress, + originAddress: this.originAddress, + trackingNumber: this.trackingNumber + }; + } + + /** + * Get a human-readable summary of size and options. + * Does not include address data. + * @returns {String} + */ + async toString() { + let summary = []; + let packaging = await getPackagingByID(this.packaging.internalid); + let weight = ozToLbsOz(this.packaging.weightOz); + let weightStr = this.packaging.weightOz >= 16 ? `${weight[0]} lbs ${weight[1]} oz` : `${weight[1]} oz`; + if (packaging != false) { + if (packaging.weight === false) { + summary.push(packaging.name); + } else { + summary.push(`${weightStr} ${packaging.name}`); + } + } else { + summary.push(weightStr); + } + if (this.extraServices.liveAnimal) { + summary.push("Contains Live Animals"); + } + if (this.extraServices.certifiedMail) { + summary.push("Certified Mail"); + } else if (this.extraServices.registeredMail) { + summary.push("Registered Mail"); + summary.push("Registered for $" + (this.extraServices.registeredMailAmount * 1.0).toFixed(2)); + } else if (this.extraServices.signature == "SIGNATURE") { + summary.push("Signature Required"); + } + if (this.extraServices.signature == "SIGNATURE_RESTRICTED") { + summary.push("Restricted Delivery"); + } + if (this.extraServices.returnReceiptElectronic) { + summary.push("Return Receipt Electronic"); + } + if (this.extraServices.returnReceipt) { + summary.push("Return Receipt"); + } + if (this.extraServices.insurance) { + summary.push("Insured for $" + (this.extraServices.insurance * 1.0).toFixed(2)); + } + if (this.extraServices.cod) { + summary.push("Collect on Delivery: $" + (this.extraServices.codAmount * 1.0).toFixed(2)); + } + return summary.join("\n"); + } + + async needsHAZMATPrompt() { + try { + let packagingInfo = await getPackagingByID(this.packaging.internalid); + if (packagingInfo.hazmat) { + return true; + } + if (this.packaging.weight > 10) { + return true; + } + if (packagingInfo.l >= -1 && Math.max(this.packaging.length, this.packaging.width, this.packaging.height) > 0.5) { + return true; + } + switch (packagingInfo.type) { + case "Letter": + case "Card": + return false; + } + return true; + } catch (ex) { + return true; + } + } + + get isPrepaid() { + return this.prepaid == true; + } + + setCustomsInfo(contents, contentsExplanation, signature, restriction, restrictionComments, nonDelivery) { + let items = this.customs.items; // Save this and copy it back in so we don't overwrite it + this.customs = { + contents: contents, + contentsExplanation: contentsExplanation, // needed if contents is "other" + signature: signature, + restriction: restriction, + restrictionComments: restrictionComments, // needed if restriction is "other" + nonDelivery: nonDelivery, // "return" or "abandon", + items: items + }; + } + + /** + * Get the customs items, ignoring any that are blank. + * @returns {Array} + */ + getCustomsItems() { + let items = []; + for (let i = 0; i < this.customs.items.length; i++) { + let item = this.customs.items[i]; + if (item.description == "" && (item.qty == "" || item.qty == 0) && (item.weight == "" || item.weight == 0) && (item.value == "" || item.value == 0)) { + continue; + } + items.push(item); + } + return items; + } + + setCustomsItems(items) { + this.customs.items = items; + } + + getCustoms() { + this.customs.items = this.getCustomsItems(); + return this.customs; + } + + /** + * Attempt to automatically fix simple issues like overweight letters. + * @returns {undefined} + */ + async fixIssues() { + if (this.packaging.type == "Letter" && this.packaging.weightOz > 3.5) { + if (this.packaging.nonmachinable) { + return; // Has to be a parcel, can't fix without dimensions + } + this.packaging.type = "Flat"; + this.packaging.internalid = 104; + } + } + + /** + * Do some basic checks to see if this package is even remotely shippable + * @param {boolean} kioskMode If true, returned strings are suitable for display in kiosk mode. + * @returns {boolean|string} true if okay, human-readable error message and instructions if not okay + */ + async isValid(kioskMode = false) { + // removed for brevity. Just a bunch of if statements. + } + + /** + * Set package characteristics + * @param {string} type "Parcel", "Letter", "Flat", "Card" + * @param {type} service + * @param {type} carrier + * @param {type} length + * @param {type} width + * @param {type} height + * @param {type} weightOz + * @returns {undefined} + */ + setPackaging(type, service, carrier, length, width, height, weightOz, nonmachinable) { + if (typeof nonmachinable == "undefined") { + nonmachinable = false; + } + if (type == "Card") { + // Postcards + weightOz = 1; + this.packaging.internalid = 105; + } else if (type == "Flat") { + this.packaging.internalid = 104; + } else if (type == "Letter") { + this.packaging.internalid = 102; + if (nonmachinable) { + this.packaging.internalid = 103; + } + } + this.packaging.type = type; + this.packaging.service = service; + this.packaging.carrier = carrier; + this.packaging.weightOz = weightOz; + this.packaging.nonmachinable = nonmachinable; + + // Enforce Length > Width > Height + let size = [length, width, height]; + size.sort(function (a, b) { + return b - a; + }); + this.packaging.length = size[0]; + this.packaging.width = size[1]; + this.packaging.height = size[2]; + } + + /** + * Set an extra service + * @param {string} id Service ID + * @param {boolean} enabled Turn it on or off + * @param {string} value Service value, if needed (some are not just a boolean) + * @returns {undefined} + */ + setExtraService(id, enabled, value) { + if (typeof value != "undefined" && enabled) { + this.extraServices[id] = value; + } else { + this.extraServices[id] = enabled == true; + } + } + + getExtraServices() { + return this.extraServices; + } + + /** + * Set to "MEDIA_MAIL", "LIBRARY_MAIL", or false + * @param {type} rate + * @returns {undefined} + */ + set specialRate(rate) { + if (rate == "MEDIA") { + rate = "MEDIA_MAIL"; + } else if (rate == "LIBRARY") { + rate = "LIBRARY_MAIL"; + } + if (rate != "MEDIA_MAIL" && rate != "LIBRARY_MAIL") { + rate = false; + } + this.specialRateEligibility = rate; + } + + get specialRate() { + return this.specialRateEligibility; + } + + /** + * Save an address to this package. + * @param {string} type "to", "return", or "origin" + * @param {string} name + * @param {string} company + * @param {string} street1 + * @param {string} street2 + * @param {string} city + * @param {string} state + * @param {string} zip + * @param {string} country ISO 2-char country code + * @param {string} phone + * @param {string} email + * @returns {undefined} + */ + setAddress(type, name, company, street1, street2, city, state, zip, country, phone, email) { + let address = Address.fromObject({ + name: name, + company: company, + street1: street1, + street2: street2, + city: city, + state: state, + zip: zip, + country: country, + phone: phone, + email: email + }); + switch (type) { + case "to": + this.toAddress = address; + break; + case "return": + this.returnAddress = address; + break; + case "origin": + this.originAddress = address; + break; + } + } + + /** + * Set an address using an object that matches the internal form (see setAddress()) + * @param {string} type + * @param {object} data + * @returns {undefined} + */ + setAddressWhole(type, address) { + switch (type) { + case "to": + this.toAddress = Address.fromObject(address); + break; + case "return": + this.returnAddress = Address.fromObject(address); + break; + case "origin": + this.originAddress = Address.fromObject(address); + break; + } + } + + get tracking() { + return this.trackingNumber; + } + + set tracking(n) { + this.trackingNumber = n; + } + + /** + * Get the "from" address that will be shown, + * using the return address or origin address as needed + * @returns {address} + */ + getReturnAddress() { + if (typeof this.returnAddress == "object") { + return this.returnAddress; + } + return this.originAddress; + } + + getToAddress() { + return this.toAddress; + } + + getFromAddress() { + if (typeof this.originAddress == "object") { + return this.originAddress; + } + return this.returnAddress; + } +} +``` \ No newline at end of file diff --git a/docs/Receipt.md b/docs/Receipt.md new file mode 100644 index 0000000..c3b4090 --- /dev/null +++ b/docs/Receipt.md @@ -0,0 +1,253 @@ +# Receipt Objects + +## global.apis.pos.ReceiptItem + +```javascript +export class ReceiptItem { + /** + * + * @param {string|number} id Unique ID number for this item (UPC code, inventory number, etc). Used to deduplicate line items. Unique items (like shipping labels) should be random or empty. + * @param {string} label One-line item information. + * @param {string} text Extra item information. + * @param {number} priceEach Price per unit + * @param {number} quantity Number of units + * @param {number} cost Cost per unit. Used for automatic expense tracking. + * @param {number} taxrate Examples: 0 (for 0%), 0.05 (for 5%), etc + * @returns {ReceiptItem} + */ + constructor(id, label, text, priceEach, quantity, cost, taxrate) { + this.id = id; + this.label = label; + if (text == null) { + this.txt == ""; + } else { + this.txt = text; + } + this.priceEach = num(priceEach); + this.qty = num(quantity); + this.cost = num(cost); + if (isNaN(taxrate)) { + this.taxRate = 0; + } else { + this.taxRate = num(taxrate); + } + this.merch = false; + this.merchid = null; + this.surcharge = false; + this.retail = 0; // For ensuring PostalPoint fee collection on office mode shipments + } + + static fromJSON(obj) { + var item = new ReceiptItem(obj.id, obj.label, obj.text, obj.priceEach, obj.qty, obj.cost, obj.taxRate); + item.free = obj.free; + item.barcode = obj.barcode; + item.certifiedInfo = obj.certifiedInfo; + item.toAddress = obj.toAddress; + item.fromAddress = obj.fromAddress; + item.merch = obj.isMerch == true; + item.merchid = item.merch ? obj.merchid : null; + item.surcharge = obj.surcharge; + item.retailPrice = obj.retail; + return item; + } + + toJSON() { + return { + id: this.id, + label: this.label, + text: this.text, + priceEach: num(this.priceEach), + qty: num(this.qty), + cost: num(this.cost), + retail: num(this.retail), + taxRate: num(this.taxRate), + free: this.free, + barcode: this.barcode, + certifiedInfo: this.certifiedInfo, + isMerch: this.merch, + merchid: this.merchid, + surcharge: this.surcharge, + toAddress: this.toAddress, + fromAddress: this.fromAddress + }; + } + + get text() { + if (typeof this.txt == "string") { + return this.txt; + } + return ""; + } + + set text(t) { + if (typeof t == "string") { + this.txt = t; + } else { + this.txt = ""; + } + } + + get certifiedInfo() { + if (typeof this.certified == "undefined") { + return false; + } + return this.certified; + } + + set certifiedInfo(info) { + this.certified = info; + } + + setCertifiedInfo(tracking, certfee, extrafees, postage, date, location, toaddress) { + this.certified = { + tracking: tracking, + certifiedFee: num(certfee), + extraFees: extrafees, + postage: num(postage), + date: date, + location: location, + to: toaddress + }; + } + + setQuantity(q) { + this.qty = num(q); + } + + get free() { + return this.isFree == true; + } + + set free(free) { + this.isFree = free == true; + } + + get barcode() { + if (typeof this.barcodeData != "string") { + return ""; + } + return this.barcodeData; + } + + set barcode(data) { + this.barcodeData = data; + } + + get linePrice() { + return round(m(this.priceEach, this.qty), 2); + } + + get priceEachFormatted() { + return "$" + round(num(this.priceEach), 2).toFixed(2); + } + + get linePriceFormatted() { + return "$" + round(num(this.linePrice), 2).toFixed(2); + } + + get texthtml() { + if (typeof this.text != "string") { + return ""; + } + var lines = this.text.split("\n"); + for (var i = 0; i < lines.length; i++) { + if (lines[i].startsWith("Tracking # ")) { + // Allow copying tracking number + lines[i] = "Tracking # " + lines[i].replace("Tracking # ", "") + ""; + } + } + return lines.join("
"); + } + + get taxAmount() { + return round(m(this.linePrice, this.taxRate), 2); + } + + get retailPrice() { + if (typeof this.retail == "number") { + return this.retail; + } + return this.priceEach * this.qty; + } + + set retailPrice(price) { + this.retail = num(price); + } + +} +``` + +## global.apis.pos.ReceiptPayment + +```javascript +export class ReceiptPayment { + + /** + * + * @param {number} amount amount paid + * @param {string} type payment type + * @param {string} text extra data (credit card info, etc) + * @returns {ReceiptPayment} + */ + constructor(amount, type, text) { + this.id = (Math.random() * 100000000) + "_" + type + "_" + amount; + this.text = (typeof text != "string" ? "" : text); + this.type = type; + this.amount = amount; + } + + static fromJSON(obj) { + var item = new ReceiptPayment(obj.amount, obj.type, obj.text); + item.id = obj.id; + return item; + } + + toJSON() { + return { + amount: round(this.amount, 2), + type: this.type, + text: this.text, + id: this.id + }; + } + + get texthtml() { + if (typeof this.text != "string") { + return ""; + } + return this.text.replaceAll("\n", "
"); + } + + get amountFormatted() { + return "$" + this.amount.toFixed(2); + } + + get label() { + if (typeof this.type != "string") { + return "Payment"; + } + switch (this.type) { + case "cash": + return "Cash"; + case "check": + return "Check"; + case "card": + return "Card"; + case "card_manual": + return "Card"; + case "account": + return "Account"; + case "free": + return "Free"; + case "discount": + return "Discount"; + case "crypto": + return "Cryptocurrency"; + case "ach": + return "ACH Debit"; + default: + return this.type; + } + } +} +``` \ No newline at end of file diff --git a/docs/ReceiptPrinter.md b/docs/ReceiptPrinter.md new file mode 100644 index 0000000..d8f8a67 --- /dev/null +++ b/docs/ReceiptPrinter.md @@ -0,0 +1,64 @@ +# Receipt Printer driver functions + +PostalPoint abstracts the receipt printer hardware commands, so the same functions are available on +all brands and languages of receipt printer, and printer media size and settings are also handled for you. + +The drivers operate in line mode, where each successive command appends content to the bottom of the page. + +These functions are available on the object supplied by the promise returned from +`global.apis.print.getReceiptPrinter()`. + +## Functions + +```javascript + +// +// Add one or more lines of text, with automatic wrapping. +// If both firsttext and secondtext are provided, two columns of text are generated, +// with the first left-justified and the second right-justified. +// `firstjustify` can be "L" (left), "C" (center), or "R" (right). +// Not all printers support all the formatting options, and may render them in different ways, +// but the formatting intent is made clear regardless. +addFieldBlock(firsttext, firstjustify, secondtext = "", secondjustify = "R", bold = false, doubleheight = false, underline = false); + +// Add a blank line to the label. +newLine(); + +// Draw a horizontal line across the page. +drawLine(); + +// Render a Code 128 barcode, centered horizontally, with a human-readable label beneath. +// Important: this function is sometimes asynchronous depending on the printer driver. +barcode128(content); + +// Print an image. Width is in pixels. +// pixelByteArray is a Uint8Array where each bit is a pixel (1=black, 0=white), +// starting at the top-left of the image and going across and then down. Use `imageToBitmap` to +// obtain this data from a Jimp image. +// Use "L" as the position to print on the next line, centered horizontally. +// Some printers also support position = "C", which will +// ignore other commands and print the image centered on the label, +// but if you're doing that, just use `global.apis.print.printLabelImage()` instead. +drawImage(width, position, pixelByteArray); + +// If supported by the printer, opens an attached cash drawer. Command is ignored if unavailable. +openCashDrawer(); + +// The last command to run, when ready to print. Returns the raw data to send to the printer. +// Important: this function is sometimes asynchronous depending on the printer driver. +getData(); + +``` + +## Example + +```javascript +var printer = await global.apis.print.getReceiptPrinter(); + +printer.addFieldBlock("Hello Bold World!", "C", "", "", true); +printer.drawLine(); +await printer.barcode128("1234567890"); +printer.newLine(); + +await global.apis.printer.printReceiptData(await printer.getData()); +``` \ No newline at end of file diff --git a/docs/TrackingBarcode.md b/docs/TrackingBarcode.md new file mode 100644 index 0000000..3dbfd80 --- /dev/null +++ b/docs/TrackingBarcode.md @@ -0,0 +1,149 @@ +# TrackingBarcode class + +For your reference, here is the source code of the TrackingBarcode class, used to represent a prepaid drop-off. +This class is provided to plugins as `global.apis.barcode.TrackingBarcode`. + +```javascript +export class TrackingBarcode { + /** + * Create a tracking barcode object. + * @param {string} code Tracking number. + * @returns {TrackingBarcode} + */ + constructor(code) { + // All data are optional except for the tracking number. Missing data is gracefully handled by the PostalPoint UI. + this.cleanCode = code; + // Destination ZIP Code, for domestic shipments. The city and state are automatically added. If toAddress is specified, toZip is ignored in favor of it. + this.toZip = ""; + // Two-letter destination country code. If not "US", toZip is ignored, and the full country name is appended to the displayed address information. + this.toCountry = "US"; + // If toAddress is set, it will be used instead of the toZip when displaying the destination. + // If both toZip and toAddress are empty strings, no destination will be displayed. + this.toAddress = ""; + // If message is not empty, the barcode will NOT be added and the message will be displayed to the user. + this.message = ""; + // If warning is not empty, the barcode WILL be added and a message will be displayed to the user. + this.warning = ""; + // Shipping carrier name. + this.carrier = ""; + // Shipping service/mail class full name and description. Example: "Priority Mail Adult Signature Required". + this.serviceName = ""; + // Shipping service/mail class name, without extra info such as "signature required". + // Example: "Priority Mail" + this.serviceShort = ""; + // If set to false, the barcode will be rejected with a suitable message when PostalPoint is running in self-serve kiosk mode. + this.dropoff = true; + } + + /** + * Set the tracking number + * @param {string} str + * @returns {undefined} + */ + set tracking(str) { + this.cleanCode = str; + } + + /** + * Set the service/mail class description string. + * @param {string} str + * @returns {undefined} + */ + set service(str) { + this.serviceShort = str; + this.serviceName = str; + } + + /** + * Get the tracking number. + * @returns {String} + */ + get tracking() { + return this.cleanCode; + } + + /** + * Get the destination ZIP code. + * @returns {String} + */ + get zip() { + return this.toZip; + } + + /** + * Get the service/mail class description. + * @returns {String} + */ + get service() { + if (this.serviceShort != "") { + return this.serviceShort; + } else if (this.serviceName != "") { + return this.serviceName; + } + return ""; + } + + /** + * Get the carrier and service info. + * @returns {String} + */ + get serviceString() { + var str = []; + if (this.carrier != "") { + str.push(this.carrier); + } + if (this.serviceShort != "") { + str.push(this.serviceShort); + } else if (this.serviceName != "") { + str.push(this.serviceName); + } + return str.join(" "); + } + + /** + * Get the destination information as a human-presentable multiline string. + * @returns {String} + */ + get destString() { + var addressLines = []; + if (this.toAddress != "") { + addressLines.push(...this.toAddress.split("\n")); + } + if (this.toCountry.toUpperCase() == "US" && this.toZip != "" && this.toAddress == "") { + var zipdata = getZIP(this.toZip); + if (zipdata != false) { + addressLines.push(`${zipdata.city} ${zipdata.state} ${this.toZip}`); + } else { + addressLines.push(`${this.toZip}`); + } + } else if (this.toCountry.toUpperCase() != "US") { + addressLines.push(getCountryNameForISO(this.toCountry)); + } + return addressLines.join("\n"); + } + + /** + * Get the package information in a format suitable for display on a receipt. + * @param {boolean} includeTrackingNumber If false, the tracking number will be suppressed. + * @returns {String} + */ + toString(includeTrackingNumber = true) { + var lines = []; + if (includeTrackingNumber && this.cleanCode) { + lines.push(this.cleanCode); + } + var serv = this.serviceString; + if (serv != "") { + lines.push(serv); + } + var dest = this.destString; + if (dest != "") { + var destlines = dest.split("\n"); + destlines[0] = "To " + destlines[0]; + lines.push(...destlines); + } + + return lines.join("\n"); + } +} +``` \ No newline at end of file diff --git a/plugins/basic-demo/plugin.js b/plugins/basic-demo/plugin.js new file mode 100644 index 0000000..1f94ce3 --- /dev/null +++ b/plugins/basic-demo/plugin.js @@ -0,0 +1,62 @@ +// Sample plugin to demonstrate plugin capabilities and structure. + +async function getPage() { + // A Framework7 component page + return global.apis.getPluginFolder("basic-demo") + "/uipluginpage.f7"; +} + +// This is run when PostalPoint loads the plugin at launch. +// Use it to register for things you want to do, like adding a page, hooking into payments or shipping rates, etc. +exports.init = function () { + console.log(global.apis.settings.get("basic-demo_secretcode")); + global.apis.ui.addToolsPage(getPage, "Sample Page Title", "sampletool1234", "A sample plugin page", "Sample", "fa-solid fa-circle"); +}; + +// This defines a settings UI to display for the plugin. +// If exports.config is a function instead of an array, it will be executed when opening the settings +// and must return an array like the one below. +// If exports.config is undefined, a settings menu will not be provided to the user. +exports.config = [ + { + type: "button", + label: "Test Button", + text: "Some text about the button", + onClick: function () { + global.apis.alert("Button pressed"); + } + }, + { + type: "text", + key: "app.postalpoint.basic-demo_somestring", // Try to make sure this is unique by using a prefix, + // settings storage is global so there could be conflicts if you aren't careful + defaultVal: "", + label: "Type a string", + placeholder: "", + text: "Description text next to the input box" + }, + { + type: "password", + key: "app.postalpoint.basic-demo_secretcode", + defaultVal: "", + label: "Secret Code", + placeholder: "", + text: "Don't tell anyone this secret code:" + }, + { + type: "textarea", + key: "app.postalpoint.basic-demo_sometext", + defaultVal: "", + label: "Text Box", + placeholder: "...", + text: "You can type a few lines of text here." + }, + { + type: "select", + key: "app.postalpoint.basic-demo_dropdownbox", + defaultVal: "", + label: "Choose an option", + placeholder: "", + text: "", + options: [["key1", "Value 1"], ["key2", "Value 2"]] + } +]; diff --git a/plugins/basic-demo/uipluginpage.f7 b/plugins/basic-demo/uipluginpage.f7 new file mode 100644 index 0000000..f890bdd --- /dev/null +++ b/plugins/basic-demo/uipluginpage.f7 @@ -0,0 +1,56 @@ + + + + + diff --git a/plugins/payment-processor/plugin.js b/plugins/payment-processor/plugin.js new file mode 100644 index 0000000..c2583e5 --- /dev/null +++ b/plugins/payment-processor/plugin.js @@ -0,0 +1,236 @@ +// This is a sample PostalPoint plugin that adds a card payment processor. + +exports.init = function () { + global.apis.pos.registerCardProcessor({ + name: "Demo Card Processor", + init: async function () { + // This function runs once after starting PostalPoint + // and before any other card processor functions are called. + }, + checkout: async function({amount, capture = true}) { + // amount is an integer number of pennies. + + // If an error is encountered during processing, + // display an error message in a dialog and return boolean false. + // If this function returns anything except false or undefined, and doesn't throw an error, + // it is assumed the payment was successful. + try { + if (capture) { + // authorize, capture, add a ReceiptPayment to the receipt, and return boolean true. + global.apis.pos.addOnscreenPaymentLog("Getting card payment..."); // Add a line to the onscreen card processing status log + await global.apis.util.delay(1000); // Replace this with something useful! + global.apis.pos.addReceiptPayment( + new global.apis.pos.ReceiptPayment( + (amount / 100).toFixed(2) * 1, + "card", // Payment type. Accepted values are card, ach, crypto, cash, check, account, and free. Other types will be displayed as-is to the user and on the receipt. + "Demo Card\nCardholder Name, etc\nMore info for receipt" // Additional text for receipt + ) + ); + global.apis.pos.addOnscreenPaymentLog("Payment successful!"); + return true; + } else { + // only authorize the payment, don't actually capture/charge the payment method, + // and return whatever transaction data that will be passed to finishPayment to capture the payment. + await global.apis.util.delay(1000); // Replace this with something useful! + return {amount: amount}; + } + } catch (ex) { + global.apis.pos.addOnscreenPaymentLog(`Error: ${ex.message} [okay to put extra details here for troubleshooting or tech support, it's visible to the cashier]`); + if (global.apis.kiosk.isKiosk()) { + // This message will be shown to an end-user/customer, not a cashier/employee + global.apis.alert("Your card was declined.", "Card Error"); + } else { + global.apis.alert("The customer's card was declined.", "Card Error"); + } + return false; + } + }, + cancelCheckout: function () { + // The user requested to cancel the payment. + // Reset the terminal to its resting state, clear its screen, etc. + }, + finishPayment: async function ({checkoutResponse}) { + // Finish a payment that was authorized but not captured because checkout was called with capture = false + // If payment was already captured and added to the receipt for some reason, just return true. + await global.apis.util.delay(1000); // Replace this with something useful! + global.apis.pos.addReceiptPayment( + new global.apis.pos.ReceiptPayment( + (checkoutResponse.amount / 100).toFixed(2) * 1, + "card", // Payment type. + "Demo Card\nCardholder Name, etc\nMore info for receipt" // Additional text for receipt + ) + ); + return true; + }, + updateCartDisplay: function (receipt) { + // Show transaction data on the card reader display. + // This function will be called when the cart or total changes. + console.log(receipt); + // Sample structure of the receipt variable: + receipt = { + "items": [ + { + "id": "testitem", + "label": "Test Item", + "text": "", + "priceEach": 2, + "qty": 1, + "cost": 0, + "retail": 2, + "taxRate": 0.1, + "free": false, + "barcode": "", + "certifiedInfo": false, + "isMerch": true, + "surcharge": false + }, + { + "id": "9100123456789012345678", + "label": "Test Package", + "text": "Package Details\nTracking # 9100 1234 5678 9012 3456 78\nTo:\nTEST PERSON\nORGANIZATION INC\n123 TEST ROAD\nTESTTOWN TE 99999-0001", + "priceEach": 8, + "qty": 1, + "cost": 0, + "retail": 8, + "taxRate": 0, + "free": false, + "barcode": "9100123456789012345678", + "certifiedInfo": false, + "isMerch": false, + "surcharge": false, + "toAddress": { + "name": "TEST PERSON", + "company": "ORGANIZATION INC", + "street1": "123 TEST ROAD", + "street2": null, + "city": "TESTTOWN", + "state": "TE", + "zip": "99999-0001", + "email": null, + "phone": null, + "country": "US" + }, + "fromAddress": { + "name": "TEST PERSON", + "company": "ORGANIZATION INC", + "street1": "123 TEST ROAD", + "street2": null, + "city": "TESTTOWN", + "state": "TE", + "zip": "99999-0001", + "email": null, + "phone": null, + "country": "US" + } + } + ], + "payments": [ + { + "amount": 10, + "amountFormatted": "$10.00", + "type": "cash", + "label": "Cash", + "text": "", + "texthtml": "", + "id": "12345678_cash_10" + }, + { + "amount": 12.34, + "amountFormatted": "$12.34", + "type": "card", + "label": "Card", + "text": "Card Details here\n1234abcd", + "texthtml": "Card Details here
1234abcd", + "id": "87654321_card_12.34" + } + ], + "subtotal": 10, + "subtotalFormatted": "$10.00", + "tax": 0.2, + "taxFormatted": "$0.20", + "grandTotal": 10.2, + "grandTotalFormatted": "$10.20", + "paid": 22.34, + "paidFormatted": "$22.34", + "due": -12.14, // If negative, is the amount of change owed to the customer instead + "dueFormatted": "$12.14" + } + }, + checkoutSavedMethod: async function ({cardProcessorCustomerID, paymentMethodID, amount}) { + // Same as checkout() except using a payment method already on file. + // cardProcessorCustomerID and paymentMethodID are provided by getSavedPaymentMethods below. + await global.apis.util.delay(1000); // Replace this with something useful! + global.apis.pos.addReceiptPayment( + new global.apis.pos.ReceiptPayment( + (amount / 100).toFixed(2) * 1, + "card", // Payment type. + "Card on File\nx1234" // Additional text for receipt + ) + ); + return true; + }, + saveCardForOfflineUse: async function ({statusCallback, customerUUID, name, company, street1, street2, city, state, zip, country, email, phone}) { + // Use the card reader to capture an in-person card and save it for offline use. + // Provided details are the customer's info, which might be empty strings except for the customerUUID. + // Saved card details must be tied to the customerUUID, as that's how saved cards are looked up. + + // statusCallback(string, boolean) updates the progress message on the cashier's screen. + // If the boolean is true, the progress message is replaced with a confirmation message. + statusCallback("Insert the card into the reader.", false); + + await global.apis.util.delay(1000); // Wait for the customer to insert their card, + //then save it for later offline billing + + statusCallback("Saving card details...", false); + + await global.apis.util.delay(1000); + + statusCallback("Card saved!", true); + + return true; // Card saved to customer + // If an error occurred, you can throw it and the error message will be displayed to the cashier. + // Alternatively, return boolean false and display the error yourself with global.apis.alert(message, title) or something. + }, + cancelSaveCardForOfflineUse: function () { + // Cancel the process running in saveCardForOfflineUse() at the user/cashier's request. + }, + getSavedPaymentMethods: async function ({customerUUID}) { + // Return all saved payment methods tied to the provided customer UUID. + var methods = []; + methods.push({ + customer: "", // Passed to checkoutSavedMethod as cardProcessorCustomerID + customer_uuid: customerUUID, + id: "", // Passed to checkoutSavedMethod as paymentMethodID + type: "card", // Payment type. Accepted values are card, ach, crypto, cash, check, account, and free. + label: "Visa debit x1234 (exp. 12/29)", // Label for payment method + label_short: "Visa debit x1234" // Abbreviated label for payment method + }); + return methods; + }, + deleteSavedPaymentMethod: async function ({customerUUID, customerID, paymentMethodID}) { + // Delete the payment method identified by paymentMethodID and tied to the PostalPoint customerUUID and the card processor customerID. + // If unable to delete, throw an error and the error message will be displayed to the cashier. + await global.apis.util.delay(1000); + } + }); +} + +// Plugin settings to display. +exports.config = [ + { + type: "password", + key: "democardprocessor_apikey", + defaultVal: "", + label: "API Key", + placeholder: "", + text: "API Key" + }, + { + type: "button", + label: "Test Button", + text: "Some text about the button", + onClick: function () { + global.apis.ui.openSystemWebBrowser("https://postalpoint.app"); + } + } +]; diff --git a/plugins/shipping/plugin.js b/plugins/shipping/plugin.js new file mode 100644 index 0000000..395e448 --- /dev/null +++ b/plugins/shipping/plugin.js @@ -0,0 +1,77 @@ +// This is a sample PostalPoint plugin for adding support for a shipping carrier. + +var rateCache = []; +var parcelCache = {}; + +exports.init = function () { + global.apis.shipping.registerRateEndpoint(getRates, purchase, "uniqueprefixhere_"); + global.apis.barcode.onPrepaidScan(function (barcode) { + if (barcode.startsWith("mycarrierbarcode")) { // Replace this with your checks for barcode validity + var data = new global.apis.barcode.TrackingBarcode(barcode); + data.carrier = "Carrier Name"; + data.service = "Service Name"; + return data; + } + return false; + }); +} + +async function purchase(rateid) { + for (var i = 0; i < rateCache.length; i++) { + if (rateCache[i].rateid == rateid) { + var rate = rateCache[i]; + // + // Fetch label and tracking and such + // + var label; + var tracking = "123456"; + var toAddressLines = parcelCache.toAddress.toStringArray(); + + // Create receipt item + var receiptitem = new global.apis.pos.ReceiptItem(`uniqueprefixhere_${tracking}`, + `${rate.carrierName} ${rate.serviceName}`, + `Tracking # ${global.apis.util.string.chunk(tracking, 3).join(" ")}\nTo:\n${toAddressLines.join("\n")}`, + rate.retail_rate, 1, rate.cost_rate, 0 + ); + receiptitem.barcode = tracking; + + return { + label: label, + labeltype: "PNG", + receiptItem: receiptitem, + tracking: tracking, + cost: rate.cost_rate, + price: rate.retail_rate, + carrier: rate.carrierName, + service: rate.serviceName, + delivery_days: rate.delivery_days, + delivery_date: rate.delivery_date, + to: toAddressLines + }; + } + } +} + +async function getRates(parcel) { + // parcel is an object as shown in docs/Parcel.md + var rates = []; + rates.push({ + rateid: "uniqueprefixhere_" + global.apis.util.uuid.v4(), + carrier: "Carrier", + carrierName: "Carrier Name", + service: "CARRIER_SERVICE_ID", + cost_rate: 10, + retail_rate: 15, + delivery_days: 3, + delivery_date: null, + guaranteed: true, + serviceName: "Service Name", + color: "green" // Rate card color + }); + + // Save details for later use if purchased + rateCache = rates; + parcelCache = parcel; + + return rates; +} \ No newline at end of file