@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 +63 -10
- package/docs/tools/import-pipeline.md +6 -6
- package/package.json +1 -1
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
|
-
`
|
|
24681
|
-
`
|
|
24682
|
-
` assets:bank:revolut:btc ${btcQuantity} BTC
|
|
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}`, `
|
|
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
|
-
`
|
|
25984
|
-
`
|
|
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 {
|
|
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
|
|
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
|
|
224
|
-
equity:
|
|
225
|
-
equity:
|
|
226
|
-
assets:bank:revolut: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
|
|
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
|
|