1 package App
::HomeBank2Ledger
;
2 # ABSTRACT: A tool to convert HomeBank files to Ledger format
6 App::HomeBank2Ledger->main(@args);
10 This module is part of the L<homebank2ledger> script.
14 # TODO - add posting memo
15 # TODO - transaction description ("narration" in beancount)
17 # TODO - budget/scheduled
18 # TODO - consolidate tags on transaction
19 # TODO - consolidate payees on transaction
21 use warnings FATAL
=> 'all'; # temp fatal all
24 use App
::HomeBank2Ledger
::Formatter
;
25 use App
::HomeBank2Ledger
::Ledger
;
27 use Getopt
::Long
2.38 qw(GetOptionsFromArray);
30 our $VERSION = '9999.999'; # VERSION
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',
41 expense
=> 'Expenses',
44 my %STATUS_SYMBOLS = (
46 reconciled
=> 'cleared',
49 my $UNKNOWN_ACCOUNT = 'Assets:Unknown';
50 my $OPENING_BALANCES_ACCOUNT = 'Equity:Opening Balances';
54 App
::HomeBank2Ledger-
>main(@args);
56 Run the script
and exit; does not return.
62 my $self = bless {}, $class;
64 my $opts = $self->parse_args(@_);
66 if ($opts->{version
}) {
67 print "homebank2ledger ${VERSION}\n";
71 pod2usage
(-exitval
=> 0, -verbose
=> 99, -sections
=> [qw(NAME SYNOPSIS OPTIONS)]);
73 if ($opts->{manual
}) {
74 pod2usage
(-exitval
=> 0, -verbose
=> 2);
77 my $homebank = File
::HomeBank-
>new(file
=> $opts->{input
});
79 my $formatter = eval { $self->formatter($homebank, $opts) };
81 if ($err =~ /^Invalid formatter/) {
82 print STDERR
"Invalid format: $opts->{format}\n";
88 my $ledger = $self->convert_homebank_to_ledger($homebank, $opts);
90 $self->print_to_file($formatter->format($ledger), $opts->{output
});
97 $formatter = $app->formatter($homebank, $opts);
99 Generate a L
<App
::HomeBank2Ledger
::Formatter
>.
105 my $homebank = shift;
106 my $opts = shift || {};
108 return App
::HomeBank2Ledger
::Formatter-
>new(
109 type
=> $opts->{format
},
110 account_width
=> $opts->{account_width
},
111 name
=> $homebank->title,
112 file
=> $homebank->file,
116 =method convert_homebank_to_ledger
118 my $ledger = $app->convert_homebank_to_ledger($homebank, $opts);
120 Converts a L
<File
::HomeBank
> to a L
<App
::HomeBank2Ledger
::Ledger
>.
124 sub convert_homebank_to_ledger
{
126 my $homebank = shift;
127 my $opts = shift || {};
129 my $ledger = App
::HomeBank2Ledger
::Ledger-
>new;
131 my $transactions = $homebank->sorted_transactions;
132 my $accounts = $homebank->accounts;
133 my $categories = $homebank->categories;
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}";
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}";
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/;
151 for my $re (@{$opts->{exclude_accounts
}}) {
152 $item->{excluded
} = 1 if $item->{ledger_name
} =~ /$re/;
156 my $has_initial_balance = grep { $_->{initial
} && !$_->{excluded
} } @$accounts;
158 if ($opts->{accounts
}) {
159 my @accounts = map { $_->{ledger_name
} } grep { !$_->{excluded
} } @$accounts, @$categories;
161 push @accounts, $opts->{default_account
};
162 push @accounts, $OPENING_BALANCES_ACCOUNT if $has_initial_balance;
164 $ledger->add_accounts(@accounts);
167 if ($opts->{payees
}) {
168 my $payees = $homebank->payees;
169 my @payees = map { $_->{name
} } @$payees;
171 $ledger->add_payees(@payees);
175 my $tags = $homebank->tags;
177 $ledger->add_tags(@$tags);
182 for my $currency (@{$homebank->currencies}) {
184 symbol
=> $currency->{symbol
},
185 format
=> $homebank->format_amount(1_000, $currency),
186 iso
=> $currency->{iso
},
187 name
=> $currency->{name
},
189 $commodities{$currency->{key
}} = {
191 syprf
=> $currency->{syprf
},
192 dchar
=> $currency->{dchar
},
193 gchar
=> $currency->{gchar
},
194 frac
=> $currency->{frac
},
197 $ledger->add_commodities($commodity) if $opts->{commodities
};
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";
209 for my $account (@$accounts) {
210 next if !$account->{initial
} || $account->{excluded
};
213 account
=> $account->{ledger_name
},
214 amount
=> $account->{initial
},
215 commodity
=> $commodities{$account->{currency
}},
220 account
=> $OPENING_BALANCES_ACCOUNT,
223 $ledger->add_transactions({
225 payee
=> 'Opening Balance',
227 postings
=> \
@postings,
234 for my $transaction (@$transactions) {
235 next if $seen{$transaction->{transfer_key
} || ''};
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
});
248 account
=> $account->{ledger_name
},
250 commodity
=> $commodities{$account->{currency
}},
251 payee
=> $payee->{name
},
257 if ($paymode eq 'internaltransfer') {
258 my $paired_transaction = $homebank->find_transaction_transfer_pair($transaction);
260 my $dst_account = $homebank->find_account_by_key($transaction->{dst_account
});
262 if ($paired_transaction) {
263 $dst_account = $homebank->find_account_by_key($paired_transaction->{account
});
266 warn "Skipping internal transfer transaction with no destination account.\n";
271 $seen{$transaction->{transfer_key
}}++ if $transaction->{transfer_key
};
272 $seen{$paired_transaction->{transfer_key
}}++ if $paired_transaction->{transfer_key
};
274 my $paired_payee = $homebank->find_payee_by_key($paired_transaction->{payee
});
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
}),
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
} || '');
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
};
298 account
=> $other_account,
299 commodity
=> $commodities{$account->{currency
}},
301 payee
=> $payee->{name
},
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
};
312 account
=> $other_account,
313 commodity
=> $commodities{$account->{currency
}},
314 amount
=> -$transaction->{amount
},
315 payee
=> $payee->{name
},
322 # skip excluded accounts
323 for my $posting (@postings) {
324 for my $re (@{$opts->{exclude_accounts
}}) {
325 next TRANSACTION
if $posting->{account
} =~ /$re/;
329 $ledger->add_transactions({
330 date
=> $transaction->{date
},
331 payee
=> 'Payee TODO',
332 postings
=> \
@postings,
339 =method print_to_file
341 $app->print_to_file($str);
342 $app->print_to_file($str, $filepath);
344 Print a string to a file
(or STDOUT
).
351 my $filepath = shift;
353 my $out_fh = \
*STDOUT
;
355 open($out_fh, '>', $filepath) or die "open failed: $!";
362 $opts = $app->parse_args(@args);
364 Parse command-line arguments
.
385 default_account
=> 'Expenses:No Category',
386 rename_accounts
=> {},
387 exclude_accounts
=> [],
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)]);
408 $opts{input
} = shift @args if !$opts{input
};
410 print STDERR
"Input file is required.\n";
419 return [split(/\h+/, $tags || '')];