1 package App
::HomeBank2Ledger
;
2 # ABSTRACT: A tool to convert HomeBank files to Ledger format
8 use App
::HomeBank2Ledger
::Formatter
;
9 use App
::HomeBank2Ledger
::Ledger
;
11 use Getopt
::Long
2.38 qw(GetOptionsFromArray);
14 our $VERSION = '0.010'; # VERSION
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',
25 expense
=> 'Expenses',
28 my %STATUS_SYMBOLS = (
30 reconciled
=> 'cleared',
33 my $UNKNOWN_ACCOUNT = 'Assets:Unknown';
34 my $OPENING_BALANCES_ACCOUNT = 'Equity:Opening Balances';
39 my $self = bless {}, $class;
41 my $opts = $self->parse_args(@_);
43 if ($opts->{version
}) {
44 print "homebank2ledger ${VERSION}\n";
48 pod2usage
(-exitval
=> 0, -verbose
=> 99, -sections
=> [qw(NAME SYNOPSIS OPTIONS)]);
50 if ($opts->{manual
}) {
51 pod2usage
(-exitval
=> 0, -verbose
=> 2);
53 if (!$opts->{input
}) {
54 print STDERR
"Input file is required.\n";
58 my $homebank = File
::HomeBank-
>new(file
=> $opts->{input
});
60 my $formatter = eval { $self->formatter($homebank, $opts) };
62 if ($err =~ /^Invalid formatter/) {
63 print STDERR
"Invalid format: $opts->{format}\n";
69 my $ledger = $self->convert_homebank_to_ledger($homebank, $opts);
71 $self->print_to_file($formatter->format($ledger), $opts->{output
});
80 my $opts = shift || {};
82 return App
::HomeBank2Ledger
::Formatter-
>new(
83 type
=> $opts->{format
},
84 account_width
=> $opts->{account_width
},
85 name
=> $homebank->title,
86 file
=> $homebank->file,
91 sub convert_homebank_to_ledger
{
94 my $opts = shift || {};
96 my $default_account_income = 'Income:Unknown';
97 my $default_account_expenses = 'Expenses:Unknown';
99 my $ledger = App
::HomeBank2Ledger
::Ledger-
>new;
101 my $transactions = $homebank->sorted_transactions;
102 my $accounts = $homebank->accounts;
103 my $categories = $homebank->categories;
106 # determine full Ledger account names
107 for my $account (@$accounts) {
108 my $type = $ACCOUNT_TYPES{$account->{type
}} || $UNKNOWN_ACCOUNT;
109 $account->{ledger_name
} = "${type}:$account->{name}";
111 for my $category (@$categories) {
112 my $type = $category->{flags
}{income
} ? 'Income' : 'Expenses';
113 my $full_name = $homebank->full_category_name($category->{key
});
114 $category->{ledger_name
} = "${type}:${full_name}";
116 if ($opts->{budget
} && $category->{flags
}{budget
}) {
117 for my $month_num ($category->{flags
}{custom
} ? (1 .. 12) : 0) {
118 my $amount = $category->{budget_amounts
}[$month_num] || 0;
119 next if !$amount && !$category->{flags
}{forced
};
121 $budget[$month_num]{$category->{ledger_name
}} = $amount;
126 # handle renaming and marking excluded accounts
127 for my $item (@$accounts, @$categories) {
128 while (my ($re, $replacement) = each %{$opts->{rename_accounts
}}) {
129 $item->{ledger_name
} =~ s/$re/$replacement/;
131 for my $re (@{$opts->{exclude_accounts
}}) {
132 $item->{excluded
} = 1 if $item->{ledger_name
} =~ /$re/;
135 while (my ($re, $replacement) = each %{$opts->{rename_accounts
}}) {
136 $default_account_income =~ s/$re/$replacement/;
137 $default_account_expenses =~ s/$re/$replacement/;
140 my $has_initial_balance = grep { $_->{initial
} && !$_->{excluded
} } @$accounts;
142 if ($opts->{accounts
}) {
143 my @accounts = map { $_->{ledger_name
} } grep { !$_->{excluded
} } @$accounts, @$categories;
145 push @accounts, $default_account_income if !grep { $_ eq $default_account_income } @accounts;
146 push @accounts, $default_account_expenses if !grep { $_ eq $default_account_expenses } @accounts;
147 push @accounts, $OPENING_BALANCES_ACCOUNT if $has_initial_balance;
149 $ledger->add_accounts(@accounts);
152 if ($opts->{payees
}) {
153 my $payees = $homebank->payees;
154 my @payees = map { $_->{name
} } @$payees;
156 $ledger->add_payees(@payees);
160 my $tags = $homebank->tags;
162 $ledger->add_tags(@$tags);
167 for my $currency (@{$homebank->currencies}) {
169 symbol
=> $currency->{symbol
},
170 format
=> $homebank->format_amount(1_000, $currency),
171 iso
=> $currency->{iso
},
172 name
=> $currency->{name
},
174 $commodities{$currency->{key
}} = {
176 syprf
=> $currency->{syprf
},
177 dchar
=> $currency->{dchar
},
178 gchar
=> $currency->{gchar
},
179 frac
=> $currency->{frac
},
182 $ledger->add_commodities($commodity) if $opts->{commodities
};
186 if ($has_initial_balance) {
187 # transactions are sorted, so the first transaction is the oldest
188 $first_date = $opts->{opening_date
} || $transactions->[0]{date
};
189 if ($first_date !~ /^\d{4}-\d{2}-\d{2}$/) {
190 die "Opening date must be in the form YYYY-MM-DD.\n";
195 for my $account (@$accounts) {
196 next if !$account->{initial
} || $account->{excluded
};
199 account
=> $account->{ledger_name
},
200 amount
=> $account->{initial
},
201 commodity
=> $commodities{$account->{currency
}},
206 account
=> $OPENING_BALANCES_ACCOUNT,
209 $ledger->add_transactions({
211 payee
=> 'Opening Balance',
213 postings
=> \
@postings,
217 if ($opts->{budget
}) {
218 my ($first_year) = $first_date =~ /^(\d{4})/;
220 for my $month_num (0 .. 12) {
221 next if !$budget[$month_num];
223 my $payee = 'Monthly';
224 if (0 < $month_num) {
225 my $year = $first_year;
226 $year += 1 if sprintf('%04d-%02d-99', $first_year, $month_num) lt $first_date;
227 my $date = sprintf('%04d-%02d', $year, $month_num);
228 $payee = "Every 12 months from ${date}";
230 # my @MONTHS = qw(ALL Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec);
231 # $payee = "Monthly this $MONTHS[$month_num]" if 0 < $month_num;
235 for my $account (sort keys %{$budget[$month_num]}) {
236 my $amount = $budget[$month_num]{$account};
240 commodity
=> $commodities{$homebank->base_currency},
247 $ledger->add_transactions({
250 postings
=> \
@postings,
258 for my $transaction (@$transactions) {
259 next if $seen{$transaction->{transfer_key
} || ''};
261 my $account = $homebank->find_account_by_key($transaction->{account
});
262 my $amount = $transaction->{amount
};
263 my $status = $STATUS_SYMBOLS{$transaction->{status
} || ''} || '';
264 my $memo = $transaction->{wording
} || '';
265 my $payee = $homebank->find_payee_by_key($transaction->{payee
});
266 my $tags = _split_tags
($transaction->{tags
});
267 my $date = $transaction->{date
};
268 my $code = $transaction->{paymode
} =~ /^(?:check|epayment)$/ ? $transaction->{info
}
275 account
=> $account->{ledger_name
},
277 commodity
=> $commodities{$account->{currency
}},
278 payee
=> $payee->{name
},
284 if ($transaction->{dst_account
}) { # is an internal transfer
285 my $paired_transaction = $homebank->find_transaction_transfer_pair($transaction);
287 my $dst_account = $homebank->find_account_by_key($transaction->{dst_account
});
289 if ($paired_transaction) {
290 $dst_account = $homebank->find_account_by_key($paired_transaction->{account
});
293 warn "Skipping internal transfer transaction with no destination account.\n";
298 $seen{$transaction->{transfer_key
}}++ if $transaction->{transfer_key
};
299 $seen{$paired_transaction->{transfer_key
}}++ if $paired_transaction->{transfer_key
};
301 my $paired_date = $paired_transaction && $paired_transaction->{date
};
302 my $paired_payee = $homebank->find_payee_by_key($paired_transaction->{payee
});
305 date
=> $paired_date,
306 account
=> $dst_account->{ledger_name
},
307 amount
=> $paired_transaction->{amount
} || -$transaction->{amount
},
308 commodity
=> $commodities{$dst_account->{currency
}},
309 payee
=> $paired_payee->{name
},
310 note
=> $paired_transaction->{wording
} || '',
311 status
=> $STATUS_SYMBOLS{$paired_transaction->{status
} || ''} || $status,
312 tags
=> _split_tags
($paired_transaction->{tags
}),
315 elsif ($transaction->{flags
}{split}) {
316 my @amounts = split(/\|\|/, $transaction->{split_amount
} || '');
317 my @memos = split(/\|\|/, $transaction->{split_memo
} || '');
318 my @categories = split(/\|\|/, $transaction->{split_category
} || '');
320 for (my $i = 0; $amounts[$i]; ++$i) {
321 my $amount = -$amounts[$i];
322 my $category = $homebank->find_category_by_key($categories[$i]);
323 my $memo = $memos[$i] || '';
324 my $other_account = $category ? $category->{ledger_name
}
325 : $amount < 0 ? $default_account_income
326 : $default_account_expenses;
329 account
=> $other_account,
330 commodity
=> $commodities{$account->{currency
}},
332 payee
=> $payee->{name
},
339 else { # normal transaction with or without category
340 my $amount = -$transaction->{amount
};
341 my $category = $homebank->find_category_by_key($transaction->{category
});
342 my $other_account = $category ? $category->{ledger_name
}
343 : $amount < 0 ? $default_account_income
344 : $default_account_expenses;
347 account
=> $other_account,
348 commodity
=> $commodities{$account->{currency
}},
350 payee
=> $payee->{name
},
357 # skip excluded accounts
358 for my $posting (@postings) {
359 for my $re (@{$opts->{exclude_accounts
}}) {
360 next TRANSACTION
if $posting->{account
} =~ /$re/;
364 $ledger->add_transactions({
366 payee
=> $payee->{name
},
369 postings
=> \
@postings,
380 my $filepath = shift;
382 my $out_fh = \
*STDOUT
;
384 open($out_fh, '>', $filepath) or die "open failed: $!";
408 rename_accounts
=> {},
409 exclude_accounts
=> [],
412 GetOptionsFromArray
(\
@args,
413 'version|V' => \
$opts{version
},
414 'help|h|?' => \
$opts{help
},
415 'manual|man' => \
$opts{manual
},
416 'input|file|i=s' => \
$opts{input
},
417 'output|o=s' => \
$opts{output
},
418 'format|f=s' => \
$opts{format
},
419 'account-width=i' => \
$opts{account_width
},
420 'accounts!' => \
$opts{accounts
},
421 'payees!' => \
$opts{payees
},
422 'tags!' => \
$opts{tags
},
423 'commodities!' => \
$opts{commodities
},
424 'budget!' => \
$opts{budget
},
425 'opening-date=s' => \
$opts{opening_date
},
426 'rename-account|r=s' => \
%{$opts{rename_accounts
}},
427 'exclude-account|x=s' => \
@{$opts{exclude_accounts
}},
428 ) or pod2usage
(-exitval
=> 1, -verbose
=> 99, -sections
=> [qw(SYNOPSIS OPTIONS)]);
430 $opts{input
} = shift @args if !$opts{input
};
431 $opts{budget
} = 0 if lc($opts{format
}) ne 'ledger';
438 return [split(/\h+/, $tags || '')];
451 App::HomeBank2Ledger - A tool to convert HomeBank files to Ledger format
459 App::HomeBank2Ledger->main(@args);
463 This module is part of the L<homebank2ledger> script.
469 App::HomeBank2Ledger->main(@args);
471 Run the script and exit; does not return.
475 $formatter = $app->formatter($homebank, $opts);
477 Generate a L<App::HomeBank2Ledger::Formatter>.
479 =head2 convert_homebank_to_ledger
481 my $ledger = $app->convert_homebank_to_ledger($homebank, $opts);
483 Converts a L<File::HomeBank> to a L<App::HomeBank2Ledger::Ledger>.
487 $app->print_to_file($str);
488 $app->print_to_file($str, $filepath);
490 Print a string to a file (or STDOUT).
494 $opts = $app->parse_args(@args);
496 Parse command-line arguments.
500 Please report any bugs or feature requests on the bugtracker website
501 L<https://github.com/chazmcgarvey/homebank2ledger/issues>
503 When submitting a bug or request, please include a test-file or a
504 patch to an existing test-file that illustrates the bug or desired
509 Charles McGarvey <chazmcgarvey@brokenzipper.com>
511 =head1 COPYRIGHT AND LICENSE
513 This software is Copyright (c) 2019 by Charles McGarvey.
515 This is free software, licensed under:
517 The MIT (X11) License