@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.
Files changed (50) hide show
  1. package/.env.example +15 -0
  2. package/config.json +19 -0
  3. package/data/last.json +11 -0
  4. package/dist/cli.js +38 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/clients.js +184 -0
  7. package/dist/commands/clients.js.map +1 -0
  8. package/dist/commands/generate.js +294 -0
  9. package/dist/commands/generate.js.map +1 -0
  10. package/dist/commands/timesheet.js +34 -0
  11. package/dist/commands/timesheet.js.map +1 -0
  12. package/dist/core/json-store.js +27 -0
  13. package/dist/core/json-store.js.map +1 -0
  14. package/dist/core/paths.js +29 -0
  15. package/dist/core/paths.js.map +1 -0
  16. package/dist/core/validators.js +131 -0
  17. package/dist/core/validators.js.map +1 -0
  18. package/dist/domain/types.js +2 -0
  19. package/dist/domain/types.js.map +1 -0
  20. package/dist/services/email.js +32 -0
  21. package/dist/services/email.js.map +1 -0
  22. package/dist/services/invoice-number.js +33 -0
  23. package/dist/services/invoice-number.js.map +1 -0
  24. package/dist/services/pdf-render.js +69 -0
  25. package/dist/services/pdf-render.js.map +1 -0
  26. package/dist/services/timesheet.js +99 -0
  27. package/dist/services/timesheet.js.map +1 -0
  28. package/dist/utils/sanitize.js +8 -0
  29. package/dist/utils/sanitize.js.map +1 -0
  30. package/index.html +2584 -0
  31. package/logo.svg +17 -0
  32. package/package.json +39 -0
  33. package/scripts/invoice.mjs +23 -0
  34. package/src/cli.ts +44 -0
  35. package/src/commands/clients.ts +221 -0
  36. package/src/commands/generate.ts +379 -0
  37. package/src/commands/timesheet.ts +47 -0
  38. package/src/core/json-store.ts +33 -0
  39. package/src/core/paths.ts +42 -0
  40. package/src/core/validators.ts +168 -0
  41. package/src/domain/types.ts +129 -0
  42. package/src/services/email.ts +40 -0
  43. package/src/services/invoice-number.ts +46 -0
  44. package/src/services/pdf-render.ts +95 -0
  45. package/src/services/timesheet.ts +173 -0
  46. package/src/utils/sanitize.ts +8 -0
  47. package/test/cli-wiring.test.ts +25 -0
  48. package/test/invoice-number.test.ts +45 -0
  49. package/test/timesheet.test.ts +104 -0
  50. package/tsconfig.json +17 -0
