@fuzzle/opencode-accountant 0.12.2 → 0.13.0-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.
package/dist/index.js CHANGED
@@ -24674,17 +24674,22 @@ function formatJournalEntry(match2) {
24674
24674
  const btcPrice = formatAmount(btcRow.price.amount);
24675
24675
  const priceCurrency = btcRow.price.currency;
24676
24676
  const hasFees = btcRow.fees.amount > 0;
24677
+ const isBaseCurrency = fiatCurrency === "CHF";
24678
+ const pair = `${fiatCurrency.toLowerCase()}-btc`;
24679
+ const equityFiat = `equity:conversion:${pair}:${fiatCurrency.toLowerCase()}`;
24680
+ const equityBtc = `equity:conversion:${pair}:btc`;
24681
+ const assetCostAnnotation = isBaseCurrency ? ` @ ${btcPrice} ${priceCurrency}` : "";
24677
24682
  const lines = [
24678
24683
  `${date5} Bitcoin purchase`,
24679
24684
  ` assets:bank:revolut:${fiatCurrency.toLowerCase()} -${fiatAmount} ${fiatCurrency}`,
24680
- ` equity:bitcoin:conversion ${fiatAmount} ${fiatCurrency}`,
24681
- ` equity:bitcoin:conversion -${btcQuantity} BTC @ ${btcPrice} ${priceCurrency}`,
24682
- ` assets:bank:revolut:btc ${btcQuantity} BTC @ ${btcPrice} ${priceCurrency}`
24685
+ ` ${equityFiat} ${fiatAmount} ${fiatCurrency}`,
24686
+ ` ${equityBtc} -${btcQuantity} BTC`,
24687
+ ` assets:bank:revolut:btc ${btcQuantity} BTC${assetCostAnnotation}`
24683
24688
  ];
24684
24689
  if (hasFees) {
24685
24690
  const feeAmount = formatAmount(btcRow.fees.amount);
24686
24691
  const feeCurrency = btcRow.fees.currency;
24687
- lines.push(` expenses:fees:btc ${feeAmount} ${feeCurrency}`, ` equity:bitcoin:conversion -${feeAmount} ${feeCurrency}`);
24692
+ lines.push(` expenses:fees:btc ${feeAmount} ${feeCurrency}`, ` ${equityFiat} -${feeAmount} ${feeCurrency}`);
24688
24693
  }
24689
24694
  return lines.join(`
24690
24695
  `);
@@ -24740,8 +24745,18 @@ function generateBtcPurchaseJournal(fiatCsvPaths, btcCsvPath, yearJournalPath, l
24740
24745
  }
24741
24746
  const newEntries = [];
24742
24747
  let skippedDuplicates = 0;
24748
+ const accountsUsed = new Set;
24743
24749
  const sortedMatches = [...matches].sort((a, b) => a.fiatRow.date.getTime() - b.fiatRow.date.getTime());
24744
24750
  for (const match2 of sortedMatches) {
24751
+ const fiat = match2.fiatRow.currency.toLowerCase();
24752
+ const pair = `${fiat}-btc`;
24753
+ accountsUsed.add(`assets:bank:revolut:${fiat}`);
24754
+ accountsUsed.add(`equity:conversion:${pair}:${fiat}`);
24755
+ accountsUsed.add(`equity:conversion:${pair}:btc`);
24756
+ accountsUsed.add("assets:bank:revolut:btc");
24757
+ if (match2.btcRow.fees.amount > 0) {
24758
+ accountsUsed.add("expenses:fees:btc");
24759
+ }
24745
24760
  if (isDuplicate(match2, journalContent)) {
24746
24761
  skippedDuplicates++;
24747
24762
  logger?.debug(`Skipping duplicate: ${match2.fiatRow.dateStr} ${formatAmount(match2.fiatRow.amount)} ${match2.fiatRow.currency}`);
@@ -24765,7 +24780,8 @@ function generateBtcPurchaseJournal(fiatCsvPaths, btcCsvPath, yearJournalPath, l
24765
24780
  entriesAdded: newEntries.length,
24766
24781
  skippedDuplicates,
24767
24782
  unmatchedFiat,
24768
- unmatchedBtc
24783
+ unmatchedBtc,
24784
+ accountsUsed
24769
24785
  };
24770
24786
  }
24771
24787
 
@@ -25977,11 +25993,14 @@ function formatExchangeEntry(match2) {
25977
25993
  const targetCurrency = target.currency;
25978
25994
  const sourceAmount = formatAmount3(source.amount);
25979
25995
  const targetAmount = formatAmount3(target.amount);
25996
+ const pair = `${sourceCurrency.toLowerCase()}-${targetCurrency.toLowerCase()}`;
25997
+ const equitySource = `equity:conversion:${pair}:${sourceCurrency.toLowerCase()}`;
25998
+ const equityTarget = `equity:conversion:${pair}:${targetCurrency.toLowerCase()}`;
25980
25999
  return [
25981
26000
  `${date5} ${description}`,
25982
26001
  ` assets:bank:revolut:${sourceCurrency.toLowerCase()} -${sourceAmount} ${sourceCurrency}`,
25983
- ` equity:currency:conversion ${sourceAmount} ${sourceCurrency}`,
25984
- ` equity:currency:conversion -${targetAmount} ${targetCurrency}`,
26002
+ ` ${equitySource} ${sourceAmount} ${sourceCurrency}`,
26003
+ ` ${equityTarget} -${targetAmount} ${targetCurrency}`,
25985
26004
  ` assets:bank:revolut:${targetCurrency.toLowerCase()} ${targetAmount} ${targetCurrency}`
25986
26005
  ].join(`
25987
26006
  `);
@@ -26046,7 +26065,13 @@ function generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger)
26046
26065
  }
26047
26066
  if (rowsByCsv.size < 2) {
26048
26067
  logger?.info("Need at least 2 CSVs with EXCHANGE rows to match pairs, skipping");
26049
- return { matchCount: 0, entriesAdded: 0, skippedDuplicates: 0, matchedRowIndices: new Map };
26068
+ return {
26069
+ matchCount: 0,
26070
+ entriesAdded: 0,
26071
+ skippedDuplicates: 0,
26072
+ matchedRowIndices: new Map,
26073
+ accountsUsed: new Set
26074
+ };
26050
26075
  }
26051
26076
  logger?.info("Matching exchange pairs across CSVs...");
26052
26077
  const matches = matchExchangePairs(rowsByCsv);
@@ -26057,8 +26082,16 @@ function generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger)
26057
26082
  }
