File Coverage

blib/lib/Finance/Bank/Schwab.pm
Criterion Covered Total %
statement 24 124 19.3
branch 0 50 0.0
condition n/a
subroutine 8 24 33.3
pod 1 12 8.3
total 33 210 15.7


line stmt bran cond sub pod time code
1             package Finance::Bank::Schwab;
2              
3             # ABSTRACT: Check your account balances at Charles Schwab
4              
5 2     2   51079 use strict;
  2         4  
  2         71  
6 2     2   10 use warnings;
  2         4  
  2         51  
7              
8 2     2   125 use Carp;
  2         8  
  2         157  
9 2     2   2526 use WWW::Mechanize;
  2         407196  
  2         86  
10 2     2   2894 use HTML::TableExtract;
  2         19496  
  2         16  
11              
12             our $VERSION = '2.03';
13              
14             our $ua = WWW::Mechanize->new(
15             env_proxy => 1,
16             keep_alive => 1,
17             timeout => 30,
18             cookie_jar => {},
19             );
20              
21             # Debug logging:
22             # $ua->default_header( 'Accept-Encoding' => scalar HTTP::Message::decodable() );
23             # $ua->add_handler( "request_send", sub { shift->dump; return } );
24             # $ua->add_handler( "response_done", sub { shift->dump; return } );
25              
26             sub check_balance {
27 0     0 1   my ( $class, %opts ) = @_;
28              
29 0           my $content = retrieve_summary_page(%opts);
30              
31 0           my $te = HTML::TableExtract->new(
32             headers => [
33             'Account', 'Name',
34             '(?:Value|Available\s+Balance)', '(?:Cash|Balance\sOwed)'
35             ],
36             keep_html => 1,
37             ## decode => 0,
38             );
39              
40             { # HTML::TableExtract warns about undef value with keep_html option
41 0           $SIG{__WARN__} = sub {
42 0 0   0     warn @_ unless $_[0] =~ /uninitialized value in subroutine entry/;
43 0           };
44 0           $te->parse($content);
45             }
46              
47 0           my @accounts;
48 0           for my $ts ( $te->tables ) {
49              
50             # print "Table (", join( ',', $ts->coords ), "):\n";
51              
52 0           for my $row ( $ts->rows ) {
53 0 0         next if $row->[1] =~ /Totals/; # Skip total rows
54              
55             ## strip_superscript( @$row[0..3] );
56 0           strip_html( @$row[ 0 .. 3 ] );
57 0           trim_whitespace(@$row);
58 0           remove_currency_symbol( @$row[ 2 .. 3 ] );
59 0           $row->[0] =~ s{^([\d.-]+).*$}{$1}s; # Strip all but num from name
60              
61             # If this is an account with positions, go grab that data.
62             # If not it is probably a bank account, ignore for now.
63 0 0         my @positions =
64             ( $row->[0] =~ /\d{4}-\d{4}/ )
65             ? get_positions( $row->[0], %opts )
66             : ();
67              
68 0           push @accounts, (
69             bless {
70             cash => $row->[3],
71             balance => $row->[2],
72             name => $row->[1],
73             sort_code => $row->[1],
74             account_no => $row->[0],
75             statement => undef,
76             positions => \@positions,
77             ## parent => $self,
78             },
79             "Finance::Bank::Schwab::Account"
80             );
81              
82             # print join( ',', @$row ), "\n";
83             }
84             }
85              
86 0           return @accounts;
87             }
88              
89             sub retrieve_summary_page {
90 0     0 0   my (%opts) = @_;
91              
92             # Use the stored page content if requested
93 0 0         return slurp( $opts{content} ) if $opts{content};
94              
95 0 0         croak "Must provide a password" unless exists $opts{password};
96 0 0         croak "Must provide a username" unless exists $opts{username};
97              
98             # Get the login page
99 0 0         $ua->get('https://client.schwab.com/Login/SignOn/CustomerCenterLogin.aspx')
100             or croak "couldn't load inital page";
101              
102             # Find the login form, change the action url, then set the username/
103             # password and submit
104 0 0         my $login_form = $ua->form_name('aspnetForm')
105             or croak "Couldn't find the login form";
106 0 0         $login_form->action('https://client.schwab.com/Login/SignOn/signon.ashx')
107             or croak "Couldn't update the action url on login form";
108 0           my $username_field =
109             'ctl00$WebPartManager1$CenterLogin$LoginUserControlId$txtLoginID';
110 0           $login_form->value( $username_field => $opts{username} );
111 0           $login_form->value( 'txtPassword' => $opts{password} );
112 0 0         $ua->submit() or croak "couldn't sign on to account";
113              
114 0           my $content = $ua->content;
115              
116             # Dump to the filename passed in log
117 0 0         spew( $opts{log}, $content ) if $opts{log};
118              
119 0           return $content;
120             }
121              
122             sub get_positions {
123 0     0 0   my ( $acct, %opts ) = @_;
124              
125             # Only retrieve positions if requested
126 0 0         return () unless $opts{get_positions};
127              
128 0           $acct =~ s/-//;
129              
130 0           my $content = retrieve_account_page( $acct, %opts );
131 0           my @positions = retrieve_account_positions($content);
132             }
133              
134             sub retrieve_account_page {
135 0     0 0   my ( $acct, %opts ) = @_;
136              
137             # Used the saved data if content was supplied
138 0 0         return slurp("$opts{content}.$acct") if $opts{content};
139              
140             # Grab the data from the Schwab site
141 0 0         $ua->get(
142             "https://client.schwab.com/Accounts/Positions/AccountPositionsSummary.aspx?selAcct=$acct"
143             ) or croak "couldn't load position page for $acct";
144 0           my $content = $ua->content;
145              
146             # Dump the page to a log file if the log filename was provided
147 0 0         spew( "$opts{log}.$acct", $content ) if $opts{log};
148             }
149              
150             sub retrieve_account_positions {
151 0     0 0   my ($content) = @_;
152              
153 0           my $te = HTML::TableExtract->new(
154             headers => [ 'Symbol', 'Quantity', 'Price', 'Change' ],
155             keep_html => 1,
156             ## decode => 0,
157             );
158              
159             { # HTML::TableExtract warns about undef value with keep_html option
160 0           $SIG{__WARN__} = sub {
161 0 0   0     warn @_
162             unless $_[0] =~ /uninitialized value in subroutine entry/;
163 0           };
164 0           $te->parse($content);
165             }
166              
167 0           my @positions;
168 0           for my $ts ( $te->tables ) {
169              
170             # print "Table (", join( ',', $ts->coords ), "):\n";
171 2     2   3733 no warnings 'uninitialized';
  2         3  
  2         1411  
172              
173 0           for my $row ( $ts->rows ) {
174              
175 0 0         next if $row->[2] eq ''; # Skip empty rows
176 0 0         next if $row->[0] =~ /Total/; # Skip total rows
177              
178 0           strip_superscript( @$row[ 0 .. 3 ] );
179 0           strip_html( @$row[ 0 .. 3 ] );
180 0           trim_whitespace(@$row);
181 0           remove_commas( $row->[1] );
182 0           remove_currency_symbol( @$row[ 2, 3 ] );
183              
184             # Note if these are stocks/bonds/cash/unknown
185 0           my $type;
186 0           SWITCH: {
187 0           local $_ = $row->[0];
188 0 0         m/SymbolRouting/ and $type = 'Stock', last;
189 0 0         m/TradeBondSuperPopUp/ and $type = 'Bond', last;
190 0 0         m/Cash/ and $type = 'Cash', last;
191 0           $type = 'Unknown';
192             }
193              
194             # The "Cash & Cash Investments" line is screwy, where the value is
195             # in the "Change" column. Let's correct it and set "price" to be 1,
196             # and "shares" be value.
197 0 0         if ( $row->[0] =~ m/Cash/ ) {
198 0           $row->[0] = 'Cash'; # Trim "& Cash Investments"
199 0           $row->[1] = $row->[3];
200 0           $row->[2] = 1;
201             }
202              
203             # The Bond types use funny math, where bond prices are shown per
204             # 100 shares. Correction is to divide price or quantity by 100. I
205             # elect price.
206 0 0         if ( $type =~ m/Bond/ ) {
207 0           $row->[2] = $row->[2] / 100;
208             }
209              
210 0           push @positions,
211             bless {
212             symbol => $row->[0],
213             quantity => $row->[1],
214             price => $row->[2],
215             type => $type,
216             },
217             'Finance::Bank::Schwab::Account::Positions';
218             }
219             }
220 0           return @positions;
221              
222             }
223              
224             sub strip_html {
225              
226             # Simple regex to strip html from cells. Not the best practice, but this is
227             # certainly not the most fragile part of this module.
228 0     0 0   s{<[^>]*>}{}mg for grep { defined } @_;
  0            
229             }
230              
231             sub trim_whitespace {
232 0     0 0   s{^\s*|\s*$}{}g for grep { defined } @_;
  0            
233             }
234              
235             sub remove_commas {
236 0     0 0   s/[,]//xg for grep { defined } @_;
  0            
237             }
238              
239             sub remove_currency_symbol {
240 0     0 0   s/[\$,]//xg for grep { defined } @_;
  0            
241             }
242              
243             sub strip_superscript {
244 0     0 0   s{]*>[^<]*}{}mg for grep { defined } @_;
  0            
