// 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!


${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!


${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