Create repo

This commit is contained in:
Skylar Ittner 2026-02-13 14:59:30 -07:00
commit aa13da6a23
31 changed files with 1689 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*.zip
node_modules
nbproject/private

View File

@ -0,0 +1 @@
browser=Chrome.INTEGRATED

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project-private xmlns="http://www.netbeans.org/ns/project-private/1">
<editor-bookmarks xmlns="http://www.netbeans.org/ns/editor-bookmarks/2" lastBookmarkId="0"/>
</project-private>

View File

@ -0,0 +1,5 @@
file.reference.Custom_Web_Tools-public_html=public_html
file.reference.Custom_Web_Tools-test=test
file.reference.PostalPoint_Official_Plugins-Custom_Web_Tools=.
files.encoding=UTF-8
site.root.folder=${file.reference.PostalPoint_Official_Plugins-Custom_Web_Tools}

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://www.netbeans.org/ns/project/1">
<type>org.netbeans.modules.web.clientproject</type>
<configuration>
<data xmlns="http://www.netbeans.org/ns/clientside-project/1">
<name>Custom Web Tools</name>
</data>
</configuration>
</project>

View File

@ -0,0 +1,12 @@
{
"name": "postalpoint_custom_web_tools",
"version": "1.0.0",
"main": "plugin.js",
"author": "PostalPortal LLC",
"license": "BSD-3-Clause",
"description": "Add websites to the Tools page for instant access without leaving PostalPoint. Supports scanning barcodes into the websites for easy data entry.",
"postalpoint": {
"pluginname": "Custom Web Tools",
"minVersion": "000034"
}
}

View File

@ -0,0 +1,44 @@
var urls = [];
var titles = [];
exports.init = function () {
for (var i = 0; i < 5; i++) {
urls.push(global.apis.settings.get("app.postalpoint.customwebtools.url" + i, ""));
titles.push(global.apis.settings.get("app.postalpoint.customwebtools.title" + i, ""));
}
for (let i = 0; i < 5; i++) {
if (urls[i] == "") {
continue;
}
if (titles[i] == "") {
titles[i] = urls[i];
}
global.apis.ui.addToolsPage(function () {
global.apis.ui.openInternalWebBrowser(urls[i]);
}, titles[i], "", (titles[i] == urls[i] ? "" : urls[i]), titles[i], "fa-duotone fa-globe-pointer", "function");
}
};
var config = [];
for (var i = 0; i < 5; i++) {
config.push({
type: "text",
key: "app.postalpoint.customwebtools.title" + i,
defaultVal: "",
label: `Website ${i + 1} Title`,
placeholder: "",
text: ""
});
config.push({
type: "text",
key: "app.postalpoint.customwebtools.url" + i,
defaultVal: "",
label: `Website ${i + 1} Address/URL`,
placeholder: "https://example.com",
text: ""
});
}
exports.config = config;

16
Helcim/build.sh Executable file
View File

@ -0,0 +1,16 @@
#!/bin/bash
mkdir -p build/Helcim
cp plugin.js build/Helcim/plugin.js
cp package.json build/Helcim/package.json
rm Helcim.zip
cd build
zip -r ../Helcim.v0.0.1.zip Helcim
cd ..
rm -r build

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project-private xmlns="http://www.netbeans.org/ns/project-private/1">
<editor-bookmarks xmlns="http://www.netbeans.org/ns/editor-bookmarks/2" lastBookmarkId="0"/>
<open-files xmlns="http://www.netbeans.org/ns/projectui-open-files/2">
<group/>
</open-files>
</project-private>

View File

@ -0,0 +1,7 @@
auxiliary.org-netbeans-modules-javascript-nodejs.enabled=true
auxiliary.org-netbeans-modules-javascript-nodejs.run_2e_enabled=true
auxiliary.org-netbeans-modules-javascript-nodejs.start_2e_file=plugin.js
auxiliary.org-netbeans-modules-javascript-nodejs.sync_2e_enabled=false
files.encoding=UTF-8
run.as=node.js
source.folder=

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://www.netbeans.org/ns/project/1">
<type>org.netbeans.modules.web.clientproject</type>
<configuration>
<data xmlns="http://www.netbeans.org/ns/clientside-project/1">
<name>PostalPoint_Helcim_Plugin</name>
</data>
</configuration>
</project>

13
Helcim/package-lock.json generated Normal file
View File

@ -0,0 +1,13 @@
{
"name": "postalpoint_helcim_plugin",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "postalpoint_helcim_plugin",
"version": "1.0.0",
"license": "BSD-3-Clause"
}
}
}

12
Helcim/package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "postalpoint_helcim_plugin",
"version": "0.0.1",
"main": "plugin.js",
"author": "PostalPortal LLC",
"license": "BSD-3-Clause",
"description": "Connect PostalPoint to your Helcim account and a Helcim smart card reader.",
"postalpoint": {
"pluginname": "Helcim",
"minVersion": "000046"
}
}

332
Helcim/plugin.js Normal file
View File

