@fuzzle/opencode-accountant 0.0.7 → 0.0.8-next.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.
Files changed (3) hide show
  1. package/README.md +42 -12
  2. package/dist/index.js +164 -79
  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
@@ -1341,7 +1341,7 @@ var require_papaparse = __commonJS((exports, module) => {
1341
1341
  });
1342
1342
 
1343
1343
  // src/index.ts
1344
- import { dirname, join as join5 } from "path";
1344
+ import { dirname as dirname2, join as join5 } from "path";
1345
1345
  import { fileURLToPath } from "url";
1346
1346
 
1347
1347
  // src/utils/agentLoader.ts
@@ -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
  }
@@ -16759,35 +16848,6 @@ function findCSVFiles(importsDir) {
16759
16848
  return fs4.statSync(fullPath).isFile();
16760
16849
  });
16761
16850
  }
16762
- function checkPendingFiles(directory, pendingBasePath) {
16763
- const pendingDir = path4.join(directory, pendingBasePath);
16764
- const pendingFiles = [];
16765
- if (!fs4.existsSync(pendingDir)) {
16766
- return [];
16767
- }
16768
- const providers = fs4.readdirSync(pendingDir);
16769
- for (const provider of providers) {
16770
- const providerPath = path4.join(pendingDir, provider);
16771
- if (!fs4.statSync(providerPath).isDirectory())
16772
- continue;
16773
- const currencies = fs4.readdirSync(providerPath);
16774
- for (const currency of currencies) {
16775
- const currencyPath = path4.join(providerPath, currency);
16776
- if (!fs4.statSync(currencyPath).isDirectory())
16777
- continue;
16778
- const files = fs4.readdirSync(currencyPath).filter((f) => f.toLowerCase().endsWith(".csv"));
16779
- for (const file2 of files) {
16780
- pendingFiles.push({
16781
- provider,
16782
- currency,
16783
- filename: file2,
16784
- path: path4.join(currencyPath, file2)
16785
- });
16786
- }
16787
- }
16788
- }
16789
- return pendingFiles;
16790
- }
16791
16851
  function ensureDirectory(dirPath) {
16792
16852
  if (!fs4.existsSync(dirPath)) {
16793
16853
  fs4.mkdirSync(dirPath, { recursive: true });
@@ -16815,52 +16875,77 @@ async function classifyStatementsCore(directory, agent, configLoader = loadImpor
16815
16875
  unrecognized: []
16816
16876
  });
16817
16877
  }
16818
- const importsDir = path4.join(directory, config2.paths.imports);
16878
+ const importsDir = path4.join(directory, config2.paths.import);
16819
16879
  const pendingDir = path4.join(directory, config2.paths.pending);
16820
16880
  const unrecognizedDir = path4.join(directory, config2.paths.unrecognized);
16821
- const pendingFiles = checkPendingFiles(directory, config2.paths.pending);
16822
- if (pendingFiles.length > 0) {
16823
- return JSON.stringify({
16824
- success: false,
16825
- error: `Found ${pendingFiles.length} pending file(s) that must be processed before classifying new statements.`,
16826
- pendingFiles,
16827
- classified: [],
16828
- unrecognized: []
16829
- });
16830
- }
16831
16881
  const csvFiles = findCSVFiles(importsDir);
16832
16882
  if (csvFiles.length === 0) {
16833
16883
  return JSON.stringify({
16834
16884
  success: true,
16835
16885
  classified: [],
16836
16886
  unrecognized: [],
16837
- message: `No CSV files found in ${config2.paths.imports}`
16887
+ message: `No CSV files found in ${config2.paths.import}`
16838
16888
  });
16839
16889
  }
16840
- const classified = [];
16841
- const unrecognized = [];
16890
+ const plannedMoves = [];
16891
+ const collisions = [];
16842
16892
  for (const filename of csvFiles) {
16843
16893
  const sourcePath = path4.join(importsDir, filename);
16844
16894
  const content = fs4.readFileSync(sourcePath, "utf-8");
16845
16895
  const detection = detectProvider(filename, content, config2);
16896
+ let targetPath;
16897
+ let targetFilename;
16846
16898
  if (detection) {
16899
+ targetFilename = detection.outputFilename || filename;
16847
16900
  const targetDir = path4.join(pendingDir, detection.provider, detection.currency);
16901
+ targetPath = path4.join(targetDir, targetFilename);
16902
+ } else {
16903
+ targetFilename = filename;
16904
+ targetPath = path4.join(unrecognizedDir, filename);
16905
+ }
16906
+ if (fs4.existsSync(targetPath)) {
16907
+ collisions.push({
16908
+ filename,
16909
+ existingPath: targetPath
16910
+ });
16911
+ }
16912
+ plannedMoves.push({
16913
+ filename,
16914
+ sourcePath,
16915
+ targetPath,
16916
+ targetFilename,
16917
+ detection
16918
+ });
16919
+ }
16920
+ if (collisions.length > 0) {
16921
+ return JSON.stringify({
16922
+ success: false,
16923
+ error: `Cannot classify: ${collisions.length} file(s) would overwrite existing pending files.`,
16924
+ collisions,
16925
+ classified: [],
16926
+ unrecognized: []
16927
+ });
16928
+ }
16929
+ const classified = [];
16930
+ const unrecognized = [];
16931
+ for (const move of plannedMoves) {
16932
+ if (move.detection) {
16933
+ const targetDir = path4.dirname(move.targetPath);
16848
16934
  ensureDirectory(targetDir);
16849
- const targetPath = path4.join(targetDir, filename);
16850
- fs4.renameSync(sourcePath, targetPath);
16935
+ fs4.renameSync(move.sourcePath, move.targetPath);
16851
16936
  classified.push({
16852
- filename,
16853
- provider: detection.provider,
16854
- currency: detection.currency,
16855
- targetPath: path4.join(config2.paths.pending, detection.provider, detection.currency, filename)
16937
+ filename: move.targetFilename,
16938
+ originalFilename: move.detection.outputFilename ? move.filename : undefined,
16939
+ provider: move.detection.provider,
16940
+ currency: move.detection.currency,
16941
+ targetPath: path4.join(config2.paths.pending, move.detection.provider, move.detection.currency, move.targetFilename)
16856
16942
  });
16857
16943
  } else {
16858
16944
  ensureDirectory(unrecognizedDir);
16859
- const targetPath = path4.join(unrecognizedDir, filename);
16860
- fs4.renameSync(sourcePath, targetPath);
16945
+ fs4.renameSync(move.sourcePath, move.targetPath);
16861
16946
  unrecognized.push({
16862
- filename,
16863
- targetPath: path4.join(config2.paths.unrecognized, filename)
16947
+ filename: move.filename,
16948
+ targetPath: path4.join(config2.paths.unrecognized, move.filename)
16864
16949
  });
16865
16950
  }
16866
16951
  }
@@ -16884,7 +16969,7 @@ var classify_statements_default = tool({
16884
16969
  }
16885
16970
  });
16886
16971
  // src/index.ts
16887
- var __dirname2 = dirname(fileURLToPath(import.meta.url));
16972
+ var __dirname2 = dirname2(fileURLToPath(import.meta.url));
16888
16973
  var AGENT_FILE = join5(__dirname2, "..", "agent", "accountant.md");
16889
16974
  var AccountantPlugin = async () => {
16890
16975
  const agent = loadAgent(AGENT_FILE);
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-next.1",
4
4
  "description": "An OpenCode accounting agent, specialized in double-entry-bookkepping with hledger",
5
5
  "author": {
6
6
  "name": "ali bengali",