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