@ -0,0 +1,332 @@
// Adds Helcim as a payment processor option.
const PLUGINID = "net.postalportal.helcimplugin";
const URL_BASE = "https://api.helcim.com/v2";
const PARTNER_TOKEN = "f29104118c0d65"; // f29104118c0d65 is a test partner token
const WEBHOOK_SOURCE = "hcpaymentstatus";
async function apiRequest(url, data = {}, method = "POST", responseType = "json") {
var apikey = global.apis.settings.get(PLUGINID + ".apikey", "");
console.log(`${url}:`, data);
var headers = {
"accept": `application/json`,
"api-token": `${apikey}`
};
if (PARTNER_TOKEN != "") {
headers["partner-token"] = PARTNER_TOKEN;
}
if (method == "POST") {
headers["content-type"] = "application/json";
}
return await global.apis.util.http.post(`${URL_BASE}/${url}`, data, responseType, headers, method, true);
}
async function pingDevice() {
global.apis.ui.showProgressSpinner("Pinging Helcim device...");
var deviceCode = global.apis.settings.get(PLUGINID + ".devicecode", "");
if (deviceCode == "") {
global.apis.alert("Did you remember to save settings before testing?" + "<br /><br />" + "No device code saved. Enter the device code from the reader screen.", "Test failed");
return;
}
try {
var resp = await apiRequest(`devices/${deviceCode}/ping`, {}, "GET", "text");
global.apis.ui.hideProgressSpinner();
global.apis.alert("The card reader is set up correctly.", "Success!");
if (typeof resp.errors != "undefined" && resp.errors.length > 0) {
global.apis.alert("Did you remember to save settings before testing?" + "<br /><br />" + resp.errors[0], "Test failed");
}
} catch (ex) {
global.apis.ui.hideProgressSpinner();
global.apis.alert("Did you remember to save settings before testing?" + "<br /><br />" + ex.message, "Test failed");
}
}
function getCardBrand(cardType) {
switch (cardType) {
case "VI":
cardType = "Visa";
break;
case "MC":
cardType = "MasterCard";
break;
case "AX":
cardType = "American Express";
break;
case "DI":
cardType = "Discover";
break;
case "DCI":
cardType = "Diners Club";
break;
case "JCB":
cardType = "JCB";
break;
case "UP":
cardType = "China Union Pay";
break;
case "MR":
cardType = "Maestro";
break;
case "AF":
cardType = "AFFN";
break;
case "AO":
cardType = "Alaska Option";
break;
case "CU":
cardType = "Credit Union 24";
break;
case "EB":
cardType = "EBT Network";
break;
case "EX":
cardType = "Accel";
break;
case "IL":
cardType = "Interlink";
break;
case "NT":
cardType = "Nets";
break;
case "NY":
cardType = "NYCE";
break;
case "PS":
cardType = "Pulse";
break;
case "ST":
cardType = "Star";
break;
case "SZ":
cardType = "Shazam";
break;
case "AT":
cardType = "ATH";
break;
case "IN":
cardType = "Interac";
break;
case "DB":
cardType = "Debit";
break;
}
return cardType;
}
exports.init = function () {
global.apis.pos.registerCardProcessor({
name: "Helcim",
init: async function () {
// This function runs once after starting PostalPoint
// and before any other card processor functions are called.
console.info("Hello from Helcim plugin!");
},
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.
const code = global.apis.settings.get(PLUGINID + ".devicecode", "");
const receiptID = global.apis.pos.getReceiptID();
try {
// 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
var purchaseResp = await apiRequest(`devices/${code}/payment/purchase`, {
currency: global.apis.i18n.currency().toUpperCase(),
transactionAmount: global.apis.i18n.moneyToFixed(amount / 100.0) * 1.0,
invoiceNumber: receiptID
}, "POST", "text");
if (purchaseResp.length > 0) {
var json = JSON.parse(purchaseResp);
if (typeof json.errors != "undefined" && json.errors.length > 0) {
global.apis.pos.addOnscreenPaymentLog("Helcim card reader error: " + json.errors[0]);
global.apis.alert("Could not start card payment: " + json.errors[0], "Card Reader Error");
return false;
}
}
var paymentID = "";
while (paymentID == "") {
await global.apis.util.delay(1000); // Wait a second
try {
var pollResults = await global.apis.util.http.webhook.poll(WEBHOOK_SOURCE);
for (var i = 0; i < pollResults.length; i++) {
var resultBody = JSON.parse(pollResults[i].body);
if (resultBody.type == "cardTransaction") {
paymentID = resultBody.id;
global.apis.util.http.webhook.ack(pollResults[i].id);
} else if (resultBody.type == "terminalCancel") {
if (resultBody.data.deviceCode == code && resultBody.data.invoiceNumber == receiptID) {
paymentID = "CANCEL";
global.apis.util.http.webhook.ack(pollResults[i].id);
}
}
}
} catch (ex) {
console.error(ex);
}
}
if (paymentID == "CANCEL") {
global.apis.pos.addOnscreenPaymentLog("Helcim payment not completed.");
global.apis.alert("The card payment was canceled.", "Payment canceled");
return false;
}
console.log("Got transaction ID from Helcim webhook:", paymentID);
purchaseResp = await apiRequest(`card-transactions/${paymentID}`, null, "GET");
console.log("purchaseResp", purchaseResp);
if (typeof purchaseResp?.errors != "undefined" && purchaseResp.errors.length > 0) {
global.apis.pos.addOnscreenPaymentLog("Helcim card payment error: " + purchaseResp.errors[0]);
global.apis.alert("Could not finish card payment: " + purchaseResp.errors[0], "Card Payment Error");
return false;
}
if (purchaseResp.status == "APPROVED") {
global.apis.pos.addOnscreenPaymentLog("Payment approved!");
if (purchaseResp.type == "purchase") {
pstring = `${purchaseResp.cardHolderName ?? ""}\n${getCardBrand(purchaseResp.cardType)}\nx${purchaseResp.cardNumber.slice(-4)}\nApproval: ${purchaseResp.approvalCode}`;
global.apis.pos.addReceiptPayment(
new global.apis.pos.ReceiptPayment(
purchaseResp.amount,
"card",
pstring
)
);
return true;
}
} else if (purchaseResp.status == "DECLINED") {
global.apis.pos.addOnscreenPaymentLog("Card payment declined.");
if (global.apis.kiosk.isKiosk()) {
global.apis.alert("Your card was declined.", "Card Error");
} else {
global.apis.alert("The customer's card was declined.", "Card Error");
}
return false;
}
if (global.apis.kiosk.isKiosk()) {
global.apis.pos.addOnscreenPaymentLog("Unknown card payment error.");
global.apis.alert("Your card payment did not go through for an unknown reason.", "Card Error");
} else {
global.apis.pos.addOnscreenPaymentLog("Unknown card payment error.");
global.apis.alert("The transaction didn't complete correctly (Helcim card plugin reached an undefined state)", "Card Error");
}
return false;
} catch (ex) {
global.apis.pos.addOnscreenPaymentLog(`Error: ${ex.message}`);
if (global.apis.kiosk.isKiosk()) {
// This message will be shown to an end-user/customer, not a cashier/employee
global.apis.alert("Your card payment was not successful due to a system error.", "Card Error");
} else {
global.apis.alert("The customer's payment was not successful due to a system error.", "Card Error");
}
return false;
}
;
},
cancelCheckout: async function () {
// The user requested to cancel the payment.
},
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.
return true;
},
updateCartDisplay: function (receipt) {
// no-op
},
checkoutSavedMethod: async function ( {customerID, paymentMethodID, amount}) {
// Same as checkout() except using a payment method already on file.
// customerID and paymentMethodID are provided by getSavedPaymentMethods below.
global.apis.pos.addOnscreenPaymentLog("Saved card payments not supported with Helcim yet.");
throw new Error("Saved card payments not supported with Helcim yet.");
return false;
},
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.
global.apis.pos.addOnscreenPaymentLog("Saved card payments not supported with Helcim yet.");
throw new Error("Saved card payments not supported with Helcim yet.");
return false;
},
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.
return [];
},
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.
global.apis.pos.addOnscreenPaymentLog("Saved card payments not supported with Helcim yet.");
throw new Error("Saved card payments not supported with Helcim yet.");
return false;
}
});
}
async function webhookSetup() {
var url = await global.apis.util.http.webhook.geturl(WEBHOOK_SOURCE);
global.apis.util.clipboard.copy(url);
global.apis.alert("The webhook URL shown below has been copied to your clipboard. Setup steps: \n\
<ol><li>Log in to your Helcim account</li><li>Click \"All Tools\"</li><li>Select \"Integrations\"</li>\n\
<li>Click Webhooks</li><li>Turn the toggle on</li><li>Click into the \"Deliver URL\" box</li>\n\
<li>Paste the URL (Ctrl-V or right-click and Paste)</li>\n\
<li>Check all the boxes next to \"Notify app when changes made to\"</li>\n\
<li>Click \"Save\"</li></ol><br /><br />" + url, "Webhook Setup");
}
// Plugin settings to display.
exports.config = [
{
type: "password",
key: PLUGINID + ".apikey",
defaultVal: "",
label: "API Token",
placeholder: "",
text: "To get an API Token, in your Helcim dashboard, click All Tools -> Integrations -> API Access Configurations. Generate a new API Access. Fill in the name (PostalPoint). Under Access Restrictions set General to Read & Write, and set Transaction Processing to Admin. Press Create to get the token."
},
{
type: "text",
key: PLUGINID + ".devicecode",
defaultVal: "",
label: "Device Code",
placeholder: "123A",
text: "The short code shown on your Helcim card reader's \"Ready to pair\" screen."
},
{
type: "button",
label: "Test Connection",
text: "After saving settings, press this button to test the card reader.",
onClick: function () {
pingDevice();
}
},
{
type: "button",
label: "Set Up Webhook",
text: "You must add a webhook in your Helcim account so PostalPoint can receive payment status updates.",
onClick: function () {
webhookSetup();
}
},
{
type: "password",
key: PLUGINID + ".verifiertoken",
defaultVal: "",
label: "Verifier Token",
placeholder: "",
text: "For extra security, enter the Verifier Token from the Helcim dashboard, found under Integrations -> Webhooks."
}
];

