pacioli 1.0.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/src/index.ts ADDED
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * CLI Invoice Generator
5
+ * Main entry point
6
+ */
7
+
8
+ import { fileExists, readJSON, getOutputPath } from "./utils";
9
+ import {
10
+ validateInvoice,
11
+ validateQuotation,
12
+ validateReceipt,
13
+ validateFreelancerConfig,
14
+ validateCustomer,
15
+ type DocumentData,
16
+ type FreelancerConfig,
17
+ type Customer,
18
+ } from "./validator";
19
+ import { generatePDF } from "./generator";
20
+ import {
21
+ getNextDocumentNumber,
22
+ incrementDocumentCounter,
23
+ } from "./metadata";
24
+
25
+ const VALID_TYPES = ["invoice", "quotation", "receipt"] as const;
26
+ type DocumentType = (typeof VALID_TYPES)[number];
27
+
28
+ /**
29
+ * Print usage information
30
+ */
31
+ function printUsage() {
32
+ console.log(`
33
+ CLI Invoice Generator
34
+
35
+ Usage:
36
+ bun run generate <type> <input-json> --customer <customer-json> [options]
37
+
38
+ Arguments:
39
+ <type> Document type: invoice, quotation, or receipt
40
+ <input-json> Path to JSON data file
41
+ --customer Path to customer JSON file (required)
42
+
43
+ Options:
44
+ --output <path> Custom output PDF path (default: output/{type}-{number}.pdf)
45
+ --profile <path> Path to freelancer profile (default: config/freelancer.json)
46
+ --help Show this help message
47
+
48
+ Examples:
49
+ bun run generate invoice data/invoice-001.json --customer customers/acme-corp.json
50
+ bun run generate quotation data/quote-001.json --customer customers/demo.json --output custom/path.pdf
51
+ bun run generate receipt data/receipt-001.json --customer customers/test.json --profile config/freelancer.json
52
+ `);
53
+ }
54
+
55
+ /**
56
+ * Parse command line arguments
57
+ */
58
+ function parseArgs(args: string[]) {
59
+ const options: {
60
+ type?: DocumentType;
61
+ inputPath?: string;
62
+ customerPath?: string;
63
+ outputPath?: string;
64
+ configPath: string;
65
+ help: boolean;
66
+ } = {
67
+ configPath: "config/freelancer.json",
68
+ help: false,
69
+ };
70
+
71
+ // Check for help flag
72
+ if (args.includes("--help") || args.includes("-h")) {
73
+ options.help = true;
74
+ return options;
75
+ }
76
+
77
+ // Get positional arguments
78
+ const positionalArgs = args.filter((arg) => !arg.startsWith("--"));
79
+
80
+ if (positionalArgs.length >= 2) {
81
+ const type = positionalArgs[0];
82
+ if (VALID_TYPES.includes(type as DocumentType)) {
83
+ options.type = type as DocumentType;
84
+ }
85
+ options.inputPath = positionalArgs[1];
86
+ }
87
+
88
+ // Parse options
89
+ for (let i = 0; i < args.length; i++) {
90
+ const arg = args[i];
91
+ const nextArg = args[i + 1];
92
+
93
+ if (arg === "--customer" && nextArg) {
94
+ options.customerPath = nextArg;
95
+ i++;
96
+ } else if (arg === "--output" && nextArg) {
97
+ options.outputPath = nextArg;
98
+ i++;
99
+ } else if (arg === "--profile" && nextArg) {
100
+ options.configPath = nextArg;
101
+ i++;
102
+ }
103
+ }
104
+
105
+ return options;
106
+ }
107
+
108
+ /**
109
+ * Main function
110
+ */
111
+ async function main() {
112
+ const args = process.argv.slice(2);
113
+
114
+ if (args.length === 0) {
115
+ printUsage();
116
+ process.exit(0);
117
+ }
118
+
119
+ const options = parseArgs(args);
120
+
121
+ if (options.help) {
122
+ printUsage();
123
+ process.exit(0);
124
+ }
125
+
126
+ // Validate arguments
127
+ if (!options.type) {
128
+ console.error("Error: Invalid or missing document type");
129
+ console.error(
130
+ `Valid types: ${VALID_TYPES.join(", ")}`
131
+ );
132
+ process.exit(1);
133
+ }
134
+
135
+ if (!options.inputPath) {
136
+ console.error("Error: Input JSON file path is required");
137
+ printUsage();
138
+ process.exit(1);
139
+ }
140
+
141
+ if (!options.customerPath) {
142
+ console.error("Error: Customer JSON file path is required (use --customer)");
143
+ printUsage();
144
+ process.exit(1);
145
+ }
146
+
147
+ // Check if input file exists
148
+ if (!(await fileExists(options.inputPath))) {
149
+ console.error(`Error: Input file not found: ${options.inputPath}`);
150
+ process.exit(1);
151
+ }
152
+
153
+ // Check if customer file exists
154
+ if (!(await fileExists(options.customerPath))) {
155
+ console.error(`Error: Customer file not found: ${options.customerPath}`);
156
+ process.exit(1);
157
+ }
158
+
159
+ // Check if profile file exists
160
+ if (!(await fileExists(options.configPath))) {
161
+ console.error(`Error: Profile file not found: ${options.configPath}`);
162
+ console.error(
163
+ "Tip: Copy config/freelancer.example.json to config/freelancer.json"
164
+ );
165
+ process.exit(1);
166
+ }
167
+
168
+ try {
169
+ console.log(`📄 Generating ${options.type}...`);
170
+
171
+ // Load freelancer profile
172
+ console.log(`📋 Loading profile from ${options.configPath}...`);
173
+ const config = await readJSON<FreelancerConfig>(options.configPath);
174
+
175
+ // Validate profile
176
+ const configValidation = validateFreelancerConfig(config);
177
+ if (!configValidation.valid) {
178
+ console.error("Error: Invalid freelancer profile:");
179
+ configValidation.errors.forEach((err) => console.error(` - ${err}`));
180
+ process.exit(1);
181
+ }
182
+
183
+ // Load customer data
184
+ console.log(`👤 Loading customer from ${options.customerPath}...`);
185
+ const customer = await readJSON<Customer>(options.customerPath);
186
+
187
+ // Validate customer
188
+ const customerValidation = validateCustomer(customer);
189
+ if (!customerValidation.valid) {
190
+ console.error("Error: Invalid customer data:");
191
+ customerValidation.errors.forEach((err) => console.error(` - ${err}`));
192
+ process.exit(1);
193
+ }
194
+
195
+ // Load document data
196
+ console.log(`📋 Loading data from ${options.inputPath}...`);
197
+ const data = await readJSON<DocumentData>(options.inputPath);
198
+
199
+ // Validate document data based on type
200
+ let validation;
201
+ switch (options.type) {
202
+ case "invoice":
203
+ validation = validateInvoice(data);
204
+ break;
205
+ case "quotation":
206
+ validation = validateQuotation(data);
207
+ break;
208
+ case "receipt":
209
+ validation = validateReceipt(data);
210
+ break;
211
+ }
212
+
213
+ if (!validation.valid) {
214
+ console.error(`Error: Invalid ${options.type} data:`);
215
+ validation.errors.forEach((err) => console.error(` - ${err}`));
216
+ process.exit(1);
217
+ }
218
+
219
+ // Handle auto-numbering
220
+ let resolvedDocumentNumber = data.documentNumber;
221
+ if (data.documentNumber === "auto") {
222
+ console.log(`🔢 Generating next document number...`);
223
+ resolvedDocumentNumber = await getNextDocumentNumber(options.type);
224
+ data.documentNumber = resolvedDocumentNumber;
225
+ console.log(`✓ Document number: ${resolvedDocumentNumber}`);
226
+ }
227
+
228
+ // Determine output path
229
+ const outputPath = getOutputPath(
230
+ options.type,
231
+ resolvedDocumentNumber,
232
+ options.outputPath
233
+ );
234
+
235
+ // Generate PDF
236
+ console.log(`🔨 Generating PDF...`);
237
+ await generatePDF(options.type, data, customer, config, outputPath);
238
+
239
+ // Update metadata counter after successful generation
240
+ await incrementDocumentCounter(options.type, resolvedDocumentNumber);
241
+
242
+ console.log(`\n✅ Success! PDF saved to: ${outputPath}`);
243
+ } catch (error) {
244
+ console.error("Error:", error instanceof Error ? error.message : error);
245
+ process.exit(1);
246
+ }
247
+ }
248
+
249
+ // Run main
250
+ main();
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Document metadata management for auto-numbering
3
+ */
4
+
5
+ import { fileExists } from "./utils";
6
+
7
+ export interface DocumentTypeMetadata {
8
+ lastNumber: number;
9
+ prefix: string;
10
+ year: number;
11
+ month: number;
12
+ }
13
+
14
+ export interface Metadata {
15
+ invoice: DocumentTypeMetadata;
16
+ quotation: DocumentTypeMetadata;
17
+ receipt: DocumentTypeMetadata;
18
+ }
19
+
20
+ const METADATA_PATH = ".metadata.json";
21
+
22
+ /**
23
+ * Get default metadata structure
24
+ */
25
+ function getDefaultMetadata(): Metadata {
26
+ const now = new Date();
27
+ const currentYear = now.getFullYear();
28
+ const currentMonth = now.getMonth() + 1; // 1-12
29
+
30
+ return {
31
+ invoice: {
32
+ lastNumber: 0,
33
+ prefix: "INV",
34
+ year: currentYear,
35
+ month: currentMonth,
36
+ },
37
+ quotation: {
38
+ lastNumber: 0,
39
+ prefix: "QT",
40
+ year: currentYear,
41
+ month: currentMonth,
42
+ },
43
+ receipt: {
44
+ lastNumber: 0,
45
+ prefix: "REC",
46
+ year: currentYear,
47
+ month: currentMonth,
48
+ },
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Read metadata from file
54
+ */
55
+ export async function readMetadata(): Promise<Metadata> {
56
+ try {
57
+ if (await fileExists(METADATA_PATH)) {
58
+ const file = Bun.file(METADATA_PATH);
59
+ const data = await file.json();
60
+
61
+ // Validate and return metadata
62
+ return data as Metadata;
63
+ }
64
+ } catch (error) {
65
+ console.warn("Warning: Could not read metadata file, using defaults");
66
+ }
67
+
68
+ return getDefaultMetadata();
69
+ }
70
+
71
+ /**
72
+ * Write metadata to file
73
+ */
74
+ export async function writeMetadata(metadata: Metadata): Promise<void> {
75
+ try {
76
+ await Bun.write(METADATA_PATH, JSON.stringify(metadata, null, 2));
77
+ } catch (error) {
78
+ throw new Error(`Failed to write metadata: ${error}`);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Generate next document number for a given type
84
+ */
85
+ export async function getNextDocumentNumber(
86
+ type: "invoice" | "quotation" | "receipt"
87
+ ): Promise<string> {
88
+ const metadata = await readMetadata();
89
+ const typeMetadata = metadata[type];
90
+ const now = new Date();
91
+ const currentYear = now.getFullYear();
92
+ const currentMonth = now.getMonth() + 1; // 1-12
93
+
94
+ // Reset counter if year or month has changed
95
+ if (typeMetadata.year !== currentYear || typeMetadata.month !== currentMonth) {
96
+ typeMetadata.year = currentYear;
97
+ typeMetadata.month = currentMonth;
98
+ typeMetadata.lastNumber = 0;
99
+ }
100
+
101
+ // Increment counter
102
+ const nextNumber = typeMetadata.lastNumber + 1;
103
+
104
+ // Format: PREFIX-YYYYMM-00N (e.g., INV-202410-001)
105
+ const yearMonth = `${currentYear}${String(currentMonth).padStart(2, "0")}`;
106
+ const documentNumber = `${typeMetadata.prefix}-${yearMonth}-${String(nextNumber).padStart(3, "0")}`;
107
+
108
+ return documentNumber;
109
+ }
110
+
111
+ /**
112
+ * Update metadata after successful document generation
113
+ */
114
+ export async function incrementDocumentCounter(
115
+ type: "invoice" | "quotation" | "receipt",
116
+ documentNumber: string
117
+ ): Promise<void> {
118
+ const metadata = await readMetadata();
119
+ const typeMetadata = metadata[type];
120
+ const now = new Date();
121
+ const currentYear = now.getFullYear();
122
+ const currentMonth = now.getMonth() + 1; // 1-12
123
+
124
+ // Reset counter if year or month has changed
125
+ if (typeMetadata.year !== currentYear || typeMetadata.month !== currentMonth) {
126
+ typeMetadata.year = currentYear;
127
+ typeMetadata.month = currentMonth;
128
+ typeMetadata.lastNumber = 0;
129
+ }
130
+
131
+ // Extract number from document number (e.g., "INV-202410-005" -> 5)
132
+ const match = documentNumber.match(/-(\d+)$/);
133
+ if (match && match[1]) {
134
+ const number = parseInt(match[1], 10);
135
+ // Only update if this number is higher than current
136
+ if (number > typeMetadata.lastNumber) {
137
+ typeMetadata.lastNumber = number;
138
+ }
139
+ }
140
+
141
+ await writeMetadata(metadata);
142
+ }
143
+
144
+ /**
145
+ * Initialize metadata file if it doesn't exist
146
+ */
147
+ export async function initMetadata(): Promise<void> {
148
+ if (!(await fileExists(METADATA_PATH))) {
149
+ const defaultMetadata = getDefaultMetadata();
150
+ await writeMetadata(defaultMetadata);
151
+ console.log("✓ Created .metadata.json with default values");
152
+ } else {
153
+ console.log("✓ .metadata.json already exists");
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Reset counter for a specific document type
159
+ */
160
+ export async function resetCounter(
161
+ type: "invoice" | "quotation" | "receipt"
162
+ ): Promise<void> {
163
+ const metadata = await readMetadata();
164
+ const now = new Date();
165
+ const currentYear = now.getFullYear();
166
+ const currentMonth = now.getMonth() + 1; // 1-12
167
+
168
+ metadata[type].lastNumber = 0;
169
+ metadata[type].year = currentYear;
170
+ metadata[type].month = currentMonth;
171
+
172
+ await writeMetadata(metadata);
173
+ console.log(`✓ Reset ${type} counter to 0 for ${currentYear}-${String(currentMonth).padStart(2, "0")}`);
174
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Utility functions for invoice generation
3
+ */
4
+
5
+ export interface LineItem {
6
+ description: string;
7
+ quantity: number;
8
+ unit: string;
9
+ unitPrice: number;
10
+ }
11
+
12
+ export interface CalculationResult {
13
+ subtotal: number;
14
+ taxAmount: number;
15
+ total: number;
16
+ }
17
+
18
+ /**
19
+ * Calculate subtotal, tax, and total from line items
20
+ */
21
+ export function calculateTotals(
22
+ items: LineItem[],
23
+ taxRate: number,
24
+ taxType: "withholding" | "vat"
25
+ ): CalculationResult {
26
+ const subtotal = items.reduce(
27
+ (sum, item) => sum + item.quantity * item.unitPrice,
28
+ 0
29
+ );
30
+
31
+ const taxAmount = subtotal * taxRate;
32
+
33
+ let total: number;
34
+ if (taxType === "withholding") {
35
+ // Withholding tax is deducted from subtotal
36
+ total = subtotal - taxAmount;
37
+ } else {
38
+ // VAT is added to subtotal
39
+ total = subtotal + taxAmount;
40
+ }
41
+
42
+ return {
43
+ subtotal,
44
+ taxAmount,
45
+ total,
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Format number with Thai thousand separators
51
+ */
52
+ export function formatNumber(num: number, decimals: number = 2): string {
53
+ return num.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
54
+ }
55
+
56
+ /**
57
+ * Convert Gregorian date to Buddhist Era (BE) format
58
+ */
59
+ export function formatDateThai(dateString: string): string {
60
+ const date = new Date(dateString);
61
+
62
+ const thaiMonths = [
63
+ "มกราคม", "กุมภาพันธ์", "มีนาคม", "เมษายน",
64
+ "พฤษภาคม", "มิถุนายน", "กรกฎาคม", "สิงหาคม",
65
+ "กันยายน", "ตุลาคม", "พฤศจิกายน", "ธันวาคม"
66
+ ];
67
+
68
+ const day = date.getDate();
69
+ const month = thaiMonths[date.getMonth()];
70
+ const yearBE = date.getFullYear() + 543; // Convert to Buddhist Era
71
+
72
+ return `${day} ${month} ${yearBE}`;
73
+ }
74
+
75
+ /**
76
+ * Validate file path exists
77
+ */
78
+ export async function fileExists(path: string): Promise<boolean> {
79
+ try {
80
+ const file = Bun.file(path);
81
+ return await file.exists();
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Read and parse JSON file
89
+ */
90
+ export async function readJSON<T>(path: string): Promise<T> {
91
+ const file = Bun.file(path);
92
+ return await file.json();
93
+ }
94
+
95
+ /**
96
+ * Get output file path
97
+ */
98
+ export function getOutputPath(
99
+ type: string,
100
+ documentNumber: string,
101
+ customOutput?: string
102
+ ): string {
103
+ if (customOutput) {
104
+ return customOutput;
105
+ }
106
+
107
+ // Default: output/{type}-{number}.pdf
108
+ const filename = `${type}-${documentNumber}.pdf`;
109
+ return `output/${filename}`;
110
+ }