@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
package/src/core/json-store.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
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
|
-
}
|
package/src/core/paths.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
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
|
-
}
|
package/src/core/validators.ts
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
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
|
-
}
|
package/src/domain/types.ts
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
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
|
-
}
|
package/src/services/email.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,95 +0,0 @@
|
|
|
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
|
-
}
|