File Coverage

blib/lib/Finance/PaycheckRecords.pm
Criterion Covered Total %
statement 98 98 100.0
branch 28 40 70.0
condition 7 12 58.3
subroutine 11 11 100.0
pod 2 2 100.0
total 146 163 89.5


line stmt bran cond sub pod time code
1             #---------------------------------------------------------------------
2             package Finance::PaycheckRecords;
3             #
4             # Copyright 2013 Christopher J. Madsen
5             #
6             # Author: Christopher J. Madsen
7             # Created: 2 Feb 2013
8             #
9             # This program is free software; you can redistribute it and/or modify
10             # it under the same terms as Perl itself.
11             #
12             # This program is distributed in the hope that it will be useful,
13             # but WITHOUT ANY WARRANTY; without even the implied warranty of
14             # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See either the
15             # GNU General Public License or the Artistic License for more details.
16             #
17             # ABSTRACT: Parse data from PaycheckRecords.com
18             #---------------------------------------------------------------------
19              
20 2     2   88124 use 5.010;
  2         7  
  2         71  
21 2     2   83 use strict;
  2         5  
  2         68  
22 2     2   15 use warnings;
  2         4  
  2         126  
23              
24             our $VERSION = '1.000';
25             # This file is part of Finance-PaycheckRecords 1.000 (July 5, 2014)
26              
27 2     2   10 use Carp qw(croak);
  2         5  
  2         297  
28 2     2   4245 use HTML::TableExtract 2.10;
  2         38039  
  2         15  
29 2     2   92 use List::Util qw(sum);
  2         4  
  2         248  
30              
31              
32 2     2   10 use Exporter 5.57 'import'; # exported import method
  2         30  
  2         381  
