File Coverage

blib/lib/Net/Xero.pm
Criterion Covered Total %
statement 22 24 91.6
branch n/a
condition n/a
subroutine 8 8 100.0
pod n/a
total 30 32 93.7


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