26058
26083
  const newEntries = [];
26059
26084
  let skippedDuplicates = 0;
26085
+ const accountsUsed = new Set;
26060
26086
  const sortedMatches = [...matches].sort((a, b) => a.source.date.getTime() - b.source.date.getTime());
26061
26087
  for (const match2 of sortedMatches) {
26088
+ const source = match2.source.currency.toLowerCase();
26089
+ const target = match2.target.currency.toLowerCase();
26090
+ const pair = `${source}-${target}`;
26091
+ accountsUsed.add(`assets:bank:revolut:${source}`);
26092
+ accountsUsed.add(`equity:conversion:${pair}:${source}`);
26093
+ accountsUsed.add(`equity:conversion:${pair}:${target}`);
26094
+ accountsUsed.add(`assets:bank:revolut:${target}`);
26062
26095
  if (isDuplicate2(match2, journalContent)) {
26063
26096
  skippedDuplicates++;
26064
26097
  logger?.debug(`Skipping duplicate: ${match2.source.dateStr} ${formatAmount3(match2.source.amount)} ${match2.source.currency} \u2192 ${formatAmount3(match2.target.amount)} ${match2.target.currency}`);
@@ -26090,7 +26123,8 @@ function generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger)
26090
26123
  matchCount: matches.length,
26091
26124
  entriesAdded: newEntries.length,
26092
26125
  skippedDuplicates,
26093
- matchedRowIndices
26126
+ matchedRowIndices,
26127
+ accountsUsed
26094
26128
  };
26095
26129
  }
26096
26130
 
@@ -26262,6 +26296,9 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
26262
26296
  const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
26263
26297
  const yearJournalPath = ensureYearJournalExists(context.directory, year);
26264
26298
  const result = generateBtcPurchaseJournal(fiatCsvPaths, btcCsvPath, yearJournalPath, logger);
26299
+ for (const account of result.accountsUsed) {
26300
+ context.generatedAccounts.add(account);
26301
+ }
26265
26302
  const message = result.entriesAdded > 0 ? `Generated ${result.entriesAdded} BTC purchase entries (${result.matchCount} matched, ${result.skippedDuplicates} skipped)` : `No new BTC purchase entries (${result.matchCount} matched, ${result.skippedDuplicates} duplicates)`;
26266
26303
  logger?.logStep("BTC Purchases", "success", message);
26267
26304
  logger?.endSection();
@@ -26296,6 +26333,9 @@ async function executeCurrencyExchangeStep(context, contextIds, logger) {
26296
26333
  const yearJournalPath = ensureYearJournalExists(context.directory, year);
26297
26334
  const fiatCsvPaths = fiatContexts.map((c) => c.csvPath);
26298
26335
  const result = generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger);