33             our @EXPORT = qw(parse_paystub paystub_to_QIF);
34              
35             # This indicates which HTML::TableExtract methods to call based on
36             # the keyword passed to parse_paystub.
37             our %parse_method = qw(
38             file parse_file
39             string parse
40             );
41              
42             our %eof_after_parse = (string => 1);
43              
44             # When converting a paystub to QIF, this controls which column
45             # contains the values that will be used in the transaction.
46             our $current = 'Current';
47              
48             #=====================================================================
49              
50              
51             sub parse_paystub
52             {
53 3     3 1 238 my ($input_type, $input) = @_;
54              
55 3 50       15 my $parse_method = $parse_method{$input_type}
56             or croak("Don't know how to parse '$input_type'");
57              
58 3         25 my $te = HTML::TableExtract->new;
59 3         411 $te->$parse_method($input);
60 3 100       43161 $te->eof if $eof_after_parse{$input_type};
61              
62 3         22 my %paystub;
63              
64 3         16 foreach my $ts ($te->tables) {
65 30         114 my @coords = $ts->coords;
66 30         322 my @rows = $ts->rows;
67              
68 2     2   10 no warnings 'uninitialized';
  2         4  
  2         2381  
69 30 100 66     3935 if ($coords[0] == 2) {
    100          
    100          
70 6 100       96 $paystub{pay_period} = $1
71             if $rows[0][0] =~ /^\s*Pay stub for period:\s*(\S.+\S)\s*\z/s;
72             } elsif ($coords[0] == 4 and $coords[1] == 0) {
73 3         9 $paystub{company} = $rows[0][0];
74 3         16 $paystub{payee} = $rows[2][0];
75 3 50       34 $paystub{check_number} = $1
76             if $rows[0][2] =~ /\bCheck\s*#\s*(\d+)/;
77 3 50       22 $paystub{date} = $1
78             if $rows[0][2] =~ /\bDate:\s*(\S.+\S)/;
79 3         10 for (@paystub{qw(company payee)}) {
80 6 50       17 next unless defined;
81 6         27 s/^[\s\xA0]+//;
82 6         49 s/[\s\xA0]+\z//;
83 6         58 s/\n[ \t]+/\n/g;
84 6         47 s/\n{2,}/\n/g;
85             }
86             } elsif ($coords[0] == 3) {
87 15 100       87 if ($rows[0][-1] =~ /^\s*YTD\s*\z/ ) {
    100          
88 9         14 my $headings = shift @rows;
89 9         12 my %table;
90 9         28 $paystub{split}{ shift @$headings } = \%table;
91 9         16 for my $row (@rows) {
92 21         33 for (@$row) {
93 69 50       123 next unless defined;
94 69         187 s/^[\s\xA0]+//;
95 69         222 s/[\s\xA0]+\z//;
96             }
97 21         37 my $category = shift @$row;
98 21         30 @{ $table{$category} }{@$headings} = @$row;
  21         138  
99             }
100             } # end if YTD
101             elsif ($rows[0][0] =~ /^\s*Net\s+This\s+Check:/) {
102 3         6 for my $row (@rows) {
103 3         8 for (@$row) {
104 6 50       13 next unless defined;
105 6         22 s/^[\s\xA0]+//;
106 6         30 s/[\s\xA0]+\z//;
107             }
108 3         12 $row->[0] =~ s/:\z//;
109 3         13 $row->[1] =~ s/[\$,]//g;
110              
111 3         20 $paystub{totals}{$row->[0]} = $row->[1];
112             }
113             } # end if Net This Check
114             }
115             } # end for each $ts in tables
116              
117 3         849 \%paystub;
118             } # end parse_paystub
119             #---------------------------------------------------------------------
120              
121              
122             sub paystub_to_QIF
123             {
124 2     2 1 3056 my ($paystub, $config) = @_;
125              
126 2   50     13 my $net_deposit = $paystub->{totals}{ $config->{net_deposit}
127             // 'Net This Check'};
128 2         3 my @splits;
129              
130 2         6 _add_splits(\@splits, $paystub, $config->{income}, '');
131 2         6 _add_splits(\@splits, $paystub, $config->{expenses}, '-');
132              
133 2         4 my $sum = sprintf "%.2f", sum( map { $_->[0] } @splits);
  8         49  
134 2 50       11 croak("Sum of splits $sum != Net $net_deposit") unless $sum eq $net_deposit;
135              
136 2         5 my $qif = "D$paystub->{date}\n";
137              
138 2 50       10 $qif .= "N$paystub->{check_number}\n" if length $paystub->{check_number};
139              
140 2         3 my $company = $paystub->{company};
141 2         8 $company =~ s/\n/\nA/g; # Subsequent lines are address
142 2         4 $qif .= "P$company\n";
143              
144 2   66     10 my $memo = $config->{memo} // "Paycheck for $paystub->{pay_period}";
145 2 50       9 $qif .= "M$memo\n" if length $memo;
146              
147 2   50     10 $qif .= sprintf "T%s\nL%s\n", $net_deposit, $config->{category} // 'Income';
148              
149 2         5 for my $split (@splits) {
150 8         11 $qif .= "S$split->[1]\n";
151 8 100       21 $qif .= "E$split->[2]\n" if length $split->[2];
152 8         16 $qif .= "\$$split->[0]\n";
153             }
154              
155 2         15 $qif . "^\n";
156             } # end paystub_to_QIF
157              
158             #---------------------------------------------------------------------
159             sub _add_splits
160             {
161 4     4   8 my ($all_splits, $paystub, $config, $sign) = @_;
162              
163 4         11 my @splits;
164              
165 4         13 while (my ($section, $fields) = each %$config) {
166 4         5 while (my ($field, $values) = each %{ $paystub->{split}{$section} }) {
  12         44  
167              
168 8 50 50     31 next unless ($values->{$current} // 0) != 0;
169              
170 8 50       17 croak("Don't know what to do with $section: '$field'")
171             unless $fields->{$field};
172              
173 8         11 push @splits, [ $sign . $values->{$current}, @{ $fields->{$field} } ];
  8         23  
174             }
175             }
176              
177             # Sort splits in ascending order by category name, and
178             # descending order by absolute value within a category:
179 4 50       15 push @$all_splits, sort { $a->[1] cmp $b->[1] or
  6         22  
180             abs($b->[0]) <=> abs($a->[0]) } @splits;
181             } # end _add_splits
182              
183             #=====================================================================
184             # Package Return Value:
185              
186             1;
187              
188             __END__