This commit is contained in:
ilia 2025-07-06 13:03:08 -04:00
commit b4a3274444
10 changed files with 5582 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.history
node_modules
*.pdf

120
README.md Normal file
View File

@ -0,0 +1,120 @@
# Invoice Generator
A powerful and flexible command-line tool for generating PDF invoices from simple JSON data files. It features an interactive mode for ease of use and a robust, non-interactive mode for scripting and automation.
## Features
- **Interactive & Non-Interactive Modes**: Use the simple interactive wizard (`npm start`) or pass arguments directly for scripted use.
- **Safe Preview-First Workflow**: Always generates a development preview first. You must confirm before the official invoice is created and recorded, preventing accidental invoice generation.
- **Data-Driven**: Senders, clients, and products are managed in simple `.json` files, making them easy to update without changing code.
- **Automatic Invoice Numbering**: Invoice numbers are automatically incremented based on the year and existing records. The sequence resets annually.
- **Organized Output**: Generated PDFs are neatly stored in folders organized by sender (`invoice/<sender_name>/`).
- **Unit Tested**: Core logic is verified with unit tests using Jest.
## Prerequisites
- [Node.js](https://nodejs.org/) (v16 or higher recommended)
- [npm](https://www.npmjs.com/) (comes with Node.js)
## Setup
1. Clone the repository or download the source code.
2. Open your terminal in the project directory.
3. Install the dependencies:
```bash
npm install
```
## Usage
There are two primary ways to generate an invoice:
### 1. Interactive Mode (Recommended for most users)
Run the interactive wizard. The tool will guide you through selecting a sender, client, and one or more products.
```bash
npm start
```
The script will first generate a **development preview** PDF. After you review it, you will be prompted to confirm whether you want to create the final **production** invoice.
### 2. Non-Interactive / Scripted Mode
You can also generate an invoice by passing command-line arguments. This is useful for automation and scripting.
The basic command is:
`npm run generate -- --sender <sender_key> --client <client_key> --products <product_key_1> <product_key_2>`
**Example:**
```bash
npm run generate -- --sender company1 --client company4 --products linkedin_scraper web_design
```
Like the interactive mode, this will also generate a preview and prompt for confirmation before creating the production invoice.
### Running Tests
To run the automated tests for the project:
```bash
npm test
```
## How the Data Files Work
All data is managed in the `data/` directory.
### `sender.json`
Stores your company or sender profiles. Each key represents a unique sender.
```json
{
"company1": { "company": "Your Company", "address": ... },
"company2": { "company": "Another Profile", "address": ... }
}
```
### `client.json`
Stores your client profiles. The `invoiceFileName` key is optional; if provided, it will be used for the generated PDF's filename.
```json
{
"company3": { "company": "First Client", "address": ... },
"company4": { "company": "Second Client", "invoiceFileName": "second_client_invoices", "address": ... }
}
```
### `products.json`
A catalog of the services or products you offer.
```json
{
"linkedin_scraper": {
"description": "LinkedIn Scraper Bot",
"price": 250,
"taxRate": 0
},
"web_design": { "description": "Website Design", "price": 1200, "taxRate": 0 }
}
```
### `invoices.json`
This file is your ledger. It's **automatically updated** when you create a production invoice. It tracks all generated invoices to determine the next sequential number. You should not need to edit this file manually.
```json
{
"invoices": [
{
"number": "2024-0001",
"date": "2024-07-31",
...
}
]
}
```

16
data/client.json Normal file
View File

@ -0,0 +1,16 @@
{
"company3": {
"company": "Client Company",
"address": "456 Client Ave",
"zip": "67890",
"city": "Client City",
"country": "Client Country"
},
"levitin": {
"company": "LEVITIN EMPLOYMENT LAWYERS",
"address": "#2809 - 330 Richmond Street West",
"zip": "M5V 0M4",
"city": "Toronto, ON",
"country": "Canada"
}
}

123
data/invoices.json Normal file
View File

@ -0,0 +1,123 @@
{
"invoices": [
{
"number": "2025-0001",
"date": "2025-07-04",
"sender": "Your Company Name",
"client": "Second Client",
"products": [
{
"description": "LinkedIn Scraper Bot",
"price": 250,
"quantity": 1
}
],
"amount": 250
},
{
"number": "2025-0002",
"date": "2025-07-04",
"sender": "Your Company Name",
"client": "Client Company",
"products": [
{
"description": "LinkedIn Scraper Bot",
"price": 250,
"quantity": 1
}
],
"amount": 250
},
{
"number": "2025-0003",
"date": "2025-07-04",
"sender": "Your Company Name",
"client": "Client Company",
"products": [
{
"description": "LinkedIn Scraper Bot",
"price": 250,
"quantity": 1,
"taxRate": 13
}
],
"subtotal": 250,
"taxTotal": 32.5,
"total": 282.5
},
{
"number": "2025-0004",
"date": "2025-07-04",
"sender": "Another Sender",
"client": "Client Company",
"products": [
{
"description": "LinkedIn Scraper Bot",
"price": 250,
"quantity": 1,
"taxRate": 13
},
{
"description": "Website Design",
"price": 1200,
"quantity": 1,
"taxRate": 13
}
],
"subtotal": 1450,
"taxTotal": 188.5,
"total": 1638.5
},
{
"number": "2025-0005",
"date": "2025-07-04",
"sender": "Your Company Name",
"client": "Client Company",
"products": [
{
"description": "LinkedIn Scraper Bot",
"price": 250,
"quantity": 1,
"taxRate": 13
}
],
"subtotal": 250,
"taxTotal": 32.5,
"total": 282.5
},
{
"number": "2025-0006",
"date": "2025-07-04",
"sender": "Your Company Name",
"client": "Client Company",
"products": [
{
"description": "LinkedIn Scraper Bot",
"price": 250,
"quantity": 1,
"taxRate": 13
}
],
"subtotal": 250,
"taxTotal": 32.5,
"total": 282.5
},
{
"number": "2025-0007",
"date": "2025-07-06",
"sender": "Levkin Inc.",
"client": "Client Company",
"products": [
{
"description": "LinkedIn Scraper Bot",
"price": 250,
"quantity": 1,
"taxRate": 13
}
],
"subtotal": 250,
"taxTotal": 32.5,
"total": 282.5
}
]
}

12
data/products.json Normal file
View File

@ -0,0 +1,12 @@
{
"linkedin_scraper": {
"description": "LinkedIn Scraper Bot",
"price": 250,
"taxRate": 13
},
"web_design": {
"description": "Website Design",
"price": 1200,
"taxRate": 13
}
}

21
data/sender.json Normal file
View File

@ -0,0 +1,21 @@
{
"levkin": {
"company": "Levkin Inc.",
"address": "6 Keefer Court",
"zip": "L4J 5Y4",
"city": "Thornhill, ON",
"country": "Canada",
"taxName": "HST",
"taxNumber": "79438 2895 RT0001",
"paymentInfo": "<div style=\"text-align:left;\">All payments to be made by bank transfer to the following account:<br><br>- Levkin Inc. - Ilia Dobkin<br>- 6 Keefer Court, ON, L4J 5Y4<br>- Account: 5004295<br>- Transit: 31822<br>- 2600 Simcoe St. N. Unit 1, Oshawa, ON, L1L 0R1 - Inst: 004<br>- SWIFT Code: TDOMCATTTOR</div>"
},
"dobit": {
"company": "DOBIT Consulting",
"address": " 6 Keefer Court",
"zip": "L4J 5Y4",
"city": "Thornhill, ON",
"country": "Canada",
"taxName": "HST",
"taxNumber": "83602 7243 RT0002"
}
}

310
index.js Normal file
View File

@ -0,0 +1,310 @@
// 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);
}
}
// --- MAIN LOGIC ---
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!<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