17
PrintScanTool/build.sh Executable file
View File

@ -0,0 +1,17 @@
#!/bin/bash
mkdir -p build/PrintScanTool
cp plugin.js build/PrintScanTool/plugin.js
cp package.json build/PrintScanTool/package.json
cp page.f7 build/PrintScanTool/page.f7
rm PrintScanTool*.zip
cd build
zip -r ../PrintScanTool.v1.1.1.zip PrintScanTool
cd ..
rm -r build

View File

@ -0,0 +1,3 @@
auxiliary.org-netbeans-modules-javascript-nodejs.ask_2e_sync_2e_enabled=false
browser=Chrome.INTEGRATED
server=EXTERNAL

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project-private xmlns="http://www.netbeans.org/ns/project-private/1">
<editor-bookmarks xmlns="http://www.netbeans.org/ns/editor-bookmarks/2" lastBookmarkId="0"/>
<open-files xmlns="http://www.netbeans.org/ns/projectui-open-files/2">
<group/>
</open-files>
</project-private>

View File

@ -0,0 +1,7 @@
auxiliary.org-netbeans-modules-javascript-nodejs.enabled=true
auxiliary.org-netbeans-modules-javascript-nodejs.run_2e_enabled=true
auxiliary.org-netbeans-modules-javascript-nodejs.start_2e_file=main.js
auxiliary.org-netbeans-modules-javascript-nodejs.sync_2e_enabled=false
files.encoding=UTF-8
run.as=node.js
source.folder=

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://www.netbeans.org/ns/project/1">
<type>org.netbeans.modules.web.clientproject</type>
<configuration>
<data xmlns="http://www.netbeans.org/ns/clientside-project/1">
<name>PrintScanTool</name>
</data>
</configuration>
</project>

View File

@ -0,0 +1,15 @@
{
"name": "app.postalpoint.printscan",
"main": "plugin.js",
"version": "1.1.1",
"author": "PostalPortal LLC",
"description": "Tool to sell print, scan, and fax jobs in PostalPoint with quantity pricing.",
"postalpoint": {
"pluginname": "Print and Fax Tool",
"minVersion": "000046"
},
"contributors": [
"Skylar Ittner"
],
"dependencies": {}
}

276
PrintScanTool/page.f7 Normal file
View File

