@fuzzle/opencode-accountant 0.6.1 → 0.7.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 +157 -19
- package/docs/tools/import-pipeline.md +46 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -4249,7 +4249,7 @@ __export(exports_accountSuggester, {
|
|
|
4249
4249
|
extractRulePatternsFromFile: () => extractRulePatternsFromFile,
|
|
4250
4250
|
clearSuggestionCache: () => clearSuggestionCache
|
|
4251
4251
|
});
|
|
4252
|
-
import * as
|
|
4252
|
+
import * as fs16 from "fs";
|
|
4253
4253
|
import * as crypto from "crypto";
|
|
4254
4254
|
function clearSuggestionCache() {
|
|
4255
4255
|
Object.keys(suggestionCache).forEach((key) => delete suggestionCache[key]);
|
|
@@ -4259,10 +4259,10 @@ function hashTransaction(posting) {
|
|
|
4259
4259
|
return crypto.createHash("md5").update(data).digest("hex");
|
|
4260
4260
|
}
|
|
4261
4261
|
function loadExistingAccounts(yearJournalPath) {
|
|
4262
|
-
if (!
|
|
4262
|
+
if (!fs16.existsSync(yearJournalPath)) {
|
|
4263
4263
|
return [];
|
|
4264
4264
|
}
|
|
4265
|
-
const content =
|
|
4265
|
+
const content = fs16.readFileSync(yearJournalPath, "utf-8");
|
|
4266
4266
|
const lines = content.split(`
|
|
4267
4267
|
`);
|
|
4268
4268
|
const accounts = [];
|
|
@@ -4278,10 +4278,10 @@ function loadExistingAccounts(yearJournalPath) {
|
|
|
4278
4278
|
return accounts.sort();
|
|
4279
4279
|
}
|
|
4280
4280
|
function extractRulePatternsFromFile(rulesPath) {
|
|
4281
|
-
if (!
|
|
4281
|
+
if (!fs16.existsSync(rulesPath)) {
|
|
4282
4282
|
return [];
|
|
4283
4283
|
}
|
|
4284
|
-
const content =
|
|
4284
|
+
const content = fs16.readFileSync(rulesPath, "utf-8");
|
|
4285
4285
|
const lines = content.split(`
|
|
4286
4286
|
`);
|
|
4287
4287
|
const patterns = [];
|
|
@@ -17272,6 +17272,11 @@ function validateDetectionRule(providerName, index, rule) {
|
|
|
17272
17272
|
}
|
|
17273
17273
|
}
|
|
17274
17274
|
}
|
|
17275
|
+
if (ruleObj.closingBalanceField !== undefined) {
|
|
17276
|
+
if (typeof ruleObj.closingBalanceField !== "string" || ruleObj.closingBalanceField === "") {
|
|
17277
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].closingBalanceField must be a non-empty string`);
|
|
17278
|
+
}
|
|
17279
|
+
}
|
|
17275
17280
|
return {
|
|
17276
17281
|
filenamePattern: ruleObj.filenamePattern,
|
|
17277
17282
|
header: ruleObj.header,
|
|
@@ -17279,7 +17284,8 @@ function validateDetectionRule(providerName, index, rule) {
|
|
|
17279
17284
|
skipRows: ruleObj.skipRows,
|
|
17280
17285
|
delimiter: ruleObj.delimiter,
|
|
17281
17286
|
renamePattern: ruleObj.renamePattern,
|
|
17282
|
-
metadata: ruleObj.metadata
|
|
17287
|
+
metadata: ruleObj.metadata,
|
|
17288
|
+
closingBalanceField: ruleObj.closingBalanceField
|
|
17283
17289
|
};
|
|
17284
17290
|
}
|
|
17285
17291
|
function validateProviderConfig(name, config2) {
|
|
@@ -17386,6 +17392,25 @@ function parseCSVPreview(content, skipRows = 0, delimiter = ",") {
|
|
|
17386
17392
|
firstRow: result.data[0]
|
|
17387
17393
|
};
|
|
17388
17394
|
}
|
|
17395
|
+
function extractLastRowField(content, skipRows, delimiter, fieldName) {
|
|
17396
|
+
let csvContent = content;
|
|
17397
|
+
if (skipRows > 0) {
|
|
17398
|
+
const lines = content.split(`
|
|
17399
|
+
`);
|
|
17400
|
+
csvContent = lines.slice(skipRows).join(`
|
|
17401
|
+
`);
|
|
17402
|
+
}
|
|
17403
|
+
const result = import_papaparse.default.parse(csvContent, {
|
|
17404
|
+
header: true,
|
|
17405
|
+
skipEmptyLines: true,
|
|
17406
|
+
delimiter
|
|
17407
|
+
});
|
|
17408
|
+
if (result.data.length === 0)
|
|
17409
|
+
return;
|
|
17410
|
+
const lastRow = result.data[result.data.length - 1];
|
|
17411
|
+
const value = lastRow[fieldName];
|
|
17412
|
+
return value ? value.trim() : undefined;
|
|
17413
|
+
}
|
|
17389
17414
|
function normalizeHeader(fields) {
|
|
17390
17415
|
return fields.map((f) => f.trim()).join(",");
|
|
17391
17416
|
}
|
|
@@ -17416,6 +17441,12 @@ function detectProvider(filename, content, config2) {
|
|
|
17416
17441
|
continue;
|
|
17417
17442
|
}
|
|
17418
17443
|
const metadata = extractMetadata(content, skipRows, delimiter, rule.metadata);
|
|
17444
|
+
if (rule.closingBalanceField) {
|
|
17445
|
+
const closingBalance = extractLastRowField(content, skipRows, delimiter, rule.closingBalanceField);
|
|
17446
|
+
if (closingBalance) {
|
|
17447
|
+
metadata["closing-balance"] = closingBalance;
|
|
17448
|
+
}
|
|
17449
|
+
}
|
|
17419
17450
|
const outputFilename = generateOutputFilename(rule.renamePattern, metadata);
|
|
17420
17451
|
const normalizedCurrency = providerConfig.currencies[rawCurrency];
|
|
17421
17452
|
if (!normalizedCurrency) {
|
|
@@ -24581,7 +24612,7 @@ function formatJournalEntry(match2) {
|
|
|
24581
24612
|
if (hasFees) {
|
|
24582
24613
|
const feeAmount = formatAmount(btcRow.fees.amount);
|
|
24583
24614
|
const feeCurrency = btcRow.fees.currency;
|
|
24584
|
-
lines.push(` expenses:fees:
|
|
24615
|
+
lines.push(` expenses:fees:btc ${feeAmount} ${feeCurrency}`, ` equity:bitcoin:conversion -${feeAmount} ${feeCurrency}`);
|
|
24585
24616
|
}
|
|
24586
24617
|
return lines.join(`
|
|
24587
24618
|
`);
|
|
@@ -24666,6 +24697,80 @@ function generateBtcPurchaseJournal(fiatCsvPaths, btcCsvPath, yearJournalPath, l
|
|
|
24666
24697
|
};
|
|
24667
24698
|
}
|
|
24668
24699
|
|
|
24700
|
+
// src/utils/btcCsvPreprocessor.ts
|
|
24701
|
+
import * as fs15 from "fs";
|
|
24702
|
+
var ORIGINAL_HEADER = "Symbol,Type,Quantity,Price,Value,Fees,Date";
|
|
24703
|
+
var PREPROCESSED_HEADER = "Symbol,Type,Quantity,Price,Value,Fees,Date,Fees_BTC,Total_BTC,Price_Amount";
|
|
24704
|
+
var FEE_BTC_DECIMALS = 8;
|
|
24705
|
+
function calculateFeeBtc(fees, price) {
|
|
24706
|
+
if (price.amount === 0)
|
|
24707
|
+
return 0;
|
|
24708
|
+
return fees.amount / price.amount;
|
|
24709
|
+
}
|
|
24710
|
+
function formatPriceAmount(price) {
|
|
24711
|
+
return `${price.amount} ${price.currency}`;
|
|
24712
|
+
}
|
|
24713
|
+
function preprocessRevolutBtcCsv(csvPath, logger) {
|
|
24714
|
+
const content = fs15.readFileSync(csvPath, "utf-8");
|
|
24715
|
+
const lines = content.trim().split(`
|
|
24716
|
+
`);
|
|
24717
|
+
if (lines.length < 2) {
|
|
24718
|
+
return { rowsProcessed: 0, sendRowsEnriched: 0, alreadyPreprocessed: false };
|
|
24719
|
+
}
|
|
24720
|
+
const header = lines[0].trim();
|
|
24721
|
+
if (header === PREPROCESSED_HEADER) {
|
|
24722
|
+
logger?.info("CSV already preprocessed, skipping");
|
|
24723
|
+
return { rowsProcessed: lines.length - 1, sendRowsEnriched: 0, alreadyPreprocessed: true };
|
|
24724
|
+
}
|
|
24725
|
+
if (header !== ORIGINAL_HEADER) {
|
|
24726
|
+
throw new Error(`Unexpected CSV header. Expected:
|
|
24727
|
+
${ORIGINAL_HEADER}
|
|
24728
|
+
Got:
|
|
24729
|
+
${header}`);
|
|
24730
|
+
}
|
|
24731
|
+
const outputLines = [PREPROCESSED_HEADER];
|
|
24732
|
+
let sendRowsEnriched = 0;
|
|
24733
|
+
for (let i2 = 1;i2 < lines.length; i2++) {
|
|
24734
|
+
const line = lines[i2];
|
|
24735
|
+
if (line.trim() === "")
|
|
24736
|
+
continue;
|
|
24737
|
+
const fields = parseCryptoCsvLine(line);
|
|
24738
|
+
if (fields.length < 7) {
|
|
24739
|
+
outputLines.push(line + ",,,");
|
|
24740
|
+
continue;
|
|
24741
|
+
}
|
|
24742
|
+
const type2 = fields[1];
|
|
24743
|
+
const quantityStr = fields[2];
|
|
24744
|
+
const priceStr = fields[3];
|
|
24745
|
+
const feesStr = fields[5];
|
|
24746
|
+
let feesBtc = "";
|
|
24747
|
+
let totalBtc = "";
|
|
24748
|
+
let priceAmount = "";
|
|
24749
|
+
try {
|
|
24750
|
+
const price = parseBtcPrice(priceStr);
|
|
24751
|
+
priceAmount = formatPriceAmount(price);
|
|
24752
|
+
if (type2 === "Send") {
|
|
24753
|
+
const fees = parseBtcPrice(feesStr);
|
|
24754
|
+
const feeBtc = calculateFeeBtc(fees, price);
|
|
24755
|
+
const quantity = parseFloat(quantityStr);
|
|
24756
|
+
feesBtc = feeBtc.toFixed(FEE_BTC_DECIMALS);
|
|
24757
|
+
totalBtc = (quantity + feeBtc).toFixed(FEE_BTC_DECIMALS);
|
|
24758
|
+
sendRowsEnriched++;
|
|
24759
|
+
}
|
|
24760
|
+
} catch {
|
|
24761
|
+
logger?.debug(`Row ${i2}: could not parse price/fees, leaving computed columns empty`);
|
|
24762
|
+
}
|
|
24763
|
+
outputLines.push(`${line},${feesBtc},${totalBtc},${priceAmount}`);
|
|
24764
|
+
}
|
|
24765
|
+
const outputContent = outputLines.join(`
|
|
24766
|
+
`) + `
|
|
24767
|
+
`;
|
|
24768
|
+
fs15.writeFileSync(csvPath, outputContent);
|
|
24769
|
+
const rowsProcessed = outputLines.length - 1;
|
|
24770
|
+
logger?.info(`Preprocessed ${rowsProcessed} rows (${sendRowsEnriched} Send rows enriched)`);
|
|
24771
|
+
return { rowsProcessed, sendRowsEnriched, alreadyPreprocessed: false };
|
|
24772
|
+
}
|
|
24773
|
+
|
|
24669
24774
|
// src/tools/import-pipeline.ts
|
|
24670
24775
|
class NoTransactionsError extends Error {
|
|
24671
24776
|
constructor() {
|
|
@@ -24733,6 +24838,38 @@ async function executeClassifyStep(context, logger) {
|
|
|
24733
24838
|
logger?.endSection();
|
|
24734
24839
|
return contextIds;
|
|
24735
24840
|
}
|
|
24841
|
+
function executePreprocessBtcStep(context, contextIds, logger) {
|
|
24842
|
+
let btcCsvPath;
|
|
24843
|
+
for (const contextId of contextIds) {
|
|
24844
|
+
const importCtx = loadContext(context.directory, contextId);
|
|
24845
|
+
if (importCtx.provider === "revolut" && importCtx.currency === "btc") {
|
|
24846
|
+
btcCsvPath = path12.join(context.directory, importCtx.filePath);
|
|
24847
|
+
break;
|
|
24848
|
+
}
|
|
24849
|
+
}
|
|
24850
|
+
if (!btcCsvPath) {
|
|
24851
|
+
logger?.info("No revolut BTC CSV found, skipping preprocessing");
|
|
24852
|
+
return;
|
|
24853
|
+
}
|
|
24854
|
+
logger?.startSection("Step 1a: Preprocess BTC CSV");
|
|
24855
|
+
logger?.logStep("BTC Preprocess", "start");
|
|
24856
|
+
try {
|
|
24857
|
+
const result = preprocessRevolutBtcCsv(btcCsvPath, logger);
|
|
24858
|
+
const message = result.alreadyPreprocessed ? "BTC CSV already preprocessed" : `Preprocessed ${result.rowsProcessed} rows (${result.sendRowsEnriched} Send rows enriched)`;
|
|
24859
|
+
logger?.logStep("BTC Preprocess", "success", message);
|
|
24860
|
+
context.result.steps.btcPreprocess = buildStepResult(true, message, result);
|
|
24861
|
+
} catch (error45) {
|
|
24862
|
+
const errorMessage = `BTC CSV preprocessing failed: ${error45 instanceof Error ? error45.message : String(error45)}`;
|
|
24863
|
+
logger?.error(errorMessage);
|
|
24864
|
+
logger?.logStep("BTC Preprocess", "error", errorMessage);
|
|
24865
|
+
context.result.steps.btcPreprocess = buildStepResult(false, errorMessage);
|
|
24866
|
+
context.result.error = errorMessage;
|
|
24867
|
+
context.result.hint = "Check the BTC CSV format \u2014 expected Revolut crypto account statement";
|
|
24868
|
+
throw new Error(errorMessage);
|
|
24869
|
+
} finally {
|
|
24870
|
+
logger?.endSection();
|
|
24871
|
+
}
|
|
24872
|
+
}
|
|
24736
24873
|
async function executeBtcPurchaseStep(context, contextIds, logger) {
|
|
24737
24874
|
const fiatCsvPaths = [];
|
|
24738
24875
|
let btcCsvPath;
|
|
@@ -25077,6 +25214,7 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
|
|
|
25077
25214
|
logger.info("No files classified, nothing to import");
|
|
25078
25215
|
return buildPipelineSuccessResult(result, "No files to import");
|
|
25079
25216
|
}
|
|
25217
|
+
executePreprocessBtcStep(context, contextIds, logger);
|
|
25080
25218
|
await executeBtcPurchaseStep(context, contextIds, logger);
|
|
25081
25219
|
let totalTransactions = 0;
|
|
25082
25220
|
for (const contextId of contextIds) {
|
|
@@ -25158,7 +25296,7 @@ This tool orchestrates the full import workflow:
|
|
|
25158
25296
|
}
|
|
25159
25297
|
});
|
|
25160
25298
|
// src/tools/init-directories.ts
|
|
25161
|
-
import * as
|
|
25299
|
+
import * as fs17 from "fs";
|
|
25162
25300
|
import * as path13 from "path";
|
|
25163
25301
|
async function initDirectories(directory) {
|
|
25164
25302
|
try {
|
|
@@ -25166,8 +25304,8 @@ async function initDirectories(directory) {
|
|
|
25166
25304
|
const directoriesCreated = [];
|
|
25167
25305
|
const gitkeepFiles = [];
|
|
25168
25306
|
const importBase = path13.join(directory, "import");
|
|
25169
|
-
if (!
|
|
25170
|
-
|
|
25307
|
+
if (!fs17.existsSync(importBase)) {
|
|
25308
|
+
fs17.mkdirSync(importBase, { recursive: true });
|
|
25171
25309
|
directoriesCreated.push("import");
|
|
25172
25310
|
}
|
|
25173
25311
|
const pathsToCreate = [
|
|
@@ -25178,19 +25316,19 @@ async function initDirectories(directory) {
|
|
|
25178
25316
|
];
|
|
25179
25317
|
for (const { path: dirPath } of pathsToCreate) {
|
|
25180
25318
|
const fullPath = path13.join(directory, dirPath);
|
|
25181
|
-
if (!
|
|
25182
|
-
|
|
25319
|
+
if (!fs17.existsSync(fullPath)) {
|
|
25320
|
+
fs17.mkdirSync(fullPath, { recursive: true });
|
|
25183
25321
|
directoriesCreated.push(dirPath);
|
|
25184
25322
|
}
|
|
25185
25323
|
const gitkeepPath = path13.join(fullPath, ".gitkeep");
|
|
25186
|
-
if (!
|
|
25187
|
-
|
|
25324
|
+
if (!fs17.existsSync(gitkeepPath)) {
|
|
25325
|
+
fs17.writeFileSync(gitkeepPath, "");
|
|
25188
25326
|
gitkeepFiles.push(path13.join(dirPath, ".gitkeep"));
|
|
25189
25327
|
}
|
|
25190
25328
|
}
|
|
25191
25329
|
const gitignorePath = path13.join(importBase, ".gitignore");
|
|
25192
25330
|
let gitignoreCreated = false;
|
|
25193
|
-
if (!
|
|
25331
|
+
if (!fs17.existsSync(gitignorePath)) {
|
|
25194
25332
|
const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
|
|
25195
25333
|
/incoming/*.csv
|
|
25196
25334
|
/incoming/*.pdf
|
|
@@ -25208,7 +25346,7 @@ async function initDirectories(directory) {
|
|
|
25208
25346
|
.DS_Store
|
|
25209
25347
|
Thumbs.db
|
|
25210
25348
|
`;
|
|
25211
|
-
|
|
25349
|
+
fs17.writeFileSync(gitignorePath, gitignoreContent);
|
|
25212
25350
|
gitignoreCreated = true;
|
|
25213
25351
|
}
|
|
25214
25352
|
const parts = [];
|
|
@@ -25285,13 +25423,13 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
|
|
|
25285
25423
|
});
|
|
25286
25424
|
// src/tools/generate-btc-purchases.ts
|
|
25287
25425
|
import * as path14 from "path";
|
|
25288
|
-
import * as
|
|
25426
|
+
import * as fs18 from "fs";
|
|
25289
25427
|
function findFiatCsvPaths(directory, pendingDir, provider) {
|
|
25290
25428
|
const providerDir = path14.join(directory, pendingDir, provider);
|
|
25291
|
-
if (!
|
|
25429
|
+
if (!fs18.existsSync(providerDir))
|
|
25292
25430
|
return [];
|
|
25293
25431
|
const csvPaths = [];
|
|
25294
|
-
const entries =
|
|
25432
|
+
const entries = fs18.readdirSync(providerDir, { withFileTypes: true });
|
|
25295
25433
|
for (const entry of entries) {
|
|
25296
25434
|
if (!entry.isDirectory())
|
|
25297
25435
|
continue;
|
|
@@ -6,14 +6,18 @@ This tool is **restricted to the accountant agent only**.
|
|
|
6
6
|
|
|
7
7
|
## Overview
|
|
8
8
|
|
|
9
|
-
The pipeline automates
|
|
9
|
+
The pipeline automates these sequential steps:
|
|
10
10
|
|
|
11
11
|
1. **Classify** - Detect provider/currency, create contexts, organize files
|
|
12
|
+
1a. **Preprocess BTC CSV** _(Revolut only)_ - Add computed columns (fees in BTC, total, clean price) for hledger rules
|
|
13
|
+
1b. **Generate BTC Purchases** _(Revolut only)_ - Cross-reference fiat + BTC CSVs for equity conversion entries
|
|
12
14
|
2. **Account Declarations** - Ensure all accounts exist in year journals
|
|
13
15
|
3. **Dry Run** - Validate transactions, check for unknown accounts
|
|
14
16
|
4. **Import** - Add transactions to journals, move files to done
|
|
15
17
|
5. **Reconcile** - Verify balances match expectations
|
|
16
18
|
|
|
19
|
+
Steps 1a and 1b are Revolut-specific and are automatically skipped when no Revolut BTC CSV is present.
|
|
20
|
+
|
|
17
21
|
**Key behavior**: The pipeline processes files via **import contexts**. Each classified CSV gets a unique context ID, and subsequent steps operate on these contexts **sequentially** with **fail-fast** error handling.
|
|
18
22
|
|
|
19
23
|
## Arguments
|
|
@@ -156,6 +160,34 @@ When reconciliation fails:
|
|
|
156
160
|
|
|
157
161
|
See [classify-statements](classify-statements.md) for details.
|
|
158
162
|
|
|
163
|
+
### Step 1a: Preprocess BTC CSV
|
|
164
|
+
|
|
165
|
+
**Purpose**: Add computed columns to Revolut BTC CSVs for use in hledger rules
|
|
166
|
+
|
|
167
|
+
**What happens**:
|
|
168
|
+
|
|
169
|
+
1. Finds Revolut BTC contexts from classification
|
|
170
|
+
2. For each Send row, calculates: `fee_btc = fees_chf / price_chf_per_btc`
|
|
171
|
+
3. Adds three columns to the CSV (in-place): `Fees_BTC`, `Total_BTC`, `Price_Amount`
|
|
172
|
+
4. Idempotent — skips if CSV is already preprocessed
|
|
173
|
+
|
|
174
|
+
**Why**: hledger CSV rules don't support division. BTC Send transactions need the fee split into a separate BTC amount, which requires dividing the CHF fee by the CHF/BTC price.
|
|
175
|
+
|
|
176
|
+
**Skipped when**: No Revolut BTC CSV is present among the classified files.
|
|
177
|
+
|
|
178
|
+
### Step 1b: Generate BTC Purchase Entries
|
|
179
|
+
|
|
180
|
+
**Purpose**: Cross-reference Revolut fiat and BTC CSVs to generate equity conversion journal entries for BTC purchases
|
|
181
|
+
|
|
182
|
+
**What happens**:
|
|
183
|
+
|
|
184
|
+
1. Finds matching Revolut fiat and BTC CSV pairs from contexts
|
|
185
|
+
2. Matches fiat transfer rows to BTC Buy rows by timestamp
|
|
186
|
+
3. Generates equity conversion journal entries (fiat → equity → BTC)
|
|
187
|
+
4. Appends entries to year journal, skipping duplicates
|
|
188
|
+
|
|
189
|
+
**Skipped when**: No fiat+BTC CSV pair is found among the classified files.
|
|
190
|
+
|
|
159
191
|
### Step 2: Account Declarations
|
|
160
192
|
|
|
161
193
|
**Purpose**: Ensure all accounts referenced in rules files are declared in year journals
|
|
@@ -262,6 +294,19 @@ See [reconcile-statement](reconcile-statement.md) for details.
|
|
|
262
294
|
│ └──────────────────────────────────────────────────────────┘ │
|
|
263
295
|
│ │
|
|
264
296
|
│ OUTPUT: ["uuid-1", "uuid-2"] │
|
|
297
|
+
└────────────────────────────────────────────────────────────────┘
|
|
298
|
+
│
|
|
299
|
+
▼
|
|
300
|
+
┌────────────────────────────────────────────────────────────────┐
|
|
301
|
+
│ STEP 1a: Preprocess BTC CSV (if revolut/btc context exists) │
|
|
302
|
+
│ • Add Fees_BTC, Total_BTC, Price_Amount columns │
|
|
303
|
+
└────────────────────────────────────────────────────────────────┘
|
|
304
|
+
│
|
|
305
|
+
▼
|
|
306
|
+
┌────────────────────────────────────────────────────────────────┐
|
|
307
|
+
│ STEP 1b: Generate BTC Purchase Entries │
|
|
308
|
+
│ • Cross-reference fiat + BTC CSVs │
|
|
309
|
+
│ • Generate equity conversion journal entries │
|
|
265
310
|
└────────────────────────────────────────────────────────────────┘
|
|
266
311
|
│
|
|
267
312
|
▼
|