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