@invoicer/cli 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +15 -0
- package/config.json +19 -0
- package/data/last.json +11 -0
- package/dist/cli.js +38 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/clients.js +184 -0
- package/dist/commands/clients.js.map +1 -0
- package/dist/commands/generate.js +294 -0
- package/dist/commands/generate.js.map +1 -0
- package/dist/commands/timesheet.js +34 -0
- package/dist/commands/timesheet.js.map +1 -0
- package/dist/core/json-store.js +27 -0
- package/dist/core/json-store.js.map +1 -0
- package/dist/core/paths.js +29 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/validators.js +131 -0
- package/dist/core/validators.js.map +1 -0
- package/dist/domain/types.js +2 -0
- package/dist/domain/types.js.map +1 -0
- package/dist/services/email.js +32 -0
- package/dist/services/email.js.map +1 -0
- package/dist/services/invoice-number.js +33 -0
- package/dist/services/invoice-number.js.map +1 -0
- package/dist/services/pdf-render.js +69 -0
- package/dist/services/pdf-render.js.map +1 -0
- package/dist/services/timesheet.js +99 -0
- package/dist/services/timesheet.js.map +1 -0
- package/dist/utils/sanitize.js +8 -0
- package/dist/utils/sanitize.js.map +1 -0
- package/index.html +2584 -0
- package/logo.svg +17 -0
- package/package.json +39 -0
- package/scripts/invoice.mjs +23 -0
- package/src/cli.ts +44 -0
- package/src/commands/clients.ts +221 -0
- package/src/commands/generate.ts +379 -0
- package/src/commands/timesheet.ts +47 -0
- package/src/core/json-store.ts +33 -0
- package/src/core/paths.ts +42 -0
- package/src/core/validators.ts +168 -0
- package/src/domain/types.ts +129 -0
- package/src/services/email.ts +40 -0
- package/src/services/invoice-number.ts +46 -0
- package/src/services/pdf-render.ts +95 -0
- package/src/services/timesheet.ts +173 -0
- package/src/utils/sanitize.ts +8 -0
- package/test/cli-wiring.test.ts +25 -0
- package/test/invoice-number.test.ts +45 -0
- package/test/timesheet.test.ts +104 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
export interface FreelancerProfile {
|
|
2
|
+
name?: string;
|
|
3
|
+
email?: string;
|
|
4
|
+
address?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ClientConfig {
|
|
8
|
+
name: string;
|
|
9
|
+
email?: string;
|
|
10
|
+
address?: string;
|
|
11
|
+
currency?: string;
|
|
12
|
+
defaultRate: number;
|
|
13
|
+
payPeriodType?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Config {
|
|
17
|
+
client: ClientConfig[];
|
|
18
|
+
defaultDescription?: string;
|
|
19
|
+
from: FreelancerProfile;
|
|
20
|
+
invoicePrefix?: string;
|
|
21
|
+
note?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ClientHistory {
|
|
25
|
+
lastHours: number;
|
|
26
|
+
lastRate: number;
|
|
27
|
+
seqByMonth: Record<string, number>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LastState {
|
|
31
|
+
clients: Record<string, ClientHistory>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TimesheetEntry {
|
|
35
|
+
id: number;
|
|
36
|
+
client: string;
|
|
37
|
+
date: string;
|
|
38
|
+
hours: number;
|
|
39
|
+
description: string;
|
|
40
|
+
rate: number;
|
|
41
|
+
amount: number;
|
|
42
|
+
createdAt?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TimesheetData {
|
|
46
|
+
entries: TimesheetEntry[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ArchivedEntry extends TimesheetEntry {
|
|
50
|
+
archivedAt: string;
|
|
51
|
+
archivedReason: string;
|
|
52
|
+
invoiceNumber: string;
|
|
53
|
+
invoiceMonth: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ArchiveData {
|
|
57
|
+
archivedEntries: ArchivedEntry[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AggregatedTimesheetGroup {
|
|
61
|
+
description: string;
|
|
62
|
+
hours: number;
|
|
63
|
+
rate: number;
|
|
64
|
+
amount: number;
|
|
65
|
+
periodFrom: string;
|
|
66
|
+
periodTo: string;
|
|
67
|
+
rateVariance: boolean;
|
|
68
|
+
minRate: number;
|
|
69
|
+
maxRate: number;
|
|
70
|
+
entryIds: number[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface LineItem {
|
|
74
|
+
description: string;
|
|
75
|
+
hours: number;
|
|
76
|
+
rate: number;
|
|
77
|
+
amount: number;
|
|
78
|
+
periodFrom?: string;
|
|
79
|
+
periodTo?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface GenerateOptions {
|
|
83
|
+
fromTimesheet?: boolean;
|
|
84
|
+
client?: string;
|
|
85
|
+
month?: string;
|
|
86
|
+
hours?: string;
|
|
87
|
+
rate?: string;
|
|
88
|
+
desc?: string;
|
|
89
|
+
invoice?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ClientsOptions {
|
|
93
|
+
list?: boolean;
|
|
94
|
+
add?: boolean;
|
|
95
|
+
edit?: string;
|
|
96
|
+
delete?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface TimesheetOptions {
|
|
100
|
+
export?: string;
|
|
101
|
+
client?: string;
|
|
102
|
+
month?: string;
|
|
103
|
+
archive?: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface InvoicePayload {
|
|
107
|
+
from: FreelancerProfile;
|
|
108
|
+
client: ClientConfig;
|
|
109
|
+
currency: string;
|
|
110
|
+
invoiceNumber: string;
|
|
111
|
+
invoiceDate: string;
|
|
112
|
+
periodFrom: string;
|
|
113
|
+
periodTo: string;
|
|
114
|
+
lineItems: LineItem[];
|
|
115
|
+
paymentTerms?: string;
|
|
116
|
+
note?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface SendInvoiceEmailInput {
|
|
120
|
+
outPath: string;
|
|
121
|
+
invoiceNumber: string;
|
|
122
|
+
clientName: string;
|
|
123
|
+
clientEmail: string;
|
|
124
|
+
currency: string;
|
|
125
|
+
total: number;
|
|
126
|
+
periodFrom: string;
|
|
127
|
+
periodTo: string;
|
|
128
|
+
senderName?: string;
|
|
129
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import nodemailer from "nodemailer";
|
|
3
|
+
import { type SendInvoiceEmailInput } from "../domain/types.js";
|
|
4
|
+
|
|
5
|
+
export function hasSmtpConfig(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
6
|
+
return Boolean(env.SMTP_HOST && env.SMTP_USER && env.SMTP_PASS);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function sendInvoiceEmail(
|
|
10
|
+
input: SendInvoiceEmailInput,
|
|
11
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
const smtpPort = Number(env.SMTP_PORT || 465);
|
|
14
|
+
|
|
15
|
+
const transporter = nodemailer.createTransport({
|
|
16
|
+
host: env.SMTP_HOST,
|
|
17
|
+
port: smtpPort,
|
|
18
|
+
secure: smtpPort === 465,
|
|
19
|
+
auth: {
|
|
20
|
+
user: env.SMTP_USER,
|
|
21
|
+
pass: env.SMTP_PASS,
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const emailSubject = `Invoice ${input.invoiceNumber} - ${input.clientName}`;
|
|
26
|
+
const emailBody = `Dear ${input.clientName},\n\nPlease find attached invoice ${input.invoiceNumber}.\n\nInvoice Details:\n- Period: ${input.periodFrom} to ${input.periodTo}\n- Amount Due: ${input.currency} ${input.total.toFixed(2)}\n\nThank you for your business!\n\nBest regards,\n${input.senderName || ""}`;
|
|
27
|
+
|
|
28
|
+
await transporter.sendMail({
|
|
29
|
+
from: env.INVOICE_FROM || env.SMTP_USER,
|
|
30
|
+
to: input.clientEmail,
|
|
31
|
+
subject: emailSubject,
|
|
32
|
+
text: emailBody,
|
|
33
|
+
attachments: [
|
|
34
|
+
{
|
|
35
|
+
filename: path.basename(input.outPath),
|
|
36
|
+
path: input.outPath,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export function getDefaultMonth(now = new Date()): string {
|
|
2
|
+
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function validateMonth(month: string): boolean {
|
|
6
|
+
return /^\d{4}-\d{2}$/.test(month);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function resolveInvoiceNumber(params: {
|
|
10
|
+
month: string;
|
|
11
|
+
invoicePrefix?: string;
|
|
12
|
+
overrideInvoice?: string;
|
|
13
|
+
seqByMonth: Record<string, number>;
|
|
14
|
+
}): string {
|
|
15
|
+
const { month, invoicePrefix, overrideInvoice, seqByMonth } = params;
|
|
16
|
+
const trimmedOverride = overrideInvoice?.trim();
|
|
17
|
+
|
|
18
|
+
if (trimmedOverride) {
|
|
19
|
+
return trimmedOverride;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!seqByMonth[month]) {
|
|
23
|
+
seqByMonth[month] = 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
seqByMonth[month] += 1;
|
|
27
|
+
const yyyymm = month.replace("-", "");
|
|
28
|
+
const prefix = invoicePrefix?.trim() || "INV";
|
|
29
|
+
|
|
30
|
+
return `${prefix}-${yyyymm}-${String(seqByMonth[month]).padStart(3, "0")}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getInvoicePeriod(month: string): { periodFrom: string; periodTo: string } {
|
|
34
|
+
const year = Number(month.slice(0, 4));
|
|
35
|
+
const monthNumber = Number(month.slice(5, 7));
|
|
36
|
+
const monthEnd = new Date(year, monthNumber, 0);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
periodFrom: `${month}-01`,
|
|
40
|
+
periodTo: `${month}-${String(monthEnd.getDate()).padStart(2, "0")}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getInvoiceDate(today = new Date()): string {
|
|
45
|
+
return today.toISOString().slice(0, 10);
|
|
46
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import puppeteer from "puppeteer";
|
|
5
|
+
import { type ProjectPaths } from "../core/paths.js";
|
|
6
|
+
import { type InvoicePayload } from "../domain/types.js";
|
|
7
|
+
import { sanitizePathSegment } from "../utils/sanitize.js";
|
|
8
|
+
|
|
9
|
+
export async function renderInvoicePdf(
|
|
10
|
+
paths: ProjectPaths,
|
|
11
|
+
payload: InvoicePayload,
|
|
12
|
+
month: string,
|
|
13
|
+
): Promise<string> {
|
|
14
|
+
const clientOutDir = path.join(paths.outDir, sanitizePathSegment(payload.client.name, "client"));
|
|
15
|
+
fs.mkdirSync(clientOutDir, { recursive: true });
|
|
16
|
+
|
|
17
|
+
const fileUrl = pathToFileURL(paths.indexHtmlPath).toString();
|
|
18
|
+
|
|
19
|
+
const browser = await puppeteer.launch({ headless: true });
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const page = await browser.newPage();
|
|
23
|
+
await page.goto(fileUrl, { waitUntil: "networkidle0" });
|
|
24
|
+
|
|
25
|
+
await page.evaluate((browserPayload) => {
|
|
26
|
+
const setVal = (id: string, value: string | number | undefined) => {
|
|
27
|
+
const el = document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement | null;
|
|
28
|
+
if (!el) return;
|
|
29
|
+
el.value = String(value ?? "");
|
|
30
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
31
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
setVal("in_freelancer", browserPayload.from.name);
|
|
35
|
+
setVal("in_email", browserPayload.from.email);
|
|
36
|
+
setVal("in_freelancer_address", browserPayload.from.address);
|
|
37
|
+
|
|
38
|
+
setVal("in_client", browserPayload.client.name);
|
|
39
|
+
setVal("in_client_address", browserPayload.client.address);
|
|
40
|
+
|
|
41
|
+
setVal("in_currency", browserPayload.currency);
|
|
42
|
+
setVal("in_number", browserPayload.invoiceNumber);
|
|
43
|
+
setVal("in_date", browserPayload.invoiceDate);
|
|
44
|
+
|
|
45
|
+
setVal("in_payment_terms", browserPayload.paymentTerms || "");
|
|
46
|
+
setVal("in_note", browserPayload.note || "");
|
|
47
|
+
|
|
48
|
+
type InvoicerWindow = Window & {
|
|
49
|
+
__invoicer?: {
|
|
50
|
+
state?: { lineItems: Array<Record<string, unknown>> };
|
|
51
|
+
builder?: { renderLineItemsUI: () => void; buildPreview: () => void };
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const win = window as InvoicerWindow;
|
|
56
|
+
|
|
57
|
+
if (win.__invoicer?.state && win.__invoicer.builder) {
|
|
58
|
+
win.__invoicer.state.lineItems = browserPayload.lineItems.map((item) => ({
|
|
59
|
+
description: item.description,
|
|
60
|
+
periodFrom: browserPayload.periodFrom,
|
|
61
|
+
periodTo: browserPayload.periodTo,
|
|
62
|
+
days: "",
|
|
63
|
+
hoursPerDay: "",
|
|
64
|
+
quantity: String(item.hours),
|
|
65
|
+
rate: Number(item.rate),
|
|
66
|
+
amount: Number(item.amount),
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
win.__invoicer.builder.renderLineItemsUI();
|
|
70
|
+
win.__invoicer.builder.buildPreview();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const preview = document.querySelector("#preview") as HTMLElement | null;
|
|
74
|
+
if (preview) {
|
|
75
|
+
preview.style.display = "block";
|
|
76
|
+
document.body.innerHTML = "";
|
|
77
|
+
document.body.appendChild(preview);
|
|
78
|
+
}
|
|
79
|
+
}, payload);
|
|
80
|
+
|
|
81
|
+
const safeInvoiceNumber = sanitizePathSegment(payload.invoiceNumber, "invoice");
|
|
82
|
+
const outPath = path.join(clientOutDir, `invoice-${month}-${safeInvoiceNumber}.pdf`);
|
|
83
|
+
|
|
84
|
+
await page.pdf({
|
|
85
|
+
path: outPath,
|
|
86
|
+
format: "A4",
|
|
87
|
+
printBackground: true,
|
|
88
|
+
margin: { top: "12mm", right: "12mm", bottom: "12mm", left: "12mm" },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return outPath;
|
|
92
|
+
} finally {
|
|
93
|
+
await browser.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { loadJsonFile, writeJsonFile } from "../core/json-store.js";
|
|
2
|
+
import { type ProjectPaths } from "../core/paths.js";
|
|
3
|
+
import { validateArchiveData, validateTimesheetData } from "../core/validators.js";
|
|
4
|
+
import {
|
|
5
|
+
type AggregatedTimesheetGroup,
|
|
6
|
+
type ArchivedEntry,
|
|
7
|
+
type ArchiveData,
|
|
8
|
+
type TimesheetData,
|
|
9
|
+
type TimesheetEntry,
|
|
10
|
+
} from "../domain/types.js";
|
|
11
|
+
|
|
12
|
+
function round2(value: number): number {
|
|
13
|
+
return Math.round(value * 100) / 100;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readTimesheetEntries(
|
|
17
|
+
paths: ProjectPaths,
|
|
18
|
+
filters: { client?: string; month?: string } = {},
|
|
19
|
+
): TimesheetEntry[] {
|
|
20
|
+
const data = loadJsonFile<TimesheetData>(
|
|
21
|
+
paths.timesheetPath,
|
|
22
|
+
{ entries: [] },
|
|
23
|
+
validateTimesheetData,
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
return data.entries.filter((entry) => {
|
|
27
|
+
const matchesClient = !filters.client || entry.client === filters.client;
|
|
28
|
+
const matchesMonth = !filters.month || entry.date.startsWith(filters.month);
|
|
29
|
+
return matchesClient && matchesMonth;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readArchivedEntries(
|
|
34
|
+
paths: ProjectPaths,
|
|
35
|
+
filters: { client?: string; month?: string } = {},
|
|
36
|
+
): ArchivedEntry[] {
|
|
37
|
+
const data = loadJsonFile<ArchiveData>(
|
|
38
|
+
paths.archivePath,
|
|
39
|
+
{ archivedEntries: [] },
|
|
40
|
+
validateArchiveData,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return data.archivedEntries.filter((entry) => {
|
|
44
|
+
const matchesClient = !filters.client || entry.client === filters.client;
|
|
45
|
+
const matchesMonth = !filters.month || entry.date.startsWith(filters.month);
|
|
46
|
+
return matchesClient && matchesMonth;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function aggregateEntriesByDescription(entries: TimesheetEntry[]): AggregatedTimesheetGroup[] {
|
|
51
|
+
const groups: Record<
|
|
52
|
+
string,
|
|
53
|
+
{
|
|
54
|
+
description: string;
|
|
55
|
+
entries: TimesheetEntry[];
|
|
56
|
+
totalHours: number;
|
|
57
|
+
totalAmount: number;
|
|
58
|
+
rates: number[];
|
|
59
|
+
dates: string[];
|
|
60
|
+
}
|
|
61
|
+
> = {};
|
|
62
|
+
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
const key = entry.description || "Unnamed Service";
|
|
65
|
+
if (!groups[key]) {
|
|
66
|
+
groups[key] = {
|
|
67
|
+
description: key,
|
|
68
|
+
entries: [],
|
|
69
|
+
totalHours: 0,
|
|
70
|
+
totalAmount: 0,
|
|
71
|
+
rates: [],
|
|
72
|
+
dates: [],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
groups[key].entries.push(entry);
|
|
77
|
+
groups[key].totalHours += entry.hours;
|
|
78
|
+
groups[key].totalAmount += entry.amount;
|
|
79
|
+
groups[key].rates.push(entry.rate);
|
|
80
|
+
groups[key].dates.push(entry.date);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return Object.values(groups).map((group) => {
|
|
84
|
+
const avgRate = group.totalHours === 0 ? 0 : group.totalAmount / group.totalHours;
|
|
85
|
+
const minRate = Math.min(...group.rates);
|
|
86
|
+
const maxRate = Math.max(...group.rates);
|
|
87
|
+
const dates = [...group.dates].sort();
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
description: group.description,
|
|
91
|
+
hours: round2(group.totalHours),
|
|
92
|
+
rate: round2(avgRate),
|
|
93
|
+
amount: round2(group.totalAmount),
|
|
94
|
+
periodFrom: dates[0],
|
|
95
|
+
periodTo: dates[dates.length - 1],
|
|
96
|
+
rateVariance: minRate !== maxRate,
|
|
97
|
+
minRate,
|
|
98
|
+
maxRate,
|
|
99
|
+
entryIds: group.entries.map((entry) => entry.id),
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function archiveTimesheetEntries(
|
|
105
|
+
paths: ProjectPaths,
|
|
106
|
+
entryIds: number[],
|
|
107
|
+
invoiceNumber: string,
|
|
108
|
+
invoiceMonth: string,
|
|
109
|
+
): number {
|
|
110
|
+
const entryIdSet = new Set(entryIds);
|
|
111
|
+
const timesheetData = loadJsonFile<TimesheetData>(
|
|
112
|
+
paths.timesheetPath,
|
|
113
|
+
{ entries: [] },
|
|
114
|
+
validateTimesheetData,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const archiveData = loadJsonFile<ArchiveData>(
|
|
118
|
+
paths.archivePath,
|
|
119
|
+
{ archivedEntries: [] },
|
|
120
|
+
validateArchiveData,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const archivedAt = new Date().toISOString();
|
|
124
|
+
const moved: ArchivedEntry[] = [];
|
|
125
|
+
|
|
126
|
+
timesheetData.entries = timesheetData.entries.filter((entry) => {
|
|
127
|
+
if (!entryIdSet.has(entry.id)) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
moved.push({
|
|
132
|
+
...entry,
|
|
133
|
+
archivedAt,
|
|
134
|
+
archivedReason: "invoiced",
|
|
135
|
+
invoiceNumber,
|
|
136
|
+
invoiceMonth,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return false;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (moved.length > 0) {
|
|
143
|
+
archiveData.archivedEntries.push(...moved);
|
|
144
|
+
writeJsonFile(paths.timesheetPath, timesheetData);
|
|
145
|
+
writeJsonFile(paths.archivePath, archiveData);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return moved.length;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function toTimesheetCsv(entries: TimesheetEntry[]): string {
|
|
152
|
+
return ["date,client,description,hours,rate,amount"]
|
|
153
|
+
.concat(
|
|
154
|
+
entries.map(
|
|
155
|
+
(entry) =>
|
|
156
|
+
`${entry.date},${entry.client},"${entry.description}",${entry.hours},${entry.rate},${entry.amount}`,
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
.join("\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function toArchiveCsv(entries: ArchivedEntry[]): string {
|
|
163
|
+
return [
|
|
164
|
+
"date,client,description,hours,rate,amount,invoiceNumber,invoiceMonth,archivedAt",
|
|
165
|
+
]
|
|
166
|
+
.concat(
|
|
167
|
+
entries.map(
|
|
168
|
+
(entry) =>
|
|
169
|
+
`${entry.date},${entry.client},"${entry.description}",${entry.hours},${entry.rate},${entry.amount},${entry.invoiceNumber},${entry.invoiceMonth},${entry.archivedAt}`,
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
.join("\n");
|
|
173
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createProgram } from "../dist/cli.js";
|
|
4
|
+
|
|
5
|
+
test("CLI registers expected commands and options", () => {
|
|
6
|
+
const program = createProgram();
|
|
7
|
+
|
|
8
|
+
const commandNames = program.commands.map((command) => command.name());
|
|
9
|
+
assert.deepEqual(commandNames, ["generate", "clients", "timesheet"]);
|
|
10
|
+
assert.equal((program as unknown as { _defaultCommandName?: string })._defaultCommandName, "generate");
|
|
11
|
+
|
|
12
|
+
const generate = program.commands.find((command) => command.name() === "generate");
|
|
13
|
+
assert.ok(generate);
|
|
14
|
+
|
|
15
|
+
const generateOptionFlags = generate!.options.map((option) => option.long);
|
|
16
|
+
assert.deepEqual(generateOptionFlags, [
|
|
17
|
+
"--from-timesheet",
|
|
18
|
+
"--client",
|
|
19
|
+
"--month",
|
|
20
|
+
"--hours",
|
|
21
|
+
"--rate",
|
|
22
|
+
"--desc",
|
|
23
|
+
"--invoice",
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
getInvoicePeriod,
|
|
5
|
+
resolveInvoiceNumber,
|
|
6
|
+
validateMonth,
|
|
7
|
+
} from "../dist/services/invoice-number.js";
|
|
8
|
+
|
|
9
|
+
test("resolveInvoiceNumber increments sequence per month", () => {
|
|
10
|
+
const seqByMonth: Record<string, number> = { "2026-02": 4 };
|
|
11
|
+
|
|
12
|
+
const invoiceNumber = resolveInvoiceNumber({
|
|
13
|
+
month: "2026-02",
|
|
14
|
+
invoicePrefix: "INV",
|
|
15
|
+
seqByMonth,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
assert.equal(invoiceNumber, "INV-202602-005");
|
|
19
|
+
assert.equal(seqByMonth["2026-02"], 5);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("resolveInvoiceNumber keeps override unchanged", () => {
|
|
23
|
+
const seqByMonth: Record<string, number> = { "2026-02": 4 };
|
|
24
|
+
|
|
25
|
+
const invoiceNumber = resolveInvoiceNumber({
|
|
26
|
+
month: "2026-02",
|
|
27
|
+
invoicePrefix: "INV",
|
|
28
|
+
overrideInvoice: "INV-CUSTOM-001",
|
|
29
|
+
seqByMonth,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
assert.equal(invoiceNumber, "INV-CUSTOM-001");
|
|
33
|
+
assert.equal(seqByMonth["2026-02"], 4);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("getInvoicePeriod returns month boundaries", () => {
|
|
37
|
+
const period = getInvoicePeriod("2026-02");
|
|
38
|
+
assert.deepEqual(period, { periodFrom: "2026-02-01", periodTo: "2026-02-28" });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("validateMonth accepts YYYY-MM only", () => {
|
|
42
|
+
assert.equal(validateMonth("2026-02"), true);
|
|
43
|
+
assert.equal(validateMonth("2026-2"), false);
|
|
44
|
+
assert.equal(validateMonth("02-2026"), false);
|
|
45
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import {
|
|
7
|
+
aggregateEntriesByDescription,
|
|
8
|
+
archiveTimesheetEntries,
|
|
9
|
+
readArchivedEntries,
|
|
10
|
+
readTimesheetEntries,
|
|
11
|
+
} from "../dist/services/timesheet.js";
|
|
12
|
+
|
|
13
|
+
function createTestPaths(root: string) {
|
|
14
|
+
return {
|
|
15
|
+
projectRoot: root,
|
|
16
|
+
configPath: path.join(root, "config.json"),
|
|
17
|
+
lastPath: path.join(root, "data", "last.json"),
|
|
18
|
+
timesheetPath: path.join(root, "data", "timesheet.json"),
|
|
19
|
+
archivePath: path.join(root, "data", "archive.json"),
|
|
20
|
+
outDir: path.join(root, "out"),
|
|
21
|
+
indexHtmlPath: path.join(root, "index.html"),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("aggregateEntriesByDescription groups and computes weighted rate", () => {
|
|
26
|
+
const grouped = aggregateEntriesByDescription([
|
|
27
|
+
{
|
|
28
|
+
id: 1,
|
|
29
|
+
client: "ACME",
|
|
30
|
+
date: "2026-02-01",
|
|
31
|
+
hours: 1,
|
|
32
|
+
description: "Development",
|
|
33
|
+
rate: 40,
|
|
34
|
+
amount: 40,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 2,
|
|
38
|
+
client: "ACME",
|
|
39
|
+
date: "2026-02-02",
|
|
40
|
+
hours: 3,
|
|
41
|
+
description: "Development",
|
|
42
|
+
rate: 60,
|
|
43
|
+
amount: 180,
|
|
44
|
+
},
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
assert.equal(grouped.length, 1);
|
|
48
|
+
assert.equal(grouped[0]?.description, "Development");
|
|
49
|
+
assert.equal(grouped[0]?.hours, 4);
|
|
50
|
+
assert.equal(grouped[0]?.rate, 55);
|
|
51
|
+
assert.equal(grouped[0]?.amount, 220);
|
|
52
|
+
assert.equal(grouped[0]?.rateVariance, true);
|
|
53
|
+
assert.deepEqual(grouped[0]?.entryIds, [1, 2]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("archiveTimesheetEntries moves entries from active sheet to archive", () => {
|
|
57
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "invoicer-test-"));
|
|
58
|
+
const paths = createTestPaths(root);
|
|
59
|
+
|
|
60
|
+
fs.mkdirSync(path.dirname(paths.timesheetPath), { recursive: true });
|
|
61
|
+
fs.writeFileSync(
|
|
62
|
+
paths.timesheetPath,
|
|
63
|
+
JSON.stringify(
|
|
64
|
+
{
|
|
65
|
+
entries: [
|
|
66
|
+
{
|
|
67
|
+
id: 10,
|
|
68
|
+
client: "ACME",
|
|
69
|
+
date: "2026-02-10",
|
|
70
|
+
hours: 2,
|
|
71
|
+
description: "Dev",
|
|
72
|
+
rate: 50,
|
|
73
|
+
amount: 100,
|
|
74
|
+
createdAt: "2026-02-10T12:00:00.000Z",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 11,
|
|
78
|
+
client: "ACME",
|
|
79
|
+
date: "2026-02-11",
|
|
80
|
+
hours: 1,
|
|
81
|
+
description: "Meeting",
|
|
82
|
+
rate: 50,
|
|
83
|
+
amount: 50,
|
|
84
|
+
createdAt: "2026-02-11T12:00:00.000Z",
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
null,
|
|
89
|
+
2,
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const movedCount = archiveTimesheetEntries(paths, [10], "INV-202602-001", "2026-02");
|
|
94
|
+
assert.equal(movedCount, 1);
|
|
95
|
+
|
|
96
|
+
const remaining = readTimesheetEntries(paths, { client: "ACME", month: "2026-02" });
|
|
97
|
+
assert.equal(remaining.length, 1);
|
|
98
|
+
assert.equal(remaining[0]?.id, 11);
|
|
99
|
+
|
|
100
|
+
const archived = readArchivedEntries(paths, { client: "ACME", month: "2026-02" });
|
|
101
|
+
assert.equal(archived.length, 1);
|
|
102
|
+
assert.equal(archived[0]?.id, 10);
|
|
103
|
+
assert.equal(archived[0]?.invoiceNumber, "INV-202602-001");
|
|
104
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"rootDir": "src",
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"allowSyntheticDefaultImports": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"sourceMap": true,
|
|
13
|
+
"types": ["node"]
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*.ts"],
|
|
16
|
+
"exclude": ["dist", "node_modules"]
|
|
17
|
+
}
|