From 2d371fdd6b9d805e7d7b35f53c3c626fa1408740 Mon Sep 17 00:00:00 2001 From: Alan Pearce Date: Mon, 6 Apr 2015 10:58:16 +0100 Subject: Implement Ledger output --- src/Command/ConvertCommand.php | 232 ++++++++++++++++++++++++++++++++------- src/Item/BudgetTransaction.php | 1 + src/Item/LedgerPosting.php | 11 ++ src/Item/LedgerTransaction.php | 11 ++ src/Item/RegisterTransaction.php | 3 +- 5 files changed, 220 insertions(+), 38 deletions(-) create mode 100644 src/Item/LedgerPosting.php create mode 100644 src/Item/LedgerTransaction.php diff --git a/src/Command/ConvertCommand.php b/src/Command/ConvertCommand.php index 5a9a484..21f0a03 100644 --- a/src/Command/ConvertCommand.php +++ b/src/Command/ConvertCommand.php @@ -9,9 +9,12 @@ use SeekableIterator; use ArrayIterator; use DateTimeImmutable; use NumberFormatter; +use UnexpectedValueException; use CLIFramework\Command; use YnabLedger\Item\RegisterTransaction; use YnabLedger\Item\BudgetTransaction; +use YnabLedger\Item\LedgerTransaction; +use YnabLedger\Item\LedgerPosting; function convertDate ($currencySymbol) { $dateFormat; @@ -38,6 +41,154 @@ function convertDate ($currencySymbol) { }; } +function convertCleared ($val) { + switch ($val) { + case 'U': case null: + return ''; + case 'R': case 'C': + return '*'; + default: + throw new UnexpectedValueException( + sprintf("Found a cleared flag with value '%s', but I don't know what it means", $val) + ); + } +} + +function getAccount ($name, $txn) { + static $accounts = []; + if (!isset($accounts[$name])) { + if ($txn->in >= 0.01 || stripos($name, 'credit') === false) { + $accounts[$name] = ['Assets', $name]; + } else { + $accounts[$name] = ['Liabilities', $name ?: end($txn->category)]; + } + } + return $accounts[$name]; +} + +function toLedger (Generator $transactions) { + $transactions->rewind(); + $accounts = []; + + while ($transactions->valid()) { + $inc = true; + try { + $txn = $transactions->current(); + $lTxn = new LedgerTransaction; + $lTxn->date = $txn->date; + $lTxn->state = convertCleared($txn->cleared); + $lTxn->payee = $txn->payee; + $lTxn->note = $txn->memo; + + if ($txn instanceof BudgetTransaction) { + $startDate = $txn->date; + do { + $posting = new LedgerPosting; + $posting->currency = $txn->currency; + $posting->amount = $txn->in - $txn->out; + $posting->account = array_merge(['Expenses'], $txn->category); + if ($posting->amount !== 0.00) { + $lTxn->postings[] = $posting; + } + + $transactions->next(); + $txn = $transactions->current(); + $inc = false; + } while ( + $txn instanceof BudgetTransaction && + $txn->date == $startDate + ); + + $posting = new LedgerPosting; + $posting->account = ['Assets']; + $lTxn->postings[] = $posting; + } elseif ($txn->payee == 'Starting Balance') { + $startDate = $txn->date; + do { + $posting = new LedgerPosting; + $posting->currency = $txn->currency; + $posting->account = getAccount($txn->account, $txn); + $posting->amount = $txn->in - $txn->out; + $lTxn->postings[] = $posting; + + $transactions->next(); + $txn = $transactions->current(); + $inc = false; + } while ( + $txn->payee == 'Starting Balance' && + $txn->date == $startDate + ); + + $posting = new LedgerPosting; + $posting->account = ['Equity', 'Opening Balances']; + $lTxn->postings[] = $posting; + } elseif (substr($txn->payee, 0, 8) === 'Transfer') { + if ($txn->out <= 0.00) { + goto next; + } + $target = new LedgerPosting; + $target->account = getAccount(explode(' : ', $txn->payee)[1], $txn); + $target->currency = $txn->currency; + $target->amount = $txn->out; + $lTxn->postings[] = $target; + + $source = new LedgerPosting; + $source->account = getAccount($txn->account, $txn); + $lTxn->postings[] = $source; + + $lTxn->payee = 'Transfer'; + } elseif (substr($lTxn->note, 0, 6) == '(Split') { + $payee = $txn->payee; + $startDate = $txn->date; + $lTxn->note = ''; + + $source = new LedgerPosting; + $source->account = getAccount($txn->account, $txn); + + do { + $target = new LedgerPosting; + $target->account = array_merge(['Expenses'], $txn->category); + $target->currency = $txn->currency; + $target->amount = $txn->out; + sscanf($txn->memo, "(Split %d/%d) %[^\r]", $i, $k, $target->note); + $lTxn->postings[] = $target; + + $transactions->next(); + $txn = $transactions->current(); + $inc = false; + } while ( + $txn->date == $startDate && + $txn->payee === $payee && + substr($txn->memo, 0, 6) == '(Split' + ); + $lTxn->postings[] = $source; + + } else { + $target = new LedgerPosting; + $target->account = array_merge(['Expenses'], $txn->category); + $target->currency = $txn->currency; + $target->amount = $txn->out; + $lTxn->postings[] = $target; + + $source = new LedgerPosting; + $source->account = getAccount($txn->account, $txn); + $lTxn->postings[] = $source; + } + yield $lTxn; + next: + if ($inc) { + $transactions->next(); + } + if (!$transactions->valid()) { + return; + } + } catch (UnexpectedValueException $e) { + var_dump($txn); + throw $e; + } + } +} + function readBudget (SplFileObject $budgetFile) { $budgetFile->setFlags(SplFileObject::READ_CSV | SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY | SplFileObject::DROP_NEW_LINE); $file = new LimitIterator($budgetFile, 1); @@ -47,25 +198,23 @@ function readBudget (SplFileObject $budgetFile) { $txn = new BudgetTransaction; $txn->date = DateTimeImmutable::createFromFormat('d F Y', '1 ' . $row[0])->setTime(0, 0, 0); $txn->category = explode(':', $row[1], 2); - $txn->in = $fmt->parseCurrency($row[4], $curr); - $txn->out = $fmt->parseCurrency($row[5], $curr); + $txn->in = $fmt->parseCurrency($row[4], $txn->currency); + $txn->out = $fmt->parseCurrency($row[5], $txn->currency); $txn->balance = $row[6]; - if (!in_array($row[2], $txn->category, true)) { - array_unshift($txn->category, $row[2]); - } + // if (!in_array($row[2], $txn->category, true)) { + // array_unshift($txn->category, $row[2]); + // } yield $txn; } } -function readRegister (SplFileObject $registerFile, $locale) { +function readRegister (SplFileObject $registerFile, NumberFormatter $fmt) { $registerFile->setFlags(SplFileObject::READ_CSV | SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY | SplFileObject::DROP_NEW_LINE); $iterator = new LimitIterator($registerFile, 1); $iterator->rewind(); $convertDate = convertDate($iterator->current()[9]); - $fmt = new NumberFormatter($locale, NumberFormatter::CURRENCY); - $txns = []; foreach ($iterator as $i => $row) { // "Account","Flag","Check Number","Date","Payee","Category","Master Category","Sub Category","Memo","Outflow","Inflow","Cleared","Running Balance" @@ -73,44 +222,48 @@ function readRegister (SplFileObject $registerFile, $locale) { $txn->account = $row[0]; $txn->date = $convertDate($row[3])->setTime(0, 0, 0); $txn->payee = $row[4]; - $txn->category = explode(':', $row[5]); - if (!in_array($row[6], $txn->category, true)) { - array_unshift($txn->category, $row[6]); - } - $txn->memo = $row[8]; - $txn->out = $fmt->parseCurrency($row[9], $curr); - $txn->in = $fmt->parseCurrency($row[10], $curr); - $txn->cleared = ($row[11] !== 'U'); + $txn->category = array_map('trim', explode(':', $row[5])); + // if (!in_array($row[6], $txn->category, true)) { + // array_unshift($txn->category, $row[6]); + // } + $txn->memo = trim($row[8]); + $txn->out = $fmt->parseCurrency($row[9], $txn->currency); + $txn->in = $fmt->parseCurrency($row[10], $txn->currency); + $txn->cleared = $row[11]; $txn->line = $i; $txns[] = $txn; } - // Sort by date, then promote income and fall back on line in file + // Sort by date, group splits, promote income and fall back on line in file usort($txns, function ($a, $b) use ($convertDate) { return strcmp($a->date->format('U'), $b->date->format('U')) - ?: ($b->in < 0.01 - ? 0 : $a->line - $b->line); + ?: ((in_array('Split', [substr($a->memo, 1, 5), substr($b->memo, 1, 5)]) ? + strcmp($a->memo, $b->memo) : false) + ?: ($b->out > 0.01 // Include empty starting balance transactions + ? 0 : $a->line - $b->line)); } ); foreach ($txns as $txn) yield $txn; } -function multiRead (Generator $budgetFile, Generator $registerFile) { - $budgetFile->rewind(); - foreach ($registerFile as $rTxn) { +function multiRead (SplFileObject $budgetFile, SplFileObject $registerFile, NumberFormatter $fmt) { + $budget = readBudget($budgetFile); + $register = readRegister($registerFile, $fmt); + $budget->rewind(); + foreach ($register as $rTxn) { if ($rTxn->payee !== 'Starting Balance' && $rTxn->category[1] !== 'Available this month') { - if ($budgetFile->valid()) { - $bTxn = $budgetFile->current(); + if ($budget->valid()) { + $bTxn = $budget->current(); $bDate = $bTxn->date; if ($bTxn->date < $rTxn->date) { - sleep(2); do { + $bTxn->date = $rTxn->date; yield $bTxn; - $budgetFile->next(); - $bTxn = $budgetFile->current(); + $budget->next(); + $bTxn = $budget->current(); } while ($bTxn->date == $bDate); } } @@ -137,16 +290,21 @@ class ConvertCommand extends Command { } public function execute ($budgetFile, $registerFile) { - $locale = 'en_GB'; - $budget = readBudget(new SplFileObject($budgetFile)); - $register = readRegister(new SplFileObject($registerFile), $locale); - foreach (multiRead($budget, $register) as $txn) { - printf("%-8s %-10s\t%-30s %s\n", - $txn instanceof RegisterTransaction ? 'Register' : 'Budget', - $txn->date->format('Y-m-d'), - implode(':', $txn->category), - isset($txn->payee) ? $txn->payee : '' - ); + $fmt = new NumberFormatter('en_GB', NumberFormatter::CURRENCY); + $export = toLedger(multiRead(new SplFileObject($budgetFile), new SplFileObject($registerFile), $fmt)); + + foreach ($export as $txn) { + echo "{$txn->date->format('Y-m-d')} $txn->payee" + , !empty($txn->note) ? " ; $txn->note" : "" + , PHP_EOL; + foreach ($txn->postings as $posting) { + echo " " . implode(':', $posting->account); + if ($posting->currency !== null) { + echo " {$fmt->formatCurrency($posting->amount, $posting->currency)}"; + } + echo !empty($posting->note) ? " ; $posting->note" : "", PHP_EOL; + } + echo PHP_EOL; } } } diff --git a/src/Item/BudgetTransaction.php b/src/Item/BudgetTransaction.php index e4899f9..e073ed2 100644 --- a/src/Item/BudgetTransaction.php +++ b/src/Item/BudgetTransaction.php @@ -7,4 +7,5 @@ class BudgetTransaction { public $category = []; public $in; public $out; + public $currency; } diff --git a/src/Item/LedgerPosting.php b/src/Item/LedgerPosting.php new file mode 100644 index 0000000..79aeb49 --- /dev/null +++ b/src/Item/LedgerPosting.php @@ -0,0 +1,11 @@ +