@fuzzle/opencode-accountant 0.0.7-next.1 → 0.0.8
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/README.md +39 -9
- package/dist/index.js +120 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -93,10 +93,10 @@ The `classify-statements` tool classifies bank statement CSV files by provider a
|
|
|
93
93
|
|
|
94
94
|
```yaml
|
|
95
95
|
paths:
|
|
96
|
-
|
|
96
|
+
import: statements/import
|
|
97
97
|
pending: doc/agent/todo/import
|
|
98
98
|
done: doc/agent/done/import
|
|
99
|
-
unrecognized: statements/
|
|
99
|
+
unrecognized: statements/import/unrecognized
|
|
100
100
|
|
|
101
101
|
providers:
|
|
102
102
|
revolut:
|
|
@@ -112,6 +112,23 @@ providers:
|
|
|
112
112
|
EUR: eur
|
|
113
113
|
USD: usd
|
|
114
114
|
BTC: btc
|
|
115
|
+
|
|
116
|
+
ubs:
|
|
117
|
+
detect:
|
|
118
|
+
- header: 'Trade date,Trade time,Booking date,Value date,Currency,Debit,Credit,Individual amount,Balance,Transaction no.,Description1,Description2,Description3,Footnotes'
|
|
119
|
+
currencyField: Currency
|
|
120
|
+
skipRows: 9
|
|
121
|
+
delimiter: ';'
|
|
122
|
+
renamePattern: 'transactions-ubs-{accountnumber}.csv'
|
|
123
|
+
metadata:
|
|
124
|
+
- field: accountnumber
|
|
125
|
+
row: 0
|
|
126
|
+
column: 1
|
|
127
|
+
normalize: spaces-to-dashes
|
|
128
|
+
currencies:
|
|
129
|
+
CHF: chf
|
|
130
|
+
EUR: eur
|
|
131
|
+
USD: usd
|
|
115
132
|
```
|
|
116
133
|
|
|
117
134
|
#### Configuration Options
|
|
@@ -120,19 +137,32 @@ providers:
|
|
|
120
137
|
|
|
121
138
|
| Field | Description |
|
|
122
139
|
| -------------- | ----------------------------------------------- |
|
|
123
|
-
| `
|
|
140
|
+
| `import` | Drop zone for new CSV files |
|
|
124
141
|
| `pending` | Base path for classified files awaiting import |
|
|
125
142
|
| `done` | Base path for archived files after import |
|
|
126
143
|
| `unrecognized` | Directory for files that couldn't be classified |
|
|
127
144
|
|
|
128
145
|
**Provider Detection Rules:**
|
|
129
146
|
|
|
130
|
-
| Field | Description
|
|
131
|
-
| ----------------- |
|
|
132
|
-
| `filenamePattern` | Regex pattern to match against filename
|
|
133
|
-
| `header` | Expected CSV header row (exact match)
|
|
134
|
-
| `currencyField` | Column name containing the currency/symbol
|
|
135
|
-
| `
|
|
147
|
+
| Field | Required | Description |
|
|
148
|
+
| ----------------- | -------- | ---------------------------------------------------------- |
|
|
149
|
+
| `filenamePattern` | No | Regex pattern to match against filename |
|
|
150
|
+
| `header` | Yes | Expected CSV header row (exact match) |
|
|
151
|
+
| `currencyField` | Yes | Column name containing the currency/symbol |
|
|
152
|
+
| `skipRows` | No | Number of rows to skip before header (default: 0) |
|
|
153
|
+
| `delimiter` | No | CSV delimiter character (default: `,`) |
|
|
154
|
+
| `renamePattern` | No | Output filename pattern with `{placeholder}` substitutions |
|
|
155
|
+
| `metadata` | No | Array of metadata extraction rules (see below) |
|
|
156
|
+
| `currencies` | Yes | Map of raw currency values to normalized folder names |
|
|
157
|
+
|
|
158
|
+
**Metadata Extraction Rules:**
|
|
159
|
+
|
|
160
|
+
| Field | Required | Description |
|
|
161
|
+
| ----------- | -------- | ------------------------------------------------------- |
|
|
162
|
+
| `field` | Yes | Placeholder name to use in `renamePattern` |
|
|
163
|
+
| `row` | Yes | Row index within `skipRows` to extract from (0-indexed) |
|
|
164
|
+
| `column` | Yes | Column index to extract from (0-indexed) |
|
|
165
|
+
| `normalize` | No | Normalization type: `spaces-to-dashes` |
|
|
136
166
|
|
|
137
167
|
#### Directory Structure
|
|
138
168
|
|
package/dist/index.js
CHANGED
|
@@ -16584,12 +16584,8 @@ import * as fs4 from "fs";
|
|
|
16584
16584
|
import * as fs3 from "fs";
|
|
16585
16585
|
import * as path3 from "path";
|
|
16586
16586
|
var CONFIG_FILE2 = "config/import/providers.yaml";
|
|
16587
|
-
var REQUIRED_PATH_FIELDS = ["
|
|
16588
|
-
var REQUIRED_DETECTION_FIELDS = [
|
|
16589
|
-
"filenamePattern",
|
|
16590
|
-
"header",
|
|
16591
|
-
"currencyField"
|
|
16592
|
-
];
|
|
16587
|
+
var REQUIRED_PATH_FIELDS = ["import", "pending", "done", "unrecognized"];
|
|
16588
|
+
var REQUIRED_DETECTION_FIELDS = ["header", "currencyField"];
|
|
16593
16589
|
function validatePaths(paths) {
|
|
16594
16590
|
if (typeof paths !== "object" || paths === null) {
|
|
16595
16591
|
throw new Error("Invalid config: 'paths' must be an object");
|
|
@@ -16601,7 +16597,7 @@ function validatePaths(paths) {
|
|
|
16601
16597
|
}
|
|
16602
16598
|
}
|
|
16603
16599
|
return {
|
|
16604
|
-
|
|
16600
|
+
import: pathsObj.import,
|
|
16605
16601
|
pending: pathsObj.pending,
|
|
16606
16602
|
done: pathsObj.done,
|
|
16607
16603
|
unrecognized: pathsObj.unrecognized
|
|
@@ -16617,15 +16613,59 @@ function validateDetectionRule(providerName, index, rule) {
|
|
|
16617
16613
|
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].${field} is required`);
|
|
16618
16614
|
}
|
|
16619
16615
|
}
|
|
16620
|
-
|
|
16621
|
-
|
|
16622
|
-
|
|
16623
|
-
|
|
16616
|
+
if (ruleObj.filenamePattern !== undefined) {
|
|
16617
|
+
if (typeof ruleObj.filenamePattern !== "string") {
|
|
16618
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].filenamePattern must be a string`);
|
|
16619
|
+
}
|
|
16620
|
+
try {
|
|
16621
|
+
new RegExp(ruleObj.filenamePattern);
|
|
16622
|
+
} catch {
|
|
16623
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].filenamePattern is not a valid regex`);
|
|
16624
|
+
}
|
|
16625
|
+
}
|
|
16626
|
+
if (ruleObj.skipRows !== undefined) {
|
|
16627
|
+
if (typeof ruleObj.skipRows !== "number" || ruleObj.skipRows < 0) {
|
|
16628
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].skipRows must be a non-negative number`);
|
|
16629
|
+
}
|
|
16630
|
+
}
|
|
16631
|
+
if (ruleObj.delimiter !== undefined) {
|
|
16632
|
+
if (typeof ruleObj.delimiter !== "string" || ruleObj.delimiter.length !== 1) {
|
|
16633
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].delimiter must be a single character`);
|
|
16634
|
+
}
|
|
16635
|
+
}
|
|
16636
|
+
if (ruleObj.renamePattern !== undefined) {
|
|
16637
|
+
if (typeof ruleObj.renamePattern !== "string") {
|
|
16638
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].renamePattern must be a string`);
|
|
16639
|
+
}
|
|
16640
|
+
}
|
|
16641
|
+
if (ruleObj.metadata !== undefined) {
|
|
16642
|
+
if (!Array.isArray(ruleObj.metadata)) {
|
|
16643
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].metadata must be an array`);
|
|
16644
|
+
}
|
|
16645
|
+
for (let i2 = 0;i2 < ruleObj.metadata.length; i2++) {
|
|
16646
|
+
const meta = ruleObj.metadata[i2];
|
|
16647
|
+
if (typeof meta.field !== "string" || meta.field === "") {
|
|
16648
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].metadata[${i2}].field is required`);
|
|
16649
|
+
}
|
|
16650
|
+
if (typeof meta.row !== "number" || meta.row < 0) {
|
|
16651
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].metadata[${i2}].row must be a non-negative number`);
|
|
16652
|
+
}
|
|
16653
|
+
if (typeof meta.column !== "number" || meta.column < 0) {
|
|
16654
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].metadata[${i2}].column must be a non-negative number`);
|
|
16655
|
+
}
|
|
16656
|
+
if (meta.normalize !== undefined && meta.normalize !== "spaces-to-dashes") {
|
|
16657
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].metadata[${i2}].normalize must be 'spaces-to-dashes'`);
|
|
16658
|
+
}
|
|
16659
|
+
}
|
|
16624
16660
|
}
|
|
16625
16661
|
return {
|
|
16626
16662
|
filenamePattern: ruleObj.filenamePattern,
|
|
16627
16663
|
header: ruleObj.header,
|
|
16628
|
-
currencyField: ruleObj.currencyField
|
|
16664
|
+
currencyField: ruleObj.currencyField,
|
|
16665
|
+
skipRows: ruleObj.skipRows,
|
|
16666
|
+
delimiter: ruleObj.delimiter,
|
|
16667
|
+
renamePattern: ruleObj.renamePattern,
|
|
16668
|
+
metadata: ruleObj.metadata
|
|
16629
16669
|
};
|
|
16630
16670
|
}
|
|
16631
16671
|
function validateProviderConfig(name, config2) {
|
|
@@ -16695,11 +16735,50 @@ function loadImportConfig(directory) {
|
|
|
16695
16735
|
|
|
16696
16736
|
// src/utils/providerDetector.ts
|
|
16697
16737
|
var import_papaparse = __toESM(require_papaparse(), 1);
|
|
16698
|
-
function
|
|
16699
|
-
|
|
16738
|
+
function extractMetadata(content, skipRows, delimiter, metadataConfig) {
|
|
16739
|
+
if (!metadataConfig || metadataConfig.length === 0 || skipRows === 0) {
|
|
16740
|
+
return {};
|
|
16741
|
+
}
|
|
16742
|
+
const lines = content.split(`
|
|
16743
|
+
`).slice(0, skipRows);
|
|
16744
|
+
const metadata = {};
|
|
16745
|
+
for (const config2 of metadataConfig) {
|
|
16746
|
+
if (config2.row >= lines.length)
|
|
16747
|
+
continue;
|
|
16748
|
+
const columns = lines[config2.row].split(delimiter);
|
|
16749
|
+
if (config2.column >= columns.length)
|
|
16750
|
+
continue;
|
|
16751
|
+
let value = columns[config2.column].trim();
|
|
16752
|
+
if (config2.normalize === "spaces-to-dashes") {
|
|
16753
|
+
value = value.replace(/\s+/g, "-");
|
|
16754
|
+
}
|
|
16755
|
+
metadata[config2.field] = value;
|
|
16756
|
+
}
|
|
16757
|
+
return metadata;
|
|
16758
|
+
}
|
|
16759
|
+
function generateOutputFilename(renamePattern, metadata) {
|
|
16760
|
+
if (!renamePattern) {
|
|
16761
|
+
return;
|
|
16762
|
+
}
|
|
16763
|
+
let filename = renamePattern;
|
|
16764
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
16765
|
+
filename = filename.replace(`{${key}}`, value);
|
|
16766
|
+
}
|
|
16767
|
+
return filename;
|
|
16768
|
+
}
|
|
16769
|
+
function parseCSVPreview(content, skipRows = 0, delimiter = ",") {
|
|
16770
|
+
let csvContent = content;
|
|
16771
|
+
if (skipRows > 0) {
|
|
16772
|
+
const lines = content.split(`
|
|
16773
|
+
`);
|
|
16774
|
+
csvContent = lines.slice(skipRows).join(`
|
|
16775
|
+
`);
|
|
16776
|
+
}
|
|
16777
|
+
const result = import_papaparse.default.parse(csvContent, {
|
|
16700
16778
|
header: true,
|
|
16701
16779
|
preview: 1,
|
|
16702
|
-
skipEmptyLines: true
|
|
16780
|
+
skipEmptyLines: true,
|
|
16781
|
+
delimiter
|
|
16703
16782
|
});
|
|
16704
16783
|
return {
|
|
16705
16784
|
fields: result.meta.fields,
|
|
@@ -16710,17 +16789,21 @@ function normalizeHeader(fields) {
|
|
|
16710
16789
|
return fields.map((f) => f.trim()).join(",");
|
|
16711
16790
|
}
|
|
16712
16791
|
function detectProvider(filename, content, config2) {
|
|
16713
|
-
const { fields, firstRow } = parseCSVPreview(content);
|
|
16714
|
-
if (!fields || fields.length === 0) {
|
|
16715
|
-
return null;
|
|
16716
|
-
}
|
|
16717
|
-
const actualHeader = normalizeHeader(fields);
|
|
16718
16792
|
for (const [providerName, providerConfig] of Object.entries(config2.providers)) {
|
|
16719
16793
|
for (const rule of providerConfig.detect) {
|
|
16720
|
-
|
|
16721
|
-
|
|
16794
|
+
if (rule.filenamePattern !== undefined) {
|
|
16795
|
+
const filenameRegex = new RegExp(rule.filenamePattern);
|
|
16796
|
+
if (!filenameRegex.test(filename)) {
|
|
16797
|
+
continue;
|
|
16798
|
+
}
|
|
16799
|
+
}
|
|
16800
|
+
const skipRows = rule.skipRows ?? 0;
|
|
16801
|
+
const delimiter = rule.delimiter ?? ",";
|
|
16802
|
+
const { fields, firstRow } = parseCSVPreview(content, skipRows, delimiter);
|
|
16803
|
+
if (!fields || fields.length === 0) {
|
|
16722
16804
|
continue;
|
|
16723
16805
|
}
|
|
16806
|
+
const actualHeader = normalizeHeader(fields);
|
|
16724
16807
|
if (actualHeader !== rule.header) {
|
|
16725
16808
|
continue;
|
|
16726
16809
|
}
|
|
@@ -16731,18 +16814,24 @@ function detectProvider(filename, content, config2) {
|
|
|
16731
16814
|
if (!rawCurrency) {
|
|
16732
16815
|
continue;
|
|
16733
16816
|
}
|
|
16817
|
+
const metadata = extractMetadata(content, skipRows, delimiter, rule.metadata);
|
|
16818
|
+
const outputFilename = generateOutputFilename(rule.renamePattern, metadata);
|
|
16734
16819
|
const normalizedCurrency = providerConfig.currencies[rawCurrency];
|
|
16735
16820
|
if (!normalizedCurrency) {
|
|
16736
16821
|
return {
|
|
16737
16822
|
provider: providerName,
|
|
16738
16823
|
currency: rawCurrency.toLowerCase(),
|
|
16739
|
-
rule
|
|
16824
|
+
rule,
|
|
16825
|
+
outputFilename,
|
|
16826
|
+
metadata: Object.keys(metadata).length > 0 ? metadata : undefined
|
|
16740
16827
|
};
|
|
16741
16828
|
}
|
|
16742
16829
|
return {
|
|
16743
16830
|
provider: providerName,
|
|
16744
16831
|
currency: normalizedCurrency,
|
|
16745
|
-
rule
|
|
16832
|
+
rule,
|
|
16833
|
+
outputFilename,
|
|
16834
|
+
metadata: Object.keys(metadata).length > 0 ? metadata : undefined
|
|
16746
16835
|
};
|
|
16747
16836
|
}
|
|
16748
16837
|
}
|
|
@@ -16815,7 +16904,7 @@ async function classifyStatementsCore(directory, agent, configLoader = loadImpor
|
|
|
16815
16904
|
unrecognized: []
|
|
16816
16905
|
});
|
|
16817
16906
|
}
|
|
16818
|
-
const importsDir = path4.join(directory, config2.paths.
|
|
16907
|
+
const importsDir = path4.join(directory, config2.paths.import);
|
|
16819
16908
|
const pendingDir = path4.join(directory, config2.paths.pending);
|
|
16820
16909
|
const unrecognizedDir = path4.join(directory, config2.paths.unrecognized);
|
|
16821
16910
|
const pendingFiles = checkPendingFiles(directory, config2.paths.pending);
|
|
@@ -16834,7 +16923,7 @@ async function classifyStatementsCore(directory, agent, configLoader = loadImpor
|
|
|
16834
16923
|
success: true,
|
|
16835
16924
|
classified: [],
|
|
16836
16925
|
unrecognized: [],
|
|
16837
|
-
message: `No CSV files found in ${config2.paths.
|
|
16926
|
+
message: `No CSV files found in ${config2.paths.import}`
|
|
16838
16927
|
});
|
|
16839
16928
|
}
|
|
16840
16929
|
const classified = [];
|
|
@@ -16844,15 +16933,17 @@ async function classifyStatementsCore(directory, agent, configLoader = loadImpor
|
|
|
16844
16933
|
const content = fs4.readFileSync(sourcePath, "utf-8");
|
|
16845
16934
|
const detection = detectProvider(filename, content, config2);
|
|
16846
16935
|
if (detection) {
|
|
16936
|
+
const targetFilename = detection.outputFilename || filename;
|
|
16847
16937
|
const targetDir = path4.join(pendingDir, detection.provider, detection.currency);
|
|
16848
16938
|
ensureDirectory(targetDir);
|
|
16849
|
-
const targetPath = path4.join(targetDir,
|
|
16939
|
+
const targetPath = path4.join(targetDir, targetFilename);
|
|
16850
16940
|
fs4.renameSync(sourcePath, targetPath);
|
|
16851
16941
|
classified.push({
|
|
16852
|
-
filename,
|
|
16942
|
+
filename: targetFilename,
|
|
16943
|
+
originalFilename: detection.outputFilename ? filename : undefined,
|
|
16853
16944
|
provider: detection.provider,
|
|
16854
16945
|
currency: detection.currency,
|
|
16855
|
-
targetPath: path4.join(config2.paths.pending, detection.provider, detection.currency,
|
|
16946
|
+
targetPath: path4.join(config2.paths.pending, detection.provider, detection.currency, targetFilename)
|
|
16856
16947
|
});
|
|
16857
16948
|
} else {
|
|
16858
16949
|
ensureDirectory(unrecognizedDir);
|