245             }
246              
247             sub spew {
248 0     0 0   my ( $filename, $content ) = @_;
249              
250 0 0         open( my $fh, ">", $filename ) or confess;
251 0           print $fh $content;
252 0           close $fh;
253             }
254              
255             sub slurp {
256 0     0 0   my ($filename) = @_;
257              
258 0 0         open my $fh, "<", $filename or confess;
259 0           my $content = do { local $/; <$fh> };
  0            
  0            
260 0           close $fh;
261              
262 0           return $content;
263             }
264              
265             package Finance::Bank::Schwab::Account;
266              
267             # Basic OO smoke-and-mirrors Thingy
268 2     2   11 no strict;
  2         3  
  2         304  
269              
270             sub AUTOLOAD {
271 0     0     my $self = shift;
272 0           $AUTOLOAD =~ s/.*:://x;
273 0           return $self->{$AUTOLOAD};
274             }
275              
276             package Finance::Bank::Schwab::Account::Positions;
277              
278             # Basic OO smoke-and-mirrors Thingy
279 2     2   11 no strict;
  2         2  
  2         176  
280              
281             sub AUTOLOAD {
282 0     0     my $self = shift;
283 0           $AUTOLOAD =~ s/.*:://x;
284 0           return $self->{$AUTOLOAD};
285             }
286              
287             1;
288              
289             __END__