File Coverage

blib/lib/Finance/Budget.pm
Criterion Covered Total %
statement 45 294 15.3
branch 0 96 0.0
condition 0 26 0.0
subroutine 15 45 33.3
pod 4 5 80.0
total 64 466 13.7


line stmt bran cond sub pod time code
1             package Finance::Budget;
2              
3 1     1   47318 use strict;
  1         1  
  1         23  
4 1     1   4 use warnings;
  1         2  
  1         31  
5             our $VERSION = '0.06';
6 1     1   5 { use Carp;
  1         4  
  1         41  
7 1     1   417 use Text::CSV;
  1         15258  
  1         60  
8 1     1   350 use Date::Manip;
  1         120923  
  1         109  
9 1     1   315 use File::Slurp;
  1         10759  
  1         1788  
10             }
11              
12             sub new
13             {
14 0     0 0   my ( $class, $param_hr ) = @_;
15              
16 0 0         croak sprintf 'usage: %s->new()', $class
17             if ref $param_hr ne 'HASH';
18              
19             my %self = (
20             days => 365,
21             currency_symbol => '$',
22             date_format => '%m/%d/%Y',
23 0     0     markup_callback => sub { return $_[0]->{string} },
24 0           opening_balance => 0,
25             transaction_types => [],
26             exceptions => [],
27             recent_history => [],
28             categorizer => {},
29             );
30              
31 0           for my $opt (keys %{$param_hr})
  0            
32             {
33             croak sprintf '%s is not a recognized option among %s',
34             $opt, ( join ',', keys %self )
35 0 0         if not exists $self{$opt};
36              
37 0           my $type = ref $param_hr->{$opt};
38              
39 0 0 0       if ( !$type && stat $param_hr->{$opt} )
40             {
41 0 0         if ( $param_hr->{$opt} =~ m{ [.] csv \z}xmsi )
42             {
43 0           my @lines = File::Slurp::slurp( $param_hr->{$opt} );
44              
45 0           $param_hr->{$opt} = \@lines;
46             }
47             else
48             {
49 0           $param_hr->{$opt} = do $param_hr->{$opt};
50             }
51              
52 0           $type = ref $param_hr->{$opt};
53              
54 0 0         if ( $type eq 'ARRAY' )
55             {
56 0           chomp @{ $param_hr->{$opt} };
  0            
57             }
58             }
59              
60 0 0         if ( ref $self{$opt} )
    0          
    0          
61             {
62             croak sprintf '%s should be of type %s ref',
63             $opt, ( ref $self{$opt} )
64 0 0         if $type ne ref $self{$opt};
65             }
66             elsif ( $self{$opt} =~ m{\A [%] }xms )
67             {
68             croak sprintf '%s should be a format string', $opt
69 0 0         if $param_hr->{$opt} !~ m{ [%][a-z] }xmsi;
70             }
71             elsif ( $self{$opt} =~ m{\A \d }xms )
72             {
73             croak sprintf '%s should be a number', $opt
74 0 0         if $param_hr->{$opt} !~ m{\A \d+ (?: [.] \d+ )? \z}xms;
75             }
76              
77 0           $self{$opt} = $param_hr->{$opt};
78             }
79              
80             croak 'there must be 1+ transaction types'
81 0 0         if 0 == @{ $self{transaction_types} };
  0            
82              
83             croak 'there must be 1+ recent history for each transaction type'
84 0 0         if @{ $self{recent_history} } < 1 + @{ $self{transaction_types} };
  0            
  0            
85              
86 0           $self{opening_balance} *= 100; # dollars to cents
87              
88 0           _set_base_dates( \%self );
89              
90 0           _build_transactions( \%self );
91              
92 0           return bless \%self, $class;
93             }
94              
95             sub get_last_occurences {
96 0     0 1   my ($self) = @_;
97              
98 0           my %last_occurred;
99              
100             TYPE:
101 0           for my $type_hr (@{ $self->{transaction_types} })
  0            
102             {
103 0           my $category = $type_hr->{category};
104 0           my $base_date = $type_hr->{base_date};
105              
106             next TYPE
107 0 0         if not $category;
108              
109 0           $last_occurred{$category} = $base_date->printf('%m/%d/%Y');
110             }
111              
112 0           return \%last_occurred;
113             }
114              
115             sub opening_balance
116             {
117 0     0 1   my ($self) = @_;
118 0           return _format_currency( $self->{opening_balance}, $self->{currency_symbol} );
119             }
120              
121             sub next
122             {
123 0     0 1   my ($self) = @_;
124              
125             return
126 0 0         if not @{ $self->{transactions} };
  0            
127              
128 0           return shift @{ $self->{transactions} };
  0            
129             }
130              
131             sub get_chokepoints
132             {
133 0     0 1   my ($self) = @_;
134              
135             croak "no chokepoints encountered"
136 0 0         if not exists $self->{chokepoints};
137              
138             return Finance::Budget::Chokepoints->new(
139             { chokepoints => $self->{chokepoints},
140             markup => $self->{markup_callback},
141             }
142 0           );
143             }
144              
145             sub _set_base_dates
146             {
147 0     0     my ($self) = @_;
148              
149             my %base_date_for
150 0           = map { $_->{category} => 0 }
151 0           grep { exists $_->{category} }
152 0           @{ $self->{transaction_types} };
  0            
153              
154 0 0         if ( @{ $self->{recent_history} } )
  0            
155             {
156 0   0       my $csv = Text::CSV->new()
157             || die sprintf "Text::CSV: %s\n",
158             Text::CSV->error_diag();
159              
160 0           chomp @{ $self->{recent_history} };
  0            
161              
162 0           my @cols;
163             {
164 0           my ($header) = shift @{ $self->{recent_history} };
  0            
  0            
165              
166 0 0         $csv->parse(lc $header)
167             || die sprintf "Text::CSV::parse %s\n",
168             Text::CSV->error_diag();
169              
170 0           @cols = $csv->fields();
171             }
172              
173             croak "recent history CSV header must have a 'date' column"
174 0 0         if 0 == grep { $_ eq 'date' } @cols;
  0            
175              
176             croak "recent history CSV header must have a 'description' column"
177 0 0         if 0 == grep { $_ eq 'description' } @cols;
  0            
178              
179             my $categorize = sub {
180 0     0     my ($event_hr) = @_;
181 0           for my $category (sort keys %{ $self->{categorizer} })
  0            
182             {
183 0           my $categorizer = $self->{categorizer}->{$category};
184 0           my $type = ref $categorizer;
185              
186 0 0 0       croak 'categorizer should be a %s or a %s',
187             'hash of array refs', 'hash of code refs'
188             if $type ne 'ARRAY' && $type ne 'CODE';
189              
190 0 0         if ( $type eq 'CODE' )
191             {
192 0 0         return $category
193             if $categorizer->($event_hr);
194             }
195             else
196             {
197 0           for my $regex (@{$categorizer})
  0            
198             {
199             return $category
200 0 0         if $event_hr->{description} =~ m/$regex/xmsi;
201             }
202             }
203             }
204              
205             TYPE:
206 0           for my $type_hr ( @{ $self->{transaction_types} } )
  0            
207             {
208 0           my $category = $type_hr->{category};
209              
210             next TYPE
211 0 0         if exists $self->{categorizer}->{$category};
212              
213 0           my ( $amount, $cents ) = $type_hr->{amount} =~ m{
214             ( \d+ [.] ( \d+ ) )
215             }xms;
216              
217             next TYPE
218 0 0         if not $cents;
219              
220             return $category
221 0 0         if 1 == grep { $amount eq $_ } values %{$event_hr};
  0            
  0            
222             }
223              
224 0           return;
225 0           };
226              
227             EVENT:
228 0           for my $event_csv (@{ $self->{recent_history} })
  0            
229             {
230 0 0         $csv->parse($event_csv)
231             || die sprintf "Text::CSV::parse %s\n",
232             Text::CSV->error_diag();
233              
234 0           my %event;
235 0           @event{@cols} = $csv->fields();
236              
237 0   0       my $category = $categorize->( \%event ) // "";
238              
239             next EVENT
240 0 0         if not $category;
241              
242             next EVENT
243 0 0         if ref $base_date_for{$category};
244              
245 0           my $date = Date::Manip::Date->new();
246              
247 0           my $err = $date->parse($event{date});
248              
249             die sprintf "Date::Manip::Date::parse %s -- %s\n",
250 0 0         $event{date}, $err
251             if $err;
252              
253 0           $base_date_for{$category} = $date;
254             }
255             }
256              
257             EXC:
258 0           for my $except_hr (@{ $self->{exceptions} })
  0            
259             {
260 0           my $category = $except_hr->{category};
261              
262             next EXC
263 0 0         if ref $base_date_for{$category};
264              
265 0           my $date = Date::Manip::Date->new();
266              
267 0           my $err = $date->parse($except_hr->{date});
268              
269             die sprintf "Date::Manip::Date::parse %s -- %s\n",
270 0 0         $except_hr->{date}, $err
271             if $err;
272              
273 0           $base_date_for{$category} = $date;
274             }
275              
276             CAT:
277 0           for my $category (keys %base_date_for)
278             {
279             croak sprintf 'failed to find base date for %s in recent history',
280             $category
281 0 0         if not ref $base_date_for{$category};
282              
283 0           for my $type_hr (@{ $self->{transaction_types} })
  0            
284             {
285 0 0         if ( $type_hr->{category} eq $category )
286             {
287 0           $type_hr->{base_date} = $base_date_for{$category};
288              
289 0           next CAT;
290             }
291             }
292             }
293              
294 0           return;
295             }
296              
297             sub _build_transactions {
298 0     0     my ($self) = @_;
299              
300 0           my $width = 0;
301 0           my $balance = $self->{opening_balance};
302 0           my ( %events_for, @transactions, @chokepoints );
303              
304 0           my $start_date = ParseDate('today');
305 0           my $end_date = DateCalc('today', sprintf '+%dD', $self->{days});
306              
307             my ($major_income)
308 0           = map { $_->{category} }
309 0           sort { $b->{amount} <=> $a->{amount} }
310 0           @{ $self->{transaction_types} };
  0            
311              
312             my ($major_payment)
313 0           = map { $_->{category} }
314 0           sort { $a->{amount} <=> $b->{amount} }
315 0           @{ $self->{transaction_types} };
  0            
316              
317 0           my %amount_for;
318              
319 0           for my $except_hr (@{ $self->{exceptions} })
  0            
320             {
321 0           for my $field ( keys %{$except_hr} )
  0            
322             {
323             croak "exceptions must have: { date, category, amount }"
324 0 0         if not exists $except_hr->{$field};
325             }
326              
327 0           my $date = Date::Manip::Date->new();
328              
329 0           my $err = $date->parse($except_hr->{date});
330              
331             die sprintf "Date::Manip::Date::parse %s -- %s\n",
332 0 0         $except_hr->{date}, $err
333             if $err;
334              
335 0           my $category = lc $except_hr->{category};
336              
337 0           $amount_for{ $date->printf("$category:%s") } = $except_hr->{amount};
338             }
339              
340             my $find_amount_cr = sub {
341 0     0     my ( $category, $epoch, $default ) = @_;
342              
343 0           my $exception_key = lc "$category:$epoch";
344              
345             return $amount_for{$exception_key}
346 0 0         if exists $amount_for{$exception_key};
347              
348 0           return $default;
349 0           };
350              
351 0           my $type_help
352             = "transaction_types must have { category, amount, recurrence }";
353              
354 0           my $major_payment_hit = 0;
355              
356 0           for my $type_hr (@{ $self->{transaction_types} })
  0            
357             {
358 0   0       my $category = $type_hr->{category} || die "$type_help\n";
359 0   0       my $amount = $type_hr->{amount} // die "$type_help\n";
360 0   0       my $recurrence = $type_hr->{recurrence} || die "$type_help\n";
361 0           my $base_date = $type_hr->{base_date};
362              
363 0 0         $width = $width < length $category ? length $category : $width;
364              
365 0           my $recur = Date::Manip::Recur->new();
366              
367 0           $recur->parse( $recurrence, $base_date, $start_date, $end_date );
368              
369 0           my @dates = $recur->dates();
370              
371 0           for my $date (@dates)
372             {
373 0           my $epoch = $date->printf('%s');
374              
375             $amount = $find_amount_cr->(
376             $type_hr->{category},
377             $epoch,
378             $type_hr->{amount},
379 0           );
380              
381 0   0       $events_for{$epoch} //= [];
382              
383 0           push @{ $events_for{$epoch} },
384             { category => $type_hr->{category},
385 0           cents => ( 100 * $amount ),
386             date => $date,
387             };
388             }
389             }
390              
391 0           for my $epoch (sort { $a <=> $b } keys %events_for)
  0            
392             {
393             my @events
394 0           = sort { $b->{cents} <=> $a->{cents} } @{ $events_for{$epoch} };
  0            
  0            
395              
396 0           for my $event_hr (@events)
397             {
398 0 0         if ( $event_hr->{category} eq $major_income )
    0          
399             {
400 0 0         if ( $major_payment_hit )
401             {
402 0           my $past_hr = $transactions[-1]->{event};
403              
404             push @chokepoints,
405             { date => $past_hr->{date},
406             date_str => $past_hr->{date_str},
407             cents => $balance,
408             balance => $balance,
409             balance_str => _format_currency(
410             $balance,
411             $self->{currency_symbol}
412 0           ),
413             };
414             }
415              
416 0           $major_payment_hit = 0;
417             }
418             elsif ( $event_hr->{category} eq $major_payment )
419             {
420 0           $major_payment_hit = 1;
421             }
422              
423 0           $balance += $event_hr->{cents};
424              
425             push @transactions,
426             Finance::Budget::Transaction->new(
427             { event => $event_hr,
428             balance => $balance,
429             width => $width,
430             date_format => $self->{date_format},
431             markup => $self->{markup_callback},
432             currency_symbol => $self->{currency_symbol},
433             }
434 0           );
435             }
436             }
437              
438 0           $self->{chokepoints} = \@chokepoints;
439 0           $self->{transactions} = \@transactions;
440              
441 0           return;
442             }
443              
444             sub _format_currency {
445 0     0     my ($cents, $currency) = @_;
446              
447 0 0         my $sign = $cents < 0 ? '-' : '';
448              
449 0           $cents = abs $cents;
450              
451 0           my ( $dollars, $pennies );
452              
453 0 0         if ( $cents < 99 )
454             {
455 0           $dollars = substr 0, 1, sprintf '0.%02d', $cents;
456 0   0       $dollars ||= '0.00';
457             }
458             else
459             {
460 0           $dollars = sprintf '%.2f', ( $cents / 100 );
461             }
462              
463 0           return sprintf '% 10s', "${sign}${currency}${dollars}";
464             }
465              
466             package Finance::Budget::Transaction;
467              
468 1     1   10 use strict;
  1         2  
  1         23  
