Create docs and examples

This commit is contained in:
Skylar Ittner 2025-03-06 15:02:55 -07:00
commit d2e998eafc
11 changed files with 1703 additions and 0 deletions

199
README.md Normal file
View File

@ -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".

69
docs/Address.md Normal file
View File

@ -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;
}
}
```

144
docs/Database.md Normal file
View File

@ -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);
}
}
}
```

394
docs/Parcel.md Normal file
View File

@ -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;
}
}
```

253
docs/Receipt.md Normal file
View File

@ -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 # <span class=\"usall\">" + lines[i].replace("Tracking # ", "") + "</span>";
}
}
return lines.join("<br />");
}
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", "<br />");
}
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;
}
}
}
```

64
docs/ReceiptPrinter.md Normal file
View File

@ -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());
```

149
docs/TrackingBarcode.md Normal file
View File

@ -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");
}
}
```

View File

@ -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"]]
}
];

View File

@ -0,0 +1,56 @@
<template>
<div class="page">
<div class="navbar">
<div class="navbar-bg"></div>
<div class="navbar-inner">
<div class="title">${title}</div>
</div>
</div>
<div class="page-content">
<a class="button" @click=${openAlert}>Open Alert</a>
<a class="button" @click=${printSomething}>Print Something</a>
<div class="list simple-list">
<ul>
${names.map((name) => $h`
<li>${name}</li>
`)}
</ul>
</div>
</div>
</div>
</template>
<!-- component styles -->
<style>
.red-link {
color: red;
}
</style>
<!-- rest of component logic -->
<script>
// script must return/export component function
export default (props, { $f7, $on }) => {
const title = 'Component Page';
const names = ['John', 'Vladimir', 'Timo'];
const openAlert = () => {
$f7.dialog.alert('Hello world!\nblah blah blah');
}
async function printSomething() {
// Print some text to the receipt printer
var printer = await global.apis.print.getReceiptPrinter();
printer.addFieldBlock('Hello world!\nblah blah blah\n\n', "C");
global.apis.print.printReceiptData(await printer.getData());
}
$on('pageInit', () => {
// do something on page init
});
$on('pageAfterOut', () => {
// page has left the view
});
// component function must return render function
return $render;
}
</script>

View File

@ -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<br />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: "<internal string referencing the customer>", // Passed to checkoutSavedMethod as cardProcessorCustomerID
customer_uuid: customerUUID,
id: "<card/payment method identifier>", // 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");
}
}
];

View File

@ -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;
}