]> Dogcows Code - chaz/homebank2ledger/blob - lib/App/HomeBank2Ledger/Formatter/Beancount.pm
Version 0.005
[chaz/homebank2ledger] / lib / App / HomeBank2Ledger / Formatter / Beancount.pm
1 package App::HomeBank2Ledger::Formatter::Beancount;
2 # ABSTRACT: Beancount formatter
3
4
5 use v5.10.1; # defined-or
6 use warnings;
7 use strict;
8
9 use App::HomeBank2Ledger::Util qw(commify rtrim);
10
11 use parent 'App::HomeBank2Ledger::Formatter';
12
13 our $VERSION = '0.005'; # VERSION
14
15 my %STATUS_SYMBOLS = (
16 cleared => '*',
17 pending => '!',
18 );
19 my $UNKNOWN_DATE = '0001-01-01';
20
21 sub _croak { require Carp; Carp::croak(@_) }
22
23 sub format {
24 my $self = shift;
25 my $ledger = shift;
26
27 my @out = (
28 $self->format_header,
29 $self->format_accounts($ledger),
30 $self->format_commodities($ledger),
31 # $self->format_payees,
32 # $self->format_tags,
33 $self->format_transactions($ledger),
34 );
35
36 return join($/, map { rtrim($_) } @out);
37 }
38
39
40 sub format_header {
41 my $self = shift;
42
43 my @out;
44
45 if (my $name = $self->name) {
46 push @out, "; Name: $name";
47 }
48 if (my $file = $self->file) {
49 push @out, "; File: $file";
50 }
51
52 push @out, '';
53
54 return @out;
55 }
56
57
58 sub format_accounts {
59 my $self = shift;
60 my $ledger = shift;
61
62 my @out;
63
64 for my $account (sort @{$ledger->accounts}) {
65 my $oldest_transaction = $self->_find_oldest_transaction_by_account($account, $ledger);
66 my $account_date = $oldest_transaction->{date} || $UNKNOWN_DATE;
67 $account = $self->_format_account($account);
68
69 push @out, "${account_date} open ${account}";
70 }
71 push @out, '';
72
73 return @out;
74 }
75
76
77 sub format_commodities {
78 my $self = shift;
79 my $ledger = shift;
80
81 my @out;
82
83 for my $commodity (@{$ledger->commodities}) {
84 my $oldest_transaction = $self->_find_oldest_transaction_by_commodity($commodity, $ledger);
85 my $commodity_date = $oldest_transaction->{date} || $UNKNOWN_DATE;
86
87 push @out, "${commodity_date} commodity $commodity->{iso}";
88 push @out, ' name: '.$self->_format_string($commodity->{name}) if $commodity->{name};
89 }
90
91 push @out, '';
92
93 return @out;
94 }
95
96
97 sub format_transactions {
98 my $self = shift;
99 my $ledger = shift;
100
101 my @out;
102
103 for my $transaction (@{$ledger->transactions}) {
104 push @out, $self->_format_transaction($transaction);
105 }
106
107 return @out;
108 }
109
110 sub _format_transaction {
111 my $self = shift;
112 my $transaction = shift;
113
114 my $account_width = $self->account_width;
115
116 my $date = $transaction->{date};
117 my $status = $transaction->{status};
118 my $payee = $transaction->{payee} || '';
119 my $memo = $transaction->{memo} || '';
120 my @postings = @{$transaction->{postings}};
121
122 my @out;
123
124 # figure out the Ledger transaction status
125 my $status_symbol = $STATUS_SYMBOLS{$status || ''};
126 if (!$status_symbol) {
127 my %posting_statuses = map { ($_->{status} || '') => 1 } @postings;
128 if (keys(%posting_statuses) == 1) {
129 my ($status) = keys %posting_statuses;
130 $status_symbol = $STATUS_SYMBOLS{$status || 'none'} || '';
131 }
132 }
133
134 push @out, sprintf('%s%s%s%s', $date,
135 $status_symbol && ' '.$status_symbol || ' *', # status (or "txn") is required
136 ($payee || $memo) && ' '.$self->_format_string($payee),
137 $memo && ' '.$self->_format_string($memo),
138 );
139
140 if (my %tags = map { $_ => 1 } map { @{$_->{tags} || []} } @postings) {
141 my @tags = map { "#$_" } keys %tags;
142 $out[-1] .= ' '.join(' ', @tags);
143 }
144
145 for my $posting (@postings) {
146 my @line;
147
148 my $posting_status_symbol = '';
149 if (!$status_symbol) {
150 $posting_status_symbol = $STATUS_SYMBOLS{$posting->{status} || ''} || '';
151 }
152
153 my $account = $self->_format_account($posting->{account});
154
155 push @line, ($posting_status_symbol ? " $posting_status_symbol " : ' ');
156 push @line, sprintf("\%-${account_width}s", $account);
157 push @line, ' ';
158 if (defined $posting->{amount}) {
159 push @line, $self->_format_amount($posting->{amount}, $posting->{commodity});
160 my $lot_price = $posting->{lot_price};
161 my $lot_date = $posting->{lot_date};
162 my $lot_ref = $posting->{lot_ref};
163 if ($lot_price || $lot_date || $lot_ref) {
164 push @line, ' {',
165 join(', ',
166 $lot_price ? $self->_format_amount($lot_price->{amount}, $lot_price->{commodity}) : (),
167 $lot_date ? $lot_date : (),
168 $lot_ref ? $self->_format_string($lot_ref) : (),
169 ),
170 '}';
171 }
172 if (my $cost = $posting->{total_cost} // $posting->{cost}) {
173 my $is_total = defined $posting->{total_cost};
174 my $cost_symbol = $is_total ? '@@' : '@';
175 push @line, ' ', $cost_symbol, ' ',
176 $self->_format_amount($cost->{amount}, $cost->{commodity});
177 }
178 }
179
180 push @out, join('', @line);
181 }
182
183 push @out, '';
184
185 return @out;
186 }
187
188 sub _format_account {
189 my $self = shift;
190 my $account = shift;
191 $account =~ s/[^A-Za-z0-9:]+/-/g;
192 $account =~ s/-+/-/g;
193 $account =~ s/(?:^|(?<=:))([a-z])/uc($1)/eg;
194 return $account;
195 }
196
197 sub _format_string {
198 my $self = shift;
199 my $str = shift;
200 $str =~ s/"/\\"/g;
201 return "\"$str\"";
202 }
203
204 sub _format_amount {
205 my $self = shift;
206 my $amount = shift;
207 my $commodity = shift or _croak 'Must provide a valid currency';
208
209 my $format = "\% .$commodity->{frac}f";
210 my ($whole, $fraction) = split(/\./, sprintf($format, $amount));
211
212 # beancount doesn't support different notations
213 my $num = join('.', commify($whole), $fraction);
214
215 $num = "$num $commodity->{iso}";
216
217 return $num;
218 }
219
220 sub _find_oldest_transaction_by_account {
221 my $self = shift;
222 my $account = shift;
223 my $ledger = shift;
224
225 $account = $self->_format_account($account);
226
227 my $oldest = $self->{oldest_transaction_by_account};
228 if (!$oldest) {
229 # build index
230 for my $transaction (@{$ledger->transactions}) {
231 for my $posting (@{$transaction->{postings}}) {
232 my $account = $self->_format_account($posting->{account});
233
234 if ($transaction->{date} lt ($oldest->{$account}{date} || '9999-99-99')) {
235 $oldest->{$account} = $transaction;
236 }
237 }
238 }
239
240 $self->{oldest_transaction_by_account} = $oldest;
241 }
242
243 return $oldest->{$account};
244 }
245
246 sub _find_oldest_transaction_by_commodity {
247 my $self = shift;
248 my $commodity = shift;
249 my $ledger = shift;
250
251 my $oldest = $self->{oldest_transaction_by_commodity};
252 if (!$oldest) {
253 # build index
254 for my $transaction (@{$ledger->transactions}) {
255 for my $posting (@{$transaction->{postings}}) {
256 my $symbol = $posting->{commodity}{symbol};
257 next if !$symbol;
258
259 if ($transaction->{date} lt ($oldest->{$symbol}{date} || '9999-99-99')) {
260 $oldest->{$symbol} = $transaction;
261 }
262 }
263 }
264
265 $self->{oldest_transaction_by_commodity} = $oldest;
266 }
267
268 return $oldest->{$commodity->{symbol}};
269 }
270
271 1;
272
273 __END__
274
275 =pod
276
277 =encoding UTF-8
278
279 =head1 NAME
280
281 App::HomeBank2Ledger::Formatter::Beancount - Beancount formatter
282
283 =head1 VERSION
284
285 version 0.005
286
287 =head1 DESCRIPTION
288
289 This is a formatter for L<Beancount|http://furius.ca/beancount/>.
290
291 =head1 METHODS
292
293 =head2 format_header
294
295 @lines = $formatter->format_header;
296
297 Get formatted header. For example,
298
299 ; Name: My Finances
300 ; File: path/to/finances.xhb
301
302 =head2 format_accounts
303
304 @lines = $formatter->format_accounts($ledger);
305
306 Get formatted accounts. For example,
307
308 2003-02-14 open Assets:Bank:Credit-Union:Savings
309 2003-02-14 open Assets:Bank:Credit-Union:Checking
310 ...
311
312 =head2 format_commodities
313
314 @lines = $formatter->format_commodities($ledger);
315
316 Get formattted commodities. For example,
317
318 2003-02-14 commodity USD
319 name: "US Dollar"
320 ...
321
322 =head2 format_transactions
323
324 @lines = $formatter->format_transactions($ledger);
325
326 Get formatted transactions. For example,
327
328 2003-02-14 * "Opening Balance"
329 Assets:Bank:Credit-Union:Savings 458.21 USD
330 Assets:Bank:Credit-Union:Checking 194.17 USD
331 Equity:Opening-Balances
332
333 ...
334
335 =head1 SEE ALSO
336
337 L<App::HomeBank2Ledger::Formatter>
338
339 =head1 BUGS
340
341 Please report any bugs or feature requests on the bugtracker website
342 L<https://github.com/chazmcgarvey/homebank2ledger/issues>
343
344 When submitting a bug or request, please include a test-file or a
345 patch to an existing test-file that illustrates the bug or desired
346 feature.
347
348 =head1 AUTHOR
349
350 Charles McGarvey <chazmcgarvey@brokenzipper.com>
351
352 =head1 COPYRIGHT AND LICENSE
353
354 This software is Copyright (c) 2019 by Charles McGarvey.
355
356 This is free software, licensed under:
357
358 The MIT (X11) License
359
360 =cut
This page took 0.0544559999999999 seconds and 4 git commands to generate.