@ -0,0 +1,276 @@
<template>
<div class="page">
<div class="navbar">
<div class="navbar-bg"></div>
<div class="navbar-inner sliding">
<div class="left">
<a href="#" class="link back">
<i class="icon icon-back"></i>
<span class="if-not-md">Back</span>
</a>
</div>
<div class="title">Print and Fax</div>
</div>
</div>
<div class="page-content">
<div class="margin">
<p class="segmented segmented-strong">
${modes.map((m) => $h`
<button class="button ${fnMode===m.m ? 'button-active' : '' }" @click=${()=>setMode(m.m)}>${m.s}</button>
`)}
<span class="segmented-highlight"></span>
</p>
</div>
<div class="grid grid-cols-2">
<div class="margin">
<div id="copiesFormContainer">
<input type="text" class="padding margin-bottom" maxlength="4" placeholder="# of pages" name="copies" id="copiesInput" @input="${updateTotal}" />
<osk-numberpad target="copiesInput" />
<div class="list">
<ul>
<li-input type="text" placeholder="Job ID, Fax #, etc." name="notes" id="notesInput" />
</ul>
</div>
<div class="button button-large button-fill margin-top" @click="${addToSale}">
Add to Sale
</div>
</div>
</div>
<div>
<input type="text" class="padding margin-bottom" value="${global.apis.i18n.moneyString(currentTotal)}" placeholder="${global.apis.i18n.moneyString(0.0)}" id="totalPriceInput" readonly='readonly' />
<div class="block-title">Pricing</div>
<div class="list list-outline inset list-dividers simple-list no-margin-top">
<ul>
${pricingToDisplayPriceChart().map((p) => $h`
<li>
<div>${p.num}</div>
<div>${p.each}</div></li>
`)}
</ul>
</div>
</div>
</div>
</div>
</div>
</template>
<style>
#copiesFormContainer {
width: 100%;
max-width: 30em;
margin: 0 auto;
text-align: center;
}
#copiesInput, #totalPriceInput {
width: 100%;
font-size: 25px;
text-align: center;
border-radius: 5px;
margin-right: auto;
margin-left: auto;
}
#copiesInput {
background: var(--f7-input-item-bg-color);
}
</style>
<script>
export default (props, { $f7, $on, $update }) => {
const plugin = require(global.apis.getPluginFolder("app.postalpoint.printscan") + "/plugin.js");
var modes = [
{
m: "print_bw", // ID
s: "Print (Black)", // human name
t: "print", // tax type
r: "Printing, BW", // receipt item title
p: [] // price chart
},
{
m: "print_color",
s: "Print (Color)",
t: "print",
r: "Printing, Color",
p: []
},
{
m: "copy_bw", // ID
s: "Copy (Black)", // human name
t: "print", // tax type
r: "Copies, BW", // receipt item title
p: [] // price chart
},
{
m: "copy_color",
s: "Copy (Color)",
t: "print",
r: "Copies, Color",
p: []
},
{
m: "scan",
s: "Scan",
t: "scanfax",
r: "Scanning",
p: []
},
{
m: "fax",
s: "Fax",
t: "scanfax",
r: "Faxing",
p: []
}
];
var fnMode = modes[0].m;
var priceChart = modes[0].p;
var currentTotal = 0;
async function loadPricing() {
for (var i = 0; i < modes.length; i++) {
modes[i].p = plugin.parsePricingString(await plugin.getPricing(modes[i].m));
}
}
function getPricingForMode(mode) {
return getMode(mode).p;
}
function getMode(mode = fnMode) {
for (var i = 0; i < modes.length; i++) {
if (modes[i].m == mode) {
return modes[i];
}
}
;
}
function pricingToDisplayPriceChart() {
var out = [];
var len = priceChart.length;
for (var i = 0; i < len; i++) {
const th = priceChart[i];
var r;
if (th.num == "MIN") {
r = {
num: `First ${th.min > 1 ? th.min + ' pages' : 'page'}`,
each: global.apis.i18n.moneyString(th.each)
};
} else {
r = {
num: th.num,
each: global.apis.i18n.moneyString(th.each) + " per page"
};
if (i < len - 1) {
r.num = `${th.num} to ${priceChart[i + 1].num}`;
} else {
r.num = `${th.num} and up`;
}
}
out.push(r);
}
return out;
}
async function setMode(m) {
fnMode = m;
priceChart = getPricingForMode(m);
updateTotal();
$update();
}
function getPriceTotal(count, chart = priceChart) {
if (!count) {
return 0.0;
}
var total = 0.0;
if (chart[0].num == "MIN") {
if (count <= chart[0].min) {
return chart[0].each;
} else {
total += chart[0].each;
var priceEach = 0.0;
for (var i = 1; i < chart.length; i++) {
if (count >= chart[i].num) {
priceEach = chart[i].each;
}
}
total += priceEach * (count - chart[0].min);
return total;
}
}
var priceEach = chart[0].each;
for (var i = 0; i < chart.length; i++) {
if (count >= chart[i].num) {
priceEach = chart[i].each;
}
}
return priceEach * count;
}
function updateTotal() {
var copies = Math.floor((document.getElementById("copiesInput").value * 1) ?? 0);
if (copies > 9999) {
copies = 9999;
document.getElementById("copiesInput").value = "9999";
}
currentTotal = getPriceTotal(copies);
$update();
}
async function addToSale() {
var copies = document.getElementById("copiesInput").value;
if (!copies || copies.replace(/\D/g, '') == "") {
global.apis.alert("Enter a number of pages for the job.", "Whoops!");
return;
}
copies = Math.floor(copies * 1);
var mode = getMode();
var taxRate = await global.apis.storage.getDB(`${plugin.id}.${mode.t}_taxrate`, 0) * 1.0;
var merchCategory = await global.apis.storage.getDB(`${plugin.id}.merchcategory`, "Print/Scan/Fax");
var pt = getPriceTotal(copies);
var jobInfo = document.getElementById("notesInput").value ?? null;
var receiptItem = new global.apis.pos.ReceiptItem(
`printscan_${mode.m}_${global.apis.util.uuid.short()}`, // SKU/ID
mode.r, // label
jobInfo ?? null,
global.apis.i18n.moneyToFixed(pt / (copies * 1)) * 1.0,
copies * 1,
0,
taxRate / 100.0
);
receiptItem.merch = true;
if (merchCategory && merchCategory != "") {
receiptItem.category = merchCategory;
}
global.apis.pos.addReceiptItem(receiptItem);
global.apis.f7.notification.create({
icon: '<i class="icon fa-solid fa-check"></i>',
title: 'Job Added to Transaction',
titleRightText: '',
closeTimeout: 3000
}).open();
document.getElementById("copiesInput").value = "";
updateTotal();
}
$on('pageInit', () => {
// do something on page init
loadPricing().then(() => {
setMode(fnMode);
});
});
$on('pageAfterOut', () => {
// page has left the view
});
// component function must return render function
return $render;
}
</script>

165
PrintScanTool/plugin.js Normal file
View File

@ -0,0 +1,165 @@
/*
* Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license
* Click nbfs://nbhost/SystemFileSystem/Templates/Other/javascript.js to edit this template
*/
const pluginID = "app.postalpoint.printscan";
exports.id = pluginID;
exports.init = function () {
global.apis.ui.addToolsPage(
global.apis.getPluginFolder(pluginID) + "/page.f7",
"Print and Fax",
"printscantool",
"Add print, scan, copy, and fax jobs to a sale.",
"Print and Fax",
"fa-regular fa-file"
);
// Sync pricing changes with the store database
global.apis.eventbus.on("pluginSettingsSaved", async function (pluginid) {
if (pluginid != pluginID) {
return;
}
for (var i = 0; i < exports.config.length; i++) {
const key = exports.config[i].key;
const defVal = exports.config[i].defaultVal;
await global.apis.storage.setDB(key, global.apis.settings.get(key, defVal));
}
});
};
exports.getPricing = async function (mode) {
const modeKey = `${pluginID}.${mode}_prices`;
for (var i = 0; i < exports.config.length; i++) {
const key = exports.config[i].key;
const defVal = exports.config[i].defaultVal;
if (key != modeKey) {
continue;
}
return await global.apis.storage.getDB(key, global.apis.settings.get(key, defVal));
}
};
exports.parsePricingString = function (str) {
var lines = str.split("\n");
var chart = [];
for (var i = 0; i < lines.length; i++) {
var num = lines[i].split(":")[0].trim();
if (num.toUpperCase() == "MIN") {
var p = lines[i].split(":")[1].trim() * 1.0;
var q = lines[i].split(":")[2].trim() * 1;
chart.push({
num: "MIN",
each: p,
min: q
});
} else {
var n = num * 1.0;
var p = lines[i].split(":")[1].trim() * 1.0;
chart.push({
num: n,
each: p
});
}
}
return chart;
};
exports.config = [
{
type: "textarea",
key: pluginID + ".print_bw_prices",
defaultVal: `1: 0.50
10: 0.45
50: 0.40`,
label: "Black/White Print Prices",
placeholder: `1: 0.50
10: 0.45
50: 0.40`,
text: "<b>Black/White Printing</b>: Enter the number of pages and the price per page, as \"[number]: [dollar amount]\". To set a minimum job price/quantity, have the first line be \"MIN: [money] : [count]\". This will result in the first [count] pages costing a flat fee of [money]."
},
{
type: "textarea",
key: pluginID + ".print_color_prices",
defaultVal: `1: 0.75
10: 0.70
50: 0.65`,
label: "Color Print Prices",
placeholder: `1: 0.75
10: 0.70
50: 0.65`,
text: "<b>Color Printing</b>"
},
{
type: "textarea",
key: pluginID + ".copy_bw_prices",
defaultVal: `1: 0.50
10: 0.45
50: 0.40`,
label: "Black/White Copy Prices",
placeholder: `1: 0.50
10: 0.45
50: 0.40`,
text: "<b>Black/White Copies</b>"
},
{
type: "textarea",
key: pluginID + ".copy_color_prices",
defaultVal: `1: 0.75
10: 0.70
50: 0.65`,
label: "Color Copy Prices",
placeholder: `1: 0.75
10: 0.70
50: 0.65`,
text: "<b>Color Copies</b>"
},
{
type: "textarea",
key: pluginID + ".scan_prices",
defaultVal: `1: 0.25
10: 0.20
50: 0.15`,
label: "Scanning Prices",
placeholder: `1: 0.25
10: 0.20
50: 0.15`,
text: "<b>Scanning</b>"
},
{
type: "textarea",
key: pluginID + ".fax_prices",
defaultVal: `1: 0.50
10: 0.40
50: 0.30`,
label: "Faxing Prices",
placeholder: `1: 0.50
10: 0.40
50: 0.30`,
text: "<b>Faxing</b>"
},
{
type: "text",
key: pluginID + ".print_taxrate",
defaultVal: "0",
label: "Printing Tax Rate %",
placeholder: "0"
},
{
type: "text",
key: pluginID + ".scanfax_taxrate",
defaultVal: "0",
label: "Scan/Fax Tax Rate %",
placeholder: "0"
},
{
type: "text",
key: pluginID + ".merchcategory",
defaultVal: "Print/Scan/Fax",
label: "Merchandise Category",
placeholder: ""
}
];

