format payees and memo on transactions
authorCharles McGarvey <chazmcgarvey@brokenzipper.com>
Thu, 13 Jun 2019 04:25:43 +0000 (22:25 -0600)
committerCharles McGarvey <chazmcgarvey@brokenzipper.com>
Thu, 13 Jun 2019 04:43:30 +0000 (22:43 -0600)
bin/homebank2ledger
lib/App/HomeBank2Ledger.pm
lib/App/HomeBank2Ledger/Formatter/Beancount.pm
lib/App/HomeBank2Ledger/Formatter/Ledger.pm
lib/App/HomeBank2Ledger/Util.pm

index d4a433fe7609ad4a0d11170f9eee625e2784a5b8..2d46501656f33b8c9697a0025d7c6b9ae0f0d11e 100644 (file)
@@ -32,9 +32,9 @@ converts from, so there won't be any crazy data loss bugs... but no warranty.
 * Retains HomeBank metadata, including payees and tags.
 * Offers some customization of the output ledger, like account renaming.
 
-There aren't really any features I think this program is missing -- actually it may have too many
-features -- but if there is anything you think this program could do to be even better, feedback is
-welcome; just file a bug report. Or fork the code and have fun!
+This program is feature-complete in my opinion (well, almost -- see L</CAVEATS>), but if there is
+anything you think it could do to be even better, feedback is welcome; just file a bug report. Or
+fork the code and have fun!
 
 =head2 Use cases
 
@@ -200,6 +200,13 @@ it's just plain text.
     # Run the balances report:
     bean-report ledger.beancount balances
 
+=head1 CAVEATS
+
+=for :list
+* I didn't intend to make this a releasable robust product, so it's lacking tests.
+* Budgets and scheduled transactions are not (yet) converted.
+* There are some minor formatting tweaks I will make (e.g. consolidate transaction tags and payees)
+
 =cut
 
 use warnings;
index 52f426783dbbc815757b1f661953ebcd7df421a8..2075bd80e2227547b41cde202b2a7b62f0f120f0 100644 (file)
@@ -11,13 +11,6 @@ This module is part of the L<homebank2ledger> script.
 
 =cut
 
-# TODO - add posting memo
-# TODO - transaction description ("narration" in beancount)
-# TODO - payees
-# TODO - budget/scheduled
-# TODO - consolidate tags on transaction
-# TODO - consolidate payees on transaction
-
 use warnings FATAL => 'all';    # temp fatal all
 use strict;
 
