// 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);
invoicesData.invoices.push({
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)),
});
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("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);
// 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!
${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