@@ -0,0 +1,379 @@
1
+ import fs from "node:fs";
2
+ import inquirer from "inquirer";
3
+ import type { Command } from "commander";
4
+ import { loadJsonFile, writeJsonFile } from "../core/json-store.js";
5
+ import { type ProjectPaths } from "../core/paths.js";
6
+ import { validateConfig, validateLastState } from "../core/validators.js";
7
+ import { type Config, type GenerateOptions, type LastState, type LineItem } from "../domain/types.js";
8
+ import {
9
+ getDefaultMonth,
10
+ getInvoiceDate,
11
+ getInvoicePeriod,
12
+ resolveInvoiceNumber,
13
+ validateMonth,
14
+ } from "../services/invoice-number.js";
15
+ import { hasSmtpConfig, sendInvoiceEmail } from "../services/email.js";
16
+ import { renderInvoicePdf } from "../services/pdf-render.js";
17
+ import {
18
+ aggregateEntriesByDescription,
19
+ archiveTimesheetEntries,
20
+ readTimesheetEntries,
21
+ } from "../services/timesheet.js";
22
+
23
+ function exitWithError(message: string): never {
24
+ console.error(message);
25
+ process.exit(1);
26
+ }
27
+
28
+ function loadConfig(paths: ProjectPaths): Config {
29
+ return loadJsonFile(paths.configPath, { client: [], from: {} }, validateConfig);
30
+ }
31
+
32
+ function loadLastState(paths: ProjectPaths): LastState {
33
+ return loadJsonFile(paths.lastPath, { clients: {} }, validateLastState);
34
+ }
35
+
36
+ function getCurrency(cfgCurrency?: string): string {
37
+ return cfgCurrency?.trim() || "USD";
38
+ }
39
+
40
+ async function selectClient(cfg: Config, preferredClientName?: string) {
41
+ if (preferredClientName) {
42
+ const matched = cfg.client.find((client) => client.name === preferredClientName);
43
+ if (!matched) {
44
+ exitWithError(`❌ Client "${preferredClientName}" not found in config.json`);
45
+ }
46
+ console.log(`📋 Using client: ${matched.name}`);
47
+ return matched;
48
+ }
49
+
50
+ if (cfg.client.length === 1) {
51
+ const [single] = cfg.client;
52
+ console.log(`📋 Using client: ${single.name}`);
53
+ return single;
54
+ }
55
+
56
+ const clientChoices = cfg.client.map((client, index) => ({
57
+ name: `${client.name} (${getCurrency(client.currency)} ${client.defaultRate}/hr)`,
58
+ value: index,
59
+ }));
60
+
61
+ const answer = await inquirer.prompt<{ clientIndex: number }>([
62
+ {
63
+ type: "list",
64
+ name: "clientIndex",
65
+ message: "Select client:",
66
+ choices: clientChoices,
67
+ },
68
+ ]);
69
+
70
+ return cfg.client[answer.clientIndex];
71
+ }
72
+
73
+ async function collectManualLineItems(params: {
74
+ options: GenerateOptions;
75
+ cfg: Config;
76
+ currency: string;
77
+ lastHours: number;
78
+ lastRate: number;
79
+ defaultRate: number;
80
+ }): Promise<LineItem[]> {
81
+ const items: LineItem[] = [];
82
+ let addingItems = true;
83
+ let itemIndex = 1;
84
+
85
+ while (addingItems) {
86
+ const answer = await inquirer.prompt<{
87
+ description: string;
88
+ hours: number;
89
+ rate: number;
90
+ }>([
91
+ {
92
+ type: "input",
93
+ name: "description",
94
+ message: `Description (Item ${itemIndex}):`,
95
+ default:
96
+ itemIndex === 1
97
+ ? (params.options.desc || params.cfg.defaultDescription || "Services")
98
+ : "",
99
+ },
100
+ {
101
+ type: "number",
102
+ name: "hours",
103
+ message: "Hours:",
104
+ default:
105
+ itemIndex === 1
106
+ ? (params.options.hours ? Number(params.options.hours) : Number(params.lastHours ?? 160))
107
+ : undefined,
108
+ },
109
+ {
110
+ type: "number",
111
+ name: "rate",
112
+ message: "Rate per hour:",
113
+ default:
114
+ itemIndex === 1
115
+ ? (params.options.rate ? Number(params.options.rate) : Number(params.lastRate ?? params.defaultRate))
116
+ : params.defaultRate,
117
+ },
118
+ ]);
119
+
120
+ const hours = Number(answer.hours);
121
+ const rate = Number(answer.rate);
122
+
123
+ if (!Number.isFinite(hours) || !Number.isFinite(rate)) {
124
+ exitWithError("❌ Hours and rate must be valid numbers");
125
+ }
126
+
127
+ const amount = hours * rate;
128
+
129
+ items.push({
130
+ description: answer.description,
131
+ hours,
132
+ rate,
133
+ amount,
134
+ });
135
+
136
+ console.log(
137
+ ` ✓ ${answer.description}: ${hours}h × ${params.currency}${rate} = ${params.currency}${amount.toFixed(2)}`,
138
+ );
139
+
140
+ const addAnotherAnswer = await inquirer.prompt<{ addAnother: boolean }>([
141
+ {
142
+ type: "confirm",
143
+ name: "addAnother",
144
+ message: "Add another line item?",
145
+ default: false,
146
+ },
147
+ ]);
148
+
149
+ addingItems = addAnotherAnswer.addAnother;
150
+ itemIndex += 1;
151
+ }
152
+
153
+ return items;
154
+ }
155
+
156
+ export async function handleGenerateCommand(options: GenerateOptions, paths: ProjectPaths): Promise<void> {
157
+ fs.mkdirSync(paths.outDir, { recursive: true });
158
+
159
+ if (!fs.existsSync(paths.configPath)) {
160
+ console.error("❌ Missing config.json. Create it first (see README).");
161
+ console.error(` Looked in: ${paths.projectRoot}`);
162
+ process.exit(1);
163
+ }
164
+
165
+ const cfg = loadConfig(paths);
166
+ const last = loadLastState(paths);
167
+
168
+ if (cfg.client.length === 0) {
169
+ exitWithError("❌ No clients found in config.json");
170
+ }
171
+
172
+ const selectedClient = await selectClient(cfg, options.client);
173
+
174
+ const clientKey = selectedClient.name;
175
+ const clientHistory = last.clients[clientKey] || {
176
+ lastHours: 160,
177
+ lastRate: selectedClient.defaultRate ?? 0,
178
+ seqByMonth: {},
179
+ };
180
+
181
+ const month = options.month || getDefaultMonth();
182
+
183
+ if (!validateMonth(month)) {
184
+ exitWithError("❌ Month must be in YYYY-MM format");
185
+ }
186
+
187
+ let lineItems: LineItem[] = [];
188
+ let entryIdsToArchive: number[] = [];
189
+
190
+ if (options.fromTimesheet) {
191
+ console.log(`\n🔍 Reading timesheet for ${selectedClient.name} (${month})...`);
192
+ const entries = readTimesheetEntries(paths, { client: selectedClient.name, month });
193
+
194
+ if (entries.length === 0) {
195
+ console.log(" No unbilled entries found for this period.");
196
+ console.log(" Falling back to manual entry mode.\n");
197
+ } else {
198
+ console.log(` Found ${entries.length} unbilled entries.\n`);
199
+
200
+ const aggregated = aggregateEntriesByDescription(entries);
201
+
202
+ console.log("📊 Aggregated by service/project:\n");
203
+ aggregated.forEach((group, index) => {
204
+ console.log(` ${index + 1}. ${group.description}`);
205
+ console.log(` ${group.hours}h × $${group.rate}/hr = $${group.amount}`);
206
+ if (group.rateVariance) {
207
+ console.log(
208
+ ` ⚠️ Rate variance: $${group.minRate}-$${group.maxRate}/hr (using weighted average)`,
209
+ );
210
+ }
211
+ console.log(` Period: ${group.periodFrom} to ${group.periodTo}`);
212
+ console.log(` Based on ${group.entryIds.length} timesheet entries\n`);
213
+ });
214
+
215
+ const answer = await inquirer.prompt<{ confirmUse: boolean }>([
216
+ {
217
+ type: "confirm",
218
+ name: "confirmUse",
219
+ message: "Use these aggregated line items?",
220
+ default: true,
221
+ },
222
+ ]);
223
+
224
+ if (answer.confirmUse) {
225
+ lineItems = aggregated.map((group) => ({
226
+ description: group.description,
227
+ hours: group.hours,
228
+ rate: group.rate,
229
+ amount: group.amount,
230
+ periodFrom: group.periodFrom,
231
+ periodTo: group.periodTo,
232
+ }));
233
+ entryIdsToArchive = aggregated.flatMap((group) => group.entryIds);
234
+ }
235
+ }
236
+ }
237
+
238
+ if (lineItems.length === 0) {
239
+ lineItems = await collectManualLineItems({
240
+ options,
241
+ cfg,
242
+ currency: getCurrency(selectedClient.currency),
243
+ lastHours: clientHistory.lastHours,
244
+ lastRate: clientHistory.lastRate,
245
+ defaultRate: selectedClient.defaultRate ?? 0,
246
+ });
247
+ }
248
+
249
+ const seqByMonth = { ...(clientHistory.seqByMonth || {}) };
250
+ const invoiceNumber = resolveInvoiceNumber({
251
+ month,
252
+ invoicePrefix: cfg.invoicePrefix,
253
+ overrideInvoice: options.invoice,
254
+ seqByMonth,
255
+ });
256
+
257
+ const invoiceDate = getInvoiceDate();
258
+ const { periodFrom, periodTo } = getInvoicePeriod(month);
259
+
260
+ const currency = getCurrency(selectedClient.currency);
261
+ const total = lineItems.reduce((sum, item) => sum + item.amount, 0);
262
+
263
+ console.log(`\n💰 Total: ${currency}${total.toFixed(2)}\n`);
264
+
265
+ const outPath = await renderInvoicePdf(
266
+ paths,
267
+ {
268
+ from: cfg.from || {},
269
+ client: selectedClient,
270
+ currency,
271
+ invoiceNumber,
272
+ invoiceDate,
273
+ periodFrom,
274
+ periodTo,
275
+ lineItems,
276
+ paymentTerms: selectedClient.payPeriodType,
277
+ note: cfg.note,
278
+ },
279
+ month,
280
+ );
281
+
282
+ if (!last.clients) {
283
+ last.clients = {};
284
+ }
285
+
286
+ const firstLineItem = lineItems[0];
287
+
288
+ last.clients[clientKey] = {
289
+ lastHours: firstLineItem?.hours ?? clientHistory.lastHours,
290
+ lastRate: firstLineItem?.rate ?? clientHistory.lastRate,
291
+ seqByMonth,
292
+ };
293
+
294
+ writeJsonFile(paths.lastPath, last);
295
+
296
+ console.log("✅ Invoice generated:", outPath);
297
+ console.log("Invoice #:", invoiceNumber);
298
+ console.log("Total:", `${currency} ${total.toFixed(2)}`);
299
+
300
+ if (entryIdsToArchive.length > 0) {
301
+ const answer = await inquirer.prompt<{ confirmArchive: boolean }>([
302
+ {
303
+ type: "confirm",
304
+ name: "confirmArchive",
305
+ message: `Archive ${entryIdsToArchive.length} timesheet entries used in this invoice?`,
306
+ default: true,
307
+ },
308
+ ]);
309
+
310
+ if (answer.confirmArchive) {
311
+ const archivedCount = archiveTimesheetEntries(paths, entryIdsToArchive, invoiceNumber, month);
312
+ console.log(`✅ Archived ${archivedCount} entries to data/archive.json`);
313
+ }
314
+ }
315
+
316
+ if (hasSmtpConfig()) {
317
+ const answer = await inquirer.prompt<{ sendEmail: boolean }>([
318
+ {
319
+ type: "confirm",
320
+ name: "sendEmail",
321
+ message: "Send invoice via email?",
322
+ default: true,
323
+ },
324
+ ]);
325
+
326
+ if (answer.sendEmail) {
327
+ const emailAnswer = await inquirer.prompt<{ clientEmail: string }>([
328
+ {
329
+ type: "input",
330
+ name: "clientEmail",
331
+ message: "Client email address:",
332
+ default: selectedClient.email || "",
333
+ validate: (value: string) =>
334
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || "Enter valid email",
335
+ },
336
+ ]);
337
+
338
+ try {
339
+ console.log("\n📧 Sending email...");
340
+
341
+ await sendInvoiceEmail({
342
+ outPath,
343
+ invoiceNumber,
344
+ clientName: selectedClient.name,
345
+ clientEmail: emailAnswer.clientEmail,
346
+ currency,
347
+ total,
348
+ periodFrom,
349
+ periodTo,
350
+ senderName: cfg.from?.name,
351
+ });
352
+
353
+ console.log(`✅ Email sent successfully to ${emailAnswer.clientEmail}`);
354
+ } catch (error) {
355
+ const message = error instanceof Error ? error.message : String(error);
356
+ console.error("❌ Failed to send email:", message);
357
+ console.log(" PDF saved locally at:", outPath);
358
+ }
359
+ }
360
+ } else {
361
+ console.log("\n💡 Tip: Configure SMTP in .env to enable email sending");
362
+ }
363
+ }
364
+
365
+ export function registerGenerateCommand(program: Command, paths: ProjectPaths): void {
366
+ program
367
+ .command("generate", { isDefault: true })
368
+ .description("Generate an invoice from manual entry or timesheet")
369
+ .option("--from-timesheet", "Auto-populate from timesheet entries", false)
370
+ .option("--client <name>", "Client name (auto-selected if only one client)")
371
+ .option("--month <YYYY-MM>", "Invoice month (default: current month)")
372
+ .option("--hours <number>", "Hours for first line item")
373
+ .option("--rate <number>", "Rate for first line item")
374
+ .option("--desc <text>", "Description for first line item")
375
+ .option("--invoice <number>", "Custom invoice number")
376
+ .action(async (options: GenerateOptions) => {
377
+ await handleGenerateCommand(options, paths);
378
+ });
379
+ }
@@ -0,0 +1,47 @@
1
+ import type { Command } from "commander";
2
+ import { type ProjectPaths } from "../core/paths.js";
3
+ import { type TimesheetOptions } from "../domain/types.js";
4
+ import {
5
+ readArchivedEntries,
6
+ readTimesheetEntries,
7
+ toArchiveCsv,
8
+ toTimesheetCsv,
9
+ } from "../services/timesheet.js";
10
+
11
+ export function registerTimesheetCommand(program: Command, paths: ProjectPaths): void {
12
+ program
13
+ .command("timesheet")
14
+ .description("Manage timesheet data")
15
+ .option("--export <format>", "Export timesheet (csv|json)", "")
16
+ .option("--client <name>", "Filter by client")
17
+ .option("--month <YYYY-MM>", "Filter by month")
18
+ .option("--archive", "Show archived entries", false)
19
+ .action(async (options: TimesheetOptions) => {
20
+ if (options.archive) {
21
+ const archived = readArchivedEntries(paths, {
22
+ client: options.client || "",
23
+ month: options.month || "",
24
+ });
25
+
26
+ if (options.export === "csv") {
27
+ console.log(toArchiveCsv(archived));
28
+ process.exit(0);
29
+ }
30
+
31
+ console.log(JSON.stringify(archived, null, 2));
32
+ process.exit(0);
33
+ }
34
+
35
+ const entries = readTimesheetEntries(paths, {
36
+ client: options.client || "",
37
+ month: options.month || "",
38
+ });
39
+
40
+ if (options.export === "csv") {
41
+ console.log(toTimesheetCsv(entries));
42
+ process.exit(0);
43
+ }
44
+
45
+ console.log(JSON.stringify(entries, null, 2));
46
+ });
47
+ }
@@ -0,0 +1,33 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export function readJsonFile(filePath: string): unknown | undefined {
5
+ if (!fs.existsSync(filePath)) {
6
+ return undefined;
7
+ }
8
+
9
+ const raw = fs.readFileSync(filePath, "utf8");
10
+ try {
11
+ return JSON.parse(raw);
12
+ } catch (error) {
13
+ const message = error instanceof Error ? error.message : String(error);
14
+ throw new Error(`Invalid JSON in ${filePath}: ${message}`);
15
+ }
16
+ }
17
+
18
+ export function loadJsonFile<T>(
19
+ filePath: string,
20
+ fallback: T,
21
+ validate: (value: unknown) => T,
22
+ ): T {
23
+ const parsed = readJsonFile(filePath);
24
+ if (parsed === undefined) {
25
+ return fallback;
26
+ }
27
+ return validate(parsed);
28
+ }
29
+
30
+ export function writeJsonFile(filePath: string, value: unknown): void {
31
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
32
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
33
+ }
@@ -0,0 +1,42 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export interface ProjectPaths {
6
+ projectRoot: string;
7
+ configPath: string;
8
+ lastPath: string;
9
+ timesheetPath: string;
10
+ archivePath: string;
11
+ outDir: string;
12
+ indexHtmlPath: string;
13
+ }
14
+
15
+ export function findProjectRoot(startDir = process.cwd()): string | null {
16
+ let currentDir = startDir;
17
+ const root = path.parse(currentDir).root;
18
+
19
+ while (currentDir !== root) {
20
+ const configPath = path.join(currentDir, "config.json");
21
+ if (fs.existsSync(configPath)) {
22
+ return currentDir;
23
+ }
24
+ currentDir = path.dirname(currentDir);
25
+ }
26
+
27
+ return null;
28
+ }
29
+
30
+ export function createProjectPaths(startDir = process.cwd()): ProjectPaths {
31
+ const moduleRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
32
+ const projectRoot = findProjectRoot(startDir) ?? (fs.existsSync(path.join(moduleRoot, "config.json")) ? moduleRoot : process.cwd());
33
+ return {
34
+ projectRoot,
35
+ configPath: path.join(projectRoot, "config.json"),
36
+ lastPath: path.join(projectRoot, "data", "last.json"),
37
+ timesheetPath: path.join(projectRoot, "data", "timesheet.json"),
38
+ archivePath: path.join(projectRoot, "data", "archive.json"),
39
+ outDir: path.join(projectRoot, "out"),
40
+ indexHtmlPath: path.join(projectRoot, "index.html"),
41
+ };
42
+ }
@@ -0,0 +1,168 @@
1
+ import {
2
+ type ArchiveData,
3
+ type ArchivedEntry,
4
+ type ClientConfig,
5
+ type ClientHistory,
6
+ type Config,
7
+ type LastState,
8
+ type TimesheetData,
9
+ type TimesheetEntry,
10
+ } from "../domain/types.js";
11
+
12
+ function ensureObject(value: unknown, label: string): Record<string, unknown> {
13
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
14
+ throw new Error(`${label} must be an object`);
15
+ }
16
+ return value as Record<string, unknown>;
17
+ }
18
+
19
+ function ensureString(value: unknown, label: string, fallback = ""): string {
20
+ if (value === undefined || value === null) {
21
+ return fallback;
22
+ }
23
+ if (typeof value !== "string") {
24
+ throw new Error(`${label} must be a string`);
25
+ }
26
+ return value;
27
+ }
28
+
29
+ function ensureNumber(value: unknown, label: string): number {
30
+ if (typeof value !== "number" || Number.isNaN(value)) {
31
+ throw new Error(`${label} must be a number`);
32
+ }
33
+ return value;
34
+ }
35
+
36
+ function ensureOptionalNumber(value: unknown, label: string, fallback: number): number {
37
+ if (value === undefined || value === null || value === "") {
38
+ return fallback;
39
+ }
40
+ if (typeof value === "string") {
41
+ const parsed = Number(value);
42
+ if (!Number.isFinite(parsed)) {
43
+ throw new Error(`${label} must be numeric`);
44
+ }
45
+ return parsed;
46
+ }
47
+ return ensureNumber(value, label);
48
+ }
49
+
50
+ export function validateClientConfig(value: unknown, indexLabel = "client"): ClientConfig {
51
+ const client = ensureObject(value, indexLabel);
52
+
53
+ return {
54
+ name: ensureString(client.name, `${indexLabel}.name`).trim(),
55
+ email: ensureString(client.email, `${indexLabel}.email`, "").trim(),
56
+ address: ensureString(client.address, `${indexLabel}.address`, "").trim(),
57
+ currency: ensureString(client.currency, `${indexLabel}.currency`, "USD") || "USD",
58
+ defaultRate: ensureOptionalNumber(client.defaultRate, `${indexLabel}.defaultRate`, 0),
59
+ payPeriodType: ensureString(client.payPeriodType, `${indexLabel}.payPeriodType`, "").trim(),
60
+ };
61
+ }
62
+
63
+ export function validateConfig(value: unknown): Config {
64
+ const obj = ensureObject(value, "config");
65
+ const clientsRaw = obj.client;
66
+
67
+ if (!Array.isArray(clientsRaw)) {
68
+ throw new Error("config.client must be an array");
69
+ }
70
+
71
+ const fromRaw = obj.from === undefined ? {} : ensureObject(obj.from, "config.from");
72
+
73
+ return {
74
+ client: clientsRaw.map((client, index) => validateClientConfig(client, `config.client[${index}]`)),
75
+ defaultDescription: ensureString(obj.defaultDescription, "config.defaultDescription", ""),
76
+ from: {
77
+ name: ensureString(fromRaw.name, "config.from.name", ""),
78
+ email: ensureString(fromRaw.email, "config.from.email", ""),
79
+ address: ensureString(fromRaw.address, "config.from.address", ""),
80
+ },
81
+ invoicePrefix: ensureString(obj.invoicePrefix, "config.invoicePrefix", "INV"),
82
+ note: ensureString(obj.note, "config.note", ""),
83
+ };
84
+ }
85
+
86
+ function validateClientHistory(value: unknown, label: string): ClientHistory {
87
+ const obj = ensureObject(value, label);
88
+ const seqByMonthRaw = obj.seqByMonth === undefined ? {} : ensureObject(obj.seqByMonth, `${label}.seqByMonth`);
89
+
90
+ const seqByMonth = Object.entries(seqByMonthRaw).reduce<Record<string, number>>((acc, [month, seq]) => {
91
+ acc[month] = ensureOptionalNumber(seq, `${label}.seqByMonth.${month}`, 0);
92
+ return acc;
93
+ }, {});
94
+
95
+ return {
96
+ lastHours: ensureOptionalNumber(obj.lastHours, `${label}.lastHours`, 160),
97
+ lastRate: ensureOptionalNumber(obj.lastRate, `${label}.lastRate`, 0),
98
+ seqByMonth,
99
+ };
100
+ }
101
+
102
+ export function validateLastState(value: unknown): LastState {
103
+ const obj = ensureObject(value, "last");
104
+ const clientsRaw = obj.clients === undefined ? {} : ensureObject(obj.clients, "last.clients");
105
+
106
+ const clients = Object.entries(clientsRaw).reduce<Record<string, ClientHistory>>((acc, [name, history]) => {
107
+ acc[name] = validateClientHistory(history, `last.clients.${name}`);
108
+ return acc;
109
+ }, {});
110
+
111
+ return { clients };
112
+ }
113
+
114
+ function validateTimesheetEntry(value: unknown, label: string): TimesheetEntry {
115
+ const obj = ensureObject(value, label);
116
+
117
+ return {
118
+ id: ensureOptionalNumber(obj.id, `${label}.id`, Date.now()),
119
+ client: ensureString(obj.client, `${label}.client`),
120
+ date: ensureString(obj.date, `${label}.date`),
121
+ hours: ensureOptionalNumber(obj.hours, `${label}.hours`, 0),
122
+ description: ensureString(obj.description, `${label}.description`, ""),
123
+ rate: ensureOptionalNumber(obj.rate, `${label}.rate`, 0),
124
+ amount: ensureOptionalNumber(obj.amount, `${label}.amount`, 0),
125
+ createdAt: ensureString(obj.createdAt, `${label}.createdAt`, ""),
126
+ };
127
+ }
128
+
129
+ export function validateTimesheetData(value: unknown): TimesheetData {
130
+ const obj = ensureObject(value, "timesheet");
131
+ const entriesRaw = obj.entries === undefined ? [] : obj.entries;
132
+
133
+ if (!Array.isArray(entriesRaw)) {
134
+ throw new Error("timesheet.entries must be an array");
135
+ }
136
+
137
+ return {
138
+ entries: entriesRaw.map((entry, index) => validateTimesheetEntry(entry, `timesheet.entries[${index}]`)),
139
+ };
140
+ }
141
+
142
+ function validateArchivedEntry(value: unknown, label: string): ArchivedEntry {
143
+ const entry = validateTimesheetEntry(value, label);
144
+ const obj = ensureObject(value, label);
145
+
146
+ return {
147
+ ...entry,
148
+ archivedAt: ensureString(obj.archivedAt, `${label}.archivedAt`),
149
+ archivedReason: ensureString(obj.archivedReason, `${label}.archivedReason`, "invoiced"),
150
+ invoiceNumber: ensureString(obj.invoiceNumber, `${label}.invoiceNumber`),
151
+ invoiceMonth: ensureString(obj.invoiceMonth, `${label}.invoiceMonth`),
152
+ };
153
+ }
154
+
155
+ export function validateArchiveData(value: unknown): ArchiveData {
156
+ const obj = ensureObject(value, "archive");
157
+ const archivedRaw = obj.archivedEntries === undefined ? [] : obj.archivedEntries;
158
+
159
+ if (!Array.isArray(archivedRaw)) {
160
+ throw new Error("archive.archivedEntries must be an array");
161
+ }
162
+
163
+ return {
164
+ archivedEntries: archivedRaw.map((entry, index) =>
165
+ validateArchivedEntry(entry, `archive.archivedEntries[${index}]`),
166
+ ),
167
+ };
168
+ }