@fuzzle/opencode-accountant 0.0.7 → 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.
Files changed (3) hide show
  1. package/README.md +42 -12
  2. package/dist/index.js +120 -29
  3. 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
- imports: statements/imports
96
+ import: statements/import
97
97
  pending: doc/agent/todo/import
98
98
  done: doc/agent/done/import
99
- unrecognized: statements/imports/unrecognized
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
- | `imports` | Drop zone for new CSV files |
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
- | `currencies` | Map of raw currency values to normalized folder names |
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
 
@@ -142,7 +172,7 @@ your-project/
142
172
  │ └── import/
143
173
  │ └── providers.yaml
144
174
  ├── statements/
145
- │ └── imports/ # Drop CSV files here
175
+ │ └── import/ # Drop CSV files here
146
176
  │ └── unrecognized/ # Unclassified files moved here
147
177
  └── doc/
148
178
  └── agent/
@@ -158,10 +188,10 @@ your-project/
158
188
 
159
189
  #### Workflow
160
190
 
161
- 1. Drop CSV files into `statements/imports/`
191
+ 1. Drop CSV files into `statements/import/`
162
192
  2. Run `classify-statements` tool
163
193
  3. Files are moved to `doc/agent/todo/import/<provider>/<currency>/`
164
- 4. Unrecognized files are moved to `statements/imports/unrecognized/`
194
+ 4. Unrecognized files are moved to `statements/import/unrecognized/`
165
195
  5. After successful import, files should be moved to `doc/agent/done/import/`
166
196
 
167
197
  ## Development
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 = ["imports", "pending", "done", "unrecognized"];
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
- imports: pathsObj.imports,
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
- try {
16621
- new RegExp(ruleObj.filenamePattern);
16622
- } catch {
16623
- throw new Error(`Invalid config: provider '${providerName}' detect[${index}].filenamePattern is not a valid regex`);
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 parseCSVPreview(content) {
16699
- const result = import_papaparse.default.parse(content, {
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
- const filenameRegex = new RegExp(rule.filenamePattern);
16721
- if (!filenameRegex.test(filename)) {
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.imports);
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.imports}`
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, filename);
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, filename)
16946
+ targetPath: path4.join(config2.paths.pending, detection.provider, detection.currency, targetFilename)
16856
16947
  });
16857
16948
  } else {
16858
16949
  ensureDirectory(unrecognizedDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzzle/opencode-accountant",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "description": "An OpenCode accounting agent, specialized in double-entry-bookkepping with hledger",
5
5
  "author": {
6
6
  "name": "ali bengali",