File Coverage

blib/lib/Net/Xero.pm
Criterion Covered Total %
statement 21 23 91.3
branch n/a
condition n/a
subroutine 8 8 100.0
pod n/a
total 29 31 93.5


line stmt bran cond sub pod time code
1             package Net::Xero;
2             $Net::Xero::VERSION = '0.44';
3 1     1   13206 use 5.010;
  1         3  
4 1     1   426 use strictures 1;
  1         1091  
  1         29  
5 1     1   513 use Moo;
  1         9336  
  1         4  
6 1     1   1417 use Net::OAuth;
  1         536  
  1         25  
7 1     1   553 use LWP::UserAgent;
  1         30701  
  1         45  
8 1     1   408 use HTTP::Request::Common;
  1         1806  
  1         55  
9 1     1   487 use Data::Random qw(rand_chars);
  1         8126  
  1         64  
10 1     1   461 use XML::LibXML::Simple qw(XMLin);
  0            
  0            
11             use File::ShareDir 'dist_dir';
12             use Template;
13             use Crypt::OpenSSL::RSA;
14             use URI::Escape;
15             use Data::Dumper;
16             use IO::All;
17             no warnings 'experimental::smartmatch';
18              
19             $Net::OAuth::PROTOCOL_VERSION = Net::OAuth::PROTOCOL_VERSION_1_0A;
20              
21             =head1 NAME
22              
23             Net::Xero - Interface to Xero accounting
24              
25             =head1 VERSION
26              
27             Version 0.40
28              
29             =cut
30              
31             has 'api_url' => (
32             is => 'rw',
33             default => 'https://api.xero.com',
34             );
35              
36             has 'ua' => (
37             is => 'rw',
38             default => sub { LWP::UserAgent->new },
39             );
40              
41             has 'debug' => (
42             is => 'rw',
43             default => 0,
44             );
45              
46             has 'error' => (
47             is => 'rw',
48             predicate => 'has_error',
49             clearer => 'clear_error',
50             );
51              
52             has 'key' => (is => 'rw');
53             has 'secret' => (is => 'rw');
54             has 'cert' => (is => 'rw');
55              
56             has 'nonce' => (
57             is => 'ro',
58             default => join('', rand_chars(size => 16, set => 'alphanumeric')),
59             );
60              
61             has 'login_link' => (is => 'rw');
62              
63             has 'callback_url' => (
64             is => 'rw',
65             default => 'http://localhost:3000/callback',
66             );
67              
68             has 'request_token' => (is => 'rw');
69             has 'request_secret' => (is => 'rw');
70             has 'access_token' => (is => 'rw');
71             has 'access_secret' => (is => 'rw');
72              
73             has 'template_path' => (
74             is => 'rw',
75             default => ( dist_dir('Net-Xero') ),
76             );
77              
78             #has 'template_path' => (is => 'rw', isa => 'Str');
79              
80             =head1 SYNOPSIS
81              
82             Quick summary of what the module does.
83              
84             For a private application you will receive the access_token/secret when you
85             submit your X509 to xero. You can ignore login/auth in this instance as follows:
86             use Net::Xero;
87              
88             my $foo = Net::Xero->new(
89             access_token => 'YY',
90             access_secret => 'XX',
91             );
92              
93             =head1 EXPORT
94              
95             A list of functions that can be exported. You can delete this section
96             if you don't export anything, such as for a purely object-oriented module.
97              
98             =head1 FUNCTIONS
99              
100             =cut
101              
102             =head2 login
103              
104             This sets up the initial OAuth handshake and returns the login URL. This
105             URL has to be clicked by the user and the the user then has to accept
106             the application in xero.
107              
108             Xero then redirects back to the callback URL defined with
109             C<$self-Ecallback_url>. If the user already accepted the application the
110             redirect may happen without the user actually clicking anywhere.
111              
112             =cut
113              
114             sub login {
115             my $self = shift;
116              
117             my $request = Net::OAuth->request("request token")->new(
118             consumer_key => $self->key,
119             consumer_secret => $self->secret,
120             request_url => $self->api_url . '/oauth/RequestToken',
121             request_method => 'POST',
122             signature_method => 'RSA-SHA1',
123             timestamp => time,
124             nonce => $self->nonce,
125             callback => $self->callback_url,
126             );
127              
128             my $private_key = Crypt::OpenSSL::RSA->new_private_key($self->cert);
129             $request->sign($private_key);
130             my $res = $self->ua->request(GET $request->to_url);
131              
132             if ($res->is_success) {
133             my $response =
134             Net::OAuth->response('request token')
135             ->from_post_body($res->content);
136             $self->request_token($response->token);
137             $self->request_secret($response->token_secret);
138             print STDERR "Got Request Token ", $response->token, "\n"
139             if $self->debug;
140             print STDERR "Got Request Token Secret ", $response->token_secret, "\n"
141             if $self->debug;
142             return
143             $self->api_url
144             . '/oauth/Authorize?oauth_token='
145             . $response->token
146             . '&oauth_callback='
147             . $self->callback_url;
148             }
149             else {
150             $self->error($res->status_line);
151             warn "Something went wrong: " . $res->status_line;
152             }
153             }
154              
155             =head2 auth
156              
157             The auth method changes the initial request token into access token that we need
158             for subsequent access to the API. This method only has to be called once
159             after login.
160              
161             =cut
162              
163             sub auth {
164             my $self = shift;
165              
166             my $request = Net::OAuth->request("access token")->new(
167             consumer_key => $self->key,
168             consumer_secret => $self->secret,
169             request_url => $self->api_url . '/oauth/AccessToken',
170             request_method => 'POST',
171             signature_method => 'RSA-SHA1',
172             timestamp => time,
173             nonce => $self->nonce,
174             callback => $self->callback_url,
175             token => $self->request_token,
176             token_secret => $self->request_secret,
177             );
178             my $private_key = Crypt::OpenSSL::RSA->new_private_key($self->cert);
179             $request->sign($private_key);
180             my $res = $self->ua->request(GET $request->to_url);
181              
182             if ($res->is_success) {
183             my $response =
184             Net::OAuth->response('access token')->from_post_body($res->content);
185             $self->access_token($response->token);
186             $self->access_secret($response->token_secret);
187             print STDERR "Got Access Token ", $response->token, "\n"
188             if $self->debug;
189             print STDERR "Got Access Token Secret ", $response->token_secret, "\n"
190             if $self->debug;
191             }
192             else {
193             $self->error($res->status_line);
194             $self->error($res->status_line . "\n" . $res->content);
195             }
196             }
197              
198             =head2 set_cert
199              
200             =cut
201              
202             sub set_cert {
203             my ($self, $path) = @_;
204             my $cert = io $path;
205             $self->cert($cert->all);
206             }
207              
208             =head2 get_inv_by_ref
209              
210             =cut
211              
212             sub get_inv_by_ref {
213             my ($self, @ref) = @_;
214              
215             my $path = 'Invoices?where=Reference.ToString()=="' . (shift @ref) . '"';
216             $path .= ' OR Reference.ToString()=="' . $_ . '"' foreach (@ref);
217              
218             return $self->_talk($path, 'GET');
219             }
220              
221             =head2 get_invoices
222              
223             =cut
224              
225             sub get_invoices {
226             my ($self, $where) = @_;
227              
228             my $path = 'Invoices';
229              
230             return $self->_talk($path, 'GET') unless (ref $where eq 'HASH');
231              
232             $path .= '?where=';
233             my $conjunction =
234             (exists $where->{'conjunction'}) ? uc $where->{'conjunction'} : 'OR';
235             my $first = 1;
236              
237             foreach my $key (%{$where}) {
238             $path .= " $conjunction " unless $first;
239              
240             given ($key) {
241             when ('reference') {
242             my @refs = @{ $where->{$key} };
243             $path .= 'Reference.ToString()=="' . (shift @refs) . '"';
244             $path .= ' OR Reference.ToString()=="' . $_ . '"'
245             foreach (@refs);
246             }
247             when ('contact') {
248             my @contacts = @{ $where->{$key} };
249             my $contact = shift @contacts;
250             $path .= join(
251             ' AND ',
252             map {
253             "Contact."
254             . ucfirst($_) . '=="'
255             . $contact->{$_} . '"'
256             } keys %{$contact});
257              
258             # finish foreach
259             }
260             when ('number') {
261             my @numbers = @{ $where->{$key} };
262             $path .= ' OR InvoiceNumber.ToString()=="' . $_ . '"'
263             foreach (@numbers);
264             }
265             }
266              
267             $first = 0;
268             }
269              
270             return $self->_talk($path, 'GET');
271             }
272              
273             =head2 create_invoice
274              
275             =cut
276              
277             sub create_invoice {
278             my ($self, $hash) = @_;
279             $hash->{command} = 'create_invoice';
280             return $self->_talk('Invoices', 'POST', $hash);
281             }
282              
283             sub void_invoice {
284             my ($self, $guid) = @_;
285             my $hash = { guid => $guid } ;
286             $hash->{command} = 'void_invoice';
287             return $self->_talk('Invoices', 'POST', $hash );
288             }
289              
290             =head2 create_payment
291              
292             =cut
293              
294             sub create_payment {
295             my ($self, $data) = @_;
296             $data->{command} = 'payments';
297             return $self->_talk('Payments', 'POST', $data);
298             }
299              
300             =head2 create_contact
301              
302             =cut
303              
304             sub create_contact {
305             my ($self, $data) = @_;
306             $data->{command} = 'create_contact';
307             $data->{Contacts}->{Contact} = $data;
308             return $self->_talk('Contacts', 'POST', $data);
309             }
310              
311             =head2 approve_credit_note
312              
313             =cut
314              
315             sub approve_credit_note {
316             my ($self, $hash) = @_;
317             $hash->{command} = 'approve_credit_note';
318             return $self->_talk('CreditNotes', 'POST', $hash);
319             }
320              
321             =head2 status_invoice
322              
323             =cut
324              
325             sub status_invoice {
326             my ($self, $hash) = @_;
327             $hash->{command} = 'status_invoice';
328             return $self->_talk('Invoices', 'POST', $hash);
329             }
330              
331             =head2 get
332              
333             =cut
334              
335             sub get {
336             my ($self, $command) = @_;
337             return $self->_talk($command, 'GET');
338             }
339              
340             =head2 post
341              
342             =cut
343              
344             sub post {
345             my ($self, $command, $hash) = @_;
346             return $self->_talk($command, 'POST', $hash);
347             }
348              
349             =head2 put
350              
351             =cut
352              
353             sub put {
354             my ($self, $command, $hash) = @_;
355             return $self->_talk($command, 'PUT', $hash);
356             }
357              
358             =head1 INTERNAL API
359              
360             =head2 _talk
361              
362             _talk handles the access to the restricted resources. You should
363             normally not need to access this directly.
364              
365             =cut
366              
367             sub _talk {
368             my ($self, $command, $method, $hash) = @_;
369              
370             $self->clear_error;
371              
372             my $path = join('', map(ucfirst, split(/_/, $command)));
373              
374             my $request_url = $self->api_url . '/api.xro/2.0/' . $path;
375             my %opts = (
376             consumer_key => $self->key,
377             consumer_secret => $self->secret,
378             request_url => $request_url,
379             request_method => $method,
380             signature_method => 'RSA-SHA1',
381             timestamp => time,
382             nonce => join('', rand_chars(size => 16, set => 'alphanumeric')),
383             token => $self->access_token,
384             token_secret => $self->access_secret,
385             );
386              
387             my $content;
388             if ($method =~ m/^(POST|PUT)$/) {
389             $hash->{command} ||= $command;
390             $content = $self->_template($hash);
391             $opts{extra_params} = { xml => $content } if ($method eq 'POST');
392             }
393              
394             my $request = Net::OAuth->request("protected resource")->new(%opts);
395             my $private_key = Crypt::OpenSSL::RSA->new_private_key($self->cert);
396             $request->sign($private_key);
397             #my $req = HTTP::Request->new($method, $request->to_url);
398             my $req = HTTP::Request->new($method, $request_url);
399             if ($hash and ($method eq 'POST')) {
400             $req->content($request->to_post_body);
401             $req->header('Content-Type' =>
402             'application/x-www-form-urlencoded; charset=utf-8');
403             }
404             else {
405             $req->content($content) if ($hash and ($method eq 'PUT'));
406             $req->header(Authorization => $request->to_authorization_header);
407             }
408              
409             print STDERR $req->as_string if $self->debug;
410              
411             my $res = $self->ua->request($req);
412              
413             if ($res->is_success) {
414             print STDERR "Got Content ", $res->content, "\n" if $self->debug;
415             return XMLin($res->content);
416             }
417             else {
418             warn "Something went wrong: " . $res->content;
419             $self->error($res->status_line . " " . $res->content);
420             }
421              
422             return;
423             }
424              
425             =head2 _template
426              
427             =cut
428              
429             sub _template {
430             my ($self, $hash) = @_;
431              
432             $hash->{command} .= '.tt';
433             print STDERR Dumper($hash) if $self->debug;
434             my $tt;
435             if ($self->debug) {
436             $tt = Template->new(
437             #DEBUG => 'all',
438             INCLUDE_PATH => [ $self->template_path ],
439             );
440             }
441             else {
442             $tt = Template->new(INCLUDE_PATH => [ $self->template_path ]);
443             }
444              
445             my $template = '';
446             $tt->process('frame.tt', $hash, \$template)
447             || die $tt->error;
448             utf8::encode($template);
449             print STDERR $template if $self->debug;
450              
451             return $template;
452             }
453              
454             =head1 AUTHOR
455              
456             Lenz Gschwendtner, C<< >>
457              
458             =head1 BUGS
459              
460             Please report any bugs or feature requests to C, or through
461             the web interface at L. I will be notified, and then you'll
462             automatically be notified of progress on your bug as I make changes.
463              
464              
465              
466              
467             =head1 SUPPORT
468              
469             You can find documentation for this module with the perldoc command.
470              
471             perldoc Net::Xero
472              
473              
474             You can also look for information at:
475              
476             =over 4
477              
478             =item * RT: CPAN's request tracker
479              
480             L
481              
482             =item * AnnoCPAN: Annotated CPAN documentation
483              
484             L
485              
486             =item * CPAN Ratings
487              
488             L
489              
490             =item * Search CPAN
491              
492             L
493              
494             =back
495              
496              
497             =head1 ACKNOWLEDGEMENTS
498              
499              
500             =head1 COPYRIGHT & LICENSE
501              
502             Copyright 2010 Lenz Gschwendtner.
503              
504             This program is free software; you can redistribute it and/or modify it
505             under the terms of either: the GNU General Public License as published
506             by the Free Software Foundation; or the Artistic License.
507              
508             See http://dev.perl.org/licenses/ for more information.
509              
510              
511             =cut
512              
513             __PACKAGE__->meta->make_immutable();