@invoicer/cli 1.1.1 → 1.1.2
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/package.json +6 -1
- package/.env.example +0 -15
- package/config.json +0 -19
- package/data/last.json +0 -11
- package/index.html +0 -2584
- package/logo.svg +0 -17
- package/src/cli.ts +0 -44
- package/src/commands/clients.ts +0 -221
- package/src/commands/generate.ts +0 -379
- package/src/commands/timesheet.ts +0 -47
- package/src/core/json-store.ts +0 -33
- package/src/core/paths.ts +0 -42
- package/src/core/validators.ts +0 -168
- package/src/domain/types.ts +0 -129
- package/src/services/email.ts +0 -40
- package/src/services/invoice-number.ts +0 -46
- package/src/services/pdf-render.ts +0 -95
- package/src/services/timesheet.ts +0 -173
- package/src/utils/sanitize.ts +0 -8
- package/test/cli-wiring.test.ts +0 -25
- package/test/invoice-number.test.ts +0 -45
- package/test/timesheet.test.ts +0 -104
- package/tsconfig.json +0 -17
|
@@ -1,173 +0,0 @@
|
|
|
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
|
-
}
|
package/src/utils/sanitize.ts
DELETED
package/test/cli-wiring.test.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,45 +0,0 @@
|
|
|
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
|
-
});
|
package/test/timesheet.test.ts
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
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
|
-
}
|