26336
+ for (const account of result.accountsUsed) {
26337
+ context.generatedAccounts.add(account);
26338
+ }
26299
26339
  if (result.matchedRowIndices.size > 0) {
26300
26340
  for (const { contextId, csvPath } of fiatContexts) {
26301
26341
  const indices = result.matchedRowIndices.get(csvPath);
@@ -26698,7 +26738,8 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
26698
26738
  options,
26699
26739
  configLoader,
26700
26740
  hledgerExecutor,
26701
- result
26741
+ result,
26742
+ generatedAccounts: new Set
26702
26743
  };
26703
26744
  try {
26704
26745
  const contextIds = await executeClassifyStep(context, logger);
@@ -26711,6 +26752,18 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
26711
26752
  await executeBtcPurchaseStep(context, contextIds, logger);
26712
26753
  await executeCurrencyExchangeStep(context, contextIds, logger);
26713
26754
  await executeSwissquotePreprocessStep(context, contextIds, logger);
26755
+ if (context.generatedAccounts.size > 0) {
26756
+ const firstCtx = contextIds.map((id) => loadContext(context.directory, id)).find((c) => c.provider === "revolut");
26757
+ if (firstCtx) {
26758
+ const yearMatch = firstCtx.filePath.match(/(\d{4})-\d{2}-\d{2}/);
26759
+ const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
26760
+ const yearJournalPath = ensureYearJournalExists(context.directory, year);
26761
+ const declResult = ensureAccountDeclarations(yearJournalPath, context.generatedAccounts);
26762
+ if (declResult.added.length > 0) {
26763
+ logger.info(`Declared ${declResult.added.length} account(s) from generated entries: ${declResult.added.join(", ")}`);
26764
+ }
26765
+ }
26766
+ }
26714
26767
  const importConfig = loadImportConfig(context.directory);
26715
26768
  const orderedContextIds = [...contextIds].sort((a, b) => {
26716
26769
  const ctxA = loadContext(context.directory, a);
@@ -211,7 +211,7 @@ See [classify-statements](classify-statements.md) for details.
211
211
  1. Finds all Revolut fiat contexts (provider=revolut, currency≠btc) from classification
212
212
  2. Parses each CSV for `EXCHANGE` rows with "Exchanged to" descriptions
213
213
  3. Matches source (debited) and target (credited) rows across CSVs by timestamp (within 1 second)
214
- 4. Generates unified 4-posting journal entries using `equity:currency:conversion` with `@ cost` notation
214
+ 4. Generates unified 4-posting journal entries using per-pair `equity:conversion:{pair}:{currency}` trading sub-accounts
215
215
  5. Appends entries to year journal, skipping duplicates
216
216
  6. Filters matched EXCHANGE rows from CSVs (writes `-filtered.csv`) so hledger import doesn't double-count them
217
217
  7. Updates import contexts to point to filtered CSVs
@@ -220,13 +220,13 @@ See [classify-statements](classify-statements.md) for details.
220
220
 
221
221
  ```journal
222
222
  2026-01-15 Exchanged to EUR
223
- assets:bank:revolut:chf -100.00 CHF @ 1.0713 EUR
224
- equity:currency:conversion 100.00 CHF
225
- equity:currency:conversion -107.13 EUR
226
- assets:bank:revolut:eur 107.13 EUR
223
+ assets:bank:revolut:chf -100.00 CHF
224
+ equity:conversion:chf-eur:chf 100.00 CHF
225
+ equity:conversion:chf-eur:eur -107.13 EUR
226
+ assets:bank:revolut:eur 107.13 EUR
227
227
  ```
228
228
 
229
- **Why**: Revolut exports currency exchanges as two separate CSV rows — one per currency. Processing each through its own rules file produces two independent 2-posting transactions with a non-zero `equity:conversion` balance. This step intercepts EXCHANGE rows before hledger import, matches them across currencies, and generates a single unified transaction with explicit cost notation that keeps `equity:currency:conversion` balanced.
229
+ **Why**: Revolut exports currency exchanges as two separate CSV rows — one per currency. Processing each through its own rules file produces two independent 2-posting transactions with a non-zero `equity:conversion` balance. This step intercepts EXCHANGE rows before hledger import, matches them across currencies, and generates a single unified transaction with per-pair trading sub-accounts that keep each `equity:conversion:{pair}` balanced.
230
230
 
231
231
  **Skipped when**: Fewer than 2 Revolut fiat CSVs with EXCHANGE rows are present among the classified files.
232
232
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzzle/opencode-accountant",
3
- "version": "0.12.2",
3
+ "version": "0.13.0-next.1",
4
4
  "description": "An OpenCode accounting agent, specialized in double-entry-bookkepping with hledger",
5
5
  "author": {
6
6
  "name": "ali bengali",