@@ -198,7 +191,7 @@ sub convert_homebank_to_ledger {
     }
 
     if ($has_initial_balance) {
-        # transactions are sorted, so the first transaction is the earliest
+        # transactions are sorted, so the first transaction is the oldest
         my $first_date = $opts->{opening_date} || $transactions->[0]{date};
         if ($first_date !~ /^\d{4}-\d{2}-\d{2}$/) {
             die "Opening date must be in the form YYYY-MM-DD.\n";
@@ -313,7 +306,7 @@ sub convert_homebank_to_ledger {
                 commodity   => $commodities{$account->{currency}},
                 amount      => -$transaction->{amount},
                 payee       => $payee->{name},
-                memo        => '',  # TODO
+                memo        => $memo,
                 status      => $status,
                 tags        => $tags,
             };
@@ -328,7 +321,8 @@ sub convert_homebank_to_ledger {
 
         $ledger->add_transactions({
             date        => $transaction->{date},
-            payee       => 'Payee TODO',
+            payee       => $payee->{name},
+            memo        => $memo,
             postings    => \@postings,
         });
     }
index 4c496a8765afb9f51127a9d52d006330658b1117..b2606c8064df2912fc9c885cd7c725251682f691 100644 (file)
@@ -14,7 +14,7 @@ L<App::HomeBank2Ledger::Formatter>
 use warnings;
 use strict;
 
-use App::HomeBank2Ledger::Util qw(commify);
+use App::HomeBank2Ledger::Util qw(commify rtrim);
 
 use parent 'App::HomeBank2Ledger::Formatter';
 
@@ -24,6 +24,9 @@ my %STATUS_SYMBOLS = (
     cleared => '*',
     pending => '!',
 );
+my $UNKNOWN_DATE = '0001-01-01';
+
+sub _croak { require Carp; Carp::croak(@_) }
 
 sub format {
     my $self   = shift;
@@ -38,7 +41,7 @@ sub format {
         $self->_format_transactions($ledger),
     );
 
-    return join($/, @out);
+    return join($/, map { rtrim($_) } @out);
 }
 
 sub _format_header {
@@ -46,13 +49,13 @@ sub _format_header {
 
     my @out;
 
-    my $file = $self->file;
-    push @out, "; Converted from $file using homebank2ledger ${VERSION}";
-
     if (my $name = $self->name) {
         push @out, "; Name: $name";
     }
 
+    my $file = $self->file;
+    push @out, "; Converted from ${file} using homebank2ledger ${VERSION}";
+
     push @out, '';
 
     return @out;
@@ -65,8 +68,11 @@ sub _format_accounts {
     my @out;
 
     for my $account (sort @{$ledger->accounts}) {
-        $account = $self->_munge_account($account);
-        push @out, "1970-01-01 open $account";  # TODO pick better date?
+        my $oldest_transaction = $self->_find_oldest_transaction_by_account($account, $ledger);
+        my $account_date = $oldest_transaction->{date} || $UNKNOWN_DATE;
+        $account = $self->_format_account($account);
+
+        push @out, "${account_date} open ${account}";
     }
     push @out, '';
 
@@ -80,8 +86,11 @@ sub _format_commodities {
     my @out;
 
     for my $commodity (@{$ledger->commodities}) {
-        push @out, "1970-01-01 commodity $commodity->{iso}";    # TODO
-        push @out, "    name: \"$commodity->{name}\"" if $commodity->{name};
+        my $oldest_transaction = $self->_find_oldest_transaction_by_commodity($commodity, $ledger);
+        my $commodity_date = $oldest_transaction->{date} || $UNKNOWN_DATE;
+
+        push @out, "${commodity_date} commodity $commodity->{iso}";
+        push @out, '    name: '.$self->_format_string($commodity->{name}) if $commodity->{name};
     }
 
     push @out, '';
@@ -110,8 +119,8 @@ sub _format_transaction {
 
     my $date        = $transaction->{date};
     my $status      = $transaction->{status};
-    my $payee       = $transaction->{payee} || 'No Payee TODO';
-    my $memo        = $transaction->{memo} || '';
+    my $payee       = $transaction->{payee} || '';
+    my $memo        = $transaction->{memo}  || '';
     my @postings    = @{$transaction->{postings}};
 
     my @out;
@@ -123,17 +132,18 @@ sub _format_transaction {
         if (keys(%posting_statuses) == 1) {
             my ($status) = keys %posting_statuses;
             $status_symbol = $STATUS_SYMBOLS{$status || 'none'} || '';
-            $status_symbol .= ' ' if $status_symbol;
         }
     }
 
-    my $symbol = $status_symbol ? "${status_symbol} " : '';
-    push @out, "${date} ${symbol}\"${payee}\" \"$memo\"";   # TODO handle proper quoting
-    $out[-1] =~ s/\h+$//;
+    push @out, sprintf('%s%s%s%s', $date,
+        $status_symbol    && ' '.$status_symbol || ' *',   # status (or "txn") is required
+        ($payee || $memo) && ' '.$self->_format_string($payee),
+        $memo             && ' '.$self->_format_string($memo),
+    );
 
     if (my %tags = map { $_ => 1 } map { @{$_->{tags} || []} } @postings) {
         my @tags = map { "#$_" } keys %tags;
-        $out[-1] .= "  ".join(' ', @tags);
+        $out[-1] .= ' '.join(' ', @tags);
     }
 
     for my $posting (@postings) {
@@ -144,7 +154,7 @@ sub _format_transaction {
             $posting_status_symbol = $STATUS_SYMBOLS{$posting->{status} || ''} || '';
         }
 
-        my $account = $self->_munge_account($posting->{account});
+        my $account = $self->_format_account($posting->{account});
 
         push @line, ($posting_status_symbol ? "  $posting_status_symbol " : '    ');
         push @line, sprintf("\%-${account_width}s", $account);
@@ -152,11 +162,6 @@ sub _format_transaction {
         push @line, $self->_format_amount($posting->{amount}, $posting->{commodity}) if defined $posting->{amount};
 
         push @out, join('', @line);
-        $out[-1] =~ s/\h+$//;
-
-        # if (my $payee = $posting->{payee}) {
-        #     push @out, "      ; Payee: $payee";
-        # }
     }
 
     push @out, '';
@@ -164,12 +169,26 @@ sub _format_transaction {
     return @out;
 }
 
+sub _format_account {
+    my $self = shift;
+    my $account = shift;
+    $account =~ s/[^A-Za-z0-9:]+/-/g;
+    $account =~ s/-+/-/g;
+    $account =~ s/(?:^|(?<=:))([a-z])/uc($1)/eg;
+    return $account;
+}
+
+sub _format_string {
+    my $self = shift;
+    my $str  = shift;
+    $str =~ s/"/\\"/g;
+    return "\"$str\"";
+}
+
 sub _format_amount {
     my $self      = shift;
     my $amount    = shift;
-    my $commodity = shift;
-
-    # _croak 'Must provide a valid currency' if !$commodity;
+    my $commodity = shift or _croak 'Must provide a valid currency';
 
     my $format = "\% .$commodity->{frac}f";
     my ($whole, $fraction) = split(/\./, sprintf($format, $amount));
@@ -182,13 +201,55 @@ sub _format_amount {
     return $num;
 }
 
-sub _munge_account {
-    my $self = shift;
+sub _find_oldest_transaction_by_account {
+    my $self    = shift;
     my $account = shift;
-    $account =~ s/[^A-Za-z0-9:]+/-/g;
-    $account =~ s/-+/-/g;
-    $account =~ s/(?:^|(?<=:))([a-z])/uc($1)/eg;
-    return $account;
+    my $ledger  = shift;
+
+    $account = $self->_format_account($account);
+
+    my $oldest = $self->{oldest_transaction_by_account};
+    if (!$oldest) {
+        # build index
+        for my $transaction (@{$ledger->transactions}) {
+            for my $posting (@{$transaction->{postings}}) {
+                my $account = $self->_format_account($posting->{account});
+
+                if ($transaction->{date} lt ($oldest->{$account}{date} || '9999-99-99')) {
+                    $oldest->{$account} = $transaction;
+                }
+            }
+        }
+
+        $self->{oldest_transaction_by_account} = $oldest;
+    }
+
+    return $oldest->{$account};
+}
+
+sub _find_oldest_transaction_by_commodity {
+    my $self      = shift;
+    my $commodity = shift;
+    my $ledger    = shift;
+
+    my $oldest = $self->{oldest_transaction_by_commodity};
+    if (!$oldest) {
+        # build index
+        for my $transaction (@{$ledger->transactions}) {
+            for my $posting (@{$transaction->{postings}}) {
+                my $symbol = $posting->{commodity}{symbol};
+                next if !$symbol;
+
+                if ($transaction->{date} lt ($oldest->{$symbol}{date} || '9999-99-99')) {
+                    $oldest->{$symbol} = $transaction;
+                }
+            }
+        }
+
+        $self->{oldest_transaction_by_commodity} = $oldest;
+    }
+
+    return $oldest->{$commodity->{symbol}};
 }
 
 1;
index 332be8ca9d6303386497d187693debf78cd0b40a..b815cd3f0c072407a8d08042610fd7c8ad8196c0 100644 (file)
@@ -14,7 +14,7 @@ L<App::HomeBank2Ledger::Formatter>
 use warnings;
 use strict;
 
-use App::HomeBank2Ledger::Util qw(commify);
+use App::HomeBank2Ledger::Util qw(commify rtrim);
 
 use parent 'App::HomeBank2Ledger::Formatter';
 
@@ -25,6 +25,8 @@ my %STATUS_SYMBOLS = (
     pending => '!',
 );
 
+sub _croak { require Carp; Carp::croak(@_) }
+
 sub format {
     my $self   = shift;
     my $ledger = shift;
@@ -38,7 +40,7 @@ sub format {
         $self->_format_transactions($ledger),
     );
 
-    return join($/, @out);
+    return join($/, map { rtrim($_) } @out);
 }
 
 sub _format_header {
@@ -46,13 +48,13 @@ sub _format_header {
 
     my @out;
 
-    my $file = $self->file;
-    push @out, "; Converted from $file using homebank2ledger ${VERSION}";
-
     if (my $name = $self->name) {
         push @out, "; Name: $name";
     }
 
+    my $file = $self->file;
+    push @out, "; Converted from ${file} using homebank2ledger ${VERSION}";
+
     push @out, '';
 
     return @out;
@@ -133,8 +135,8 @@ sub _format_transaction {
 
     my $date        = $transaction->{date};
     my $status      = $transaction->{status};
-    my $payee       = $transaction->{payee} || 'No Payee TODO';
-    my $memo        = $transaction->{memo} || '';
+    my $payee       = $self->_format_string($transaction->{payee} || '');
+    my $memo        = $self->_format_string($transaction->{memo}  || '');
     my @postings    = @{$transaction->{postings}};
 
     my @out;
@@ -146,13 +148,16 @@ sub _format_transaction {
         if (keys(%posting_statuses) == 1) {
             my ($status) = keys %posting_statuses;
             $status_symbol = $STATUS_SYMBOLS{$status || 'none'} || '';
-            $status_symbol .= ' ' if $status_symbol;
         }
     }
 
-    my $symbol = $status_symbol ? "${status_symbol} " : '';
-    push @out, "${date} ${symbol}${payee}  ; $memo";
-    $out[-1] =~ s/\h+$//;
+    $payee =~ s/(?:  )|\t;/ ;/g;    # don't turn into a memo
+
+    push @out, sprintf('%s%s%s%s', $date,
+        $status_symbol && " ${status_symbol}",
+        $payee         && " $payee",
+        $memo          && "  ; $memo",
+    );
 
     for my $posting (@postings) {
         my @line;
@@ -168,10 +173,9 @@ sub _format_transaction {
         push @line, $self->_format_amount($posting->{amount}, $posting->{commodity}) if defined $posting->{amount};
 
         push @out, join('', @line);
-        $out[-1] =~ s/\h+$//;
 
         if (my $payee = $posting->{payee}) {
-            push @out, "      ; Payee: $payee";
+            push @out, '      ; Payee: '.$self->_format_string($payee);
         }
 
         if (my @tags = @{$posting->{tags} || []}) {
@@ -184,12 +188,17 @@ sub _format_transaction {
     return @out;
 }
 
+sub _format_string {
+    my $self = shift;
+    my $str  = shift;
+    $str =~ s/\v//g;
+    return $str;
+}
+
 sub _format_amount {
     my $self      = shift;
     my $amount    = shift;
-    my $commodity = shift;
-
-    # _croak 'Must provide a valid currency' if !$commodity;
+    my $commodity = shift or _croak 'Must provide a valid currency';
 
     my $format = "\% .$commodity->{frac}f";
     my ($whole, $fraction) = split(/\./, sprintf($format, $amount));
index d48f5d671639e70a41bf8d7fd52eeb48f802d04b..9f5b853f27dd3a9d505dcd008c3acf2428339d83 100644 (file)
@@ -8,7 +8,7 @@ use Exporter qw(import);
 
 our $VERSION = '9999.999'; # VERSION
 
-our @EXPORT_OK = qw(commify);
+our @EXPORT_OK = qw(commify rtrim);
 
 =func commify
 
@@ -29,4 +29,18 @@ sub commify {
     return scalar reverse $str;
 }
 
+=func rtrim
+
+    $trimmed_str = rtrim($str);
+
+Right-trim a string.
+
+=cut
+
+sub rtrim {
+    my $str = shift;
+    $str =~ s/\h+$//;
+    return $str;
+}
+
 1;
This page took 0.036942 seconds and 4 git commands to generate.