File Coverage

blib/lib/Finance/PaycheckRecords.pm
Criterion Covered Total %
statement 97 97 100.0
branch 31 40 77.5
condition 7 12 58.3
subroutine 11 11 100.0
pod 2 2 100.0
total 148 162 91.3


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 3     3   63096 use 5.010;
  3         11  
21 3     3   17 use strict;
  3         5  
  3         65  
22 3     3   14 use warnings;
  3         5  
  3         145  
23              
24             our $VERSION = '1.001';
25             # This file is part of Finance-PaycheckRecords 1.001 (October 24, 2015)
26              
27 3     3   17 use Carp qw(croak);
  3         5  
  3         214  
28 3     3   2921 use HTML::TableExtract 2.10;
  3         48815  
  3         21  
29 3     3   125 use List::Util qw(sum);
  3         6  
  3         312  
30              
31              
32 3     3   15 use Exporter 5.57 'import'; # exported import method
  3         41  
  3         603  
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 6     6 1 1380 my ($input_type, $input) = @_;
54              
55 6 50       38 my $parse_method = $parse_method{$input_type}
56             or croak("Don't know how to parse '$input_type'");
57              
58 6         56 my $te = HTML::TableExtract->new;
59 6         579905 $te->$parse_method($input);
60 6 100       89909 $te->eof if $eof_after_parse{$input_type};
61              
62 6         58 my %paystub;
63              
64 6         26 foreach my $ts ($te->tables) {
65 60         205 my @coords = $ts->coords;
66 60         535 my @rows = $ts->rows;
67              
68 3     3   17 no warnings 'uninitialized';
  3         9  
  3         3880  
69 60 100 66     8397 if ($coords[0] == 2) {
    100          
    100          
70 12 100       158 $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 6         18 $paystub{company} = $rows[0][0];
74 6         14 $paystub{payee} = $rows[2][0];
75 6 100       36 $paystub{check_number} = $1
76             if $rows[0][2] =~ /\bCheck\s*#\s*(\d+)/;
77 6 50       45 $paystub{date} = $1
78             if $rows[0][2] =~ /\bDate:\s*(\S.+\S)/;
79 6         17 for (@paystub{qw(company payee)}) {
80 12 50       32 next unless defined;
81 12         47 s/^[\s\xA0]+//;
82 12         99 s/[\s\xA0]+\z//;
83 12         81 s/\n[ \t]+/\n/g;
84 12         78 s/\n{2,}/\n/g;
85             }
86             } elsif ($coords[0] == 3) {
87 30 100       150 if ($rows[0][-1] =~ /^\s*YTD\s*\z/ ) {
    100          
88 18         26 my $headings = shift @rows;
89 18         28 my %table;
90 18         45 $paystub{split}{ shift @$headings } = \%table;
91 18         40 for my $row (@rows) {
92 48         81 for (@$row) {
93 168 50       312 next unless defined;
94 168         466 s/^[\s\xA0]+//;
95 168         491 s/[\s\xA0]+\z//;
96             }
97 48         71 my $category = shift @$row;
98 48         78 @{ $table{$category} }{@$headings} = @$row;
  48         264  
99             }
100             } # end if YTD
101             elsif ($rows[0][0] =~ /^\s*Net\s+This\s+Check:/) {
102 6         14 for my $row (@rows) {
103 9         17 for (@$row) {
104 18 50       37 next unless defined;
105 18         47 s/^[\s\xA0]+//;
106 18         70 s/[\s\xA0]+\z//;
107             }
108 9         29 $row->[0] =~ s/:\z//;
109 9         37 $row->[1] =~ s/[\$,]//g;
110              
111 9         41 $paystub{totals}{$row->[0]} = $row->[1];
112             }
113             } # end if Net This Check
114             }
115             } # end for each $ts in tables
116              
117 6         1320 \%paystub;
118             } # end parse_paystub
119             #---------------------------------------------------------------------
120              
121              
122             sub paystub_to_QIF
123             {
124 4     4 1 6081 my ($paystub, $config) = @_;
125              
126             my $net_deposit = $paystub->{totals}{ $config->{net_deposit}
127 4   50     29 // 'Net This Check'};
128 4         6 my @splits;
129              
130 4         16 _add_splits(\@splits, $paystub, $config->{income}, '');
131 4         12 _add_splits(\@splits, $paystub, $config->{expenses}, '-');
132              
133 4         9 my $sum = sprintf "%.2f", sum( map { $_->[0] } @splits);
  18         111  
134 4 50       23 croak("Sum of splits $sum != Net $net_deposit") unless $sum eq $net_deposit;
135              
136 4         12 my $qif = "D$paystub->{date}\n";
137              
138 4 100       17 $qif .= "N$paystub->{check_number}\n" if length $paystub->{check_number};
139              
140 4         8 my $company = $paystub->{company};
141 4         19 $company =~ s/\n/\nA/g; # Subsequent lines are address
142 4         11 $qif .= "P$company\n";
143              
144 4   66     20 my $memo = $config->{memo} // "Paycheck for $paystub->{pay_period}";
145 4 50       14 $qif .= "M$memo\n" if length $memo;
146              
147 4   50     19 $qif .= sprintf "T%s\nL%s\n", $net_deposit, $config->{category} // 'Income';
148              
149 4         8 for my $split (@splits) {
150 18         30 $qif .= "S$split->[1]\n";
151 18 100       47 $qif .= "E$split->[2]\n" if length $split->[2];
152 18         35 $qif .= "\$$split->[0]\n";
153             }
154              
155 4         34 $qif . "^\n";
156             } # end paystub_to_QIF
157              
158             #---------------------------------------------------------------------
159             sub _add_splits
160             {
161 8     8   16 my ($all_splits, $paystub, $config, $sign) = @_;
162              
163 8         11 my @splits;
164              
165 8         31 while (my ($section, $fields) = each %$config) {
166 8         11 while (my ($field, $values) = each %{ $paystub->{split}{$section} }) {
  28         110  
167              
168 20 100 50     84 next unless ($values->{$current} // 0) != 0;
169              
170             croak("Don't know what to do with $section: '$field'")
171 18 50       42 unless $fields->{$field};
172              
173 18         36 push @splits, [ $sign . $values->{$current}, @{ $fields->{$field} } ];
  18         56  
174             }
175             }
176              
177             # Sort splits in ascending order by category name, and
178             # descending order by absolute value within a category:
179 8 50       24 push @$all_splits, sort { $a->[1] cmp $b->[1] or
  12         56  
180             abs($b->[0]) <=> abs($a->[0]) } @splits;
181             } # end _add_splits
182              
183             #=====================================================================
184             # Package Return Value:
185              
186             1;
187              
188             __END__