Add --print to regenerate PDFs from invoices.json without ledger writes. Support optional dueDate on ledger rows (default remains invoice date + 14 days) and persist dueDate when saving new production rows. Data: limmud_fsu_canada client with invoiceFileName, cruise_event_av_it product, 2026-LFSU01 sample invoice, Levkin contact fields in sender.json. README and project.md describe CLI, schema, and May 2026 changelog. Co-authored-by: Cursor <cursoragent@cursor.com>
410 lines
12 KiB
JavaScript
410 lines
12 KiB
JavaScript
// index.js
|
|
const easyinvoice = require("easyinvoice");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const yargs = require("yargs/yargs");
|
|
const { hideBin } = require("yargs/helpers");
|
|
|
|
// Late import for ESM compatibility with inquirer
|
|
let inquirer;
|
|
Promise.resolve().then(() => {
|
|
inquirer = require("inquirer");
|
|
});
|
|
|
|
const FOLDERS = {
|
|
DATA: "data",
|
|
INVOICE: "invoice",
|
|
};
|
|
|
|
const FILES = {
|
|
SENDERS: path.join(FOLDERS.DATA, "sender.json"),
|
|
CLIENTS: path.join(FOLDERS.DATA, "client.json"),
|
|
PRODUCTS: path.join(FOLDERS.DATA, "products.json"),
|
|
INVOICES: path.join(FOLDERS.DATA, "invoices.json"),
|
|
};
|
|
|
|
// --- UTILITY FUNCTIONS ---
|
|
function loadJson(filePath) {
|
|
try {
|
|
if (!fs.existsSync(filePath)) {
|
|
console.error(`Error: File not found at ${filePath}. Please create it.`);
|
|
process.exit(1);
|
|
}
|
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
} catch (err) {
|
|
console.error(`Error loading or parsing ${filePath}:`, err.message);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
function getNextInvoiceNumber(year, invoicesData) {
|
|
const yearInvoices = invoicesData.invoices.filter((inv) =>
|
|
inv.number.startsWith(`${year}-`)
|
|
);
|
|
let lastNum = 0;
|
|
if (yearInvoices.length > 0) {
|
|
lastNum = Math.max(
|
|
...yearInvoices.map((inv) => parseInt(inv.number.split("-")[1], 10))
|
|
);
|
|
}
|
|
return `${year}-${String(lastNum + 1).padStart(4, "0")}`;
|
|
}
|
|
|
|
async function createInvoice(invoiceData, invoiceFilePath) {
|
|
try {
|
|
const result = await easyinvoice.createInvoice(invoiceData);
|
|
const invoiceFolder = path.dirname(invoiceFilePath);
|
|
if (!fs.existsSync(invoiceFolder)) {
|
|
fs.mkdirSync(invoiceFolder, { recursive: true });
|
|
}
|
|
fs.writeFileSync(invoiceFilePath, result.pdf, "base64");
|
|
console.log(`✅ Invoice generated: ${invoiceFilePath}`);
|
|
return true;
|
|
} catch (err) {
|
|
console.error("Error creating invoice PDF:", err.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function saveInvoiceRecord(invoiceData, invoiceProducts) {
|
|
const invoicesData = loadJson(FILES.INVOICES);
|
|
|
|
const subtotal = invoiceProducts.reduce(
|
|
(sum, p) => sum + p.price * p.quantity,
|
|
0
|
|
);
|
|
const taxTotal = invoiceProducts.reduce((sum, p) => {
|
|
const taxRate = p["tax-rate"] || 0;
|
|
return sum + (p.price * p.quantity * taxRate) / 100;
|
|
}, 0);
|
|
|
|
const ledgerRow = {
|
|
number: invoiceData.information.number,
|
|
date: invoiceData.information.date,
|
|
sender: invoiceData.sender.company,
|
|
client: invoiceData.client.company,
|
|
products: invoiceProducts.map((p) => ({
|
|
description: p.description,
|
|
price: p.price,
|
|
quantity: p.quantity,
|
|
taxRate: p["tax-rate"],
|
|
})),
|
|
subtotal: parseFloat(subtotal.toFixed(2)),
|
|
taxTotal: parseFloat(taxTotal.toFixed(2)),
|
|
total: parseFloat((subtotal + taxTotal).toFixed(2)),
|
|
};
|
|
if (invoiceData.information.dueDate) {
|
|
ledgerRow.dueDate = invoiceData.information.dueDate;
|
|
}
|
|
invoicesData.invoices.push(ledgerRow);
|
|
try {
|
|
fs.writeFileSync(FILES.INVOICES, JSON.stringify(invoicesData, null, 2));
|
|
console.log("✅ Invoice record saved to invoices.json.");
|
|
} catch (err) {
|
|
console.error("Error writing to invoices.json:", err.message);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const argv = yargs(hideBin(process.argv))
|
|
.parserConfiguration({ "update-notifier-off": true })
|
|
.option("interactive", {
|
|
type: "boolean",
|
|
describe: "Run in interactive mode",
|
|
})
|
|
.option("print", {
|
|
type: "string",
|
|
describe:
|
|
"Generate a PDF for an existing invoice number (from data/invoices.json) without modifying the ledger",
|
|
})
|
|
.option("sender", {
|
|
type: "string",
|
|
describe: "Sender key from sender.json",
|
|
})
|
|
.option("client", {
|
|
type: "string",
|
|
describe: "Client key from client.json",
|
|
})
|
|
.option("products", {
|
|
type: "array",
|
|
describe: "Product keys from products.json",
|
|
})
|
|
.option("test", {
|
|
type: "boolean",
|
|
describe: "Run in test mode (uses first available options, dev only)",
|
|
})
|
|
.help().argv;
|
|
|
|
let senderKey, clientKey, productKeys;
|
|
|
|
const senders = loadJson(FILES.SENDERS);
|
|
const clients = loadJson(FILES.CLIENTS);
|
|
const productsData = loadJson(FILES.PRODUCTS);
|
|
|
|
// --- Print existing invoice (no prompts, no ledger update) ---
|
|
if (argv.print) {
|
|
const invoiceNumber = argv.print;
|
|
const invoicesData = loadJson(FILES.INVOICES);
|
|
const record = invoicesData.invoices.find((inv) => inv.number === invoiceNumber);
|
|
if (!record) {
|
|
console.error(`Invoice not found in invoices.json: ${invoiceNumber}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const resolvedSenderKey =
|
|
Object.keys(senders).find((k) => senders[k]?.company === record.sender) || null;
|
|
const resolvedClientKey =
|
|
Object.keys(clients).find((k) => clients[k]?.company === record.client) || null;
|
|
|
|
if (!resolvedSenderKey) {
|
|
console.error(
|
|
`Could not match sender company "${record.sender}" to a key in sender.json.`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
if (!resolvedClientKey) {
|
|
console.error(
|
|
`Could not match client company "${record.client}" to a key in client.json.`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const sender = senders[resolvedSenderKey];
|
|
const client = clients[resolvedClientKey];
|
|
|
|
const invoiceProducts = (record.products || []).map((p) => ({
|
|
quantity: p.quantity ?? 1,
|
|
description: p.description,
|
|
"tax-rate": p.taxRate ?? 0,
|
|
price: p.price,
|
|
}));
|
|
if (invoiceProducts.length === 0) {
|
|
console.error(`Invoice ${invoiceNumber} has no products.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const senderForInvoice = { ...sender };
|
|
if (sender.taxNumber) {
|
|
senderForInvoice.custom1 = `${sender.taxName || "Tax"} Number: ${
|
|
sender.taxNumber
|
|
}`;
|
|
}
|
|
|
|
let dueDateStr;
|
|
if (record.dueDate) {
|
|
dueDateStr = record.dueDate;
|
|
} else {
|
|
const due = new Date(record.date);
|
|
due.setDate(due.getDate() + 14);
|
|
dueDateStr = due.toISOString().split("T")[0];
|
|
}
|
|
|
|
const commonData = {
|
|
sender: senderForInvoice,
|
|
client,
|
|
products: invoiceProducts,
|
|
bottomNotice: sender.paymentInfo
|
|
? `Thank you for your business!<br /><br /><br />${sender.paymentInfo}`
|
|
: "Thank you for your business!",
|
|
settings: { currency: "CAD" },
|
|
translate: {
|
|
taxNotation: sender.taxName || "VAT",
|
|
number: "Invoice",
|
|
},
|
|
};
|
|
|
|
const printData = {
|
|
...commonData,
|
|
information: {
|
|
number: record.number,
|
|
date: record.date,
|
|
dueDate: dueDateStr,
|
|
},
|
|
mode: "production",
|
|
};
|
|
|
|
const fileNameKey = client.invoiceFileName || resolvedClientKey;
|
|
const outFileName = `${fileNameKey}_${record.number}.pdf`;
|
|
const outFilePath = path.join(FOLDERS.INVOICE, resolvedSenderKey, outFileName);
|
|
|
|
console.log(`\nPrinting invoice ${record.number}...`);
|
|
const ok = await createInvoice(printData, outFilePath);
|
|
process.exit(ok ? 0 : 1);
|
|
}
|
|
|
|
// Test mode: use first available options
|
|
if (argv.test) {
|
|
senderKey = Object.keys(senders)[0];
|
|
clientKey = Object.keys(clients)[0];
|
|
productKeys = [Object.keys(productsData)[0]];
|
|
console.log(
|
|
`🧪 Test mode: Using sender="${senderKey}", client="${clientKey}", products=[${productKeys.join(
|
|
", "
|
|
)}]`
|
|
);
|
|
} else if (
|
|
argv.interactive ||
|
|
!(argv.sender && argv.client && argv.products)
|
|
) {
|
|
if (!inquirer) {
|
|
await new Promise((resolve) => setTimeout(resolve, 100)); // wait for dynamic import
|
|
if (!inquirer) {
|
|
// check again
|
|
console.error(
|
|
"Failed to load 'inquirer'. Please ensure it is installed correctly."
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
const answers = await inquirer.prompt([
|
|
{
|
|
type: "list",
|
|
name: "sender",
|
|
message: "Select a sender:",
|
|
choices: Object.keys(senders),
|
|
},
|
|
{
|
|
type: "list",
|
|
name: "client",
|
|
message: "Select a client:",
|
|
choices: Object.keys(clients),
|
|
},
|
|
{
|
|
type: "checkbox",
|
|
name: "products",
|
|
message: "Select products:",
|
|
choices: Object.keys(productsData),
|
|
validate: (input) =>
|
|
input.length > 0 ? true : "Please select at least one product.",
|
|
},
|
|
]);
|
|
senderKey = answers.sender;
|
|
clientKey = answers.client;
|
|
productKeys = answers.products;
|
|
} else {
|
|
senderKey = argv.sender;
|
|
clientKey = argv.client;
|
|
productKeys = argv.products;
|
|
}
|
|
|
|
// --- Data Validation and Preparation ---
|
|
if (
|
|
!senders[senderKey] ||
|
|
!clients[clientKey] ||
|
|
!productKeys.every((k) => productsData[k])
|
|
) {
|
|
console.error("Invalid sender, client, or product key provided.");
|
|
process.exit(1);
|
|
}
|
|
|
|
const sender = senders[senderKey];
|
|
const client = clients[clientKey];
|
|
const invoiceProducts = productKeys.map((key) => {
|
|
const product = productsData[key];
|
|
return {
|
|
quantity: 1, // Default quantity
|
|
description: product.description,
|
|
"tax-rate": product.taxRate || 0,
|
|
price: product.price,
|
|
};
|
|
});
|
|
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const devInvoiceNumber = `${year}-DEV-PREVIEW`;
|
|
|
|
const senderForInvoice = { ...sender };
|
|
if (sender.taxNumber) {
|
|
senderForInvoice.custom1 = `${sender.taxName || "Tax"} Number: ${
|
|
sender.taxNumber
|
|
}`;
|
|
}
|
|
|
|
const commonData = {
|
|
sender: senderForInvoice,
|
|
client,
|
|
products: invoiceProducts,
|
|
bottomNotice: sender.paymentInfo
|
|
? `Thank you for your business!<br /><br /><br />${sender.paymentInfo}`
|
|
: "Thank you for your business!",
|
|
settings: { currency: "CAD" },
|
|
translate: {
|
|
taxNotation: sender.taxName || "VAT",
|
|
number: "Invoice",
|
|
},
|
|
};
|
|
|
|
// --- Development / Preview Run ---
|
|
const devData = {
|
|
...commonData,
|
|
information: {
|
|
number: devInvoiceNumber,
|
|
date: now.toISOString().split("T")[0],
|
|
dueDate: new Date(new Date().setDate(now.getDate() + 14))
|
|
.toISOString()
|
|
.split("T")[0],
|
|
},
|
|
mode: "development",
|
|
};
|
|
const fileNameKey = client.invoiceFileName || clientKey;
|
|
const devFileName = `${fileNameKey}_${devInvoiceNumber}.pdf`;
|
|
const devFilePath = path.join(FOLDERS.INVOICE, senderKey, devFileName);
|
|
|
|
console.log("\nGenerating development preview...");
|
|
await createInvoice(devData, devFilePath);
|
|
|
|
// Skip production prompt in test mode
|
|
if (argv.test) {
|
|
console.log("\n🧪 Test mode: Skipping production invoice creation.");
|
|
return;
|
|
}
|
|
|
|
// --- Production Prompt ---
|
|
if (!inquirer) await new Promise((resolve) => setTimeout(resolve, 100)); // Ensure inquirer is loaded
|
|
const { proceed } = await inquirer.prompt([
|
|
{
|
|
type: "list",
|
|
name: "proceed",
|
|
message: "Preview generated. What would you like to do?",
|
|
choices: [
|
|
{ name: "No - Keep only the preview", value: false },
|
|
{ name: "Yes - Create production invoice", value: true },
|
|
],
|
|
default: 0, // Default to "No"
|
|
},
|
|
]);
|
|
|
|
if (proceed) {
|
|
console.log("\nGenerating production invoice...");
|
|
const invoicesData = loadJson(FILES.INVOICES);
|
|
const prodInvoiceNumber = getNextInvoiceNumber(year, invoicesData);
|
|
|
|
const prodData = {
|
|
...commonData,
|
|
information: {
|
|
number: prodInvoiceNumber,
|
|
date: now.toISOString().split("T")[0],
|
|
dueDate: new Date(new Date().setDate(now.getDate() + 14))
|
|
.toISOString()
|
|
.split("T")[0],
|
|
},
|
|
mode: "production",
|
|
};
|
|
const prodFileName = `${fileNameKey}_${prodInvoiceNumber}.pdf`;
|
|
const prodFilePath = path.join(FOLDERS.INVOICE, senderKey, prodFileName);
|
|
|
|
if (await createInvoice(prodData, prodFilePath)) {
|
|
saveInvoiceRecord(prodData, invoiceProducts);
|
|
}
|
|
} else {
|
|
console.log("\nProduction invoice creation cancelled.");
|
|
}
|
|
}
|
|
|
|
// Check if running in test environment
|
|
if (process.env.NODE_ENV !== "test") {
|
|
main().catch((err) => console.error("An unexpected error occurred:", err));
|
|
}
|
|
|
|
module.exports = { getNextInvoiceNumber, loadJson }; // For testing
|