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