@simonfestl/husky-cli 1.26.0 → 1.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/biz/invoices.d.ts +11 -0
- package/dist/commands/biz/invoices.js +661 -0
- package/dist/commands/biz/shopify.d.ts +3 -0
- package/dist/commands/biz/shopify.js +592 -0
- package/dist/commands/biz/supplier-feed.d.ts +3 -0
- package/dist/commands/biz/supplier-feed.js +168 -0
- package/dist/commands/biz.js +5 -1
- package/dist/commands/config.d.ts +3 -2
- package/dist/commands/config.js +4 -3
- package/dist/commands/e2e.js +1 -1
- package/dist/commands/plan.d.ts +14 -0
- package/dist/commands/plan.js +219 -0
- package/dist/commands/sop.d.ts +3 -0
- package/dist/commands/sop.js +458 -0
- package/dist/commands/task.js +7 -0
- package/dist/index.js +2 -0
- package/dist/lib/biz/gcs-upload.d.ts +86 -0
- package/dist/lib/biz/gcs-upload.js +189 -0
- package/dist/lib/biz/index.d.ts +5 -0
- package/dist/lib/biz/index.js +3 -0
- package/dist/lib/biz/invoice-extractor-registry.d.ts +22 -0
- package/dist/lib/biz/invoice-extractor-registry.js +416 -0
- package/dist/lib/biz/invoice-extractor-types.d.ts +127 -0
- package/dist/lib/biz/invoice-extractor-types.js +6 -0
- package/dist/lib/biz/pattern-detection.d.ts +48 -0
- package/dist/lib/biz/pattern-detection.js +205 -0
- package/dist/lib/biz/resolved-tickets.d.ts +86 -0
- package/dist/lib/biz/resolved-tickets.js +250 -0
- package/dist/lib/biz/shopify.d.ts +196 -0
- package/dist/lib/biz/shopify.js +429 -0
- package/dist/lib/biz/supplier-feed-types.d.ts +96 -0
- package/dist/lib/biz/supplier-feed-types.js +46 -0
- package/dist/lib/biz/supplier-feed.d.ts +32 -0
- package/dist/lib/biz/supplier-feed.js +244 -0
- package/dist/lib/permissions.d.ts +2 -1
- package/dist/types/roles.d.ts +3 -0
- package/dist/types/roles.js +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Husky Biz Invoices Command
|
|
3
|
+
*
|
|
4
|
+
* Unified invoice extraction service
|
|
5
|
+
* - List and manage invoice sources
|
|
6
|
+
* - Extract invoices from all configured sources
|
|
7
|
+
* - Auto-upload to Gotess for transaction matching
|
|
8
|
+
*/
|
|
9
|
+
import { Command } from "commander";
|
|
10
|
+
export declare const invoicesCommand: Command;
|
|
11
|
+
export default invoicesCommand;
|
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Husky Biz Invoices Command
|
|
3
|
+
*
|
|
4
|
+
* Unified invoice extraction service
|
|
5
|
+
* - List and manage invoice sources
|
|
6
|
+
* - Extract invoices from all configured sources
|
|
7
|
+
* - Auto-upload to Gotess for transaction matching
|
|
8
|
+
*/
|
|
9
|
+
import { Command } from "commander";
|
|
10
|
+
import { getConfig } from "../config.js";
|
|
11
|
+
import { getExtractor, getAvailableExtractorIds, hasCredentialsConfigured, getDefaultInvoiceDir, } from "../../lib/biz/invoice-extractor-registry.js";
|
|
12
|
+
import { GotessClient } from "../../lib/biz/gotess.js";
|
|
13
|
+
import { GCSUploadClient } from "../../lib/biz/gcs-upload.js";
|
|
14
|
+
export const invoicesCommand = new Command("invoices")
|
|
15
|
+
.description("Unified invoice extraction service");
|
|
16
|
+
// Helper: Get API URL and key
|
|
17
|
+
function getApiConfig() {
|
|
18
|
+
const config = getConfig();
|
|
19
|
+
if (!config.apiUrl) {
|
|
20
|
+
console.error("✗ API URL not configured. Run: husky config set api-url <url>");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
return { apiUrl: config.apiUrl, apiKey: config.apiKey };
|
|
24
|
+
}
|
|
25
|
+
// Helper: Fetch from API
|
|
26
|
+
async function fetchApi(endpoint, options) {
|
|
27
|
+
const { apiUrl, apiKey } = getApiConfig();
|
|
28
|
+
const headers = {
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
};
|
|
31
|
+
if (apiKey) {
|
|
32
|
+
headers["X-API-Key"] = apiKey;
|
|
33
|
+
}
|
|
34
|
+
const response = await fetch(`${apiUrl}${endpoint}`, {
|
|
35
|
+
...options,
|
|
36
|
+
headers: { ...headers, ...options?.headers },
|
|
37
|
+
});
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
const error = await response.text();
|
|
40
|
+
throw new Error(`API error ${response.status}: ${error}`);
|
|
41
|
+
}
|
|
42
|
+
return response.json();
|
|
43
|
+
}
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// husky biz invoices sources
|
|
46
|
+
// ============================================================================
|
|
47
|
+
invoicesCommand
|
|
48
|
+
.command("sources")
|
|
49
|
+
.description("List all invoice sources")
|
|
50
|
+
.option("--json", "Output as JSON")
|
|
51
|
+
.option("--seed", "Seed default sources if none exist")
|
|
52
|
+
.action(async (options) => {
|
|
53
|
+
try {
|
|
54
|
+
if (options.seed) {
|
|
55
|
+
console.log("Seeding default invoice sources...");
|
|
56
|
+
const result = await fetchApi("/api/invoice-sources", {
|
|
57
|
+
method: "POST",
|
|
58
|
+
body: JSON.stringify({ action: "seed" }),
|
|
59
|
+
});
|
|
60
|
+
console.log(`✓ Seeded ${result.seeded} invoice sources`);
|
|
61
|
+
if (result.seeded === 0) {
|
|
62
|
+
console.log(" (Sources already exist, no action taken)");
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const sources = await fetchApi("/api/invoice-sources");
|
|
67
|
+
if (options.json) {
|
|
68
|
+
console.log(JSON.stringify(sources, null, 2));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (sources.length === 0) {
|
|
72
|
+
console.log("\n No invoice sources configured.");
|
|
73
|
+
console.log(" Run: husky biz invoices sources --seed\n");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
console.log(`\n 📄 Invoice Sources (${sources.length})\n`);
|
|
77
|
+
// Header
|
|
78
|
+
console.log(` ${"Name".padEnd(20)} │ ` +
|
|
79
|
+
`${"Type".padEnd(12)} │ ` +
|
|
80
|
+
`${"Status".padEnd(18)} │ ` +
|
|
81
|
+
`${"Last Extracted".padEnd(20)} │ ` +
|
|
82
|
+
`${"Total"}`);
|
|
83
|
+
console.log(" " + "─".repeat(90));
|
|
84
|
+
// Sources
|
|
85
|
+
for (const source of sources) {
|
|
86
|
+
const statusIcon = {
|
|
87
|
+
active: "✓",
|
|
88
|
+
needs_credentials: "🔑",
|
|
89
|
+
disabled: "⏸",
|
|
90
|
+
error: "⚠",
|
|
91
|
+
}[source.status];
|
|
92
|
+
const lastExtracted = source.lastExtractedAt
|
|
93
|
+
? new Date(source.lastExtractedAt).toLocaleDateString()
|
|
94
|
+
: "Never";
|
|
95
|
+
// Check local credentials
|
|
96
|
+
const hasLocalCreds = hasCredentialsConfigured(source.extractorId);
|
|
97
|
+
const credStatus = hasLocalCreds ? "✓ creds" : "✗ creds";
|
|
98
|
+
console.log(` ${source.name.padEnd(20)} │ ` +
|
|
99
|
+
`${source.type.padEnd(12)} │ ` +
|
|
100
|
+
`${statusIcon} ${source.status.padEnd(15)} │ ` +
|
|
101
|
+
`${lastExtracted.padEnd(20)} │ ` +
|
|
102
|
+
`${source.totalExtracted} (${credStatus})`);
|
|
103
|
+
}
|
|
104
|
+
console.log("");
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
console.error("Error:", error.message);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
// ============================================================================
|
|
112
|
+
// husky biz invoices status
|
|
113
|
+
// ============================================================================
|
|
114
|
+
invoicesCommand
|
|
115
|
+
.command("status")
|
|
116
|
+
.description("Show extraction status for all sources")
|
|
117
|
+
.action(async () => {
|
|
118
|
+
try {
|
|
119
|
+
const sources = await fetchApi("/api/invoice-sources");
|
|
120
|
+
const jobs = await fetchApi("/api/extraction-jobs?limit=10");
|
|
121
|
+
console.log("\n 📊 Invoice Extraction Status\n");
|
|
122
|
+
// Summary
|
|
123
|
+
const activeCount = sources.filter((s) => s.status === "active").length;
|
|
124
|
+
const needsCredsCount = sources.filter((s) => s.status === "needs_credentials").length;
|
|
125
|
+
console.log(` Active sources: ${activeCount}/${sources.length}`);
|
|
126
|
+
console.log(` Need credentials: ${needsCredsCount}`);
|
|
127
|
+
console.log("");
|
|
128
|
+
// Check local credentials status
|
|
129
|
+
console.log(" Local Credentials Status:");
|
|
130
|
+
for (const source of sources) {
|
|
131
|
+
const hasLocalCreds = hasCredentialsConfigured(source.extractorId);
|
|
132
|
+
const icon = hasLocalCreds ? "✓" : "✗";
|
|
133
|
+
console.log(` ${icon} ${source.name} (${source.extractorId})`);
|
|
134
|
+
}
|
|
135
|
+
console.log("");
|
|
136
|
+
// Recent jobs
|
|
137
|
+
if (jobs.length > 0) {
|
|
138
|
+
console.log(" Recent Extraction Jobs:");
|
|
139
|
+
for (const job of jobs.slice(0, 5)) {
|
|
140
|
+
const statusIcon = {
|
|
141
|
+
pending: "⏳",
|
|
142
|
+
running: "🔄",
|
|
143
|
+
completed: "✓",
|
|
144
|
+
failed: "✗",
|
|
145
|
+
}[job.status];
|
|
146
|
+
const date = new Date(job.startedAt).toLocaleDateString();
|
|
147
|
+
console.log(` ${statusIcon} ${job.sourceName} - ${job.status} (${date}) - ${job.invoicesExtracted} extracted`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
console.log(" No extraction jobs yet.");
|
|
152
|
+
}
|
|
153
|
+
console.log("");
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
console.error("Error:", error.message);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
// ============================================================================
|
|
161
|
+
// husky biz invoices extract
|
|
162
|
+
// ============================================================================
|
|
163
|
+
invoicesCommand
|
|
164
|
+
.command("extract [source]")
|
|
165
|
+
.description("Extract invoices from source(s)")
|
|
166
|
+
.option("--all", "Extract from all active sources")
|
|
167
|
+
.option("--limit <n>", "Limit number of orders to check", parseInt)
|
|
168
|
+
.option("-o, --output <dir>", "Output directory for invoices")
|
|
169
|
+
.option("--gcs", "Upload extracted invoices to GCS bucket")
|
|
170
|
+
.option("--gcs-bucket <bucket>", "GCS bucket name (default: husky-invoices)")
|
|
171
|
+
.option("--upload", "Auto-upload to Gotess after extraction")
|
|
172
|
+
.option("--json", "Output as JSON")
|
|
173
|
+
.action(async (source, options) => {
|
|
174
|
+
try {
|
|
175
|
+
const outputDir = options.output || getDefaultInvoiceDir();
|
|
176
|
+
if (options.all) {
|
|
177
|
+
// Extract from all active sources
|
|
178
|
+
const sources = await fetchApi("/api/invoice-sources");
|
|
179
|
+
const activeSources = sources.filter((s) => s.status === "active" || hasCredentialsConfigured(s.extractorId));
|
|
180
|
+
if (activeSources.length === 0) {
|
|
181
|
+
console.log("\n No active sources to extract from.");
|
|
182
|
+
console.log(" Configure credentials first with:");
|
|
183
|
+
console.log(" husky config set <extractor>-username <user>");
|
|
184
|
+
console.log(" husky config set <extractor>-password <pass>\n");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
console.log(`\n Extracting invoices from ${activeSources.length} sources...\n`);
|
|
188
|
+
for (const sourceData of activeSources) {
|
|
189
|
+
console.log(` 📦 ${sourceData.name}`);
|
|
190
|
+
await extractFromSource(sourceData.extractorId, {
|
|
191
|
+
limit: options.limit,
|
|
192
|
+
outputDir,
|
|
193
|
+
upload: options.upload,
|
|
194
|
+
gcs: options.gcs,
|
|
195
|
+
gcsBucket: options.gcsBucket,
|
|
196
|
+
});
|
|
197
|
+
console.log("");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
else if (source) {
|
|
201
|
+
// Extract from specific source
|
|
202
|
+
await extractFromSource(source, {
|
|
203
|
+
limit: options.limit,
|
|
204
|
+
outputDir,
|
|
205
|
+
upload: options.upload,
|
|
206
|
+
json: options.json,
|
|
207
|
+
gcs: options.gcs,
|
|
208
|
+
gcsBucket: options.gcsBucket,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
// Show available extractors
|
|
213
|
+
console.log("\n Usage: husky biz invoices extract <source>");
|
|
214
|
+
console.log(" husky biz invoices extract --all\n");
|
|
215
|
+
console.log(" Available extractors:");
|
|
216
|
+
for (const id of getAvailableExtractorIds()) {
|
|
217
|
+
const hasCreds = hasCredentialsConfigured(id);
|
|
218
|
+
const icon = hasCreds ? "✓" : "✗";
|
|
219
|
+
console.log(` ${icon} ${id}`);
|
|
220
|
+
}
|
|
221
|
+
console.log("");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
console.error("Error:", error.message);
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
async function extractFromSource(extractorId, options) {
|
|
230
|
+
const extractor = getExtractor(extractorId);
|
|
231
|
+
if (!extractor) {
|
|
232
|
+
console.error(` ✗ Unknown extractor: ${extractorId}`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (!hasCredentialsConfigured(extractorId)) {
|
|
236
|
+
console.error(` ✗ Credentials not configured for ${extractorId}`);
|
|
237
|
+
console.error(` Run: husky config set ${extractorId}-username <user>`);
|
|
238
|
+
console.error(` husky config set ${extractorId}-password <pass>`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
await extractor.init({});
|
|
243
|
+
const result = await extractor.extractAll({
|
|
244
|
+
limit: options.limit,
|
|
245
|
+
outputDir: options.outputDir,
|
|
246
|
+
onProgress: (msg) => {
|
|
247
|
+
if (!options.json) {
|
|
248
|
+
console.log(` ${msg}`);
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
if (options.json) {
|
|
253
|
+
console.log(JSON.stringify(result, null, 2));
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
console.log(` ✓ Extracted ${result.invoicesExtracted}/${result.ordersFound} invoices`);
|
|
257
|
+
if (result.invoicesFailed > 0) {
|
|
258
|
+
console.log(` ⚠ ${result.invoicesFailed} failed`);
|
|
259
|
+
}
|
|
260
|
+
if (result.invoices.length > 0) {
|
|
261
|
+
console.log(` 📁 Saved to: ${options.outputDir}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Track GCS upload results for Gotess
|
|
265
|
+
let gcsResults = [];
|
|
266
|
+
// Upload to GCS if requested
|
|
267
|
+
if (options.gcs && result.invoices.length > 0) {
|
|
268
|
+
gcsResults = await uploadToGCS(result.invoices, extractorId, options.gcsBucket, options.json);
|
|
269
|
+
}
|
|
270
|
+
// Auto-upload to Gotess if requested
|
|
271
|
+
if (options.upload && result.invoices.length > 0) {
|
|
272
|
+
// Use GCS results if available, otherwise use local invoices
|
|
273
|
+
const invoicesWithGcs = gcsResults.length > 0
|
|
274
|
+
? gcsResults
|
|
275
|
+
: result.invoices.map(inv => ({ invoice: inv }));
|
|
276
|
+
await uploadToGotess(invoicesWithGcs, extractorId, options.json);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
console.error(` ✗ Extraction failed: ${error.message}`);
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
await extractor.close();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async function uploadToGCS(invoices, source, bucketName, json) {
|
|
287
|
+
if (!json) {
|
|
288
|
+
console.log(" ☁️ Uploading to GCS...");
|
|
289
|
+
}
|
|
290
|
+
const results = [];
|
|
291
|
+
try {
|
|
292
|
+
const gcs = new GCSUploadClient(bucketName);
|
|
293
|
+
// Check bucket access
|
|
294
|
+
const accessCheck = await gcs.checkAccess();
|
|
295
|
+
if (!accessCheck.accessible) {
|
|
296
|
+
console.error(` ✗ GCS access error: ${accessCheck.error}`);
|
|
297
|
+
console.error(` Make sure bucket "${gcs.getBucketName()}" exists and you have access`);
|
|
298
|
+
return invoices.map(inv => ({ invoice: inv }));
|
|
299
|
+
}
|
|
300
|
+
let successCount = 0;
|
|
301
|
+
let failCount = 0;
|
|
302
|
+
for (const invoice of invoices) {
|
|
303
|
+
const result = await gcs.uploadInvoice(invoice.localPath, {
|
|
304
|
+
source,
|
|
305
|
+
orderNumber: invoice.orderNumber,
|
|
306
|
+
invoiceDate: invoice.invoiceDate,
|
|
307
|
+
});
|
|
308
|
+
if (result.success) {
|
|
309
|
+
successCount++;
|
|
310
|
+
results.push({ invoice, gcsUri: result.gcsUri });
|
|
311
|
+
if (!json) {
|
|
312
|
+
console.log(` ✓ ${invoice.filename} → ${result.gcsUri}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
failCount++;
|
|
317
|
+
results.push({ invoice });
|
|
318
|
+
if (!json) {
|
|
319
|
+
console.log(` ✗ ${invoice.filename}: ${result.error}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (!json) {
|
|
324
|
+
console.log(` ☁️ Uploaded ${successCount}/${invoices.length} to GCS`);
|
|
325
|
+
if (failCount > 0) {
|
|
326
|
+
console.log(` ⚠ ${failCount} failed to upload`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
console.error(` ✗ GCS upload failed: ${error.message}`);
|
|
332
|
+
return invoices.map(inv => ({ invoice: inv }));
|
|
333
|
+
}
|
|
334
|
+
return results;
|
|
335
|
+
}
|
|
336
|
+
async function uploadToGotess(invoicesWithGcs, source, json) {
|
|
337
|
+
if (!json) {
|
|
338
|
+
console.log(" 📤 Uploading to Gotess...");
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
const config = getConfig();
|
|
342
|
+
if (!config.gotessToken || !config.gotessBookId) {
|
|
343
|
+
console.error(" ✗ Gotess not configured");
|
|
344
|
+
console.error(" Configure with: husky biz gotess login");
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const gotess = new GotessClient(config.gotessToken, config.gotessBookId);
|
|
348
|
+
let successCount = 0;
|
|
349
|
+
let failCount = 0;
|
|
350
|
+
for (const { invoice, gcsUri } of invoicesWithGcs) {
|
|
351
|
+
try {
|
|
352
|
+
const result = await gotess.createInvoice({
|
|
353
|
+
invoiceDate: invoice.invoiceDate,
|
|
354
|
+
amount: invoice.amount,
|
|
355
|
+
senderName: source,
|
|
356
|
+
filename: invoice.filename,
|
|
357
|
+
gcsUri: gcsUri,
|
|
358
|
+
});
|
|
359
|
+
successCount++;
|
|
360
|
+
if (!json) {
|
|
361
|
+
console.log(` ✓ ${invoice.filename} → Gotess ID: ${result.id}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
failCount++;
|
|
366
|
+
if (!json) {
|
|
367
|
+
console.log(` ✗ ${invoice.filename}: ${error.message}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (!json) {
|
|
372
|
+
console.log(` 📤 Created ${successCount}/${invoicesWithGcs.length} Gotess records`);
|
|
373
|
+
if (failCount > 0) {
|
|
374
|
+
console.log(` ⚠ ${failCount} failed`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
console.error(` ✗ Gotess upload failed: ${error.message}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// ============================================================================
|
|
383
|
+
// husky biz invoices pending
|
|
384
|
+
// ============================================================================
|
|
385
|
+
invoicesCommand
|
|
386
|
+
.command("pending")
|
|
387
|
+
.description("Show pending invoices to extract")
|
|
388
|
+
.option("--json", "Output as JSON")
|
|
389
|
+
.action(async (options) => {
|
|
390
|
+
try {
|
|
391
|
+
const sources = await fetchApi("/api/invoice-sources");
|
|
392
|
+
const pending = sources.filter((s) => s.pendingInvoices > 0);
|
|
393
|
+
if (options.json) {
|
|
394
|
+
console.log(JSON.stringify(pending, null, 2));
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (pending.length === 0) {
|
|
398
|
+
console.log("\n No pending invoices to extract.\n");
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
console.log(`\n 📋 Pending Invoices\n`);
|
|
402
|
+
let total = 0;
|
|
403
|
+
for (const source of pending) {
|
|
404
|
+
console.log(` ${source.name}: ${source.pendingInvoices} pending`);
|
|
405
|
+
total += source.pendingInvoices;
|
|
406
|
+
}
|
|
407
|
+
console.log(` ─────────────────────`);
|
|
408
|
+
console.log(` Total: ${total} pending`);
|
|
409
|
+
console.log("");
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
console.error("Error:", error.message);
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
// ============================================================================
|
|
417
|
+
// husky biz invoices test
|
|
418
|
+
// ============================================================================
|
|
419
|
+
invoicesCommand
|
|
420
|
+
.command("test <source>")
|
|
421
|
+
.description("Test credentials for a source")
|
|
422
|
+
.action(async (source) => {
|
|
423
|
+
const extractor = getExtractor(source);
|
|
424
|
+
if (!extractor) {
|
|
425
|
+
console.error(`✗ Unknown extractor: ${source}`);
|
|
426
|
+
console.log("\nAvailable extractors:");
|
|
427
|
+
for (const id of getAvailableExtractorIds()) {
|
|
428
|
+
console.log(` - ${id}`);
|
|
429
|
+
}
|
|
430
|
+
process.exit(1);
|
|
431
|
+
}
|
|
432
|
+
if (!hasCredentialsConfigured(source)) {
|
|
433
|
+
console.error(`✗ Credentials not configured for ${source}`);
|
|
434
|
+
console.log(`\nConfigure with:`);
|
|
435
|
+
console.log(` husky config set ${source}-username <user>`);
|
|
436
|
+
console.log(` husky config set ${source}-password <pass>`);
|
|
437
|
+
process.exit(1);
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
console.log(`Testing credentials for ${extractor.name}...`);
|
|
441
|
+
await extractor.init({});
|
|
442
|
+
const result = await extractor.testCredentials();
|
|
443
|
+
if (result.success) {
|
|
444
|
+
console.log(`✓ Successfully authenticated with ${extractor.name}`);
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
console.error(`✗ Authentication failed: ${result.error}`);
|
|
448
|
+
process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
console.error(`✗ Test failed: ${error.message}`);
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
455
|
+
finally {
|
|
456
|
+
await extractor.close();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
// ============================================================================
|
|
460
|
+
// husky biz invoices reconcile
|
|
461
|
+
// ============================================================================
|
|
462
|
+
// Vendor name patterns to extractor mapping
|
|
463
|
+
const VENDOR_SOURCE_MAPPING = {
|
|
464
|
+
wattiz: ["wattiz", "watti", "watt"],
|
|
465
|
+
skuterzone: ["skuterzone", "skuter", "skuterzon"],
|
|
466
|
+
emove: ["emove", "e-move", "emove distribution"],
|
|
467
|
+
};
|
|
468
|
+
function findSourceForVendor(vendorName) {
|
|
469
|
+
const normalized = vendorName.toLowerCase();
|
|
470
|
+
for (const [source, patterns] of Object.entries(VENDOR_SOURCE_MAPPING)) {
|
|
471
|
+
if (patterns.some((p) => normalized.includes(p))) {
|
|
472
|
+
return source;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
invoicesCommand
|
|
478
|
+
.command("reconcile")
|
|
479
|
+
.description("Auto-reconcile missing invoices from Gotess")
|
|
480
|
+
.option("--dry-run", "Show what would be done without executing")
|
|
481
|
+
.option("--limit <n>", "Limit transactions to check", parseInt)
|
|
482
|
+
.option("--gcs", "Upload to GCS bucket")
|
|
483
|
+
.option("--gcs-bucket <bucket>", "GCS bucket name")
|
|
484
|
+
.option("--json", "Output as JSON")
|
|
485
|
+
.action(async (options) => {
|
|
486
|
+
try {
|
|
487
|
+
const config = getConfig();
|
|
488
|
+
if (!config.gotessToken || !config.gotessBookId) {
|
|
489
|
+
console.error("✗ Gotess not configured");
|
|
490
|
+
console.error(" Configure with: husky biz gotess login");
|
|
491
|
+
process.exit(1);
|
|
492
|
+
}
|
|
493
|
+
const gotess = new GotessClient(config.gotessToken, config.gotessBookId);
|
|
494
|
+
// Step 1: Get transactions missing invoices
|
|
495
|
+
console.log("\n 🔍 Checking Gotess for missing invoices...\n");
|
|
496
|
+
const missing = await gotess.getMissingInvoices();
|
|
497
|
+
if (missing.length === 0) {
|
|
498
|
+
console.log(" ✓ No missing invoices found!\n");
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
console.log(` Found ${missing.length} transactions missing invoices\n`);
|
|
502
|
+
// Step 2: Group by potential source
|
|
503
|
+
const bySource = {};
|
|
504
|
+
const unmapped = [];
|
|
505
|
+
for (const tx of missing) {
|
|
506
|
+
const source = findSourceForVendor(tx.counterpart_name || "");
|
|
507
|
+
if (source) {
|
|
508
|
+
if (!bySource[source])
|
|
509
|
+
bySource[source] = [];
|
|
510
|
+
bySource[source].push(tx);
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
unmapped.push(tx);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// Show breakdown
|
|
517
|
+
console.log(" 📊 Breakdown by source:\n");
|
|
518
|
+
for (const [source, txs] of Object.entries(bySource)) {
|
|
519
|
+
const hasCreds = hasCredentialsConfigured(source);
|
|
520
|
+
const credIcon = hasCreds ? "✓" : "✗";
|
|
521
|
+
console.log(` ${credIcon} ${source}: ${txs.length} transactions`);
|
|
522
|
+
for (const tx of txs.slice(0, 3)) {
|
|
523
|
+
console.log(` - ${tx.counterpart_name}: €${Math.abs(tx.amount).toFixed(2)} (${tx.value_date})`);
|
|
524
|
+
}
|
|
525
|
+
if (txs.length > 3) {
|
|
526
|
+
console.log(` ... and ${txs.length - 3} more`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (unmapped.length > 0) {
|
|
530
|
+
console.log(`\n ? Unknown sources: ${unmapped.length} transactions`);
|
|
531
|
+
for (const tx of unmapped.slice(0, 3)) {
|
|
532
|
+
console.log(` - ${tx.counterpart_name || "Unknown"}: €${Math.abs(tx.amount).toFixed(2)}`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (options.dryRun) {
|
|
536
|
+
console.log("\n [Dry run - no actions taken]\n");
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
// Step 3: Extract from each mapped source
|
|
540
|
+
console.log("\n 🚀 Starting extraction...\n");
|
|
541
|
+
const outputDir = getDefaultInvoiceDir();
|
|
542
|
+
let totalExtracted = 0;
|
|
543
|
+
let totalMatched = 0;
|
|
544
|
+
for (const [source, txs] of Object.entries(bySource)) {
|
|
545
|
+
if (!hasCredentialsConfigured(source)) {
|
|
546
|
+
console.log(` ⏭ Skipping ${source} (no credentials configured)`);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
console.log(`\n 📦 Extracting from ${source}...`);
|
|
550
|
+
const extractor = getExtractor(source);
|
|
551
|
+
if (!extractor)
|
|
552
|
+
continue;
|
|
553
|
+
try {
|
|
554
|
+
await extractor.init({});
|
|
555
|
+
const result = await extractor.extractAll({
|
|
556
|
+
limit: options.limit || 20,
|
|
557
|
+
outputDir,
|
|
558
|
+
onProgress: (msg) => console.log(` ${msg}`),
|
|
559
|
+
});
|
|
560
|
+
console.log(` ✓ Extracted ${result.invoicesExtracted} invoices`);
|
|
561
|
+
totalExtracted += result.invoicesExtracted;
|
|
562
|
+
// Upload to GCS if requested
|
|
563
|
+
let gcsResults = [];
|
|
564
|
+
if (options.gcs && result.invoices.length > 0) {
|
|
565
|
+
gcsResults = await uploadToGCS(result.invoices, source, options.gcsBucket, options.json);
|
|
566
|
+
}
|
|
567
|
+
// Upload to Gotess
|
|
568
|
+
if (result.invoices.length > 0) {
|
|
569
|
+
const invoicesWithGcs = gcsResults.length > 0
|
|
570
|
+
? gcsResults
|
|
571
|
+
: result.invoices.map(inv => ({ invoice: inv }));
|
|
572
|
+
await uploadToGotess(invoicesWithGcs, source, options.json);
|
|
573
|
+
}
|
|
574
|
+
await extractor.close();
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
console.error(` ✗ Error: ${error.message}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
// Step 4: Run auto-matching
|
|
581
|
+
console.log("\n 🔗 Running auto-match...\n");
|
|
582
|
+
try {
|
|
583
|
+
const matchResult = await gotess.autoMatch();
|
|
584
|
+
totalMatched = matchResult.matched.length;
|
|
585
|
+
if (matchResult.matched.length > 0) {
|
|
586
|
+
console.log(` Found ${matchResult.matched.length} potential matches:`);
|
|
587
|
+
for (const { transaction, invoice } of matchResult.matched.slice(0, 5)) {
|
|
588
|
+
console.log(` - ${transaction.counterpart_name}: €${Math.abs(transaction.amount).toFixed(2)} → ${invoice.filename || invoice.sender_name}`);
|
|
589
|
+
}
|
|
590
|
+
// Actually link the matches
|
|
591
|
+
console.log("\n Linking invoices to transactions...");
|
|
592
|
+
let linked = 0;
|
|
593
|
+
for (const { transaction, invoice } of matchResult.matched) {
|
|
594
|
+
try {
|
|
595
|
+
await gotess.linkInvoice(transaction.id, invoice.id);
|
|
596
|
+
linked++;
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
console.log(` ✗ Failed to link ${transaction.id}: ${error.message}`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
console.log(` ✓ Linked ${linked}/${matchResult.matched.length} invoices`);
|
|
603
|
+
}
|
|
604
|
+
if (matchResult.unmatched.length > 0) {
|
|
605
|
+
console.log(`\n Still missing invoices: ${matchResult.unmatched.length}`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
console.error(` ✗ Auto-match failed: ${error.message}`);
|
|
610
|
+
}
|
|
611
|
+
// Summary
|
|
612
|
+
console.log("\n ═══════════════════════════════════════");
|
|
613
|
+
console.log(` 📊 Reconciliation Summary`);
|
|
614
|
+
console.log(` Transactions checked: ${missing.length}`);
|
|
615
|
+
console.log(` Invoices extracted: ${totalExtracted}`);
|
|
616
|
+
console.log(` Invoices matched: ${totalMatched}`);
|
|
617
|
+
console.log(` Still missing: ${missing.length - totalMatched}`);
|
|
618
|
+
console.log(" ═══════════════════════════════════════\n");
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
console.error("Error:", error.message);
|
|
622
|
+
process.exit(1);
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
// ============================================================================
|
|
626
|
+
// husky biz invoices schedule
|
|
627
|
+
// ============================================================================
|
|
628
|
+
invoicesCommand
|
|
629
|
+
.command("schedule")
|
|
630
|
+
.description("Show/configure scheduled reconciliation")
|
|
631
|
+
.option("--enable", "Enable monthly reconciliation")
|
|
632
|
+
.option("--disable", "Disable scheduled reconciliation")
|
|
633
|
+
.option("--cron <expression>", "Custom cron expression")
|
|
634
|
+
.action(async (options) => {
|
|
635
|
+
console.log("\n 📅 Invoice Reconciliation Schedule\n");
|
|
636
|
+
if (options.enable || options.disable || options.cron) {
|
|
637
|
+
console.log(" Schedule configuration is managed via:");
|
|
638
|
+
console.log(" - Cloud Scheduler in GCP Console");
|
|
639
|
+
console.log(" - Or crontab on the accounting VM");
|
|
640
|
+
console.log("");
|
|
641
|
+
console.log(" Recommended cron: 0 6 1 * * (1st of month at 6 AM)");
|
|
642
|
+
console.log("");
|
|
643
|
+
console.log(" Command to run:");
|
|
644
|
+
console.log(" husky biz invoices reconcile --gcs");
|
|
645
|
+
console.log("");
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
console.log(" To set up automated reconciliation:");
|
|
649
|
+
console.log("");
|
|
650
|
+
console.log(" 1. On a VM (crontab -e):");
|
|
651
|
+
console.log(" 0 6 1 * * /usr/local/bin/husky biz invoices reconcile --gcs >> /var/log/husky-reconcile.log 2>&1");
|
|
652
|
+
console.log("");
|
|
653
|
+
console.log(" 2. Via Cloud Scheduler:");
|
|
654
|
+
console.log(" gcloud scheduler jobs create http husky-invoice-reconcile \\");
|
|
655
|
+
console.log(" --schedule=\"0 6 1 * *\" \\");
|
|
656
|
+
console.log(" --uri=\"https://your-api/api/invoices/reconcile\" \\");
|
|
657
|
+
console.log(" --http-method=POST");
|
|
658
|
+
console.log("");
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
export default invoicesCommand;
|