469 1     1   4 use warnings;
  1         1  
  1         84  
470             use overload (
471             q{""} => \&_stringify,
472             q{<} => \&_lt,
473             q{==} => \&_eq,
474 0     0   0 q{!=} => sub { !_eq( @_ ) },
475 0   0 0   0 q{>} => sub { !_eq( @_ ) && !_lt( @_ ) },
476 1     1   753 );
  1         669  
  1         9  
477              
478             sub new {
479 0     0     my ($class, $conf_hr) = @_;
480              
481 0           $conf_hr->{width} += 1;
482              
483             $conf_hr->{event}->{date_str}
484 0           = $conf_hr->{event}->{date}->printf($conf_hr->{date_format});
485              
486             $conf_hr->{event}->{title}
487 0           = sprintf "% $conf_hr->{width}s", $conf_hr->{event}->{category};
488              
489             $conf_hr->{event}->{amount} = Finance::Budget::_format_currency(
490             $conf_hr->{event}->{cents},
491             $conf_hr->{currency_symbol}
492 0           );
493              
494 0           $conf_hr->{event}->{balance} = $conf_hr->{balance};
495              
496             $conf_hr->{event}->{balance_str} = Finance::Budget::_format_currency(
497             $conf_hr->{balance},
498             $conf_hr->{currency_symbol}
499 0           );
500              
501             $conf_hr->{event}->{string} = join ' ',
502 0           @{ $conf_hr->{event} }{qw( date_str title amount balance_str )};
  0            
503              
504 0           return bless $conf_hr, $class;
505             }
506              
507             sub get_date {
508 0     0     my ($self) = @_;
509 0           return $self->{event}->{date_str};
510             }
511              
512             sub get_category {
513 0     0     my ($self) = @_;
514 0           return $self->{event}->{category};
515             }
516              
517             sub get_amount {
518 0     0     my ($self) = @_;
519 0           return $self->{event}->{amount};
520             }
521              
522             sub get_balance {
523 0     0     my ($self) = @_;
524 0           return $self->{balance_str};
525             }
526              
527             sub _stringify {
528 0     0     my ($self) = @_;
529              
530             return $self->{markup}->( $self->{event} )
531 0 0         if $self->{markup};
532              
533 0           return $self->{event}->{string};
534             }
535              
536             sub _lt {
537 0     0     my ($self, $arg) = @_;
538 0           return $self->{event}->{cents} < $arg;
539             }
540              
541             sub _eq {
542 0     0     my ($self, $arg) = @_;
543 0           return $self->{event}->{cents} == $arg;
544             }
545              
546              
547             package Finance::Budget::Chokepoints;
548              
549 1     1   367 use strict;
  1         2  
  1         25  