16
Square/build.sh Executable file
View File

@ -0,0 +1,16 @@
#!/bin/bash
mkdir -p build/Square\ Terminal
cp plugin.js build/Square\ Terminal/plugin.js
cp package.json build/Square\ Terminal/package.json
rm SquareTerminal.zip
cd build
zip -r ../SquareTerminal.zip Square\ Terminal
cd ..
rm -r build

View File

@ -0,0 +1,3 @@
auxiliary.org-netbeans-modules-javascript-nodejs.ask_2e_sync_2e_enabled=false
browser=Chrome.INTEGRATED
server=EXTERNAL

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project-private xmlns="http://www.netbeans.org/ns/project-private/1">
<editor-bookmarks xmlns="http://www.netbeans.org/ns/editor-bookmarks/2" lastBookmarkId="0"/>
<open-files xmlns="http://www.netbeans.org/ns/projectui-open-files/2">
<group/>
</open-files>
</project-private>

View File

@ -0,0 +1,7 @@
auxiliary.org-netbeans-modules-javascript-nodejs.enabled=true
auxiliary.org-netbeans-modules-javascript-nodejs.run_2e_enabled=true
auxiliary.org-netbeans-modules-javascript-nodejs.start_2e_file=plugin.js
auxiliary.org-netbeans-modules-javascript-nodejs.sync_2e_enabled=false
files.encoding=UTF-8
run.as=node.js
source.folder=

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://www.netbeans.org/ns/project/1">
<type>org.netbeans.modules.web.clientproject</type>
<configuration>
<data xmlns="http://www.netbeans.org/ns/clientside-project/1">
<name>PostalPoint Square Terminal Plugin</name>
</data>
</configuration>
</project>

13
Square/package-lock.json generated Normal file
View File

@ -0,0 +1,13 @@
{
"name": "postalpoint_square_plugin",
"version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "postalpoint_square_plugin",
"version": "1.0.0",
"license": "BSD-3-Clause"
}
}
}

12
Square/package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "postalpoint_square_plugin",
"version": "1.0.3",
"main": "plugin.js",
"author": "PostalPortal LLC",
"license": "BSD-3-Clause",
"description": "Connect PostalPoint to your Square account and a Square Terminal smart card reader. This plugin supports in-person card payments and saving cards for later use when the customer isn't present, such as for mailbox renewals.",
"postalpoint": {
"pluginname": "Square Terminal",
"minVersion": "000034"
}
}

649
Square/plugin.js Normal file
View File

