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