add data
This commit is contained in:
commit
b4a3274444
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.history
|
||||||
|
node_modules
|
||||||
|
*.pdf
|
||||||
120
README.md
Normal file
120
README.md
Normal 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
16
data/client.json
Normal 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
123
data/invoices.json
Normal 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
12
data/products.json
Normal 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
21
data/sender.json
Normal 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
310
index.js
Normal 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
4912
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal 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
41
test/invoice.test.js
Normal 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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user