@ -0,0 +1,649 @@
// Adds Square Terminal as a payment processor option.
const SQUARE_ENV = "PROD"; // "SANDBOX"
var URL_BASE = "https://connect.squareup.com";
if (SQUARE_ENV == "SANDBOX") {
URL_BASE = "https://connect.squareupsandbox.com";
} else {
URL_BASE = "https://connect.squareup.com";
}
const APP_ID = "sq0idp-mrUlE9gCQ9yLevtbqyurDw";// "sandbox-sq0idb-EGkAFQuSaL3mPwzG8fxPIA";
const SQUARE_API_VERSION = "2025-05-21";
const REDIRECT_URI = "https://postalpoint.app/oauth/square";
var code_verifier_code = null;
var checkout_id = null;
var latestTerminalActionID = "";
async function apiRequest(url, data = {}, method = "POST") {
var accessToken = global.apis.settings.get("app.postalpoint.squareterminal.access_token", "");
console.log(`${url}:`, data);
return await global.apis.util.http.post(`${URL_BASE}/${url}`, data, "json", {
"Content-Type": "application/json",
"Square-Version": `${SQUARE_API_VERSION}`,
"Authorization": `Bearer ${accessToken}`
}, method, true);
}
async function loginToSquare() {
code_verifier_code = global.apis.util.uuid.v4();
var code_challenge_array = await window.crypto.subtle.digest("SHA-256", (new TextEncoder().encode(code_verifier_code)));
const code_challenge = Array.from(new Uint8Array(code_challenge_array))
.map((item) => item.toString(16).padStart(2, "0"))
.join("");
const scope = ["DEVICE_CREDENTIAL_MANAGEMENT", "DEVICES_READ", "MERCHANT_PROFILE_READ", "MERCHANT_PROFILE_WRITE", "CUSTOMERS_WRITE", "CUSTOMERS_READ", "PAYMENTS_READ", "PAYMENTS_WRITE"].join("+");
const state = global.apis.util.uuid.v4();
const url = `${URL_BASE}/oauth2/authorize?client_id=${APP_ID}&scope=${scope}&session=false&redirect_uri=${REDIRECT_URI}&code_challenge=${code_challenge}&state=${state}`;
async function onBrowserLogin(url) {
console.log(url);
if (url.startsWith(REDIRECT_URI)) {
console.log("Got oauth redirect!");
global.apis.eventbus.emit("browserCloseRequest");
global.apis.eventbus.off("browserNavigate", onBrowserLogin);
var params = url.split("?")[1];
var query = new URLSearchParams(params);
if (query.get("error") != null) {
global.apis.alert(`Could not authorize PostalPoint to access your Square account. Error code ${query.get("error")} (${query.get("error_description")})`, "Square Login Error");
return;
}
if (query.get("response_type") == "code" && query.get("code") != null) {
var authorizationCode = query.get("code");
var resp = await global.apis.util.http.post(`${URL_BASE}/oauth2/token`, {
client_id: APP_ID,
grant_type: "authorization_code",
redirect_uri: REDIRECT_URI,
code: authorizationCode,
code_verifier: code_verifier_code
}, "json", {
"Content-Type": "application/json",
"Square-Version": `${SQUARE_API_VERSION}`
});
if (!resp.access_token) {
global.apis.alert(`Could not authorize PostalPoint to access your Square account. The token request failed.`, "Square Login Error");
return;
}
global.apis.settings.set("app.postalpoint.squareterminal.access_token", resp.access_token);
global.apis.settings.set("app.postalpoint.squareterminal.merchant_id", resp.merchant_id);
global.apis.settings.set("app.postalpoint.squareterminal.refresh_token", resp.refresh_token);
global.apis.settings.set("app.postalpoint.squareterminal.refresh_token_expires_at", resp.refresh_token_expires_at);
global.apis.settings.set("app.postalpoint.squareterminal.access_token_expires_at", resp.expires_at);
global.apis.alert("PostalPoint is now logged in to your Square merchant account.", "Connected to Square");
} else {
global.apis.alert(`Could not authorize PostalPoint to access your Square account. No authorization code (or error code) was received from Square.`, "Square Login Error");
return;
}
}
}
global.apis.eventbus.on("browserNavigate", onBrowserLogin);
global.apis.ui.openInternalWebBrowser(url);
}
/**
* Checks if the Square tokens are expiring "soon" and refresh them if needed.
* @returns {undefined}
*/
async function refreshTokens() {
var accessExpiresAt = global.apis.settings.get("app.postalpoint.squareterminal.access_token_expires_at", "");
var refreshExpiresAt = global.apis.settings.get("app.postalpoint.squareterminal.refresh_token_expires_at", "");
if (accessExpiresAt == "" || refreshExpiresAt == "") {
return;
}
const refreshExpireThreshold = 60 * 60 * 24 * 30; // 30 days
const accessExpireThreshold = 60 * 60 * 24 * 7; // 7 days
if (global.apis.util.time.strtotime(accessExpiresAt) < global.apis.util.time.now() - accessExpireThreshold || global.apis.util.time.strtotime(refreshExpiresAt) < global.apis.util.time.now() - refreshExpireThreshold) {
// Renew tokens
console.log("Renewing Square tokens");
var resp = await global.apis.util.http.post(`${URL_BASE}/oauth2/token`, {
client_id: APP_ID,
grant_type: "refresh_token",
redirect_uri: REDIRECT_URI,
refresh_token: global.apis.settings.get("app.postalpoint.squareterminal.refresh_token", ""),
}, "json", {
"Content-Type": "application/json",
"Square-Version": `${SQUARE_API_VERSION}`
});
if (!resp.access_token) {
global.apis.alert(`You need to log in to Square again in the plugin settings.`, "Square Logged Out");
return;
}
global.apis.settings.set("app.postalpoint.squareterminal.access_token", resp.access_token);
global.apis.settings.set("app.postalpoint.squareterminal.merchant_id", resp.merchant_id);
global.apis.settings.set("app.postalpoint.squareterminal.refresh_token", resp.refresh_token);
global.apis.settings.set("app.postalpoint.squareterminal.refresh_token_expires_at", resp.refresh_token_expires_at);
global.apis.settings.set("app.postalpoint.squareterminal.access_token_expires_at", resp.expires_at);
}
}
/**
* Clear the Square access tokens and info from the settings storage, then display a message to the user.
* @returns {undefined}
*/
function logoutFromSquare() {
global.apis.settings.set("app.postalpoint.squareterminal.access_token", null);
global.apis.settings.set("app.postalpoint.squareterminal.merchant_id", null);
global.apis.settings.set("app.postalpoint.squareterminal.refresh_token", null);
global.apis.settings.set("app.postalpoint.squareterminal.refresh_token_expires_at", null);
global.apis.settings.set("app.postalpoint.squareterminal.access_token_expires_at", null);
global.apis.settings.set("app.postalpoint.squareterminal.terminal_device_code_id", null);
global.apis.settings.set("app.postalpoint.squareterminal.location_id", null);
global.apis.alert(`PostalPoint is now logged out of your Square account. You'll need to log in again to use Square with PostalPoint.`, "Square Logged Out");
}
async function pairTerminal() {
var locationId = "main";
var locations = await apiRequest("v2/locations", {}, "GET");
var locationList = [];
for (var i = 0; i < locations.locations.length; i++) {
if (locations.locations[i].type != "PHYSICAL") {
continue;
}
if (locations.locations[i].status != "ACTIVE") {
continue;
}
if (locations.locations[i].capabilities.includes("CREDIT_CARD_PROCESSING") != true) {
continue;
}
locationList.push({
id: locations.locations[i].id,
name: locations.locations[i].name
});
}
if (locationList.length == 0) {
global.apis.alert("Your Square account doesn't have a physical location that can process card payments. Set one up in your Square Dashboard.", "Square Error");
return;
}
if (locationList.length == 1) {
locationId = locationList[0].id;
getTerminalPairingCode(locationId);
} else {
var buttons = [];
for (let i = 0; i < locationList.length; i++) {
buttons.push({
text: locationList[i].name,
onClick: function () {
getTerminalPairingCode(locationList[i].id);
}
});
}
global.apis.f7.dialog.create({
title: "Select Square location",
text: "Your Square account has multiple locations. Which one is PostalPoint set up at?",
verticalButtons: true,
buttons: buttons
}).show();
}
}
async function getTerminalPairingCode(locationId = "main") {
var resp = await apiRequest("v2/devices/codes", {
idempotency_key: global.apis.util.uuid.v4(),
device_code: {
product_type: "TERMINAL_API",
location_id: locationId
}
});
console.log(resp);
global.apis.f7.dialog.alert(`On your Square Terminal device's sign in page, tap "Use a device code" and enter this pairing code within five minutes. Wait for the Square Terminal to be fully loaded, then press OK here.<br /><br /><h3>${resp.device_code.code}</h3>`, "Pairing Code", async function () {
resp = await apiRequest(`v2/devices/codes/${resp.device_code.id}`, {}, "GET");
console.log(resp);
if (resp.device_code.status != "PAIRED") {
global.apis.ui.showProgressSpinner("Pairing...", "Pairing to the Square Terminal is taking a little while.");
await global.apis.util.delay(10000);
resp = await apiRequest(`v2/devices/codes/${resp.device_code.id}`, {}, "GET");
console.log(resp);
}
global.apis.ui.hideProgressSpinner();
if (resp.device_code.status != "PAIRED") {
global.apis.alert("Your Square Terminal is not paired. Please try again.", "Pairing Failed");
return;
}
global.apis.settings.set("app.postalpoint.squareterminal.terminal_device_id", resp.device_code.device_id);
global.apis.settings.set("app.postalpoint.squareterminal.terminal_device_code_id", resp.device_code.id);
global.apis.settings.set("app.postalpoint.squareterminal.location_id", resp.device_code.location_id);
global.apis.alert("Your Square Terminal has been paired with PostalPoint. You're ready to take card payments!", "Pairing Complete");
});
}
/**
* Poll a list of payment IDs and wait for them to stop being PENDING. If a payment needs capture, do that too.
* @param {array} paymentIDs
* @returns {nm$_plugin.waitForPaymentsToFinish.paymentData}
*/
async function waitForPaymentsToFinish(paymentIDs = []) {
var paymentData = {
amountCaptured: 0,
paymentsCaptured: []
};
for (let i = 0; i < paymentIDs.length; i++) {
var payment = await apiRequest(`v2/payments/${paymentIDs[i]}`, {}, "GET");
do {
if (payment.payment.status == "PENDING") {
global.apis.pos.addOnscreenPaymentLog("Waiting for payment to process...");
await global.apis.util.delay(2000);
payment = await apiRequest(`v2/payments/${paymentIDs[i]}`, {}, "GET");
} else if (payment.payment.status == "APPROVED") {
// capture payment
payment = await apiRequest(`v2/payments/${paymentIDs[i]}/complete`, {});
}
} while (payment.payment.status == "PENDING" || payment.payment.status == "APPROVED");
switch (payment.payment.status) {
case "COMPLETED":
paymentData.amountCaptured += payment.payment.amount_money.amount;
paymentData.paymentsCaptured.push(payment.payment);
break;
case "CANCELED":
break;
case "FAILED":
break;
}
}
return paymentData;
}
function addPaymentsToReceipt(payments) {
for (let i = 0; i < payments.length; i++) {
var p = payments[i];
if (p.payment) {
p = p.payment;
}
var ptype = "card";
var pstring = "";
switch (p.source_type) {
case "CARD":
ptype = "card";
if (p.card_details.entry_method == "EMV") {
pstring = `${p.card_details.card.cardholder_name}\n${p.card_details.card.card_brand}\nx${p.card_details.card.last_4}\nEMV Chip\n${p.card_details.application_name}\nAID: ${p.card_details.application_identifier}`;
} else {
pstring = `${p.card_details.card.cardholder_name ?? ""}\n${p.card_details.card.card_brand}\nx${p.card_details.card.last_4}\n`;
switch (p.card_details.entry_method) {
case "KEYED":
pstring += "Keyed-in";
break;
case "SWIPED":
pstring += "Swiped";
break;
case "ON_FILE":
pstring += "Card on file";
break;
case "CONTACTLESS":
pstring += "Contactless";
break;
}
}
break;
case "BANK_ACCOUNT":
ptype = "ach";
if (p.bank_account_details.transfer_type == "ACH") {
pstring = `${p.bank_account_details.bank_name}\nx${p.bank_account_details.ach_details.account_number_suffix}\n${p.bank_account_details.ach_details.account_type}`;
} else {
pstring = `${p.bank_account_details.bank_name}`;
}
break;
case "CASH":
ptype = "cash";
break;
case "WALLET":
ptype = "card";
pstring = `${p.wallet_details.brand}`;
if (p.wallet_details.brand == "CASH_APP") {
pstring += `\n${p.wallet_details.cash_app_details.buyer_full_name}\n${p.wallet_details.cash_app_details.buyer_cashtag}`;
}
break;
case "BUY_NOW_PAY_LATER":
ptype = "card";
pstring = `Buy Now, Pay Later\n${p.buy_now_pay_later_details.brand}`;
break;
case "SQUARE_ACCOUNT":
ptype = "card";
pstring = "Square Account";
break;
case "EXTERNAL":
switch (p.external_details.type) {
case "CHECK":
ptype = "check";
break;
case "BANK_TRANSFER":
ptype = "ach";
break;
case "CRYPTO":
ptype = "crypto";
break;
default:
ptype = "card";
break;
}
pstring = `${p.external_details.type}\n${p.external_details.source}`;
break;
}
global.apis.pos.addReceiptPayment(
new global.apis.pos.ReceiptPayment(
(p.amount_money.amount / 100).toFixed(2) * 1,
ptype, // 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.
pstring // Additional text for receipt
)
);
}
}
exports.init = function () {
setInterval(refreshTokens, 1000 * 60 * 60);
global.apis.pos.registerCardProcessor({
name: "Square",
init: async function () {
// This function runs once after starting PostalPoint
// and before any other card processor functions are called.
console.info("Hello from Square Terminal plugin!");
},
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 {
// 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
var checkoutData = await apiRequest("v2/terminals/checkouts", {
idempotency_key: global.apis.util.uuid.v4(),
checkout: {
amount_money: {
amount: amount,
currency: "USD"
},
device_options: {
device_id: global.apis.settings.get("app.postalpoint.squareterminal.terminal_device_id", ""),
tip_settings: {
allow_tipping: false
},
skip_receipt_screen: true
},
payment_options: {
autocomplete: capture
}
}
});
checkout_id = checkoutData.checkout.id;
checkoutStatus = checkoutData.checkout.status;
while (checkoutStatus != "COMPLETED" && checkoutStatus != "CANCELED") {
await global.apis.util.delay(1000); // Wait a second
checkoutData = await apiRequest(`v2/terminals/checkouts/${checkout_id}`, {}, "GET");
checkoutStatus = checkoutData.checkout.status;
}
if (checkoutStatus != "COMPLETED") {
global.apis.pos.addOnscreenPaymentLog("Square Terminal payment not completed.");
if (checkoutStatus == "CANCELED") {
global.apis.alert("The card payment was canceled before it finished.", "Payment canceled");
switch (checkoutData.checkout.cancel_reason) {
case "BUYER_CANCELED":
global.apis.pos.addOnscreenPaymentLog("The payment was canceled from the Square device.");
break;
case "SELLER_CANCELED":
global.apis.pos.addOnscreenPaymentLog("The payment was canceled by the merchant.");
break;
case "TIMED_OUT":
global.apis.pos.addOnscreenPaymentLog("The payment was canceled because it took too long to complete.");
break;
}
} else {
global.apis.alert(`The card payment was not completed, and has status code ${checkoutStatus}.`, "Payment not complete");
}
return false;
}
console.log(checkoutData);
if (checkoutData.checkout.payment_options.autocomplete) {
var paymentInfos = await waitForPaymentsToFinish(checkoutData.checkout.payment_ids);
console.log(paymentInfos);
addPaymentsToReceipt(paymentInfos.paymentsCaptured);
global.apis.pos.addOnscreenPaymentLog("Payment successful!");
return true;
} else {
return checkoutData.checkout.payment_ids.join(",");
}
} catch (ex) {
global.apis.pos.addOnscreenPaymentLog(`Error: ${ex.message}`);
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: async function () {
// The user requested to cancel the payment.
// Reset the terminal to its resting state, clear its screen, etc.
try {
if (checkout_id != null) {
var resp = await apiRequest(`v2/terminals/checkouts/${checkout_id}/cancel`);
if (resp.checkout.status == "CANCELED") {
checkout_id = null;
}
}
} catch (ex) {
console.error(ex);
}
},
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.
var paymentIDs = checkoutResponse.split(",");
var paymentInfos = await waitForPaymentsToFinish(paymentIDs);
addPaymentsToReceipt(paymentInfos.paymentsCaptured);
return true;
},
updateCartDisplay: function (receipt) {
// no-op
},
checkoutSavedMethod: async function ( {customerID, paymentMethodID, amount}) {
// Same as checkout() except using a payment method already on file.
// customerID and paymentMethodID are provided by getSavedPaymentMethods below.
var locationID = global.apis.settings.get("app.postalpoint.squareterminal.location_id", "");
var requestdata = {
idempotency_key: global.apis.util.uuid.v4(),
amount_money: {
amount: amount,
currency: "USD"
},
autocomplete: true,
source_id: paymentMethodID,
customer_id: customerID
};
if (locationID && locationID != "") {
requestdata.location_id = locationID;
}
var paymentResponse = await apiRequest(`v2/payments`, requestdata, "POST");
console.log(paymentResponse);
if (typeof paymentResponse.errors != "undefined") {
global.apis.pos.addOnscreenPaymentLog("Error processing saved payment: " + paymentResponse.errors[0].code);
throw new Error("Saved payment error: " + paymentResponse.errors[0].code);
return false;
} else {
if (paymentResponse.payment.status == "COMPLETED") {
addPaymentsToReceipt([paymentResponse.payment]);
return true;
} else if (paymentResponse.payment.status == "CANCELED") {
global.apis.pos.addOnscreenPaymentLog("The saved card payment was canceled.");
throw new Error("Payment canceled: The saved card payment was canceled before it finished.");
return false;
} else if (paymentResponse.payment.status == "FAILED") {
global.apis.pos.addOnscreenPaymentLog("The saved card payment failed.");
throw new Error("The saved card payment failed.")
return false;
}
}
return false;
},
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.
// TODO: Lookup or create Square customer
statusCallback("Syncing customer data with Square", false);
var squareCustomerID = "";
var customerSearchResponse = await apiRequest(`v2/customers/search`, {
query: {
filter: {
reference_id: {
exact: customerUUID
}
}
},
limit: 2
}, "POST");
if (Object.keys(customerSearchResponse).length == 0 || customerSearchResponse.customers.length == 0) {
// Create customer
var newCustomerInfo = await apiRequest("v2/customers", {
idempotency_key: global.apis.util.uuid.v4(),
given_name: name.split(" ", 2)[0],
family_name: (name.split(" ", 2)[1] ?? ""),
nickname: name,
company_name: company,
email_address: email,
phone_number: phone,
address: {
address_line_1: street1,
locality: city,
administrative_district_level_1: state,
postal_code: zip
}
}, "POST");
squareCustomerID = newCustomerInfo.customer.id;
} else if (customerSearchResponse.customers.length == 1) {
// Customer found
squareCustomerID = customerSearchResponse.customers[0].id;
} else {
// Too many customers found
throw new Error("There are multiple customers in your Square account with the same PostalPoint customer UUID number (a.k.a. Square reference ID). PostalPoint can't tell which one to use for this action.");
return false;
}
statusCallback("Insert the card into the reader.", false);
var actionResponse = await apiRequest(`v2/terminals/actions`, {
idempotency_key: global.apis.util.uuid.v4(),
action: {
device_id: global.apis.settings.get("app.postalpoint.squareterminal.terminal_device_id", ""),
type: "SAVE_CARD",
save_card_options: {
customer_id: squareCustomerID,
reference_id: customerUUID
}
}
}, "POST");
// Save this in case we need to cancel
latestTerminalActionID = actionResponse.action.id;
do {
await global.apis.util.delay(1000);
actionResponse = await apiRequest(`v2/terminals/actions/${actionResponse.action.id}`, {}, 'GET');
} while (actionResponse.action.status == "PENDING" || actionResponse.action.status == "IN_PROGRESS");
if (actionResponse.action.status == "COMPLETED") {
statusCallback("Card saved!", true);
return true;
} else if (actionResponse.action.status == "CANCELED" || actionResponse.action.status == "CANCEL_REQUESTED") {
throw new Error("Operation cancelled.");
return false;
} else {
throw new Error("Can't determine if card was saved at this time.");
return false;
}
},
cancelSaveCardForOfflineUse: function () {
// Cancel the process running in saveCardForOfflineUse() at the user/cashier's request.
if (latestTerminalActionID != "") {
apiRequest(`v2/terminals/actions/${latestTerminalActionID}/cancel`, {}, "POST");
latestTerminalActionID = "";
}
},
getSavedPaymentMethods: async function ( {customerUUID}) {
// Return all saved payment methods tied to the provided customer UUID.
var methods = [];
var response = await apiRequest(`v2/cards?reference_id=${customerUUID}`, {}, "GET");
if (Object.keys(response).length == 0 || typeof response.cards == "undefined") {
return [];
}
console.log(response);
for (let i = 0; i < response.cards.length; i++) {
var card = response.cards[i];
methods.push({
customer: card.customer_id, // Passed to checkoutSavedMethod as cardProcessorCustomerID
customer_uuid: customerUUID,
id: card.id, // Passed to checkoutSavedMethod as paymentMethodID
type: "card", // Payment type. Accepted values are card, ach, crypto, cash, check, account, and free.
label: `${card.card_brand} ${card.card_type} x${card.last_4} (exp. ${card.exp_month}/${card.exp_year})`, // Label for payment method
label_short: `${card.card_brand} ${card.card_type} x${card.last_4}` // Abbreviated label for payment method
});
}
console.log(methods);
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 apiRequest(`v2/cards/${paymentMethodID}/disable`, {}, "POST");
}
});
}
// Plugin settings to display.
exports.config = [
{
type: "button",
label: "Log In to Square",
text: "Step 1: Connect PostalPoint to your Square merchant account.",
onClick: function () {
loginToSquare();
}
},
{
type: "button",
label: "Pair Square Terminal hardware",
text: "Step 2: Connect a Square Terminal device to PostalPoint and accept card payments.",
onClick: function () {
pairTerminal();
}
},
{
type: "button",
label: "Log Out from Square",
text: "Disconnect PostalPoint from your Square merchant account.",
onClick: function () {
logoutFromSquare();
}
}
];