52f426783dbbc815757b1f661953ebcd7df421a8
[chaz/homebank2ledger] / lib / App / HomeBank2Ledger.pm
1 package App::HomeBank2Ledger;
2 # ABSTRACT: A tool to convert HomeBank files to Ledger format
3
4 =head1 SYNOPSIS
5
6 App::HomeBank2Ledger->main(@args);
7
8 =head1 DESCRIPTION
9
10 This module is part of the L<homebank2ledger> script.
11
12 =cut
13
14 # TODO - add posting memo
15 # TODO - transaction description ("narration" in beancount)
16 # TODO - payees
17 # TODO - budget/scheduled
18 # TODO - consolidate tags on transaction
19 # TODO - consolidate payees on transaction
20
21 use warnings FATAL => 'all'; # temp fatal all
22 use strict;
23
24 use App::HomeBank2Ledger::Formatter;
25 use App::HomeBank2Ledger::Ledger;
26 use File::HomeBank;
27 use Getopt::Long 2.38 qw(GetOptionsFromArray);
28 use Pod::Usage;
29
30 our $VERSION = '9999.999'; # VERSION
31
32 my %ACCOUNT_TYPES = ( # map HomeBank account types to Ledger accounts
33 bank => 'Assets:Bank',
34 cash => 'Assets:Cash',
35 asset => 'Assets:Fixed Assets',
36 creditcard => 'Liabilities:Credit Card',
37 liability => 'Liabilities',
38 stock => 'Assets:Stock',
39 mutualfund => 'Assets:Mutual Fund',
40 income => 'Income',
41 expense => 'Expenses',
42 equity => 'Equity',
43 );
44 my %STATUS_SYMBOLS = (
45 cleared => 'cleared',
46 reconciled => 'cleared',
47 remind => 'pending',
48 );
49 my $UNKNOWN_ACCOUNT = 'Assets:Unknown';
50 my $OPENING_BALANCES_ACCOUNT = 'Equity:Opening Balances';
51
52 =method main
53
54 App::HomeBank2Ledger->main(@args);
55
56 Run the script and exit; does not return.
57
58 =cut
59
60 sub main {
61 my $class = shift;
62 my $self = bless {}, $class;
63
64 my $opts = $self->parse_args(@_);
65
66 if ($opts->{version}) {
67 print "homebank2ledger ${VERSION}\n";
68 exit 0;
69 }
70 if ($opts->{help}) {
71 pod2usage(-exitval => 0, -verbose => 99, -sections => [qw(NAME SYNOPSIS OPTIONS)]);
72 }
73 if ($opts->{manual}) {
74 pod2usage(-exitval => 0, -verbose => 2);
75 }
76
77 my $homebank = File::HomeBank->new(file => $opts->{input});
78
79 my $formatter = eval { $self->formatter($homebank, $opts) };
80 if (my $err = $@) {
81 if ($err =~ /^Invalid formatter/) {
82 print STDERR "Invalid format: $opts->{format}\n";
83 exit 2;
84 }
85 die $err;
86 }
87
88 my $ledger = $self->convert_homebank_to_ledger($homebank, $opts);
89
90 $self->print_to_file($formatter->format($ledger), $opts->{output});
91
92 exit 0;
93 }
94
95 =method formatter
96
97 $formatter = $app->formatter($homebank, $opts);
98
99 Generate a L<App::HomeBank2Ledger::Formatter>.
100
101 =cut
102
103 sub formatter {
104 my $self = shift;
105 my $homebank = shift;
106 my $opts = shift || {};
107
108 return App::HomeBank2Ledger::Formatter->new(
109 type => $opts->{format},
110 account_width => $opts->{account_width},
111 name => $homebank->title,
112 file => $homebank->file,
113 );
114 }
115
116 =method convert_homebank_to_ledger
117
118 my $ledger = $app->convert_homebank_to_ledger($homebank, $opts);
119
120 Converts a L<File::HomeBank> to a L<App::HomeBank2Ledger::Ledger>.
121
122 =cut
123
124 sub convert_homebank_to_ledger {
125 my $self = shift;
126 my $homebank = shift;
127 my $opts = shift || {};
128
129 my $ledger = App::HomeBank2Ledger::Ledger->new;
130
131 my $transactions = $homebank->sorted_transactions;
132 my $accounts = $homebank->accounts;
133 my $categories = $homebank->categories;
134
135 # determine full Ledger account names
136 for my $account (@$accounts) {
137 my $type = $ACCOUNT_TYPES{$account->{type}} || $UNKNOWN_ACCOUNT;
138 $account->{ledger_name} = "${type}:$account->{name}";
139 }
140 for my $category (@$categories) {
141 my $type = $category->{flags}{income} ? 'Income' : 'Expenses';
142 my $full_name = $homebank->full_category_name($category->{key});
143 $category->{ledger_name} = "${type}:${full_name}";
144 }
145
146 # handle renaming and marking excluded accounts
147 for my $item (@$accounts, @$categories) {
148 while (my ($re, $replacement) = each %{$opts->{rename_accounts}}) {
149 $item->{ledger_name} =~ s/$re/$replacement/;
150 }
151 for my $re (@{$opts->{exclude_accounts}}) {
152 $item->{excluded} = 1 if $item->{ledger_name} =~ /$re/;
153 }
154 }
155
156 my $has_initial_balance = grep { $_->{initial} && !$_->{excluded} } @$accounts;
157
158 if ($opts->{accounts}) {
159 my @accounts = map { $_->{ledger_name} } grep { !$_->{excluded} } @$accounts, @$categories;
160
161 push @accounts, $opts->{default_account};
162 push @accounts, $OPENING_BALANCES_ACCOUNT if $has_initial_balance;
163
164 $ledger->add_accounts(@accounts);
165 }
166
167 if ($opts->{payees}) {
168 my $payees = $homebank->payees;
169 my @payees = map { $_->{name} } @$payees;
170
171 $ledger->add_payees(@payees);
172 }
173
174 if ($opts->{tags}) {
175 my $tags = $homebank->tags;
176
177 $ledger->add_tags(@$tags);
178 }
179
180 my %commodities;
181
182 for my $currency (@{$homebank->currencies}) {
183 my $commodity = {
184 symbol => $currency->{symbol},
185 format => $homebank->format_amount(1_000, $currency),
186 iso => $currency->{iso},
187 name => $currency->{name},
188 };
189 $commodities{$currency->{key}} = {
190 %$commodity,
191 syprf => $currency->{syprf},
192 dchar => $currency->{dchar},
193 gchar => $currency->{gchar},
194 frac => $currency->{frac},
195 };
196
197 $ledger->add_commodities($commodity) if $opts->{commodities};
198 }
199
200 if ($has_initial_balance) {
201 # transactions are sorted, so the first transaction is the earliest
202 my $first_date = $opts->{opening_date} || $transactions->[0]{date};
203 if ($first_date !~ /^\d{4}-\d{2}-\d{2}$/) {
204 die "Opening date must be in the form YYYY-MM-DD.\n";
205 }
206
207 my @postings;
208
209 for my $account (@$accounts) {
210 next if !$account->{initial} || $account->{excluded};
211
212 push @postings, {
213 account => $account->{ledger_name},
214 amount => $account->{initial},
215 commodity => $commodities{$account->{currency}},
216 };
217 }
218
219 push @postings, {
220 account => $OPENING_BALANCES_ACCOUNT,
221 };
222
223 $ledger->add_transactions({
224 date => $first_date,
225 payee => 'Opening Balance',
226 status => 'cleared',
227 postings => \@postings,
228 });
229 }
230
231 my %seen;
232
233 TRANSACTION:
234 for my $transaction (@$transactions) {
235 next if $seen{$transaction->{transfer_key} || ''};
236
237 my $account = $homebank->find_account_by_key($transaction->{account});
238 my $amount = $transaction->{amount};
239 my $status = $STATUS_SYMBOLS{$transaction->{status} || ''} || '';
240 my $paymode = $transaction->{paymode} || ''; # internaltransfer
241 my $memo = $transaction->{wording} || '';
242 my $payee = $homebank->find_payee_by_key($transaction->{payee});
243 my $tags = _split_tags($transaction->{tags});
244
245 my @postings;
246
247 push @postings, {
248 account => $account->{ledger_name},
249 amount => $amount,
250 commodity => $commodities{$account->{currency}},
251 payee => $payee->{name},
252 memo => $memo,
253 status => $status,
254 tags => $tags,
255 };
256
257 if ($paymode eq 'internaltransfer') {
258 my $paired_transaction = $homebank->find_transaction_transfer_pair($transaction);
259
260 my $dst_account = $homebank->find_account_by_key($transaction->{dst_account});
261 if (!$dst_account) {
262 if ($paired_transaction) {
263 $dst_account = $homebank->find_account_by_key($paired_transaction->{account});
264 }
265 if (!$dst_account) {
266 warn "Skipping internal transfer transaction with no destination account.\n";
267 next TRANSACTION;
268 }
269 }
270
271 $seen{$transaction->{transfer_key}}++ if $transaction->{transfer_key};
272 $seen{$paired_transaction->{transfer_key}}++ if $paired_transaction->{transfer_key};
273
274 my $paired_payee = $homebank->find_payee_by_key($paired_transaction->{payee});
275
276 push @postings, {
277 account => $dst_account->{ledger_name},
278 amount => $paired_transaction->{amount} || -$transaction->{amount},
279 commodity => $commodities{$dst_account->{currency}},
280 payee => $paired_payee->{name},
281 memo => $paired_transaction->{wording} || '',
282 status => $STATUS_SYMBOLS{$paired_transaction->{status} || ''} || $status,
283 tags => _split_tags($paired_transaction->{tags}),
284 };
285 }
286 elsif ($transaction->{flags}{split}) {
287 my @amounts = split(/\|\|/, $transaction->{split_amount} || '');
288 my @memos = split(/\|\|/, $transaction->{split_memo} || '');
289 my @categories = split(/\|\|/, $transaction->{split_category} || '');
290
291 for (my $i = 0; $amounts[$i]; ++$i) {
292 my $amount = -$amounts[$i];
293 my $category = $homebank->find_category_by_key($categories[$i]);
294 my $memo = $memos[$i] || '';
295 my $other_account = $category ? $category->{ledger_name} : $opts->{default_account};
296
297 push @postings, {
298 account => $other_account,
299 commodity => $commodities{$account->{currency}},
300 amount => $amount,
301 payee => $payee->{name},
302 memo => $memo,
303 status => $status,
304 tags => $tags,
305 };
306 }
307 }
308 else { # with or without category
309 my $category = $homebank->find_category_by_key($transaction->{category});
310 my $other_account = $category ? $category->{ledger_name} : $opts->{default_account};
311 push @postings, {
312 account => $other_account,
313 commodity => $commodities{$account->{currency}},
314 amount => -$transaction->{amount},
315 payee => $payee->{name},
316 memo => '', # TODO
317 status => $status,
318 tags => $tags,
319 };
320 }
321
322 # skip excluded accounts
323 for my $posting (@postings) {
324 for my $re (@{$opts->{exclude_accounts}}) {
325 next TRANSACTION if $posting->{account} =~ /$re/;
326 }
327 }
328
329 $ledger->add_transactions({
330 date => $transaction->{date},
331 payee => 'Payee TODO',
332 postings => \@postings,
333 });
334 }
335
336 return $ledger;
337 }
338
339 =method print_to_file
340
341 $app->print_to_file($str);
342 $app->print_to_file($str, $filepath);
343
344 Print a string to a file (or STDOUT).
345
346 =cut
347
348 sub print_to_file {
349 my $self = shift;
350 my $str = shift;
351 my $filepath = shift;
352
353 my $out_fh = \*STDOUT;
354 if ($filepath) {
355 open($out_fh, '>', $filepath) or die "open failed: $!";
356 }
357 print $out_fh $str;
358 }
359
360 =method parse_args
361
362 $opts = $app->parse_args(@args);
363
364 Parse command-line arguments.
365
366 =cut
367
368 sub parse_args {
369 my $self = shift;
370 my @args = @_;
371
372 my %opts = (
373 version => 0,
374 help => 0,
375 manual => 0,
376 input => undef,
377 output => undef,
378 format => 'ledger',
379 account_width => 40,
380 accounts => 1,
381 payees => 1,
382 tags => 1,
383 commodities => 1,
384 opening_date => '',
385 default_account => 'Expenses:No Category',
386 rename_accounts => {},
387 exclude_accounts => [],
388 );
389
390 GetOptionsFromArray(\@args,
391 'version|V' => \$opts{version},
392 'help|h|?' => \$opts{help},
393 'manual|man' => \$opts{manual},
394 'input|file|i=s' => \$opts{input},
395 'output|o=s' => \$opts{output},
396 'format|f=s' => \$opts{format},
397 'account-width=i' => \$opts{account_width},
398 'accounts!' => \$opts{accounts},
399 'payees!' => \$opts{payees},
400 'tags!' => \$opts{tags},
401 'commodities!' => \$opts{commodities},
402 'opening-date=s' => \$opts{opening_date},
403 'default-account=s' => \$opts{default_account},
404 'rename-account|r=s' => \%{$opts{rename_accounts}},
405 'exclude-account|x=s' => \@{$opts{exclude_accounts}},
406 ) or pod2usage(-exitval => 1, -verbose => 99, -sections => [qw(SYNOPSIS OPTIONS)]);
407
408 $opts{input} = shift @args if !$opts{input};
409 if (!$opts{input}) {
410 print STDERR "Input file is required.\n";
411 exit(1);
412 }
413
414 return \%opts;
415 }
416
417 sub _split_tags {
418 my $tags = shift;
419 return [split(/\h+/, $tags || '')];
420 }
421
422 1;
This page took 0.059489 seconds and 3 git commands to generate.