4912
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "invoice",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node index.js --interactive",
"generate": "node index.js",
"test": "jest",
"test-invoice": "node index.js --test"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"easyinvoice": "^3.0.47",
"inquirer": "^8.2.4",
"yargs": "^17.7.2"
},
"devDependencies": {
"jest": "^29.7.0"
}
}

41
test/invoice.test.js Normal file
View File

@ -0,0 +1,41 @@
const { getNextInvoiceNumber } = require("../index");
describe("getNextInvoiceNumber", () => {
test("should return -0001 for the first invoice of the year", () => {
const year = new Date().getFullYear();
const invoicesData = { invoices: [] };
expect(getNextInvoiceNumber(year, invoicesData)).toBe(`${year}-0001`);
});
test("should correctly increment the invoice number for the same year", () => {
const year = new Date().getFullYear();
const invoicesData = {
invoices: [{ number: `${year}-0001` }, { number: `${year}-0002` }],
};
expect(getNextInvoiceNumber(year, invoicesData)).toBe(`${year}-0003`);
});
test("should reset number for a new year", () => {
const lastYear = new Date().getFullYear() - 1;
const year = new Date().getFullYear();
const invoicesData = {
invoices: [
{ number: `${lastYear}-0001` },
{ number: `${lastYear}-0002` },
],
};
expect(getNextInvoiceNumber(year, invoicesData)).toBe(`${year}-0001`);
});
test("should handle unordered invoice numbers correctly", () => {
const year = new Date().getFullYear();
const invoicesData = {
invoices: [
{ number: `${year}-0005` },
{ number: `${year}-0001` },
{ number: `${year}-0003` },
],
};
expect(getNextInvoiceNumber(year, invoicesData)).toBe(`${year}-0006`);
});
});