From af50b961b79b46e479faad587877b7cdf9d0f412 Mon Sep 17 00:00:00 2001 From: Skylar Ittner Date: Fri, 21 Nov 2025 15:39:03 -0700 Subject: [PATCH] Add mailbox APIs --- README.md | 94 ++++++++++ docs/FormPS1583.md | 256 +++++++++++++++++++++++++++ examples/payment-processor/plugin.js | 2 +- 3 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 docs/FormPS1583.md diff --git a/README.md b/README.md index 46ce2a5..337a780 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,100 @@ PostalPoint uses the Jimp library version 1.6 for creating and manipulating imag * `isKiosk()`: Returns a boolean to indicate if PostalPoint is running in kiosk mode. +#### Mailboxes + +Note: Functions will throw an error if a mailbox number is not valid (must be alphanumeric). +Use `boxNumberValid(number)` to validate user input and avoid errors. + +`global.apis.mailboxes.`: + +* `FormPS1583`: PS Form 1583 object. See docs/FormPS1583.md +* `boxNumberValid(number)`: Returns true if the mailbox number is an acceptable format, false if it isn't. Does not check if the box actually exists, merely if the number is acceptable to use as a mailbox number. +* `async getList()`: Get the list of mailboxes and boxholders as an array of objects (see below) +* `async addDaysToMailbox(boxNumber, days = 0, months = 0)`: Add a number of days or months to a mailbox's expiration. +* `async setMailboxExpirationDate(boxNumber, date)`: Set the box expiration to a specific JavaScript Date object, or a UNIX timestamp (in seconds). +* `async createMailbox(number, size, notes)`: Create a new mailbox number with the specified box size. Throws an error if the box number is already in use. +* `async editMailbox(oldNumber, newNumber, newSize = null)`: Change the number and/or size of a mailbox while preserving the boxholders and packages associated. If only changing size, set oldNumber and newNumber to the same value. +* `async deleteMailbox(number)`: Delete a mailbox. Throws an error if the mailbox has boxholders attached. +* `async closeMailbox(number)`: Close a mailbox by removing the boxholders and marking it as vacant. Boxholder PS Form 1583 records are archived per USPS regulations. +* `async mailboxExists(number)`: Returns true if the mailbox number exists, false if it doesn't. +* `async addOrUpdateBoxholder(boxNumber, info)`: Modify or add a boxholder to a mailbox. `info` is the boxholder structure below. If the `uuid` given already belongs to a boxholder, their info is updated with what you supply. Otherwise, the info is added as a new boxholder. +* `async removeBoxholder(boxNumber, uuid)`: Remove a boxholder by their UUID, and archive their PS Form 1583 data per USPS regulations. +* `async get1583(boxNumber, uuid, archiveNumber = false)`: Get the FormPS1583 object for a boxholder by UUID. If `archiveNumber` is true, returns the form for a deleted boxholder from the archive. +* `async set1583(boxNumber, uuid, formps1583)`: Set the FormPS1583 object for a boxholder by UUID. +* `async getMailboxProducts()`: Get a list of merchandise items that are usable for mailbox renewals. See below. + + +##### Mailbox getList() output + +```js +[{ + num: "123", // Box number as string + expires: 1234567890, // UNIX timestamp (in seconds) or false if box vacant + size: "2", // Box size, 1-10 + notes: "", // Notes for mailbox, not currently shown in Mailbox Manager UI but may be used in the future + barcode: "", // Unique barcode for the mailbox, for future use + renewalMerchID: "", // Merchandise item ID used for autorenewing this mailbox + isBusiness: false, // True if the box is for a business, false if for personal use + names: [], // Array of boxholders, see Boxholder structure below + packages: [], // Array of packages awaiting pickup, see below + vacant: false // True if the box is currently vacant, else false +}] +``` + +##### Boxholder structure + +Unless noted, all fields are strings and default to an empty string if data not available. + +```js +{ + name: [bizname, fname, mname, lname].filter(Boolean).join(" "), + fname: "", // First name + mname: "", // Middle name + lname: "", // Last name + email: "", // Email + phone: "", // Phone + uuid: "", // Customer UUID + bizname: "", // Business name + street1: "", // Street address + city: "", // City + state: "", // Two-character state + zipcode: "", // ZIP or postal code + country: "", // Two-character country code + primary: true // True if the primary (first) boxholder, false if an additional authorized mail recipient +} +``` + +##### Mailbox getList()[].packages format + +```js +{ + tracking: tracking ?? "[Untracked]", // Package tracking number + finalized: true, // True if package check-in is finished and shelf tag/mailbox slips printed, false if not finalized + available_date: Date(), // The date and time the package was checked in + tag: "" // Unique number assigned to the package and printed on shelf tags, scanned by employee when customer picks up package +} +``` + +##### Mailbox merchandise item format (getMailboxProducts) + +These fields are returned as entered in the Merchandise Admin user interface or as shown in the merchandise CSV export. + +```js +{ + id: "", // Unique ID for this entry in the merchandise table + name: "", // Merch item name + category: "", // Merch item category + price: 0.0, // Sale price in dollars + cost: 0.0, // Merchandise cost in dollars (likely not used for mailboxes) + barcode: "", // Barcode/UPC (likely not used for mailboxes) + tax: 0.0, // Sales tax rate + rentaldays: 30, // Number of days this item adds to a mailbox (mutually exclusive with rentalmonths) + rentalmonths: 1, // Number of months (mutually exclusive with rentaldays) + boxsize: "1" // Mailbox size tier, 1-10 +} +``` + #### Point of Sale `global.apis.pos.`: diff --git a/docs/FormPS1583.md b/docs/FormPS1583.md new file mode 100644 index 0000000..51a1874 --- /dev/null +++ b/docs/FormPS1583.md @@ -0,0 +1,256 @@ +# FormPS1583 object + +```javascript +export class FormPS1583 { + constructor() { + this.formRevision = LATEST_FORM_REVISION; // Currently "June2024" + this.pmbOpenedDate = new Date(); + this.pmbClosedDate = null; + this.cmraStreetAddress = getSetting("origin_street1"); + this.pmbNumber = ""; + this.cmraZIP = getSetting("origin_zip"); + var cmraZIPData = getZIP(this.cmraZIP); + if (cmraZIPData) { + this.cmraCity = cmraZIPData.city; + this.cmraState = cmraZIPData.state; + } else { + this.cmraCity = getSetting("origin_city", ""); + this.cmraState = getSetting("origin_state", ""); + } + this.serviceTypeBusiness = false; // true for business PMB, false for residential + this.applicant = { + firstName: "", + lastName: "", + middleName: "", + phone: "", + email: "", + streetAddress: "", + city: "", + state: "", + zip: "", + country: "", + courtProtected: false, + photoID: { + name: "", + number: "", + issuer: "", + expirationDate: null, + type: null // "DL/ID", "UniformedService", "USAccess", "USUni", + // "Passport", "Matricula", "NEXUS", + // "CertOfNaturalization", "USPermResident" + }, + addressID: { + name: "", + streetAddress: "", + city: "", + state: "", + zip: "", + country: "", + type: null, // "DL/ID", "Lease", "Mortgage", "Insurance", "VehicleReg", "Voter" + expirationDate: null // Optional currently but must be kept current - Oct 2025 + } + }; + this.authorizedIndividual = { + firstName: "", + lastName: "", + middleName: "", + phone: "", + email: "", + streetAddress: "", + city: "", + state: "", + zip: "", + country: "", + photoID: { + name: "", + number: "", + issuer: "", + expirationDate: null, + type: null // "DL/ID", "UniformedService", "USAccess", "USUni", + // "Passport", "Matricula", "NEXUS", + // "CertOfNaturalization", "USPermResident" + }, + addressID: { + name: "", + streetAddress: "", + city: "", + state: "", + zip: "", + country: "", + type: null, // "DL/ID", "Lease", "Mortgage", "Insurance", "VehicleReg", "Voter" + expirationDate: null // Optional currently but must be kept current - Oct 2025 + } + }; + this.mailTransferredTo = { + streetAddress: "", + city: "", + state: "", + zip: "", + country: "", + phone: "", + email: "" + }; + this.business = { + name: "", + type: "", + streetAddress: "", + city: "", + state: "", + zip: "", + country: "", + phone: "", + placeOfRegistration: "" + }; + this.additionalRecipients = []; // Array of strings containing names + this.applicantSignature = ""; // PNG image data URI + this.applicantSignatureDate = null; + this.cmraSignature = ""; // PNG image data URI + this.cmraSignatureDate = null; + this.hasForwardingAddress = false; + } + + getTermsAndConditions() { + return DEFAULT_TERMS_CONDITIONS[this.formRevision]; + } + + getApplicantForwardingAddress() { + if (this.mailTransferredTo.streetAddress != "") { + return new Address(null, + [this.applicant.firstName, this.applicant.lastName].filter(Boolean).join(" "), + this.business.name ?? "", + this.mailTransferredTo.streetAddress, + "", + this.mailTransferredTo.zip, + this.mailTransferredTo.city, + this.mailTransferredTo.state, + this.mailTransferredTo.country ?? "US", + this.mailTransferredTo.phone ?? "", + this.mailTransferredTo.email ?? "" + ); + } + return new Address(null, + [this.applicant.firstName, this.applicant.lastName].filter(Boolean).join(" "), + this.business.name ?? "", + this.applicant.streetAddress, + "", + this.applicant.zip, + this.applicant.city, + this.applicant.state, + this.applicant.country ?? "US", + this.applicant.phone ?? "", + this.applicant.email ?? "" + ); + } + + getFormFields() { + var fields = FORM_FIELDS[this.formRevision]; + function getNestedValue(obj, path) { + return path.split('.').reduce((o, key) => (o ? o[key] : ""), obj); + } + var outfields = []; + var groupheading = {}; + var groupfields = []; + for (var prop in fields) { + if (fields[prop].t == "heading") { + if (groupfields.length > 0) { + groupheading.fields = groupfields; + outfields.push(groupheading); + groupfields = []; + } + groupheading = { + heading: fields[prop].l, + groupid: fields[prop].group ?? null, + fields: [] + }; + } + fields[prop].n = prop; + fields[prop].v = getNestedValue(this, prop); + if (typeof fields[prop].v == "undefined" || fields[prop].v == null) { + fields[prop].v = ""; + } + if (fields[prop].t == "date") { + if (fields[prop].v instanceof Date) { + // Cancel out the timezone in the date object + // If we don't do this, the dates will be subtracted by one day each time we load + // https://stackoverflow.com/a/17329571 + fields[prop].v.setTime(fields[prop].v.getTime() + fields[prop].v.getTimezoneOffset() * 60 * 1000); + } + fields[prop].v = formatTimestamp("Y-m-d", fields[prop].v); + if (fields[prop].v == "1969-12-31" || fields[prop].v == "1970-01-01") { + fields[prop].v = ""; + } + } + if (fields[prop].t == "select" && typeof fields[prop].b == "boolean") { + fields[prop].v = fields[prop].v ? "true" : ""; + } + if (fields[prop].t != "heading") { + groupfields.push(fields[prop]); + } + } + if (groupfields != []) { + groupheading.fields = groupfields; + outfields.push(groupheading); + } + return outfields; + } + + static fromHTMLFormData(formdata, revision = LATEST_FORM_REVISION) { + var f = new FormPS1583(); + + function setNestedValue(obj, path, value) { + const keys = path.split('.'); + const lastKey = keys.pop(); + const target = keys.reduce((o, key) => { + if (o[key] === undefined) + o[key] = {}; + return o[key]; + }, obj); + if (typeof FORM_FIELDS[revision][path].b == "boolean") { + target[lastKey] = (value == "true" || value == true); + } else { + target[lastKey] = value; + } + } + + for (var prop in formdata) { + setNestedValue(f, prop, formdata[prop]); + } + + return f; + } + + static fromJSON(o) { + var f = new FormPS1583(); + f.formRevision = o.formRevision ?? LATEST_FORM_REVISION; + f.pmbOpenedDate = new Date(o.pmbOpenedDate); + f.pmbClosedDate = o.pmbClosedDate ? new Date(o.pmbClosedDate) : null; + f.cmraStreetAddress = o.cmraStreetAddress; + f.pmbNumber = o.pmbNumber; + f.cmraCity = o.cmraCity; + // snip, see constructor for full data structure + return f; + } + + toJSON() { + return { + formRevision: this.formRevision, + pmbOpenedDate: this.pmbOpenedDate, + pmbClosedDate: this.pmbClosedDate, + cmraStreetAddress: this.cmraStreetAddress, + pmbNumber: this.pmbNumber, + cmraCity: this.cmraCity, + // snip, see constructor for full data structure + }; + } + + /** + * Render this form to PDF + * @returns PDF bytes + */ + async getPDF() { + // snip, it draws the form contents onto a PDF using the pdf-lib library + // If you really want to see how, email us for the code + return await document.save(); + } +} +``` diff --git a/examples/payment-processor/plugin.js b/examples/payment-processor/plugin.js index 87e28c2..a5d8451 100644 --- a/examples/payment-processor/plugin.js +++ b/examples/payment-processor/plugin.js @@ -154,7 +154,7 @@ exports.init = function () { "paidFormatted": "$22.34", "due": -12.14, // If negative, is the amount of change owed to the customer instead "dueFormatted": "$12.14" - } + }; }, checkoutSavedMethod: async function ({customerID, paymentMethodID, amount}) { // Same as checkout() except using a payment method already on file.