550 1     1   4 use warnings;
  1         1  
  1         19  
551 1     1   4 use Carp;
  1         1  
  1         214  
552              
553             sub new
554             {
555 0     0     my ($class, $param_hr) = @_;
556              
557             die "$class requires 'chokepoints' parameter"
558 0 0         if not exists $param_hr->{chokepoints};
559              
560 0           return bless $param_hr, $class;
561             }
562              
563             sub eye
564             {
565 0     0     my ($self) = @_;
566              
567             croak 'no chokepoints encountered'
568 0 0         if not @{ $self->{chokepoints} };
  0            
569              
570             my ($eye_hr)
571 0           = sort { $a->{cents} <=> $b->{cents} } @{ $self->{chokepoints} };
  0            
  0            
572              
573             return Finance::Budget::Chokepoint->new(
574             { point => $eye_hr,
575             markup => $self->{markup},
576             }
577 0           );
578             }
579              
580             sub next
581             {
582 0     0     my ($self) = @_;
583              
584             return
585 0 0         if not @{ $self->{chokepoints} };
  0            
586              
587 0           my $point_hr = shift @{ $self->{chokepoints} };
  0            
588              
589             return Finance::Budget::Chokepoint->new(
590             { point => $point_hr,
591             markup => $self->{markup},
592             }
593 0           );
594             }
595              
596             package Finance::Budget::Chokepoint;
597              
598 1     1   5 use strict;
  1         2  
  1         18  
599 1     1   8 use warnings;
  1         6  
  1         77  
600             use overload (
601             q{""} => \&_stringify,
602             q{<} => \&_lt,
603             q{==} => \&_eq,
604 0     0     q{!=} => sub { !_eq( @_ ) },
605 0   0 0     q{>} => sub { !_eq( @_ ) && !_lt( @_ ) },
606 1     1   5 );
  1         1  
  1         12  
607              
608             sub new
609             {
610 0     0     my ( $class, $param_hr ) = @_;
611             $param_hr->{point}->{string}
612 0           = sprintf '%s %s', @{ $param_hr->{point} }{qw( date_str balance_str)};
  0            
613 0           return bless $param_hr, $class;
614             }
615              
616             sub _stringify
617             {
618 0     0     my ($self) = @_;
619 0           return $self->{markup}->( $self->{point} );
620             }
621              
622             sub _lt {
623 0     0     my ($self, $arg) = @_;
624 0           return $self->{point}->{cents} < $arg;
625             }
626              
627             sub _eq {
628 0     0     my ($self, $arg) = @_;
629 0           return $self->{point}->{cents} == $arg;
630             }
631              
632              
633             1;
634              
635             __END__