cli-invoice 0.1.1
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/LICENSE +21 -0
- package/README.md +173 -0
- package/dist/bin/inv.js +2010 -0
- package/dist/bin/inv.js.map +1 -0
- package/package.json +58 -0
package/dist/bin/inv.js
ADDED
|
@@ -0,0 +1,2010 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __esm = (fn, res) => function __init() {
|
|
10
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
11
|
+
};
|
|
12
|
+
var __export = (target, all) => {
|
|
13
|
+
for (var name in all)
|
|
14
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
15
|
+
};
|
|
16
|
+
var __copyProps = (to, from, except, desc) => {
|
|
17
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
18
|
+
for (let key of __getOwnPropNames(from))
|
|
19
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
20
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
21
|
+
}
|
|
22
|
+
return to;
|
|
23
|
+
};
|
|
24
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
25
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
26
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
27
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
28
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
29
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
30
|
+
mod
|
|
31
|
+
));
|
|
32
|
+
|
|
33
|
+
// src/storage/paths.ts
|
|
34
|
+
var paths_exports = {};
|
|
35
|
+
__export(paths_exports, {
|
|
36
|
+
ensureDataDirs: () => ensureDataDirs,
|
|
37
|
+
ensureDir: () => ensureDir,
|
|
38
|
+
getClientsPath: () => getClientsPath,
|
|
39
|
+
getConfigDir: () => getConfigDir,
|
|
40
|
+
getConfigPath: () => getConfigPath,
|
|
41
|
+
getCounterPath: () => getCounterPath,
|
|
42
|
+
getDataDir: () => getDataDir,
|
|
43
|
+
getInvoicesPath: () => getInvoicesPath
|
|
44
|
+
});
|
|
45
|
+
function getConfigDir() {
|
|
46
|
+
const xdgConfig = process.env["XDG_CONFIG_HOME"];
|
|
47
|
+
const base = xdgConfig || path.join(os.homedir(), ".config");
|
|
48
|
+
return path.join(base, "cli-invoice");
|
|
49
|
+
}
|
|
50
|
+
function getDataDir() {
|
|
51
|
+
return path.join(getConfigDir(), "data");
|
|
52
|
+
}
|
|
53
|
+
function getConfigPath() {
|
|
54
|
+
return path.join(getConfigDir(), "config.toml");
|
|
55
|
+
}
|
|
56
|
+
function getClientsPath() {
|
|
57
|
+
return path.join(getDataDir(), "clients.json");
|
|
58
|
+
}
|
|
59
|
+
function getInvoicesPath() {
|
|
60
|
+
return path.join(getDataDir(), "invoices.json");
|
|
61
|
+
}
|
|
62
|
+
function getCounterPath() {
|
|
63
|
+
return path.join(getDataDir(), "counter.json");
|
|
64
|
+
}
|
|
65
|
+
function ensureDir(dirPath) {
|
|
66
|
+
if (!fs.existsSync(dirPath)) {
|
|
67
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function ensureDataDirs() {
|
|
71
|
+
ensureDir(getConfigDir());
|
|
72
|
+
ensureDir(getDataDir());
|
|
73
|
+
}
|
|
74
|
+
var path, os, fs;
|
|
75
|
+
var init_paths = __esm({
|
|
76
|
+
"src/storage/paths.ts"() {
|
|
77
|
+
"use strict";
|
|
78
|
+
path = __toESM(require("path"));
|
|
79
|
+
os = __toESM(require("os"));
|
|
80
|
+
fs = __toESM(require("fs"));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// src/core/config.ts
|
|
85
|
+
var config_exports = {};
|
|
86
|
+
__export(config_exports, {
|
|
87
|
+
defaultConfig: () => defaultConfig,
|
|
88
|
+
initConfig: () => initConfig,
|
|
89
|
+
isConfigInitialized: () => isConfigInitialized,
|
|
90
|
+
loadConfig: () => loadConfig,
|
|
91
|
+
saveConfig: () => saveConfig,
|
|
92
|
+
updateConfig: () => updateConfig
|
|
93
|
+
});
|
|
94
|
+
function defaultConfig() {
|
|
95
|
+
return {
|
|
96
|
+
sender: {
|
|
97
|
+
name: "",
|
|
98
|
+
business_name: "",
|
|
99
|
+
email: "",
|
|
100
|
+
address: "",
|
|
101
|
+
logo: ""
|
|
102
|
+
},
|
|
103
|
+
defaults: {
|
|
104
|
+
currency: "USD",
|
|
105
|
+
payment_terms: 30,
|
|
106
|
+
tax_rate: 0,
|
|
107
|
+
output_dir: ""
|
|
108
|
+
},
|
|
109
|
+
invoice: {
|
|
110
|
+
number_prefix: "INV",
|
|
111
|
+
next_number: 1
|
|
112
|
+
},
|
|
113
|
+
license: {
|
|
114
|
+
key: "",
|
|
115
|
+
activated_at: ""
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function loadConfig() {
|
|
120
|
+
const configPath = getConfigPath();
|
|
121
|
+
const defaults = defaultConfig();
|
|
122
|
+
try {
|
|
123
|
+
const raw = fs2.readFileSync(configPath, "utf-8");
|
|
124
|
+
const parsed = TOML.parse(raw);
|
|
125
|
+
return deepMerge(defaults, parsed);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (isNodeError(err) && err.code === "ENOENT") {
|
|
128
|
+
return defaults;
|
|
129
|
+
}
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function saveConfig(config) {
|
|
134
|
+
ensureDataDirs();
|
|
135
|
+
const configPath = getConfigPath();
|
|
136
|
+
const dir = path2.dirname(configPath);
|
|
137
|
+
const tomlString = TOML.stringify(config);
|
|
138
|
+
const tempFile = path2.join(dir, `.tmp-config-${crypto.randomBytes(8).toString("hex")}.toml`);
|
|
139
|
+
try {
|
|
140
|
+
fs2.writeFileSync(tempFile, tomlString, "utf-8");
|
|
141
|
+
fs2.renameSync(tempFile, configPath);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
try {
|
|
144
|
+
if (fs2.existsSync(tempFile)) {
|
|
145
|
+
fs2.unlinkSync(tempFile);
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
}
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function updateConfig(key, value) {
|
|
153
|
+
const config = loadConfig();
|
|
154
|
+
const parts = key.split(".");
|
|
155
|
+
if (parts.length !== 2) {
|
|
156
|
+
throw new Error(`Invalid config key: "${key}". Use section.field format (e.g., defaults.currency).`);
|
|
157
|
+
}
|
|
158
|
+
const [section, field] = parts;
|
|
159
|
+
if (!(section in config)) {
|
|
160
|
+
const validSections = Object.keys(config).join(", ");
|
|
161
|
+
throw new Error(`Unknown config section: "${section}". Valid sections: ${validSections}`);
|
|
162
|
+
}
|
|
163
|
+
const sectionObj = config[section];
|
|
164
|
+
if (!(field in sectionObj)) {
|
|
165
|
+
const validFields = Object.keys(sectionObj).join(", ");
|
|
166
|
+
throw new Error(`Unknown config field: "${field}" in section "${section}". Valid fields: ${validFields}`);
|
|
167
|
+
}
|
|
168
|
+
const currentValue = sectionObj[field];
|
|
169
|
+
if (typeof currentValue === "number") {
|
|
170
|
+
const num = Number(value);
|
|
171
|
+
if (isNaN(num)) {
|
|
172
|
+
throw new Error(`"${field}" expects a number, got "${value}".`);
|
|
173
|
+
}
|
|
174
|
+
sectionObj[field] = num;
|
|
175
|
+
} else {
|
|
176
|
+
sectionObj[field] = value;
|
|
177
|
+
}
|
|
178
|
+
saveConfig(config);
|
|
179
|
+
}
|
|
180
|
+
async function initConfig() {
|
|
181
|
+
const { input } = await import("@inquirer/prompts");
|
|
182
|
+
const config = defaultConfig();
|
|
183
|
+
config.sender.name = await input({
|
|
184
|
+
message: "Your name (appears on invoices):",
|
|
185
|
+
validate: (v) => v.trim().length > 0 || "Name is required."
|
|
186
|
+
});
|
|
187
|
+
config.sender.business_name = await input({
|
|
188
|
+
message: "Business name (optional, press Enter to skip):",
|
|
189
|
+
default: ""
|
|
190
|
+
});
|
|
191
|
+
config.sender.email = await input({
|
|
192
|
+
message: "Email address:",
|
|
193
|
+
validate: (v) => {
|
|
194
|
+
if (v.trim().length === 0) return "Email is required.";
|
|
195
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return "Invalid email format.";
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
config.sender.address = await input({
|
|
200
|
+
message: "Address (use \\n for new lines):",
|
|
201
|
+
default: ""
|
|
202
|
+
});
|
|
203
|
+
config.sender.logo = await input({
|
|
204
|
+
message: "Path to logo file (optional, press Enter to skip):",
|
|
205
|
+
default: ""
|
|
206
|
+
});
|
|
207
|
+
config.defaults.currency = await input({
|
|
208
|
+
message: "Default currency (ISO code):",
|
|
209
|
+
default: "USD"
|
|
210
|
+
});
|
|
211
|
+
const termsStr = await input({
|
|
212
|
+
message: "Default payment terms (days):",
|
|
213
|
+
default: "30",
|
|
214
|
+
validate: (v) => {
|
|
215
|
+
const n = Number(v);
|
|
216
|
+
return !isNaN(n) && n > 0 && Number.isInteger(n) || "Must be a positive integer.";
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
config.defaults.payment_terms = parseInt(termsStr, 10);
|
|
220
|
+
ensureDataDirs();
|
|
221
|
+
saveConfig(config);
|
|
222
|
+
return config;
|
|
223
|
+
}
|
|
224
|
+
function isConfigInitialized() {
|
|
225
|
+
return fs2.existsSync(getConfigPath());
|
|
226
|
+
}
|
|
227
|
+
function deepMerge(target, source) {
|
|
228
|
+
const result = { ...target };
|
|
229
|
+
for (const key of Object.keys(source)) {
|
|
230
|
+
if (UNSAFE_KEYS.has(key)) continue;
|
|
231
|
+
if (source[key] !== null && typeof source[key] === "object" && !Array.isArray(source[key]) && key in target && target[key] !== null && typeof target[key] === "object" && !Array.isArray(target[key])) {
|
|
232
|
+
result[key] = deepMerge(
|
|
233
|
+
target[key],
|
|
234
|
+
source[key]
|
|
235
|
+
);
|
|
236
|
+
} else {
|
|
237
|
+
result[key] = source[key];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
function isNodeError(err) {
|
|
243
|
+
return err instanceof Error && "code" in err;
|
|
244
|
+
}
|
|
245
|
+
var fs2, path2, crypto, TOML, UNSAFE_KEYS;
|
|
246
|
+
var init_config = __esm({
|
|
247
|
+
"src/core/config.ts"() {
|
|
248
|
+
"use strict";
|
|
249
|
+
fs2 = __toESM(require("fs"));
|
|
250
|
+
path2 = __toESM(require("path"));
|
|
251
|
+
crypto = __toESM(require("crypto"));
|
|
252
|
+
TOML = __toESM(require("smol-toml"));
|
|
253
|
+
init_paths();
|
|
254
|
+
UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// src/utils/format.ts
|
|
259
|
+
var format_exports = {};
|
|
260
|
+
__export(format_exports, {
|
|
261
|
+
error: () => error,
|
|
262
|
+
info: () => info,
|
|
263
|
+
success: () => success,
|
|
264
|
+
warn: () => warn
|
|
265
|
+
});
|
|
266
|
+
function success(message) {
|
|
267
|
+
console.log(import_chalk.default.green("\u2713") + " " + message);
|
|
268
|
+
}
|
|
269
|
+
function error(message) {
|
|
270
|
+
console.error(import_chalk.default.red("Error:") + " " + message);
|
|
271
|
+
}
|
|
272
|
+
function warn(message) {
|
|
273
|
+
console.log(import_chalk.default.yellow("Warning:") + " " + message);
|
|
274
|
+
}
|
|
275
|
+
function info(message) {
|
|
276
|
+
console.log(import_chalk.default.blue("\u2139") + " " + message);
|
|
277
|
+
}
|
|
278
|
+
var import_chalk;
|
|
279
|
+
var init_format = __esm({
|
|
280
|
+
"src/utils/format.ts"() {
|
|
281
|
+
"use strict";
|
|
282
|
+
import_chalk = __toESM(require("chalk"));
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// src/storage/store.ts
|
|
287
|
+
var store_exports = {};
|
|
288
|
+
__export(store_exports, {
|
|
289
|
+
readStore: () => readStore,
|
|
290
|
+
writeStore: () => writeStore
|
|
291
|
+
});
|
|
292
|
+
function readStore(filePath, defaultValue) {
|
|
293
|
+
try {
|
|
294
|
+
const raw = fs3.readFileSync(filePath, "utf-8");
|
|
295
|
+
return JSON.parse(raw);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
if (isNodeError2(err) && err.code === "ENOENT") {
|
|
298
|
+
return defaultValue;
|
|
299
|
+
}
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function writeStore(filePath, data) {
|
|
304
|
+
const dir = path3.dirname(filePath);
|
|
305
|
+
ensureDir(dir);
|
|
306
|
+
const tempFile = path3.join(dir, `.tmp-${crypto2.randomBytes(8).toString("hex")}.json`);
|
|
307
|
+
try {
|
|
308
|
+
const content = JSON.stringify(data, null, 2) + "\n";
|
|
309
|
+
fs3.writeFileSync(tempFile, content, "utf-8");
|
|
310
|
+
fs3.renameSync(tempFile, filePath);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
try {
|
|
313
|
+
if (fs3.existsSync(tempFile)) {
|
|
314
|
+
fs3.unlinkSync(tempFile);
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
}
|
|
318
|
+
throw err;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function isNodeError2(err) {
|
|
322
|
+
return err instanceof Error && "code" in err;
|
|
323
|
+
}
|
|
324
|
+
var fs3, path3, crypto2;
|
|
325
|
+
var init_store = __esm({
|
|
326
|
+
"src/storage/store.ts"() {
|
|
327
|
+
"use strict";
|
|
328
|
+
fs3 = __toESM(require("fs"));
|
|
329
|
+
path3 = __toESM(require("path"));
|
|
330
|
+
crypto2 = __toESM(require("crypto"));
|
|
331
|
+
init_paths();
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// src/utils/validate.ts
|
|
336
|
+
function isValidEmail(email) {
|
|
337
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
338
|
+
return emailRegex.test(email);
|
|
339
|
+
}
|
|
340
|
+
function isNonEmpty(value) {
|
|
341
|
+
return value.trim().length > 0;
|
|
342
|
+
}
|
|
343
|
+
var init_validate = __esm({
|
|
344
|
+
"src/utils/validate.ts"() {
|
|
345
|
+
"use strict";
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// src/utils/date.ts
|
|
350
|
+
var date_exports = {};
|
|
351
|
+
__export(date_exports, {
|
|
352
|
+
addDays: () => addDays,
|
|
353
|
+
formatDate: () => formatDate,
|
|
354
|
+
isPast: () => isPast,
|
|
355
|
+
nowISO: () => nowISO,
|
|
356
|
+
today: () => today
|
|
357
|
+
});
|
|
358
|
+
function today() {
|
|
359
|
+
return (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
360
|
+
}
|
|
361
|
+
function nowISO() {
|
|
362
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
363
|
+
}
|
|
364
|
+
function addDays(dateStr, days) {
|
|
365
|
+
const date = new Date(dateStr);
|
|
366
|
+
date.setDate(date.getDate() + days);
|
|
367
|
+
return date.toISOString().split("T")[0];
|
|
368
|
+
}
|
|
369
|
+
function isPast(dateStr) {
|
|
370
|
+
const date = new Date(dateStr);
|
|
371
|
+
const now = /* @__PURE__ */ new Date();
|
|
372
|
+
now.setHours(0, 0, 0, 0);
|
|
373
|
+
return date < now;
|
|
374
|
+
}
|
|
375
|
+
function formatDate(dateStr) {
|
|
376
|
+
const [year, month, day] = dateStr.split("-").map(Number);
|
|
377
|
+
const date = new Date(year, month - 1, day);
|
|
378
|
+
return date.toLocaleDateString("en-US", {
|
|
379
|
+
year: "numeric",
|
|
380
|
+
month: "short",
|
|
381
|
+
day: "numeric"
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
var init_date = __esm({
|
|
385
|
+
"src/utils/date.ts"() {
|
|
386
|
+
"use strict";
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// src/core/license.ts
|
|
391
|
+
var license_exports = {};
|
|
392
|
+
__export(license_exports, {
|
|
393
|
+
FREE_CLIENT_LIMIT: () => FREE_CLIENT_LIMIT,
|
|
394
|
+
FREE_INVOICE_MONTHLY_LIMIT: () => FREE_INVOICE_MONTHLY_LIMIT,
|
|
395
|
+
FreemiumLimitError: () => FreemiumLimitError,
|
|
396
|
+
LicenseValidationError: () => LicenseValidationError,
|
|
397
|
+
PRO_PRICE: () => PRO_PRICE,
|
|
398
|
+
UPGRADE_URL: () => UPGRADE_URL,
|
|
399
|
+
checkClientLimit: () => checkClientLimit,
|
|
400
|
+
checkInvoiceLimit: () => checkInvoiceLimit,
|
|
401
|
+
countInvoicesThisMonth: () => countInvoicesThisMonth,
|
|
402
|
+
formatUpgradeMessage: () => formatUpgradeMessage,
|
|
403
|
+
getEmbeddedPublicKey: () => getEmbeddedPublicKey,
|
|
404
|
+
getLicenseStatus: () => getLicenseStatus,
|
|
405
|
+
printUpgradeMessage: () => printUpgradeMessage,
|
|
406
|
+
validateLicenseKey: () => validateLicenseKey
|
|
407
|
+
});
|
|
408
|
+
function getEmbeddedPublicKey() {
|
|
409
|
+
return EMBEDDED_PUBLIC_KEY;
|
|
410
|
+
}
|
|
411
|
+
function getLicenseStatus(config) {
|
|
412
|
+
const key = config.license?.key;
|
|
413
|
+
if (!key) return "free";
|
|
414
|
+
try {
|
|
415
|
+
const payload = validateLicenseKey(key);
|
|
416
|
+
if (payload.plan === "pro") return "pro";
|
|
417
|
+
return "free";
|
|
418
|
+
} catch {
|
|
419
|
+
return "free";
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function checkClientLimit(clientCount, licenseStatus) {
|
|
423
|
+
if (licenseStatus === "pro") return;
|
|
424
|
+
if (clientCount >= FREE_CLIENT_LIMIT) {
|
|
425
|
+
const message = formatUpgradeMessage("clients", FREE_CLIENT_LIMIT);
|
|
426
|
+
throw new FreemiumLimitError(message);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
function checkInvoiceLimit(monthlyInvoiceCount, licenseStatus) {
|
|
430
|
+
if (licenseStatus === "pro") return;
|
|
431
|
+
if (monthlyInvoiceCount >= FREE_INVOICE_MONTHLY_LIMIT) {
|
|
432
|
+
const message = formatUpgradeMessage("invoices this month", FREE_INVOICE_MONTHLY_LIMIT);
|
|
433
|
+
throw new FreemiumLimitError(message);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
function countInvoicesThisMonth(invoices) {
|
|
437
|
+
const now = /* @__PURE__ */ new Date();
|
|
438
|
+
const currentYear = now.getFullYear();
|
|
439
|
+
const currentMonth = now.getMonth();
|
|
440
|
+
return invoices.filter((inv) => {
|
|
441
|
+
const d = new Date(inv.created_at);
|
|
442
|
+
return d.getFullYear() === currentYear && d.getMonth() === currentMonth;
|
|
443
|
+
}).length;
|
|
444
|
+
}
|
|
445
|
+
function formatUpgradeMessage(resource, limit) {
|
|
446
|
+
return `Free tier limit: ${limit} ${resource}. You've reached the maximum.
|
|
447
|
+
|
|
448
|
+
Upgrade to CLI Invoice Pro (${PRO_PRICE}) for unlimited clients,
|
|
449
|
+
recurring templates, revenue summaries, and more.
|
|
450
|
+
|
|
451
|
+
Run \`inv upgrade\` for details, or visit ${UPGRADE_URL}`;
|
|
452
|
+
}
|
|
453
|
+
function printUpgradeMessage(resource, limit) {
|
|
454
|
+
console.error("\n" + formatUpgradeMessage(resource, limit) + "\n");
|
|
455
|
+
}
|
|
456
|
+
function validateLicenseKey(key, publicKeyPem) {
|
|
457
|
+
const pubKey = publicKeyPem || getEmbeddedPublicKey();
|
|
458
|
+
const dotIndex = key.lastIndexOf(".");
|
|
459
|
+
if (dotIndex === -1 || dotIndex === 0 || dotIndex === key.length - 1) {
|
|
460
|
+
throw new LicenseValidationError("Invalid license key format.");
|
|
461
|
+
}
|
|
462
|
+
const payloadB64 = key.substring(0, dotIndex);
|
|
463
|
+
const signatureB64 = key.substring(dotIndex + 1);
|
|
464
|
+
let payloadJson;
|
|
465
|
+
try {
|
|
466
|
+
payloadJson = Buffer.from(payloadB64, "base64url").toString("utf-8");
|
|
467
|
+
} catch {
|
|
468
|
+
throw new LicenseValidationError("Invalid license key: could not decode payload.");
|
|
469
|
+
}
|
|
470
|
+
let signatureBuffer;
|
|
471
|
+
try {
|
|
472
|
+
signatureBuffer = Buffer.from(signatureB64, "base64url");
|
|
473
|
+
} catch {
|
|
474
|
+
throw new LicenseValidationError("Invalid license key: could not decode signature.");
|
|
475
|
+
}
|
|
476
|
+
const payloadBytes = Buffer.from(payloadB64, "utf-8");
|
|
477
|
+
let valid;
|
|
478
|
+
try {
|
|
479
|
+
valid = crypto3.verify(
|
|
480
|
+
null,
|
|
481
|
+
// Ed25519 does not use a separate hash algorithm
|
|
482
|
+
payloadBytes,
|
|
483
|
+
pubKey,
|
|
484
|
+
signatureBuffer
|
|
485
|
+
);
|
|
486
|
+
} catch {
|
|
487
|
+
throw new LicenseValidationError("Invalid license key: signature verification failed.");
|
|
488
|
+
}
|
|
489
|
+
if (!valid) {
|
|
490
|
+
throw new LicenseValidationError("Invalid license key: signature does not match.");
|
|
491
|
+
}
|
|
492
|
+
let payload;
|
|
493
|
+
try {
|
|
494
|
+
payload = JSON.parse(payloadJson);
|
|
495
|
+
} catch {
|
|
496
|
+
throw new LicenseValidationError("Invalid license key: malformed payload.");
|
|
497
|
+
}
|
|
498
|
+
if (!payload.email || !payload.plan || !payload.issued_at || !payload.expires_at) {
|
|
499
|
+
throw new LicenseValidationError("Invalid license key: missing required fields.");
|
|
500
|
+
}
|
|
501
|
+
if (payload.plan !== "pro") {
|
|
502
|
+
throw new LicenseValidationError(`Invalid license key: unknown plan "${payload.plan}".`);
|
|
503
|
+
}
|
|
504
|
+
const expiresAt = new Date(payload.expires_at);
|
|
505
|
+
const now = /* @__PURE__ */ new Date();
|
|
506
|
+
now.setHours(0, 0, 0, 0);
|
|
507
|
+
if (expiresAt < now) {
|
|
508
|
+
throw new LicenseValidationError(
|
|
509
|
+
`License key expired on ${payload.expires_at}. Visit ${UPGRADE_URL} to renew.`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
return payload;
|
|
513
|
+
}
|
|
514
|
+
var crypto3, FREE_CLIENT_LIMIT, FREE_INVOICE_MONTHLY_LIMIT, UPGRADE_URL, PRO_PRICE, EMBEDDED_PUBLIC_KEY, FreemiumLimitError, LicenseValidationError;
|
|
515
|
+
var init_license = __esm({
|
|
516
|
+
"src/core/license.ts"() {
|
|
517
|
+
"use strict";
|
|
518
|
+
crypto3 = __toESM(require("crypto"));
|
|
519
|
+
FREE_CLIENT_LIMIT = 3;
|
|
520
|
+
FREE_INVOICE_MONTHLY_LIMIT = 5;
|
|
521
|
+
UPGRADE_URL = "https://cli-invoice.dev/pro";
|
|
522
|
+
PRO_PRICE = "$8/month";
|
|
523
|
+
EMBEDDED_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
|
|
524
|
+
MCowBQYDK2VwAyEAaTSk6e9jU9ASnDDNPIxFnmULgKwQgFC+Z6l23JwNyoo=
|
|
525
|
+
-----END PUBLIC KEY-----`;
|
|
526
|
+
FreemiumLimitError = class extends Error {
|
|
527
|
+
constructor(message) {
|
|
528
|
+
super(message);
|
|
529
|
+
this.name = "FreemiumLimitError";
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
LicenseValidationError = class extends Error {
|
|
533
|
+
constructor(message) {
|
|
534
|
+
super(message);
|
|
535
|
+
this.name = "LicenseValidationError";
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// src/core/client.ts
|
|
542
|
+
var client_exports = {};
|
|
543
|
+
__export(client_exports, {
|
|
544
|
+
addClient: () => addClient,
|
|
545
|
+
editClient: () => editClient,
|
|
546
|
+
findClientByName: () => findClientByName,
|
|
547
|
+
getClients: () => getClients,
|
|
548
|
+
listClients: () => listClients,
|
|
549
|
+
removeClient: () => removeClient
|
|
550
|
+
});
|
|
551
|
+
function getClients() {
|
|
552
|
+
const store = readStore(getClientsPath(), { clients: [] });
|
|
553
|
+
return store.clients;
|
|
554
|
+
}
|
|
555
|
+
function saveClients(clients) {
|
|
556
|
+
writeStore(getClientsPath(), { clients });
|
|
557
|
+
}
|
|
558
|
+
function addClient(input) {
|
|
559
|
+
if (!isNonEmpty(input.name)) {
|
|
560
|
+
throw new Error("Client name is required.");
|
|
561
|
+
}
|
|
562
|
+
if (!isNonEmpty(input.email)) {
|
|
563
|
+
throw new Error("Client email is required.");
|
|
564
|
+
}
|
|
565
|
+
const trimmedEmail = input.email.trim();
|
|
566
|
+
if (!isValidEmail(trimmedEmail)) {
|
|
567
|
+
throw new Error(`Invalid email format: "${trimmedEmail}".`);
|
|
568
|
+
}
|
|
569
|
+
const clients = getClients();
|
|
570
|
+
const config = loadConfig();
|
|
571
|
+
const licenseStatus = getLicenseStatus(config);
|
|
572
|
+
checkClientLimit(clients.length, licenseStatus);
|
|
573
|
+
const existing = clients.find(
|
|
574
|
+
(c) => c.name.toLowerCase() === input.name.trim().toLowerCase()
|
|
575
|
+
);
|
|
576
|
+
if (existing) {
|
|
577
|
+
throw new Error(`A client named "${existing.name}" already exists.`);
|
|
578
|
+
}
|
|
579
|
+
const now = nowISO();
|
|
580
|
+
const client = {
|
|
581
|
+
id: crypto4.randomUUID(),
|
|
582
|
+
name: input.name.trim(),
|
|
583
|
+
email: input.email.trim(),
|
|
584
|
+
address: input.address?.trim() ?? "",
|
|
585
|
+
payment_terms: input.payment_terms ?? 30,
|
|
586
|
+
created_at: now,
|
|
587
|
+
updated_at: now
|
|
588
|
+
};
|
|
589
|
+
clients.push(client);
|
|
590
|
+
saveClients(clients);
|
|
591
|
+
return client;
|
|
592
|
+
}
|
|
593
|
+
function listClients() {
|
|
594
|
+
const clients = getClients();
|
|
595
|
+
const invoiceStore = readStore(getInvoicesPath(), { invoices: [] });
|
|
596
|
+
return clients.map((client) => {
|
|
597
|
+
const clientInvoices = invoiceStore.invoices.filter((inv) => inv.client_id === client.id);
|
|
598
|
+
const totalBilled = clientInvoices.reduce((sum, inv) => sum + inv.total, 0);
|
|
599
|
+
return {
|
|
600
|
+
...client,
|
|
601
|
+
invoice_count: clientInvoices.length,
|
|
602
|
+
total_billed: totalBilled
|
|
603
|
+
};
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
function editClient(name, updates) {
|
|
607
|
+
const clients = getClients();
|
|
608
|
+
const index = clients.findIndex(
|
|
609
|
+
(c) => c.name.toLowerCase() === name.toLowerCase()
|
|
610
|
+
);
|
|
611
|
+
if (index === -1) {
|
|
612
|
+
throw new Error(`Client not found: "${name}".`);
|
|
613
|
+
}
|
|
614
|
+
const client = clients[index];
|
|
615
|
+
if (updates.email !== void 0) {
|
|
616
|
+
if (!isValidEmail(updates.email)) {
|
|
617
|
+
throw new Error(`Invalid email format: "${updates.email}".`);
|
|
618
|
+
}
|
|
619
|
+
client.email = updates.email.trim();
|
|
620
|
+
}
|
|
621
|
+
if (updates.address !== void 0) {
|
|
622
|
+
client.address = updates.address.trim();
|
|
623
|
+
}
|
|
624
|
+
if (updates.payment_terms !== void 0) {
|
|
625
|
+
if (updates.payment_terms <= 0 || !Number.isInteger(updates.payment_terms)) {
|
|
626
|
+
throw new Error("Payment terms must be a positive integer.");
|
|
627
|
+
}
|
|
628
|
+
client.payment_terms = updates.payment_terms;
|
|
629
|
+
}
|
|
630
|
+
if (updates.name !== void 0) {
|
|
631
|
+
if (!isNonEmpty(updates.name)) {
|
|
632
|
+
throw new Error("Client name cannot be empty.");
|
|
633
|
+
}
|
|
634
|
+
const conflict = clients.find(
|
|
635
|
+
(c, i) => i !== index && c.name.toLowerCase() === updates.name.toLowerCase()
|
|
636
|
+
);
|
|
637
|
+
if (conflict) {
|
|
638
|
+
throw new Error(`A client named "${conflict.name}" already exists.`);
|
|
639
|
+
}
|
|
640
|
+
client.name = updates.name.trim();
|
|
641
|
+
}
|
|
642
|
+
client.updated_at = nowISO();
|
|
643
|
+
clients[index] = client;
|
|
644
|
+
saveClients(clients);
|
|
645
|
+
return client;
|
|
646
|
+
}
|
|
647
|
+
function removeClient(name) {
|
|
648
|
+
const clients = getClients();
|
|
649
|
+
const index = clients.findIndex(
|
|
650
|
+
(c) => c.name.toLowerCase() === name.toLowerCase()
|
|
651
|
+
);
|
|
652
|
+
if (index === -1) {
|
|
653
|
+
throw new Error(`Client not found: "${name}".`);
|
|
654
|
+
}
|
|
655
|
+
const client = clients[index];
|
|
656
|
+
const invoiceStore = readStore(getInvoicesPath(), { invoices: [] });
|
|
657
|
+
const clientInvoices = invoiceStore.invoices.filter((inv) => inv.client_id === client.id);
|
|
658
|
+
clients.splice(index, 1);
|
|
659
|
+
saveClients(clients);
|
|
660
|
+
return {
|
|
661
|
+
client,
|
|
662
|
+
hadInvoices: clientInvoices.length > 0,
|
|
663
|
+
invoiceCount: clientInvoices.length
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
function findClientByName(name) {
|
|
667
|
+
const clients = getClients();
|
|
668
|
+
return clients.find(
|
|
669
|
+
(c) => c.name.toLowerCase() === name.toLowerCase()
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
var crypto4;
|
|
673
|
+
var init_client = __esm({
|
|
674
|
+
"src/core/client.ts"() {
|
|
675
|
+
"use strict";
|
|
676
|
+
crypto4 = __toESM(require("crypto"));
|
|
677
|
+
init_paths();
|
|
678
|
+
init_store();
|
|
679
|
+
init_validate();
|
|
680
|
+
init_date();
|
|
681
|
+
init_license();
|
|
682
|
+
init_config();
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// src/utils/currency.ts
|
|
687
|
+
var currency_exports = {};
|
|
688
|
+
__export(currency_exports, {
|
|
689
|
+
formatCurrency: () => formatCurrency
|
|
690
|
+
});
|
|
691
|
+
function formatCurrency(amount, currency = "USD") {
|
|
692
|
+
return new Intl.NumberFormat("en-US", {
|
|
693
|
+
style: "currency",
|
|
694
|
+
currency,
|
|
695
|
+
minimumFractionDigits: 2,
|
|
696
|
+
maximumFractionDigits: 2
|
|
697
|
+
}).format(amount);
|
|
698
|
+
}
|
|
699
|
+
var init_currency = __esm({
|
|
700
|
+
"src/utils/currency.ts"() {
|
|
701
|
+
"use strict";
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
// src/core/invoice.ts
|
|
706
|
+
var invoice_exports = {};
|
|
707
|
+
__export(invoice_exports, {
|
|
708
|
+
computeDisplayStatus: () => computeDisplayStatus,
|
|
709
|
+
createInvoice: () => createInvoice,
|
|
710
|
+
deleteInvoice: () => deleteInvoice,
|
|
711
|
+
editInvoice: () => editInvoice,
|
|
712
|
+
getInvoice: () => getInvoice,
|
|
713
|
+
getSummary: () => getSummary,
|
|
714
|
+
listInvoices: () => listInvoices,
|
|
715
|
+
parseLineItemsFromFile: () => parseLineItemsFromFile,
|
|
716
|
+
updateStatus: () => updateStatus
|
|
717
|
+
});
|
|
718
|
+
function readInvoices() {
|
|
719
|
+
const store = readStore(getInvoicesPath(), { invoices: [] });
|
|
720
|
+
return store.invoices;
|
|
721
|
+
}
|
|
722
|
+
function saveInvoices(invoices) {
|
|
723
|
+
writeStore(getInvoicesPath(), { invoices });
|
|
724
|
+
}
|
|
725
|
+
function readCounter() {
|
|
726
|
+
return readStore(getCounterPath(), { next_invoice_number: 1 });
|
|
727
|
+
}
|
|
728
|
+
function saveCounter(counter) {
|
|
729
|
+
writeStore(getCounterPath(), counter);
|
|
730
|
+
}
|
|
731
|
+
function allocateInvoiceNumber() {
|
|
732
|
+
const config = loadConfig();
|
|
733
|
+
const counter = readCounter();
|
|
734
|
+
const num = counter.next_invoice_number;
|
|
735
|
+
const prefix = config.invoice.number_prefix || "INV";
|
|
736
|
+
const formatted = `${prefix}-${String(num).padStart(4, "0")}`;
|
|
737
|
+
counter.next_invoice_number = num + 1;
|
|
738
|
+
saveCounter(counter);
|
|
739
|
+
return formatted;
|
|
740
|
+
}
|
|
741
|
+
function computeDisplayStatus(inv) {
|
|
742
|
+
if (inv.status === "sent" && isPast(inv.due_date)) {
|
|
743
|
+
return "overdue";
|
|
744
|
+
}
|
|
745
|
+
return inv.status;
|
|
746
|
+
}
|
|
747
|
+
function round2(n) {
|
|
748
|
+
return Math.round(n * 100) / 100;
|
|
749
|
+
}
|
|
750
|
+
function createInvoice(input) {
|
|
751
|
+
const client = findClientByName(input.clientName);
|
|
752
|
+
if (!client) {
|
|
753
|
+
throw new Error(`Client not found: "${input.clientName}". Run \`inv client list\` to see existing clients.`);
|
|
754
|
+
}
|
|
755
|
+
if (!input.items || input.items.length === 0) {
|
|
756
|
+
throw new Error("At least one line item is required.");
|
|
757
|
+
}
|
|
758
|
+
for (let i = 0; i < input.items.length; i++) {
|
|
759
|
+
const item = input.items[i];
|
|
760
|
+
if (!item.description || item.description.trim().length === 0) {
|
|
761
|
+
throw new Error(`Line item ${i + 1}: description is required.`);
|
|
762
|
+
}
|
|
763
|
+
if (typeof item.quantity !== "number" || item.quantity <= 0) {
|
|
764
|
+
throw new Error(`Line item ${i + 1}: quantity must be > 0.`);
|
|
765
|
+
}
|
|
766
|
+
if (typeof item.unit_price !== "number" || item.unit_price < 0) {
|
|
767
|
+
throw new Error(`Line item ${i + 1}: unit_price must be >= 0.`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
const existingInvoices = readInvoices();
|
|
771
|
+
const config = loadConfig();
|
|
772
|
+
const licenseStatus = getLicenseStatus(config);
|
|
773
|
+
const monthlyCount = countInvoicesThisMonth(existingInvoices);
|
|
774
|
+
checkInvoiceLimit(monthlyCount, licenseStatus);
|
|
775
|
+
const invoiceDate = input.date || today();
|
|
776
|
+
const taxRate = input.taxRate ?? config.defaults.tax_rate;
|
|
777
|
+
const currency = input.currency || config.defaults.currency;
|
|
778
|
+
const paymentTerms = client.payment_terms;
|
|
779
|
+
const dueDate = addDays(invoiceDate, paymentTerms);
|
|
780
|
+
const subtotal = round2(
|
|
781
|
+
input.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
|
|
782
|
+
);
|
|
783
|
+
const taxAmount = round2(subtotal * (taxRate / 100));
|
|
784
|
+
const total = round2(subtotal + taxAmount);
|
|
785
|
+
const now = nowISO();
|
|
786
|
+
const invoiceNumber = allocateInvoiceNumber();
|
|
787
|
+
const invoice = {
|
|
788
|
+
id: crypto5.randomUUID(),
|
|
789
|
+
number: invoiceNumber,
|
|
790
|
+
client_id: client.id,
|
|
791
|
+
client_snapshot: {
|
|
792
|
+
name: client.name,
|
|
793
|
+
email: client.email,
|
|
794
|
+
address: client.address
|
|
795
|
+
},
|
|
796
|
+
status: "draft",
|
|
797
|
+
items: input.items.map((item) => ({
|
|
798
|
+
description: item.description.trim(),
|
|
799
|
+
quantity: item.quantity,
|
|
800
|
+
unit_price: item.unit_price
|
|
801
|
+
})),
|
|
802
|
+
currency,
|
|
803
|
+
tax_rate: taxRate,
|
|
804
|
+
subtotal,
|
|
805
|
+
tax_amount: taxAmount,
|
|
806
|
+
total,
|
|
807
|
+
date: invoiceDate,
|
|
808
|
+
due_date: dueDate,
|
|
809
|
+
paid_date: null,
|
|
810
|
+
notes: input.notes?.trim() ?? "",
|
|
811
|
+
created_at: now,
|
|
812
|
+
updated_at: now
|
|
813
|
+
};
|
|
814
|
+
existingInvoices.push(invoice);
|
|
815
|
+
saveInvoices(existingInvoices);
|
|
816
|
+
return invoice;
|
|
817
|
+
}
|
|
818
|
+
function listInvoices(filter) {
|
|
819
|
+
let invoices = readInvoices();
|
|
820
|
+
let results = invoices.map((inv) => ({
|
|
821
|
+
...inv,
|
|
822
|
+
display_status: computeDisplayStatus(inv)
|
|
823
|
+
}));
|
|
824
|
+
if (filter?.status) {
|
|
825
|
+
results = results.filter((inv) => inv.display_status === filter.status);
|
|
826
|
+
}
|
|
827
|
+
if (filter?.clientName) {
|
|
828
|
+
const lowerName = filter.clientName.toLowerCase();
|
|
829
|
+
results = results.filter(
|
|
830
|
+
(inv) => inv.client_snapshot.name.toLowerCase().includes(lowerName)
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
return results;
|
|
834
|
+
}
|
|
835
|
+
function getInvoice(idOrNumber) {
|
|
836
|
+
const invoices = readInvoices();
|
|
837
|
+
return invoices.find(
|
|
838
|
+
(inv) => inv.id === idOrNumber || inv.number.toLowerCase() === idOrNumber.toLowerCase()
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
function editInvoice(idOrNumber, updates) {
|
|
842
|
+
const invoices = readInvoices();
|
|
843
|
+
const index = invoices.findIndex(
|
|
844
|
+
(inv) => inv.id === idOrNumber || inv.number.toLowerCase() === idOrNumber.toLowerCase()
|
|
845
|
+
);
|
|
846
|
+
if (index === -1) {
|
|
847
|
+
throw new Error(`Invoice not found: "${idOrNumber}".`);
|
|
848
|
+
}
|
|
849
|
+
const invoice = invoices[index];
|
|
850
|
+
if (invoice.status !== "draft") {
|
|
851
|
+
throw new Error(
|
|
852
|
+
`Cannot edit invoice ${invoice.number}: status is "${invoice.status}". Only draft invoices can be edited.`
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
if (updates.notes !== void 0) {
|
|
856
|
+
invoice.notes = updates.notes.trim();
|
|
857
|
+
}
|
|
858
|
+
if (updates.date !== void 0) {
|
|
859
|
+
invoice.date = updates.date;
|
|
860
|
+
const clients = getClients();
|
|
861
|
+
const client = clients.find((c) => c.id === invoice.client_id);
|
|
862
|
+
const terms = client?.payment_terms ?? 30;
|
|
863
|
+
invoice.due_date = addDays(updates.date, terms);
|
|
864
|
+
}
|
|
865
|
+
if (updates.currency !== void 0) {
|
|
866
|
+
invoice.currency = updates.currency;
|
|
867
|
+
}
|
|
868
|
+
if (updates.taxRate !== void 0) {
|
|
869
|
+
invoice.tax_rate = updates.taxRate;
|
|
870
|
+
invoice.tax_amount = round2(invoice.subtotal * (updates.taxRate / 100));
|
|
871
|
+
invoice.total = round2(invoice.subtotal + invoice.tax_amount);
|
|
872
|
+
}
|
|
873
|
+
invoice.updated_at = nowISO();
|
|
874
|
+
invoices[index] = invoice;
|
|
875
|
+
saveInvoices(invoices);
|
|
876
|
+
return invoice;
|
|
877
|
+
}
|
|
878
|
+
function deleteInvoice(idOrNumber) {
|
|
879
|
+
const invoices = readInvoices();
|
|
880
|
+
const index = invoices.findIndex(
|
|
881
|
+
(inv) => inv.id === idOrNumber || inv.number.toLowerCase() === idOrNumber.toLowerCase()
|
|
882
|
+
);
|
|
883
|
+
if (index === -1) {
|
|
884
|
+
throw new Error(`Invoice not found: "${idOrNumber}".`);
|
|
885
|
+
}
|
|
886
|
+
const invoice = invoices[index];
|
|
887
|
+
if (invoice.status !== "draft") {
|
|
888
|
+
throw new Error(
|
|
889
|
+
`Cannot delete invoice ${invoice.number}: status is "${invoice.status}". Only draft invoices can be deleted.`
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
invoices.splice(index, 1);
|
|
893
|
+
saveInvoices(invoices);
|
|
894
|
+
return invoice;
|
|
895
|
+
}
|
|
896
|
+
function updateStatus(idOrNumber, newStatus, paidDate) {
|
|
897
|
+
const invoices = readInvoices();
|
|
898
|
+
const index = invoices.findIndex(
|
|
899
|
+
(inv) => inv.id === idOrNumber || inv.number.toLowerCase() === idOrNumber.toLowerCase()
|
|
900
|
+
);
|
|
901
|
+
if (index === -1) {
|
|
902
|
+
throw new Error(`Invoice not found: "${idOrNumber}".`);
|
|
903
|
+
}
|
|
904
|
+
const invoice = invoices[index];
|
|
905
|
+
const allowed = VALID_TRANSITIONS[invoice.status] ?? [];
|
|
906
|
+
if (!allowed.includes(newStatus)) {
|
|
907
|
+
throw new Error(
|
|
908
|
+
`Cannot transition invoice ${invoice.number} from "${invoice.status}" to "${newStatus}". Allowed transitions from "${invoice.status}": ${allowed.length > 0 ? allowed.join(", ") : "none"}.`
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
invoice.status = newStatus;
|
|
912
|
+
invoice.updated_at = nowISO();
|
|
913
|
+
if (newStatus === "paid") {
|
|
914
|
+
invoice.paid_date = paidDate || today();
|
|
915
|
+
}
|
|
916
|
+
invoices[index] = invoice;
|
|
917
|
+
saveInvoices(invoices);
|
|
918
|
+
return invoice;
|
|
919
|
+
}
|
|
920
|
+
function getSummary(year) {
|
|
921
|
+
const targetYear = year ?? (/* @__PURE__ */ new Date()).getFullYear();
|
|
922
|
+
const invoices = readInvoices();
|
|
923
|
+
const yearInvoices = invoices.filter((inv) => {
|
|
924
|
+
const invYear = parseInt(inv.date.split("-")[0], 10);
|
|
925
|
+
return invYear === targetYear;
|
|
926
|
+
});
|
|
927
|
+
const monthMap = /* @__PURE__ */ new Map();
|
|
928
|
+
for (const inv of yearInvoices) {
|
|
929
|
+
const monthKey = inv.date.substring(0, 7);
|
|
930
|
+
let entry = monthMap.get(monthKey);
|
|
931
|
+
if (!entry) {
|
|
932
|
+
entry = { month: monthKey, invoiced: 0, paid: 0, outstanding: 0, count: 0 };
|
|
933
|
+
monthMap.set(monthKey, entry);
|
|
934
|
+
}
|
|
935
|
+
entry.invoiced = round2(entry.invoiced + inv.total);
|
|
936
|
+
entry.count += 1;
|
|
937
|
+
if (inv.status === "paid") {
|
|
938
|
+
entry.paid = round2(entry.paid + inv.total);
|
|
939
|
+
} else {
|
|
940
|
+
entry.outstanding = round2(entry.outstanding + inv.total);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
const months = Array.from(monthMap.values()).sort(
|
|
944
|
+
(a, b) => a.month.localeCompare(b.month)
|
|
945
|
+
);
|
|
946
|
+
const total_invoiced = round2(months.reduce((s, m) => s + m.invoiced, 0));
|
|
947
|
+
const total_paid = round2(months.reduce((s, m) => s + m.paid, 0));
|
|
948
|
+
const total_outstanding = round2(months.reduce((s, m) => s + m.outstanding, 0));
|
|
949
|
+
const invoice_count = months.reduce((s, m) => s + m.count, 0);
|
|
950
|
+
return {
|
|
951
|
+
year: targetYear,
|
|
952
|
+
total_invoiced,
|
|
953
|
+
total_paid,
|
|
954
|
+
total_outstanding,
|
|
955
|
+
invoice_count,
|
|
956
|
+
months
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
function parseLineItemsFromFile(filePath) {
|
|
960
|
+
if (!fs4.existsSync(filePath)) {
|
|
961
|
+
throw new Error(`File not found: "${filePath}".`);
|
|
962
|
+
}
|
|
963
|
+
let raw;
|
|
964
|
+
try {
|
|
965
|
+
raw = fs4.readFileSync(filePath, "utf-8");
|
|
966
|
+
} catch {
|
|
967
|
+
throw new Error(`Could not read file: "${filePath}".`);
|
|
968
|
+
}
|
|
969
|
+
let parsed;
|
|
970
|
+
try {
|
|
971
|
+
parsed = JSON.parse(raw);
|
|
972
|
+
} catch {
|
|
973
|
+
throw new Error(`Invalid JSON in file: "${filePath}".`);
|
|
974
|
+
}
|
|
975
|
+
if (!Array.isArray(parsed)) {
|
|
976
|
+
throw new Error(`File must contain a JSON array of line items. Got ${typeof parsed}.`);
|
|
977
|
+
}
|
|
978
|
+
if (parsed.length === 0) {
|
|
979
|
+
throw new Error("File contains an empty array. At least one line item is required.");
|
|
980
|
+
}
|
|
981
|
+
const items = [];
|
|
982
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
983
|
+
const entry = parsed[i];
|
|
984
|
+
if (!entry || typeof entry !== "object") {
|
|
985
|
+
throw new Error(`Line item ${i + 1}: must be an object with description, quantity, unit_price.`);
|
|
986
|
+
}
|
|
987
|
+
const description = entry.description;
|
|
988
|
+
const quantity = entry.quantity;
|
|
989
|
+
const unit_price = entry.unit_price;
|
|
990
|
+
if (typeof description !== "string" || description.trim().length === 0) {
|
|
991
|
+
throw new Error(`Line item ${i + 1}: "description" is required and must be a non-empty string.`);
|
|
992
|
+
}
|
|
993
|
+
if (typeof quantity !== "number" || quantity <= 0) {
|
|
994
|
+
throw new Error(`Line item ${i + 1}: "quantity" must be a number > 0.`);
|
|
995
|
+
}
|
|
996
|
+
if (typeof unit_price !== "number" || unit_price < 0) {
|
|
997
|
+
throw new Error(`Line item ${i + 1}: "unit_price" must be a number >= 0.`);
|
|
998
|
+
}
|
|
999
|
+
items.push({
|
|
1000
|
+
description: description.trim(),
|
|
1001
|
+
quantity,
|
|
1002
|
+
unit_price
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
return items;
|
|
1006
|
+
}
|
|
1007
|
+
var crypto5, fs4, VALID_TRANSITIONS;
|
|
1008
|
+
var init_invoice = __esm({
|
|
1009
|
+
"src/core/invoice.ts"() {
|
|
1010
|
+
"use strict";
|
|
1011
|
+
crypto5 = __toESM(require("crypto"));
|
|
1012
|
+
fs4 = __toESM(require("fs"));
|
|
1013
|
+
init_paths();
|
|
1014
|
+
init_store();
|
|
1015
|
+
init_client();
|
|
1016
|
+
init_config();
|
|
1017
|
+
init_license();
|
|
1018
|
+
init_date();
|
|
1019
|
+
VALID_TRANSITIONS = {
|
|
1020
|
+
draft: ["sent"],
|
|
1021
|
+
sent: ["paid"],
|
|
1022
|
+
paid: []
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
// src/templates/default.ts
|
|
1028
|
+
var PAGE, MARGIN, CONTENT_WIDTH, FONT, FONT_SIZE, COLOR, SPACING, TABLE, TOTALS;
|
|
1029
|
+
var init_default = __esm({
|
|
1030
|
+
"src/templates/default.ts"() {
|
|
1031
|
+
"use strict";
|
|
1032
|
+
PAGE = {
|
|
1033
|
+
width: 612,
|
|
1034
|
+
height: 792
|
|
1035
|
+
};
|
|
1036
|
+
MARGIN = {
|
|
1037
|
+
top: 50,
|
|
1038
|
+
bottom: 50,
|
|
1039
|
+
left: 50,
|
|
1040
|
+
right: 50
|
|
1041
|
+
};
|
|
1042
|
+
CONTENT_WIDTH = PAGE.width - MARGIN.left - MARGIN.right;
|
|
1043
|
+
FONT = {
|
|
1044
|
+
heading: "Helvetica-Bold",
|
|
1045
|
+
body: "Helvetica",
|
|
1046
|
+
bodyBold: "Helvetica-Bold"
|
|
1047
|
+
};
|
|
1048
|
+
FONT_SIZE = {
|
|
1049
|
+
title: 28,
|
|
1050
|
+
sectionHeading: 11,
|
|
1051
|
+
body: 10,
|
|
1052
|
+
small: 8,
|
|
1053
|
+
tableHeader: 9,
|
|
1054
|
+
tableBody: 9,
|
|
1055
|
+
totalLabel: 10,
|
|
1056
|
+
totalValue: 10,
|
|
1057
|
+
grandTotal: 12,
|
|
1058
|
+
footer: 9
|
|
1059
|
+
};
|
|
1060
|
+
COLOR = {
|
|
1061
|
+
primary: "#333333",
|
|
1062
|
+
secondary: "#666666",
|
|
1063
|
+
accent: "#2563EB",
|
|
1064
|
+
// blue-600
|
|
1065
|
+
border: "#D1D5DB",
|
|
1066
|
+
// gray-300
|
|
1067
|
+
tableHeaderBg: "#F3F4F6",
|
|
1068
|
+
// gray-100
|
|
1069
|
+
tableHeaderText: "#374151",
|
|
1070
|
+
// gray-700
|
|
1071
|
+
white: "#FFFFFF",
|
|
1072
|
+
lightGray: "#F9FAFB"
|
|
1073
|
+
// gray-50
|
|
1074
|
+
};
|
|
1075
|
+
SPACING = {
|
|
1076
|
+
sectionGap: 20,
|
|
1077
|
+
lineHeight: 14,
|
|
1078
|
+
tableRowHeight: 22,
|
|
1079
|
+
tableHeaderHeight: 24,
|
|
1080
|
+
afterTitle: 4,
|
|
1081
|
+
beforeTable: 12,
|
|
1082
|
+
afterTable: 8
|
|
1083
|
+
};
|
|
1084
|
+
TABLE = {
|
|
1085
|
+
/** Column definitions: x-offset from left margin and width */
|
|
1086
|
+
columns: {
|
|
1087
|
+
description: { x: 0, width: 272 },
|
|
1088
|
+
quantity: { x: 272, width: 60 },
|
|
1089
|
+
rate: { x: 332, width: 90 },
|
|
1090
|
+
amount: { x: 422, width: 90 }
|
|
1091
|
+
},
|
|
1092
|
+
/** Total width (should equal CONTENT_WIDTH) */
|
|
1093
|
+
totalWidth: CONTENT_WIDTH
|
|
1094
|
+
};
|
|
1095
|
+
TOTALS = {
|
|
1096
|
+
labelWidth: 100,
|
|
1097
|
+
valueWidth: 100,
|
|
1098
|
+
/** x start of the totals block (right-aligned on the page) */
|
|
1099
|
+
x: MARGIN.left + CONTENT_WIDTH - 100 - 100
|
|
1100
|
+
// 312
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
// src/core/pdf.ts
|
|
1106
|
+
var pdf_exports = {};
|
|
1107
|
+
__export(pdf_exports, {
|
|
1108
|
+
generatePDF: () => generatePDF
|
|
1109
|
+
});
|
|
1110
|
+
async function generatePDF(invoice, config, options) {
|
|
1111
|
+
const pdfkit = await import("pdfkit");
|
|
1112
|
+
const PDFDocument = pdfkit.default;
|
|
1113
|
+
const doc = new PDFDocument({
|
|
1114
|
+
size: "LETTER",
|
|
1115
|
+
compress: options?.compress ?? true,
|
|
1116
|
+
margins: {
|
|
1117
|
+
top: MARGIN.top,
|
|
1118
|
+
bottom: MARGIN.bottom,
|
|
1119
|
+
left: MARGIN.left,
|
|
1120
|
+
right: MARGIN.right
|
|
1121
|
+
},
|
|
1122
|
+
info: {
|
|
1123
|
+
Title: `Invoice ${invoice.number}`,
|
|
1124
|
+
Author: config.sender.name || config.sender.business_name || "CLI Invoice",
|
|
1125
|
+
Subject: `Invoice for ${invoice.client_snapshot.name}`,
|
|
1126
|
+
Creator: "CLI Invoice"
|
|
1127
|
+
},
|
|
1128
|
+
autoFirstPage: true,
|
|
1129
|
+
bufferPages: true
|
|
1130
|
+
});
|
|
1131
|
+
const bufferPromise = collectBuffer(doc);
|
|
1132
|
+
let y = MARGIN.top;
|
|
1133
|
+
y = renderHeader(doc, invoice, config, y);
|
|
1134
|
+
y += SPACING.sectionGap;
|
|
1135
|
+
y = renderSenderAndMeta(doc, invoice, config, y);
|
|
1136
|
+
y += SPACING.sectionGap;
|
|
1137
|
+
y = renderBillTo(doc, invoice, y);
|
|
1138
|
+
y += SPACING.sectionGap;
|
|
1139
|
+
y = renderLineItemsTable(doc, invoice, y);
|
|
1140
|
+
y += SPACING.afterTable;
|
|
1141
|
+
y = renderTotals(doc, invoice, y);
|
|
1142
|
+
y += SPACING.sectionGap;
|
|
1143
|
+
if (invoice.notes && invoice.notes.trim().length > 0) {
|
|
1144
|
+
y = renderNotes(doc, invoice, y);
|
|
1145
|
+
y += SPACING.sectionGap;
|
|
1146
|
+
}
|
|
1147
|
+
renderFooter(doc);
|
|
1148
|
+
doc.end();
|
|
1149
|
+
return bufferPromise;
|
|
1150
|
+
}
|
|
1151
|
+
function collectBuffer(doc) {
|
|
1152
|
+
return new Promise((resolve2, reject) => {
|
|
1153
|
+
const passThrough = new import_stream.PassThrough();
|
|
1154
|
+
const chunks = [];
|
|
1155
|
+
passThrough.on("data", (chunk) => chunks.push(chunk));
|
|
1156
|
+
passThrough.on("end", () => resolve2(Buffer.concat(chunks)));
|
|
1157
|
+
passThrough.on("error", reject);
|
|
1158
|
+
doc.pipe(passThrough);
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
function renderHeader(doc, _invoice, config, y) {
|
|
1162
|
+
let logoEndX = MARGIN.left;
|
|
1163
|
+
if (config.sender.logo && config.sender.logo.trim().length > 0) {
|
|
1164
|
+
try {
|
|
1165
|
+
if (fs5.existsSync(config.sender.logo)) {
|
|
1166
|
+
doc.image(config.sender.logo, MARGIN.left, y, {
|
|
1167
|
+
height: 50
|
|
1168
|
+
});
|
|
1169
|
+
logoEndX = MARGIN.left + 60;
|
|
1170
|
+
}
|
|
1171
|
+
} catch {
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
doc.font(FONT.heading).fontSize(FONT_SIZE.title).fillColor(COLOR.accent).text("INVOICE", logoEndX, y, {
|
|
1175
|
+
align: "right",
|
|
1176
|
+
width: MARGIN.left + CONTENT_WIDTH - logoEndX
|
|
1177
|
+
});
|
|
1178
|
+
return y + FONT_SIZE.title + SPACING.afterTitle;
|
|
1179
|
+
}
|
|
1180
|
+
function renderSenderAndMeta(doc, invoice, config, startY) {
|
|
1181
|
+
const halfWidth = CONTENT_WIDTH / 2;
|
|
1182
|
+
let leftY = startY;
|
|
1183
|
+
let rightY = startY;
|
|
1184
|
+
const sender = config.sender;
|
|
1185
|
+
if (sender.business_name) {
|
|
1186
|
+
doc.font(FONT.bodyBold).fontSize(FONT_SIZE.sectionHeading).fillColor(COLOR.primary);
|
|
1187
|
+
doc.text(sender.business_name, MARGIN.left, leftY, { width: halfWidth });
|
|
1188
|
+
leftY += SPACING.lineHeight;
|
|
1189
|
+
}
|
|
1190
|
+
if (sender.name && sender.name !== sender.business_name) {
|
|
1191
|
+
doc.font(FONT.body).fontSize(FONT_SIZE.body).fillColor(COLOR.primary);
|
|
1192
|
+
doc.text(sender.name, MARGIN.left, leftY, { width: halfWidth });
|
|
1193
|
+
leftY += SPACING.lineHeight;
|
|
1194
|
+
}
|
|
1195
|
+
if (sender.email) {
|
|
1196
|
+
doc.font(FONT.body).fontSize(FONT_SIZE.body).fillColor(COLOR.secondary);
|
|
1197
|
+
doc.text(sender.email, MARGIN.left, leftY, { width: halfWidth });
|
|
1198
|
+
leftY += SPACING.lineHeight;
|
|
1199
|
+
}
|
|
1200
|
+
if (sender.address) {
|
|
1201
|
+
const lines = sender.address.split(/\\n|\n/);
|
|
1202
|
+
for (const line of lines) {
|
|
1203
|
+
if (line.trim()) {
|
|
1204
|
+
doc.font(FONT.body).fontSize(FONT_SIZE.body).fillColor(COLOR.secondary);
|
|
1205
|
+
doc.text(line.trim(), MARGIN.left, leftY, { width: halfWidth });
|
|
1206
|
+
leftY += SPACING.lineHeight;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
const rightX = MARGIN.left + halfWidth;
|
|
1211
|
+
const labelWidth = 60;
|
|
1212
|
+
const valueWidth = halfWidth - labelWidth;
|
|
1213
|
+
const metaLines = [
|
|
1214
|
+
{ label: "Invoice:", value: invoice.number },
|
|
1215
|
+
{ label: "Date:", value: invoice.date },
|
|
1216
|
+
{ label: "Due:", value: invoice.due_date }
|
|
1217
|
+
];
|
|
1218
|
+
if (invoice.status === "paid" && invoice.paid_date) {
|
|
1219
|
+
metaLines.push({ label: "Paid:", value: invoice.paid_date });
|
|
1220
|
+
}
|
|
1221
|
+
for (const meta of metaLines) {
|
|
1222
|
+
doc.font(FONT.bodyBold).fontSize(FONT_SIZE.body).fillColor(COLOR.secondary);
|
|
1223
|
+
doc.text(meta.label, rightX, rightY, { width: labelWidth });
|
|
1224
|
+
doc.font(FONT.body).fontSize(FONT_SIZE.body).fillColor(COLOR.primary);
|
|
1225
|
+
doc.text(meta.value, rightX + labelWidth, rightY, { width: valueWidth });
|
|
1226
|
+
rightY += SPACING.lineHeight;
|
|
1227
|
+
}
|
|
1228
|
+
return Math.max(leftY, rightY);
|
|
1229
|
+
}
|
|
1230
|
+
function renderBillTo(doc, invoice, y) {
|
|
1231
|
+
const client = invoice.client_snapshot;
|
|
1232
|
+
doc.font(FONT.bodyBold).fontSize(FONT_SIZE.sectionHeading).fillColor(COLOR.accent);
|
|
1233
|
+
doc.text("Bill To", MARGIN.left, y);
|
|
1234
|
+
y += SPACING.lineHeight + 2;
|
|
1235
|
+
doc.font(FONT.bodyBold).fontSize(FONT_SIZE.body).fillColor(COLOR.primary);
|
|
1236
|
+
doc.text(client.name, MARGIN.left, y);
|
|
1237
|
+
y += SPACING.lineHeight;
|
|
1238
|
+
if (client.email) {
|
|
1239
|
+
doc.font(FONT.body).fontSize(FONT_SIZE.body).fillColor(COLOR.secondary);
|
|
1240
|
+
doc.text(client.email, MARGIN.left, y);
|
|
1241
|
+
y += SPACING.lineHeight;
|
|
1242
|
+
}
|
|
1243
|
+
if (client.address) {
|
|
1244
|
+
const lines = client.address.split(/\\n|\n/);
|
|
1245
|
+
for (const line of lines) {
|
|
1246
|
+
if (line.trim()) {
|
|
1247
|
+
doc.font(FONT.body).fontSize(FONT_SIZE.body).fillColor(COLOR.secondary);
|
|
1248
|
+
doc.text(line.trim(), MARGIN.left, y);
|
|
1249
|
+
y += SPACING.lineHeight;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
return y;
|
|
1254
|
+
}
|
|
1255
|
+
function renderLineItemsTable(doc, invoice, y) {
|
|
1256
|
+
const cols = TABLE.columns;
|
|
1257
|
+
doc.rect(MARGIN.left, y, CONTENT_WIDTH, SPACING.tableHeaderHeight).fill(COLOR.tableHeaderBg);
|
|
1258
|
+
const headerTextY = y + (SPACING.tableHeaderHeight - FONT_SIZE.tableHeader) / 2;
|
|
1259
|
+
doc.font(FONT.bodyBold).fontSize(FONT_SIZE.tableHeader).fillColor(COLOR.tableHeaderText);
|
|
1260
|
+
doc.text("Description", MARGIN.left + cols.description.x + 6, headerTextY, {
|
|
1261
|
+
width: cols.description.width - 12
|
|
1262
|
+
});
|
|
1263
|
+
doc.text("Qty", MARGIN.left + cols.quantity.x, headerTextY, {
|
|
1264
|
+
width: cols.quantity.width - 6,
|
|
1265
|
+
align: "right"
|
|
1266
|
+
});
|
|
1267
|
+
doc.text("Rate", MARGIN.left + cols.rate.x, headerTextY, {
|
|
1268
|
+
width: cols.rate.width - 6,
|
|
1269
|
+
align: "right"
|
|
1270
|
+
});
|
|
1271
|
+
doc.text("Amount", MARGIN.left + cols.amount.x, headerTextY, {
|
|
1272
|
+
width: cols.amount.width - 6,
|
|
1273
|
+
align: "right"
|
|
1274
|
+
});
|
|
1275
|
+
y += SPACING.tableHeaderHeight;
|
|
1276
|
+
doc.moveTo(MARGIN.left, y).lineTo(MARGIN.left + CONTENT_WIDTH, y).strokeColor(COLOR.border).lineWidth(0.5).stroke();
|
|
1277
|
+
for (let i = 0; i < invoice.items.length; i++) {
|
|
1278
|
+
const item = invoice.items[i];
|
|
1279
|
+
const amount = item.quantity * item.unit_price;
|
|
1280
|
+
const rowY = y + (SPACING.tableRowHeight - FONT_SIZE.tableBody) / 2;
|
|
1281
|
+
if (i % 2 === 1) {
|
|
1282
|
+
doc.rect(MARGIN.left, y, CONTENT_WIDTH, SPACING.tableRowHeight).fill(COLOR.lightGray);
|
|
1283
|
+
}
|
|
1284
|
+
doc.font(FONT.body).fontSize(FONT_SIZE.tableBody).fillColor(COLOR.primary);
|
|
1285
|
+
doc.text(item.description, MARGIN.left + cols.description.x + 6, rowY, {
|
|
1286
|
+
width: cols.description.width - 12
|
|
1287
|
+
});
|
|
1288
|
+
doc.text(String(item.quantity), MARGIN.left + cols.quantity.x, rowY, {
|
|
1289
|
+
width: cols.quantity.width - 6,
|
|
1290
|
+
align: "right"
|
|
1291
|
+
});
|
|
1292
|
+
doc.text(
|
|
1293
|
+
formatCurrency(item.unit_price, invoice.currency),
|
|
1294
|
+
MARGIN.left + cols.rate.x,
|
|
1295
|
+
rowY,
|
|
1296
|
+
{ width: cols.rate.width - 6, align: "right" }
|
|
1297
|
+
);
|
|
1298
|
+
doc.text(
|
|
1299
|
+
formatCurrency(amount, invoice.currency),
|
|
1300
|
+
MARGIN.left + cols.amount.x,
|
|
1301
|
+
rowY,
|
|
1302
|
+
{ width: cols.amount.width - 6, align: "right" }
|
|
1303
|
+
);
|
|
1304
|
+
y += SPACING.tableRowHeight;
|
|
1305
|
+
}
|
|
1306
|
+
doc.moveTo(MARGIN.left, y).lineTo(MARGIN.left + CONTENT_WIDTH, y).strokeColor(COLOR.border).lineWidth(0.5).stroke();
|
|
1307
|
+
return y;
|
|
1308
|
+
}
|
|
1309
|
+
function renderTotals(doc, invoice, y) {
|
|
1310
|
+
const labelX = TOTALS.x;
|
|
1311
|
+
const valueX = labelX + TOTALS.labelWidth;
|
|
1312
|
+
const valueWidth = TOTALS.valueWidth;
|
|
1313
|
+
y += 6;
|
|
1314
|
+
doc.font(FONT.body).fontSize(FONT_SIZE.totalLabel).fillColor(COLOR.secondary);
|
|
1315
|
+
doc.text("Subtotal:", labelX, y, { width: TOTALS.labelWidth, align: "right" });
|
|
1316
|
+
doc.font(FONT.body).fontSize(FONT_SIZE.totalValue).fillColor(COLOR.primary);
|
|
1317
|
+
doc.text(formatCurrency(invoice.subtotal, invoice.currency), valueX, y, {
|
|
1318
|
+
width: valueWidth,
|
|
1319
|
+
align: "right"
|
|
1320
|
+
});
|
|
1321
|
+
y += SPACING.lineHeight;
|
|
1322
|
+
const taxLabel = `Tax (${invoice.tax_rate}%):`;
|
|
1323
|
+
doc.font(FONT.body).fontSize(FONT_SIZE.totalLabel).fillColor(COLOR.secondary);
|
|
1324
|
+
doc.text(taxLabel, labelX, y, { width: TOTALS.labelWidth, align: "right" });
|
|
1325
|
+
doc.font(FONT.body).fontSize(FONT_SIZE.totalValue).fillColor(COLOR.primary);
|
|
1326
|
+
doc.text(formatCurrency(invoice.tax_amount, invoice.currency), valueX, y, {
|
|
1327
|
+
width: valueWidth,
|
|
1328
|
+
align: "right"
|
|
1329
|
+
});
|
|
1330
|
+
y += SPACING.lineHeight + 4;
|
|
1331
|
+
doc.moveTo(labelX, y).lineTo(valueX + valueWidth, y).strokeColor(COLOR.border).lineWidth(0.5).stroke();
|
|
1332
|
+
y += 6;
|
|
1333
|
+
doc.font(FONT.heading).fontSize(FONT_SIZE.grandTotal).fillColor(COLOR.primary);
|
|
1334
|
+
doc.text("Total:", labelX, y, { width: TOTALS.labelWidth, align: "right" });
|
|
1335
|
+
doc.text(formatCurrency(invoice.total, invoice.currency), valueX, y, {
|
|
1336
|
+
width: valueWidth,
|
|
1337
|
+
align: "right"
|
|
1338
|
+
});
|
|
1339
|
+
y += FONT_SIZE.grandTotal + 4;
|
|
1340
|
+
return y;
|
|
1341
|
+
}
|
|
1342
|
+
function renderNotes(doc, invoice, y) {
|
|
1343
|
+
doc.font(FONT.bodyBold).fontSize(FONT_SIZE.sectionHeading).fillColor(COLOR.accent);
|
|
1344
|
+
doc.text("Notes", MARGIN.left, y);
|
|
1345
|
+
y += SPACING.lineHeight;
|
|
1346
|
+
doc.font(FONT.body).fontSize(FONT_SIZE.body).fillColor(COLOR.secondary);
|
|
1347
|
+
doc.text(invoice.notes, MARGIN.left, y, {
|
|
1348
|
+
width: CONTENT_WIDTH,
|
|
1349
|
+
lineGap: 2
|
|
1350
|
+
});
|
|
1351
|
+
y = doc.y;
|
|
1352
|
+
return y;
|
|
1353
|
+
}
|
|
1354
|
+
function renderFooter(doc) {
|
|
1355
|
+
const footerY = PAGE.height - MARGIN.bottom - FONT_SIZE.footer - 4;
|
|
1356
|
+
doc.moveTo(MARGIN.left, footerY - 6).lineTo(MARGIN.left + CONTENT_WIDTH, footerY - 6).strokeColor(COLOR.border).lineWidth(0.25).stroke();
|
|
1357
|
+
doc.font(FONT.body).fontSize(FONT_SIZE.footer).fillColor(COLOR.secondary);
|
|
1358
|
+
doc.text("Thank you for your business.", MARGIN.left, footerY, {
|
|
1359
|
+
width: CONTENT_WIDTH,
|
|
1360
|
+
align: "center"
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
var fs5, import_stream;
|
|
1364
|
+
var init_pdf = __esm({
|
|
1365
|
+
"src/core/pdf.ts"() {
|
|
1366
|
+
"use strict";
|
|
1367
|
+
fs5 = __toESM(require("fs"));
|
|
1368
|
+
import_stream = require("stream");
|
|
1369
|
+
init_currency();
|
|
1370
|
+
init_default();
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
// bin/inv.ts
|
|
1375
|
+
var import_commander = require("commander");
|
|
1376
|
+
|
|
1377
|
+
// src/cli/config.ts
|
|
1378
|
+
function registerInitCommand(program2) {
|
|
1379
|
+
program2.command("init").description("Set up CLI Invoice with your sender profile").action(async () => {
|
|
1380
|
+
const { initConfig: initConfig2, isConfigInitialized: isConfigInitialized2 } = await Promise.resolve().then(() => (init_config(), config_exports));
|
|
1381
|
+
const { success: success2, warn: warn2, info: info2 } = await Promise.resolve().then(() => (init_format(), format_exports));
|
|
1382
|
+
if (isConfigInitialized2()) {
|
|
1383
|
+
warn2("Config already exists. Running init will overwrite your current settings.");
|
|
1384
|
+
}
|
|
1385
|
+
try {
|
|
1386
|
+
const config = await initConfig2();
|
|
1387
|
+
console.log("");
|
|
1388
|
+
success2(`Setup complete! Config saved.`);
|
|
1389
|
+
info2(`Sender: ${config.sender.name}${config.sender.business_name ? ` (${config.sender.business_name})` : ""}`);
|
|
1390
|
+
info2(`Currency: ${config.defaults.currency}, Payment terms: Net ${config.defaults.payment_terms}`);
|
|
1391
|
+
console.log("");
|
|
1392
|
+
info2("Run `inv client add` to add your first client.");
|
|
1393
|
+
} catch (err) {
|
|
1394
|
+
if (err instanceof Error && err.message.includes("User force closed")) {
|
|
1395
|
+
console.log("\nSetup cancelled.");
|
|
1396
|
+
process.exit(0);
|
|
1397
|
+
}
|
|
1398
|
+
throw err;
|
|
1399
|
+
}
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
function registerConfigCommand(program2) {
|
|
1403
|
+
const configCmd = program2.command("config").description("View or update configuration");
|
|
1404
|
+
configCmd.command("show").description("Display current configuration").action(async () => {
|
|
1405
|
+
const { loadConfig: loadConfig2, isConfigInitialized: isConfigInitialized2 } = await Promise.resolve().then(() => (init_config(), config_exports));
|
|
1406
|
+
const { error: error2, info: info2 } = await Promise.resolve().then(() => (init_format(), format_exports));
|
|
1407
|
+
if (!isConfigInitialized2()) {
|
|
1408
|
+
error2("No config found. Run `inv init` first.");
|
|
1409
|
+
process.exit(1);
|
|
1410
|
+
}
|
|
1411
|
+
const config = loadConfig2();
|
|
1412
|
+
info2("Current configuration:\n");
|
|
1413
|
+
console.log("[sender]");
|
|
1414
|
+
console.log(` name = "${config.sender.name}"`);
|
|
1415
|
+
console.log(` business_name = "${config.sender.business_name}"`);
|
|
1416
|
+
console.log(` email = "${config.sender.email}"`);
|
|
1417
|
+
console.log(` address = "${config.sender.address}"`);
|
|
1418
|
+
console.log(` logo = "${config.sender.logo}"`);
|
|
1419
|
+
console.log("");
|
|
1420
|
+
console.log("[defaults]");
|
|
1421
|
+
console.log(` currency = "${config.defaults.currency}"`);
|
|
1422
|
+
console.log(` payment_terms = ${config.defaults.payment_terms}`);
|
|
1423
|
+
console.log(` tax_rate = ${config.defaults.tax_rate}`);
|
|
1424
|
+
console.log(` output_dir = "${config.defaults.output_dir}"`);
|
|
1425
|
+
console.log("");
|
|
1426
|
+
console.log("[invoice]");
|
|
1427
|
+
console.log(` number_prefix = "${config.invoice.number_prefix}"`);
|
|
1428
|
+
console.log(` next_number = ${config.invoice.next_number}`);
|
|
1429
|
+
console.log("");
|
|
1430
|
+
console.log("[license]");
|
|
1431
|
+
console.log(` key = "${config.license.key ? "****" : ""}"`);
|
|
1432
|
+
console.log(` activated_at = "${config.license.activated_at}"`);
|
|
1433
|
+
});
|
|
1434
|
+
configCmd.command("set").description("Update a config value (e.g., inv config set defaults.currency EUR)").argument("<key>", "Config key in section.field format (e.g., defaults.currency)").argument("<value>", "New value").action(async (key, value) => {
|
|
1435
|
+
const { updateConfig: updateConfig2, isConfigInitialized: isConfigInitialized2 } = await Promise.resolve().then(() => (init_config(), config_exports));
|
|
1436
|
+
const { success: success2, error: showError } = await Promise.resolve().then(() => (init_format(), format_exports));
|
|
1437
|
+
if (!isConfigInitialized2()) {
|
|
1438
|
+
showError("No config found. Run `inv init` first.");
|
|
1439
|
+
process.exit(1);
|
|
1440
|
+
}
|
|
1441
|
+
try {
|
|
1442
|
+
updateConfig2(key, value);
|
|
1443
|
+
success2(`Config updated: ${key} = "${value}"`);
|
|
1444
|
+
} catch (err) {
|
|
1445
|
+
if (err instanceof Error) {
|
|
1446
|
+
showError(err.message);
|
|
1447
|
+
process.exit(1);
|
|
1448
|
+
}
|
|
1449
|
+
throw err;
|
|
1450
|
+
}
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// src/cli/client.ts
|
|
1455
|
+
function registerClientCommand(program2) {
|
|
1456
|
+
const clientCmd = program2.command("client").description("Manage clients");
|
|
1457
|
+
clientCmd.command("add").description("Add a new client").option("-n, --name <name>", "Client name (required)").option("-e, --email <email>", "Client email (required)").option("-a, --address <address>", "Client address").option("-t, --terms <days>", "Payment terms in days (default: 30)").action(async (opts) => {
|
|
1458
|
+
const { addClient: addClient2 } = await Promise.resolve().then(() => (init_client(), client_exports));
|
|
1459
|
+
const { success: success2, error: showError } = await Promise.resolve().then(() => (init_format(), format_exports));
|
|
1460
|
+
if (!opts.name || !opts.email) {
|
|
1461
|
+
showError('Both --name and --email are required. Example: inv client add --name "Acme Corp" --email "billing@acme.com"');
|
|
1462
|
+
process.exit(1);
|
|
1463
|
+
}
|
|
1464
|
+
try {
|
|
1465
|
+
const client = addClient2({
|
|
1466
|
+
name: opts.name,
|
|
1467
|
+
email: opts.email,
|
|
1468
|
+
address: opts.address,
|
|
1469
|
+
payment_terms: opts.terms ? parseInt(opts.terms, 10) : void 0
|
|
1470
|
+
});
|
|
1471
|
+
success2(`Client added: ${client.name} (${client.email})`);
|
|
1472
|
+
} catch (err) {
|
|
1473
|
+
if (err instanceof Error) {
|
|
1474
|
+
showError(err.message);
|
|
1475
|
+
process.exit(1);
|
|
1476
|
+
}
|
|
1477
|
+
throw err;
|
|
1478
|
+
}
|
|
1479
|
+
});
|
|
1480
|
+
clientCmd.command("list").description("List all clients").action(async () => {
|
|
1481
|
+
const { listClients: listClients2 } = await Promise.resolve().then(() => (init_client(), client_exports));
|
|
1482
|
+
const { formatCurrency: formatCurrency2 } = await Promise.resolve().then(() => (init_currency(), currency_exports));
|
|
1483
|
+
const Table = (await import("cli-table3")).default;
|
|
1484
|
+
const clients = listClients2();
|
|
1485
|
+
if (clients.length === 0) {
|
|
1486
|
+
console.log("No clients yet. Run `inv client add` to add one.");
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
const table = new Table({
|
|
1490
|
+
head: ["Name", "Email", "Payment Terms", "Invoices", "Total Billed"],
|
|
1491
|
+
style: { head: ["cyan"] }
|
|
1492
|
+
});
|
|
1493
|
+
for (const c of clients) {
|
|
1494
|
+
table.push([
|
|
1495
|
+
c.name,
|
|
1496
|
+
c.email,
|
|
1497
|
+
`Net ${c.payment_terms}`,
|
|
1498
|
+
String(c.invoice_count),
|
|
1499
|
+
formatCurrency2(c.total_billed)
|
|
1500
|
+
]);
|
|
1501
|
+
}
|
|
1502
|
+
console.log(table.toString());
|
|
1503
|
+
});
|
|
1504
|
+
clientCmd.command("edit").description("Edit a client").argument("<name>", "Client name to edit").option("-e, --email <email>", "New email").option("-a, --address <address>", "New address").option("-t, --terms <days>", "New payment terms in days").option("-n, --name <newName>", "New name").action(async (name, opts) => {
|
|
1505
|
+
const { editClient: editClient2 } = await Promise.resolve().then(() => (init_client(), client_exports));
|
|
1506
|
+
const { success: success2, error: showError } = await Promise.resolve().then(() => (init_format(), format_exports));
|
|
1507
|
+
try {
|
|
1508
|
+
const client = editClient2(name, {
|
|
1509
|
+
email: opts.email,
|
|
1510
|
+
address: opts.address,
|
|
1511
|
+
payment_terms: opts.terms ? parseInt(opts.terms, 10) : void 0,
|
|
1512
|
+
name: opts.name
|
|
1513
|
+
});
|
|
1514
|
+
success2(`Client updated: ${client.name} (${client.email})`);
|
|
1515
|
+
} catch (err) {
|
|
1516
|
+
if (err instanceof Error) {
|
|
1517
|
+
showError(err.message);
|
|
1518
|
+
process.exit(1);
|
|
1519
|
+
}
|
|
1520
|
+
throw err;
|
|
1521
|
+
}
|
|
1522
|
+
});
|
|
1523
|
+
clientCmd.command("remove").description("Remove a client").argument("<name>", "Client name to remove").option("-y, --yes", "Skip confirmation prompt").action(async (name, opts) => {
|
|
1524
|
+
const { removeClient: removeClient2, findClientByName: findClientByName2 } = await Promise.resolve().then(() => (init_client(), client_exports));
|
|
1525
|
+
const { success: success2, warn: warn2, error: showError } = await Promise.resolve().then(() => (init_format(), format_exports));
|
|
1526
|
+
const client = findClientByName2(name);
|
|
1527
|
+
if (!client) {
|
|
1528
|
+
showError(`Client not found: "${name}".`);
|
|
1529
|
+
process.exit(1);
|
|
1530
|
+
}
|
|
1531
|
+
const { readStore: readStore2 } = await Promise.resolve().then(() => (init_store(), store_exports));
|
|
1532
|
+
const { getInvoicesPath: getInvoicesPath2 } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
1533
|
+
const { invoices } = readStore2(
|
|
1534
|
+
getInvoicesPath2(),
|
|
1535
|
+
{ invoices: [] }
|
|
1536
|
+
);
|
|
1537
|
+
const invoiceCount = invoices.filter((inv) => inv.client_id === client.id).length;
|
|
1538
|
+
if (invoiceCount > 0 && !opts.yes) {
|
|
1539
|
+
const { confirm } = await import("@inquirer/prompts");
|
|
1540
|
+
warn2(`${client.name} has ${invoiceCount} invoice(s). Invoices will retain client info as snapshots.`);
|
|
1541
|
+
const confirmed = await confirm({
|
|
1542
|
+
message: `Remove ${client.name} anyway?`,
|
|
1543
|
+
default: false
|
|
1544
|
+
});
|
|
1545
|
+
if (!confirmed) {
|
|
1546
|
+
console.log("Cancelled.");
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
} else if (!opts.yes) {
|
|
1550
|
+
const { confirm } = await import("@inquirer/prompts");
|
|
1551
|
+
const confirmed = await confirm({
|
|
1552
|
+
message: `Remove client "${client.name}"?`,
|
|
1553
|
+
default: false
|
|
1554
|
+
});
|
|
1555
|
+
if (!confirmed) {
|
|
1556
|
+
console.log("Cancelled.");
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
try {
|
|
1561
|
+
const result = removeClient2(name);
|
|
1562
|
+
success2(`Client removed: ${result.client.name}`);
|
|
1563
|
+
} catch (err) {
|
|
1564
|
+
if (err instanceof Error) {
|
|
1565
|
+
showError(err.message);
|
|
1566
|
+
process.exit(1);
|
|
1567
|
+
}
|
|
1568
|
+
throw err;
|
|
1569
|
+
}
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// src/cli/invoice.ts
|
|
1574
|
+
function collect(value, previous) {
|
|
1575
|
+
return previous.concat([value]);
|
|
1576
|
+
}
|
|
1577
|
+
function registerInvoiceCommands(program2) {
|
|
1578
|
+
program2.command("create").description("Create a new invoice").requiredOption("-c, --client <name>", "Client name (required)").option("--item <description>", "Line item description (repeatable)", collect, []).option("--qty <number>", "Line item quantity (repeatable, matches --item order)", collect, []).option("--rate <number>", "Line item unit price (repeatable, matches --item order)", collect, []).option("--from <file>", "Load line items from a JSON file").option("-d, --date <YYYY-MM-DD>", "Invoice date (default: today)").option("--notes <text>", "Notes to include on the invoice").option("--tax-rate <percent>", "Tax rate as a percentage (e.g., 8.5)").option("--currency <code>", "Currency code (e.g., USD, EUR)").action(async (opts) => {
|
|
1579
|
+
const { createInvoice: createInvoice2, parseLineItemsFromFile: parseLineItemsFromFile2 } = await Promise.resolve().then(() => (init_invoice(), invoice_exports));
|
|
1580
|
+
const { success: success2, error: showError } = await Promise.resolve().then(() => (init_format(), format_exports));
|
|
1581
|
+
const { formatCurrency: formatCurrency2 } = await Promise.resolve().then(() => (init_currency(), currency_exports));
|
|
1582
|
+
const { formatDate: formatDate2 } = await Promise.resolve().then(() => (init_date(), date_exports));
|
|
1583
|
+
try {
|
|
1584
|
+
let items;
|
|
1585
|
+
if (opts.from) {
|
|
1586
|
+
if (opts.item.length > 0) {
|
|
1587
|
+
showError("Cannot use both --item flags and --from file. Choose one.");
|
|
1588
|
+
process.exit(1);
|
|
1589
|
+
}
|
|
1590
|
+
items = parseLineItemsFromFile2(opts.from);
|
|
1591
|
+
} else {
|
|
1592
|
+
if (opts.item.length === 0) {
|
|
1593
|
+
showError(
|
|
1594
|
+
'No line items provided. Use --item/--qty/--rate flags or --from <file>.\nExample: inv create --client "Acme" --item "Dev work" --qty 10 --rate 150'
|
|
1595
|
+
);
|
|
1596
|
+
process.exit(1);
|
|
1597
|
+
}
|
|
1598
|
+
if (opts.item.length !== opts.qty.length || opts.item.length !== opts.rate.length) {
|
|
1599
|
+
showError(
|
|
1600
|
+
`Mismatched item counts: ${opts.item.length} items, ${opts.qty.length} quantities, ${opts.rate.length} rates. Each --item must have a corresponding --qty and --rate.`
|
|
1601
|
+
);
|
|
1602
|
+
process.exit(1);
|
|
1603
|
+
}
|
|
1604
|
+
items = opts.item.map((desc, i) => ({
|
|
1605
|
+
description: desc,
|
|
1606
|
+
quantity: parseFloat(opts.qty[i]),
|
|
1607
|
+
unit_price: parseFloat(opts.rate[i])
|
|
1608
|
+
}));
|
|
1609
|
+
for (let i = 0; i < items.length; i++) {
|
|
1610
|
+
if (isNaN(items[i].quantity) || items[i].quantity <= 0) {
|
|
1611
|
+
showError(`Invalid quantity for item "${opts.item[i]}": "${opts.qty[i]}". Must be a positive number.`);
|
|
1612
|
+
process.exit(1);
|
|
1613
|
+
}
|
|
1614
|
+
if (isNaN(items[i].unit_price) || items[i].unit_price < 0) {
|
|
1615
|
+
showError(`Invalid rate for item "${opts.item[i]}": "${opts.rate[i]}". Must be a non-negative number.`);
|
|
1616
|
+
process.exit(1);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
const invoice = createInvoice2({
|
|
1621
|
+
clientName: opts.client,
|
|
1622
|
+
items,
|
|
1623
|
+
date: opts.date,
|
|
1624
|
+
notes: opts.notes,
|
|
1625
|
+
taxRate: opts.taxRate !== void 0 ? parseFloat(opts.taxRate) : void 0,
|
|
1626
|
+
currency: opts.currency
|
|
1627
|
+
});
|
|
1628
|
+
success2(`Invoice ${invoice.number} created. Total: ${formatCurrency2(invoice.total, invoice.currency)}. Due: ${formatDate2(invoice.due_date)}.`);
|
|
1629
|
+
} catch (err) {
|
|
1630
|
+
if (err instanceof Error) {
|
|
1631
|
+
showError(err.message);
|
|
1632
|
+
process.exit(1);
|
|
1633
|
+
}
|
|
1634
|
+
throw err;
|
|
1635
|
+
}
|
|
1636
|
+
});
|
|
1637
|
+
program2.command("show").description("Display full invoice details").argument("<id>", "Invoice ID or number (e.g., INV-0001)").action(async (idOrNumber) => {
|
|
1638
|
+
const { getInvoice: getInvoice2, computeDisplayStatus: computeDisplayStatus2 } = await Promise.resolve().then(() => (init_invoice(), invoice_exports));
|
|
1639
|
+
const { formatCurrency: formatCurrency2 } = await Promise.resolve().then(() => (init_currency(), currency_exports));
|
|
1640
|
+
const { formatDate: formatDate2 } = await Promise.resolve().then(() => (init_date(), date_exports));
|
|
1641
|
+
const { error: showError } = await Promise.resolve().then(() => (init_format(), format_exports));
|
|
1642
|
+
const chalk2 = (await import("chalk")).default;
|
|
1643
|
+
const invoice = getInvoice2(idOrNumber);
|
|
1644
|
+
if (!invoice) {
|
|
1645
|
+
showError(`Invoice not found: "${idOrNumber}".`);
|
|
1646
|
+
process.exit(1);
|
|
1647
|
+
}
|
|
1648
|
+
const displayStatus = computeDisplayStatus2(invoice);
|
|
1649
|
+
const statusColor = displayStatus === "paid" ? chalk2.green : displayStatus === "overdue" ? chalk2.red : displayStatus === "sent" ? chalk2.yellow : chalk2.gray;
|
|
1650
|
+
console.log("");
|
|
1651
|
+
console.log(chalk2.bold(`Invoice ${invoice.number}`) + " " + statusColor(`[${displayStatus}]`));
|
|
1652
|
+
console.log("\u2500".repeat(50));
|
|
1653
|
+
console.log(`Client: ${invoice.client_snapshot.name}`);
|
|
1654
|
+
console.log(`Email: ${invoice.client_snapshot.email}`);
|
|
1655
|
+
if (invoice.client_snapshot.address) {
|
|
1656
|
+
console.log(`Address: ${invoice.client_snapshot.address}`);
|
|
1657
|
+
}
|
|
1658
|
+
console.log(`Date: ${formatDate2(invoice.date)}`);
|
|
1659
|
+
console.log(`Due: ${formatDate2(invoice.due_date)}`);
|
|
1660
|
+
if (invoice.paid_date) {
|
|
1661
|
+
console.log(`Paid: ${formatDate2(invoice.paid_date)}`);
|
|
1662
|
+
}
|
|
1663
|
+
console.log(`Currency: ${invoice.currency}`);
|
|
1664
|
+
console.log("");
|
|
1665
|
+
const Table = (await import("cli-table3")).default;
|
|
1666
|
+
const table = new Table({
|
|
1667
|
+
head: ["Description", "Qty", "Rate", "Amount"],
|
|
1668
|
+
style: { head: ["cyan"] },
|
|
1669
|
+
colAligns: ["left", "right", "right", "right"]
|
|
1670
|
+
});
|
|
1671
|
+
for (const item of invoice.items) {
|
|
1672
|
+
const amount = item.quantity * item.unit_price;
|
|
1673
|
+
table.push([
|
|
1674
|
+
item.description,
|
|
1675
|
+
String(item.quantity),
|
|
1676
|
+
formatCurrency2(item.unit_price, invoice.currency),
|
|
1677
|
+
formatCurrency2(amount, invoice.currency)
|
|
1678
|
+
]);
|
|
1679
|
+
}
|
|
1680
|
+
console.log(table.toString());
|
|
1681
|
+
console.log("");
|
|
1682
|
+
console.log(` Subtotal: ${formatCurrency2(invoice.subtotal, invoice.currency)}`);
|
|
1683
|
+
console.log(` Tax (${invoice.tax_rate}%): ${formatCurrency2(invoice.tax_amount, invoice.currency)}`);
|
|
1684
|
+
console.log(chalk2.bold(` Total: ${formatCurrency2(invoice.total, invoice.currency)}`));
|
|
1685
|
+
if (invoice.notes) {
|
|
1686
|
+
console.log("");
|
|
1687
|
+
console.log(`Notes: ${invoice.notes}`);
|
|
1688
|
+
}
|
|
1689
|
+
console.log("");
|
|
1690
|
+
});
|
|
1691
|
+
program2.command("edit").description("Edit a draft invoice").argument("<id>", "Invoice ID or number").option("--notes <text>", "Update notes").option("-d, --date <YYYY-MM-DD>", "Update invoice date").option("--tax-rate <percent>", "Update tax rate").option("--currency <code>", "Update currency").action(async (idOrNumber, opts) => {
|
|
1692
|
+
const { editInvoice: editInvoice2 } = await Promise.resolve().then(() => (init_invoice(), invoice_exports));
|
|
1693
|
+
const { success: success2, error: showError } = await Promise.resolve().then(() => (init_format(), format_exports));
|
|
1694
|
+
try {
|
|
1695
|
+
const invoice = editInvoice2(idOrNumber, {
|
|
1696
|
+
notes: opts.notes,
|
|
1697
|
+
date: opts.date,
|
|
1698
|
+
taxRate: opts.taxRate !== void 0 ? parseFloat(opts.taxRate) : void 0,
|
|
1699
|
+
currency: opts.currency
|
|
1700
|
+
});
|
|
1701
|
+
success2(`Invoice ${invoice.number} updated.`);
|
|
1702
|
+
} catch (err) {
|
|
1703
|
+
if (err instanceof Error) {
|
|
1704
|
+
showError(err.message);
|
|
1705
|
+
process.exit(1);
|
|
1706
|
+
}
|
|
1707
|
+
throw err;
|
|
1708
|
+
}
|
|
1709
|
+
});
|
|
1710
|
+
program2.command("delete").description("Delete a draft invoice").argument("<id>", "Invoice ID or number").option("-y, --yes", "Skip confirmation prompt").action(async (idOrNumber, opts) => {
|
|
1711
|
+
const { getInvoice: getInvoice2, deleteInvoice: deleteInvoice2 } = await Promise.resolve().then(() => (init_invoice(), invoice_exports));
|
|
1712
|
+
const { success: success2, error: showError } = await Promise.resolve().then(() => (init_format(), format_exports));
|
|
1713
|
+
const invoice = getInvoice2(idOrNumber);
|
|
1714
|
+
if (!invoice) {
|
|
1715
|
+
showError(`Invoice not found: "${idOrNumber}".`);
|
|
1716
|
+
process.exit(1);
|
|
1717
|
+
}
|
|
1718
|
+
if (!opts.yes) {
|
|
1719
|
+
const { confirm } = await import("@inquirer/prompts");
|
|
1720
|
+
const confirmed = await confirm({
|
|
1721
|
+
message: `Delete invoice ${invoice.number} (${invoice.client_snapshot.name}, $${invoice.total})?`,
|
|
1722
|
+
default: false
|
|
1723
|
+
});
|
|
1724
|
+
if (!confirmed) {
|
|
1725
|
+
console.log("Cancelled.");
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
try {
|
|
1730
|
+
deleteInvoice2(idOrNumber);
|
|
1731
|
+
success2(`Invoice ${invoice.number} deleted.`);
|
|
1732
|
+
} catch (err) {
|
|
1733
|
+
if (err instanceof Error) {
|
|
1734
|
+
showError(err.message);
|
|
1735
|
+
process.exit(1);
|
|
1736
|
+
}
|
|
1737
|
+
throw err;
|
|
1738
|
+
}
|
|
1739
|
+
});
|
|
1740
|
+
program2.command("list").description("List all invoices").option("-s, --status <status>", "Filter by status (draft, sent, paid, overdue)").option("-c, --client <name>", "Filter by client name").action(async (opts) => {
|
|
1741
|
+
const { listInvoices: listInvoices2 } = await Promise.resolve().then(() => (init_invoice(), invoice_exports));
|
|
1742
|
+
const { formatCurrency: formatCurrency2 } = await Promise.resolve().then(() => (init_currency(), currency_exports));
|
|
1743
|
+
const chalk2 = (await import("chalk")).default;
|
|
1744
|
+
const Table = (await import("cli-table3")).default;
|
|
1745
|
+
const filter = {};
|
|
1746
|
+
if (opts.status) {
|
|
1747
|
+
const validStatuses = ["draft", "sent", "paid", "overdue"];
|
|
1748
|
+
if (!validStatuses.includes(opts.status)) {
|
|
1749
|
+
const { error: showError } = await Promise.resolve().then(() => (init_format(), format_exports));
|
|
1750
|
+
showError(`Invalid status: "${opts.status}". Valid: ${validStatuses.join(", ")}`);
|
|
1751
|
+
process.exit(1);
|
|
1752
|
+
}
|
|
1753
|
+
filter.status = opts.status;
|
|
1754
|
+
}
|
|
1755
|
+
if (opts.client) {
|
|
1756
|
+
filter.clientName = opts.client;
|
|
1757
|
+
}
|
|
1758
|
+
const invoices = listInvoices2(filter);
|
|
1759
|
+
if (invoices.length === 0) {
|
|
1760
|
+
console.log("No invoices found.");
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
const table = new Table({
|
|
1764
|
+
head: ["Number", "Client", "Date", "Due", "Amount", "Status"],
|
|
1765
|
+
style: { head: ["cyan"] }
|
|
1766
|
+
});
|
|
1767
|
+
for (const inv of invoices) {
|
|
1768
|
+
const statusColor = inv.display_status === "paid" ? chalk2.green : inv.display_status === "overdue" ? chalk2.red : inv.display_status === "sent" ? chalk2.yellow : chalk2.gray;
|
|
1769
|
+
table.push([
|
|
1770
|
+
inv.number,
|
|
1771
|
+
inv.client_snapshot.name,
|
|
1772
|
+
inv.date,
|
|
1773
|
+
inv.due_date,
|
|
1774
|
+
formatCurrency2(inv.total, inv.currency),
|
|
1775
|
+
statusColor(inv.display_status)
|
|
1776
|
+
]);
|
|
1777
|
+
}
|
|
1778
|
+
console.log(table.toString());
|
|
1779
|
+
});
|
|
1780
|
+
program2.command("summary").description("Revenue summary").option("-y, --year <YYYY>", "Year to summarize (default: current year)").action(async (opts) => {
|
|
1781
|
+
const { getSummary: getSummary2 } = await Promise.resolve().then(() => (init_invoice(), invoice_exports));
|
|
1782
|
+
const { formatCurrency: formatCurrency2 } = await Promise.resolve().then(() => (init_currency(), currency_exports));
|
|
1783
|
+
const chalk2 = (await import("chalk")).default;
|
|
1784
|
+
const Table = (await import("cli-table3")).default;
|
|
1785
|
+
const year = opts.year ? parseInt(opts.year, 10) : void 0;
|
|
1786
|
+
if (opts.year && (isNaN(year) || year < 1900 || year > 2200)) {
|
|
1787
|
+
const { error: showError } = await Promise.resolve().then(() => (init_format(), format_exports));
|
|
1788
|
+
showError(`Invalid year: "${opts.year}".`);
|
|
1789
|
+
process.exit(1);
|
|
1790
|
+
}
|
|
1791
|
+
const summary = getSummary2(year);
|
|
1792
|
+
console.log("");
|
|
1793
|
+
console.log(chalk2.bold(`Revenue Summary \u2014 ${summary.year}`));
|
|
1794
|
+
console.log("\u2500".repeat(50));
|
|
1795
|
+
console.log(`Total invoiced: ${formatCurrency2(summary.total_invoiced)}`);
|
|
1796
|
+
console.log(`Total paid: ${chalk2.green(formatCurrency2(summary.total_paid))}`);
|
|
1797
|
+
console.log(`Total outstanding: ${chalk2.yellow(formatCurrency2(summary.total_outstanding))}`);
|
|
1798
|
+
console.log(`Invoice count: ${summary.invoice_count}`);
|
|
1799
|
+
if (summary.months.length > 0) {
|
|
1800
|
+
console.log("");
|
|
1801
|
+
const table = new Table({
|
|
1802
|
+
head: ["Month", "Invoiced", "Paid", "Outstanding", "Count"],
|
|
1803
|
+
style: { head: ["cyan"] }
|
|
1804
|
+
});
|
|
1805
|
+
for (const m of summary.months) {
|
|
1806
|
+
table.push([
|
|
1807
|
+
m.month,
|
|
1808
|
+
formatCurrency2(m.invoiced),
|
|
1809
|
+
formatCurrency2(m.paid),
|
|
1810
|
+
formatCurrency2(m.outstanding),
|
|
1811
|
+
String(m.count)
|
|
1812
|
+
]);
|
|
1813
|
+
}
|
|
1814
|
+
console.log(table.toString());
|
|
1815
|
+
}
|
|
1816
|
+
console.log("");
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// src/cli/status.ts
|
|
1821
|
+
function registerStatusCommand(program2) {
|
|
1822
|
+
program2.command("status").description("Update invoice status (draft -> sent -> paid)").argument("<id>", "Invoice ID or number (e.g., INV-0001)").argument("<newStatus>", 'New status: "sent" or "paid"').option("-d, --date <YYYY-MM-DD>", 'Payment date (only for "paid"; default: today)').action(async (idOrNumber, newStatus, opts) => {
|
|
1823
|
+
const { updateStatus: updateStatus2 } = await Promise.resolve().then(() => (init_invoice(), invoice_exports));
|
|
1824
|
+
const { success: success2, error: showError } = await Promise.resolve().then(() => (init_format(), format_exports));
|
|
1825
|
+
const { formatDate: formatDate2 } = await Promise.resolve().then(() => (init_date(), date_exports));
|
|
1826
|
+
const validStatuses = ["sent", "paid"];
|
|
1827
|
+
if (!validStatuses.includes(newStatus)) {
|
|
1828
|
+
showError(
|
|
1829
|
+
`Invalid status: "${newStatus}". Valid values: ${validStatuses.join(", ")}.
|
|
1830
|
+
Transitions: draft -> sent -> paid`
|
|
1831
|
+
);
|
|
1832
|
+
process.exit(1);
|
|
1833
|
+
}
|
|
1834
|
+
try {
|
|
1835
|
+
const invoice = updateStatus2(
|
|
1836
|
+
idOrNumber,
|
|
1837
|
+
newStatus,
|
|
1838
|
+
opts.date
|
|
1839
|
+
);
|
|
1840
|
+
if (newStatus === "paid") {
|
|
1841
|
+
success2(`${invoice.number} marked as paid (${formatDate2(invoice.paid_date)}).`);
|
|
1842
|
+
} else {
|
|
1843
|
+
success2(`${invoice.number} marked as ${newStatus}.`);
|
|
1844
|
+
}
|
|
1845
|
+
} catch (err) {
|
|
1846
|
+
if (err instanceof Error) {
|
|
1847
|
+
showError(err.message);
|
|
1848
|
+
process.exit(1);
|
|
1849
|
+
}
|
|
1850
|
+
throw err;
|
|
1851
|
+
}
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
// src/cli/export.ts
|
|
1856
|
+
var fs6 = __toESM(require("fs"));
|
|
1857
|
+
var path4 = __toESM(require("path"));
|
|
1858
|
+
function slugify(text) {
|
|
1859
|
+
return text.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1860
|
+
}
|
|
1861
|
+
function registerExportCommand(program2) {
|
|
1862
|
+
program2.command("export").description("Generate a PDF invoice").argument("[id]", "Invoice ID or number (e.g., INV-0001)").option("-l, --latest", "Export the most recently created invoice").option("-o, --output <path>", "Output file path or directory").action(async (idArg, opts) => {
|
|
1863
|
+
const { getInvoice: getInvoice2, listInvoices: listInvoices2 } = await Promise.resolve().then(() => (init_invoice(), invoice_exports));
|
|
1864
|
+
const { generatePDF: generatePDF2 } = await Promise.resolve().then(() => (init_pdf(), pdf_exports));
|
|
1865
|
+
const { loadConfig: loadConfig2 } = await Promise.resolve().then(() => (init_config(), config_exports));
|
|
1866
|
+
const { success: success2, error: showError, warn: showWarn } = await Promise.resolve().then(() => (init_format(), format_exports));
|
|
1867
|
+
try {
|
|
1868
|
+
let invoice;
|
|
1869
|
+
if (opts.latest) {
|
|
1870
|
+
const all = listInvoices2();
|
|
1871
|
+
if (all.length === 0) {
|
|
1872
|
+
showError("No invoices found. Create one first with `inv create`.");
|
|
1873
|
+
process.exit(1);
|
|
1874
|
+
}
|
|
1875
|
+
const sorted = [...all].sort(
|
|
1876
|
+
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
1877
|
+
);
|
|
1878
|
+
invoice = sorted[0];
|
|
1879
|
+
} else if (idArg) {
|
|
1880
|
+
invoice = getInvoice2(idArg);
|
|
1881
|
+
if (!invoice) {
|
|
1882
|
+
showError(`Invoice not found: "${idArg}". Run \`inv list\` to see existing invoices.`);
|
|
1883
|
+
process.exit(1);
|
|
1884
|
+
}
|
|
1885
|
+
} else {
|
|
1886
|
+
showError("Please provide an invoice ID/number or use --latest.\nUsage: inv export <id> or inv export --latest");
|
|
1887
|
+
process.exit(1);
|
|
1888
|
+
}
|
|
1889
|
+
const config = loadConfig2();
|
|
1890
|
+
if (!config.sender.name && !config.sender.business_name) {
|
|
1891
|
+
showWarn("Sender profile is not configured. Run `inv init` to set up your business details.");
|
|
1892
|
+
}
|
|
1893
|
+
const pdfBuffer = await generatePDF2(invoice, config);
|
|
1894
|
+
const clientSlug = slugify(invoice.client_snapshot.name || "unknown");
|
|
1895
|
+
const defaultFilename = `${invoice.number}-${clientSlug}.pdf`;
|
|
1896
|
+
let outputPath;
|
|
1897
|
+
if (opts.output) {
|
|
1898
|
+
const resolved = path4.resolve(opts.output);
|
|
1899
|
+
try {
|
|
1900
|
+
const stat = fs6.statSync(resolved);
|
|
1901
|
+
if (stat.isDirectory()) {
|
|
1902
|
+
outputPath = path4.join(resolved, defaultFilename);
|
|
1903
|
+
} else {
|
|
1904
|
+
outputPath = resolved;
|
|
1905
|
+
}
|
|
1906
|
+
} catch {
|
|
1907
|
+
if (opts.output.endsWith(path4.sep) || opts.output.endsWith("/")) {
|
|
1908
|
+
fs6.mkdirSync(resolved, { recursive: true });
|
|
1909
|
+
outputPath = path4.join(resolved, defaultFilename);
|
|
1910
|
+
} else {
|
|
1911
|
+
const dir = path4.dirname(resolved);
|
|
1912
|
+
fs6.mkdirSync(dir, { recursive: true });
|
|
1913
|
+
outputPath = resolved;
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
} else {
|
|
1917
|
+
const outputDir = config.defaults.output_dir ? path4.resolve(config.defaults.output_dir) : process.cwd();
|
|
1918
|
+
outputPath = path4.join(outputDir, defaultFilename);
|
|
1919
|
+
}
|
|
1920
|
+
fs6.writeFileSync(outputPath, pdfBuffer);
|
|
1921
|
+
success2(`Exported to ${outputPath}`);
|
|
1922
|
+
} catch (err) {
|
|
1923
|
+
if (err instanceof Error) {
|
|
1924
|
+
showError(err.message);
|
|
1925
|
+
process.exit(1);
|
|
1926
|
+
}
|
|
1927
|
+
throw err;
|
|
1928
|
+
}
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
// src/cli/license.ts
|
|
1933
|
+
function registerLicenseCommands(program2) {
|
|
1934
|
+
program2.command("upgrade").description("Show Pro tier info, pricing, and purchase link").action(async () => {
|
|
1935
|
+
const chalk2 = (await import("chalk")).default;
|
|
1936
|
+
const { getLicenseStatus: getLicenseStatus2, UPGRADE_URL: UPGRADE_URL2, PRO_PRICE: PRO_PRICE2 } = await Promise.resolve().then(() => (init_license(), license_exports));
|
|
1937
|
+
const { loadConfig: loadConfig2 } = await Promise.resolve().then(() => (init_config(), config_exports));
|
|
1938
|
+
const config = loadConfig2();
|
|
1939
|
+
const status = getLicenseStatus2(config);
|
|
1940
|
+
console.log("");
|
|
1941
|
+
if (status === "pro") {
|
|
1942
|
+
console.log(chalk2.green("You are already on CLI Invoice Pro!"));
|
|
1943
|
+
console.log("");
|
|
1944
|
+
console.log("Your Pro features are active:");
|
|
1945
|
+
} else {
|
|
1946
|
+
console.log(chalk2.bold("CLI Invoice Pro"));
|
|
1947
|
+
console.log("");
|
|
1948
|
+
console.log(`Price: ${chalk2.cyan(PRO_PRICE2)}`);
|
|
1949
|
+
console.log("");
|
|
1950
|
+
console.log("Upgrade to unlock:");
|
|
1951
|
+
}
|
|
1952
|
+
console.log("");
|
|
1953
|
+
console.log(" " + chalk2.green("\u2713") + " Unlimited clients (free: 3)");
|
|
1954
|
+
console.log(" " + chalk2.green("\u2713") + " Unlimited invoices per month (free: 5)");
|
|
1955
|
+
console.log(" " + chalk2.green("\u2713") + " Recurring invoice templates");
|
|
1956
|
+
console.log(" " + chalk2.green("\u2713") + " Revenue summaries and reports");
|
|
1957
|
+
console.log(" " + chalk2.green("\u2713") + " Custom PDF templates");
|
|
1958
|
+
console.log(" " + chalk2.green("\u2713") + " Priority support");
|
|
1959
|
+
if (status !== "pro") {
|
|
1960
|
+
console.log("");
|
|
1961
|
+
console.log(`Purchase: ${chalk2.underline(UPGRADE_URL2)}`);
|
|
1962
|
+
console.log("");
|
|
1963
|
+
console.log(`After purchase, activate with: ${chalk2.cyan("inv activate <license-key>")}`);
|
|
1964
|
+
}
|
|
1965
|
+
console.log("");
|
|
1966
|
+
});
|
|
1967
|
+
program2.command("activate").description("Activate a Pro license key").argument("<key>", "License key received after purchase").action(async (key) => {
|
|
1968
|
+
const { validateLicenseKey: validateLicenseKey2, LicenseValidationError: LicenseValidationError2, UPGRADE_URL: UPGRADE_URL2 } = await Promise.resolve().then(() => (init_license(), license_exports));
|
|
1969
|
+
const { loadConfig: loadConfig2, saveConfig: saveConfig2 } = await Promise.resolve().then(() => (init_config(), config_exports));
|
|
1970
|
+
const { success: success2, error: showError } = await Promise.resolve().then(() => (init_format(), format_exports));
|
|
1971
|
+
try {
|
|
1972
|
+
const payload = validateLicenseKey2(key);
|
|
1973
|
+
const config = loadConfig2();
|
|
1974
|
+
config.license.key = key;
|
|
1975
|
+
config.license.activated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
1976
|
+
saveConfig2(config);
|
|
1977
|
+
success2(`License activated! Welcome to CLI Invoice Pro.`);
|
|
1978
|
+
console.log(` Email: ${payload.email}`);
|
|
1979
|
+
console.log(` Plan: ${payload.plan}`);
|
|
1980
|
+
console.log(` Expires: ${payload.expires_at}`);
|
|
1981
|
+
console.log("");
|
|
1982
|
+
console.log("All Pro features are now unlocked. Enjoy!");
|
|
1983
|
+
} catch (err) {
|
|
1984
|
+
if (err instanceof LicenseValidationError2) {
|
|
1985
|
+
showError(err.message);
|
|
1986
|
+
console.error("");
|
|
1987
|
+
console.error(`If you believe this is an error, visit ${UPGRADE_URL2} for support.`);
|
|
1988
|
+
process.exit(1);
|
|
1989
|
+
}
|
|
1990
|
+
if (err instanceof Error) {
|
|
1991
|
+
showError(err.message);
|
|
1992
|
+
process.exit(1);
|
|
1993
|
+
}
|
|
1994
|
+
throw err;
|
|
1995
|
+
}
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// bin/inv.ts
|
|
2000
|
+
var program = new import_commander.Command();
|
|
2001
|
+
program.name("inv").description("CLI Invoice \u2014 Generate professional invoices from the terminal").version("0.1.0");
|
|
2002
|
+
registerInitCommand(program);
|
|
2003
|
+
registerConfigCommand(program);
|
|
2004
|
+
registerClientCommand(program);
|
|
2005
|
+
registerInvoiceCommands(program);
|
|
2006
|
+
registerStatusCommand(program);
|
|
2007
|
+
registerExportCommand(program);
|
|
2008
|
+
registerLicenseCommands(program);
|
|
2009
|
+
program.parse(process.argv);
|
|
2010
|
+
//# sourceMappingURL=inv.js.map
|