File Coverage

blib/lib/Mojolicious/Plugin/PayPal.pm
Criterion Covered Total %
statement 165 182 90.6
branch 28 50 56.0
condition 15 37 40.5
subroutine 31 32 96.8
pod 3 3 100.0
total 242 304 79.6


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::PayPal;
2 2     2   1187 use Mojo::Base 'Mojolicious::Plugin';
  2         5  
  2         13  
3 2     2   352 use Mojo::JSON 'j';
  2         5  
  2         81  
4 2     2   10 use Mojo::UserAgent;
  2         4  
  2         22  
5 2   50 2   63 use constant DEBUG => $ENV{MOJO_PAYPAL_DEBUG} || 0;
  2         4  
  2         4062  
6              
7              
8             our $VERSION = '0.08';
9              
10             has base_url => 'https://api.sandbox.paypal.com';
11             has client_id => 'dummy_client';
12             has currency_code => 'USD';
13             has transaction_id_mapper => undef;
14             has secret => 'dummy_secret';
15             has _ua => sub { Mojo::UserAgent->new; };
16              
17             sub process_payment {
18 1     1 1 4 my ($self, $c, $args, $cb) = @_;
19 1         2 my %body;
20              
21 1 50 33     14 $args->{cancel} //= $c->param('return_url') ? 0 : 1;
22 1 50 33     401 $args->{token} ||= $c->param('token')
23             or return $self->$cb($self->_error('token missing in input'));
24 1 50 33     69 $args->{payer_id} ||= $c->param('PayerID')
25             or return $self->$cb($self->_error('PayerID missing in input'));
26              
27 1         95 %body = (payer_id => $args->{payer_id});
28              
29             $c->delay(
30             sub {
31 1     1   44 my ($delay) = @_;
32 1         24 $self->transaction_id_mapper->($self, $args->{token}, undef, $delay->begin);
33             },
34             sub {
35 1     1   80 my ($delay, $err, $transaction_id) = @_;
36 1 50       3 return $self->$cb($self->_error($err)) if $err;
37 1         6 my $url = $self->_url("/v1/payments/payment/$transaction_id/execute");
38 1         5 $delay->pass($transaction_id);
39 1         24 $self->_make_request_with_token($c, post => $url, j(\%body), $delay->begin);
40             },
41             sub {
42 1     1   26 my ($delay, $transaction_id, $tx) = @_;
43 1         3 my $res = Mojolicious::Plugin::PayPal::Res->new($tx->res);
44              
45 1 50       23 $res->code(0) unless $res->code;
46 1         9 $res->param(transaction_id => $transaction_id);
47              
48 1 50       111 if ($args->{cancel}) {
49 0         0 $res->param(message => 'Payment cancelled.');
50 0         0 $res->param(source => $self->base_url);
51 0         0 $res->code(205);
52 0         0 return $self->$cb($res);
53             }
54              
55 1         3 local $@;
56             eval {
57 1         4 my $json = $res->json;
58 1         1643 my $token;
59              
60 1 50       5 $json->{id} or die 'No transaction ID in response from PayPal';
61 1 50       5 $json->{state} eq 'approved' or die $json->{state};
62              
63 1 50       2 while (my ($key, $value) = each %{$json->{payer}{payer_info} || {}}) {
  5         189  
64 4         15 $res->param("payer_$key" => $value);
65             }
66              
67 1         4 $res->param(payer_id => $args->{payer_id});
68 1         45 $res->param(state => $json->{state});
69 1         43 $res->param(transaction_id => $json->{id});
70 1         46 $res->code(200);
71 1         10 $self->$cb($res);
72 1         930 1;
73 1 50       2 } or do {
74 0         0 warn "[MOJO_PAYPAL] ! $@" if DEBUG;
75 0         0 $self->$cb($self->_extract_error($res, $@));
76             };
77             },
78 1         19 );
79              
80 1         180 $self;
81             }
82              
83             sub register_payment {
84 2     2 1 6 my ($self, $c, $args, $cb) = @_;
85 2         8 my $register_url = $self->_url('/v1/payments/payment');
86 2   33     16 my $redirect_url = Mojo::URL->new($args->{redirect_url} ||= $c->req->url->to_abs);
87 2         1150 my %body;
88              
89 2 100       16 $args->{amount} or return $self->$cb($self->_error('amount missing in input'));
90              
91             %body = (
92             intent => 'sale',
93             redirect_urls => {
94             return_url => $redirect_url->query(return_url => 1)->to_abs,
95             cancel_url => $redirect_url->to_abs,
96             },
97             payer => {payment_method => 'paypal',},
98             transactions => [
99             {
100             description => $args->{description} || '',
101             amount =>
102 1   50     5 {total => $args->{amount}, currency => $args->{currency_code} || $self->currency_code,},
      33        
103             },
104             ],
105             );
106              
107             $c->delay(
108             sub {
109 1     1   48 my ($delay) = @_;
110 1         4 $self->_make_request_with_token($c, post => $register_url, j(\%body), $delay->begin);
111             },
112             sub {
113 1     1   25 my ($delay, $tx) = @_;
114 1         5 my $res = Mojolicious::Plugin::PayPal::Res->new($tx->res);
115              
116 1 50       23 $res->code(0) unless $res->code;
117              
118 1         7 local $@;
119             eval {
120 1         9 my $json = $res->json;
121 1         933 my $token;
122              
123 1 50       5 $json->{id} or die 'No transaction ID in response from PayPal';
124 1 50       5 $json->{state} eq 'created' or die $json->{state};
125              
126 1         2 for my $link (@{$json->{links}}) {
  1         4  
127 3         170 my $key = "$link->{rel}_url";
128 3         11 $key =~ s!_url_url$!_url!;
129 3         10 $res->param($key => $link->{href});
130             }
131              
132 1         42 $token = Mojo::URL->new($res->param('approval_url'))->query->param('token');
133              
134 1         272 $res->param(state => $json->{state});
135 1         46 $res->param(transaction_id => $json->{id});
136 1         41 $res->headers->location($res->param('approval_url'));
137 1         37 $res->code(302);
138 1         9 $delay->pass($res);
139 1         26 $self->transaction_id_mapper->($self, $token => $json->{id}, $delay->begin);
140 1         15 1;
141 1 50       2 } or do {
142 0         0 warn "[MOJO_PAYPAL] ! $@" if DEBUG;
143 0         0 $delay->pass($self->_extract_error($res, $@));
144             };
145             },
146             sub {
147 1     1   140 my ($delay, $res, $err, $id) = @_;
148              
149 1 50       3 return $self->$cb($self->_error($err)) if $err;
150 1         4 return $self->$cb($res);
151             },
152 1         216 );
153              
154 1         193 $self;
155             }
156              
157             sub register {
158 2     2 1 76 my ($self, $app, $config) = @_;
159              
160             # self contained
161 2 100       14 if (ref $config->{secret}) {
    50          
162 1         4 $self->_add_routes($app);
163 1         15 $self->_ua->server->app($app);
164 1         51 $config->{secret} = ${$config->{secret}};
  1         3  
165             }
166             elsif ($app->mode eq 'production') {
167 1   50     20 $config->{base_url} ||= 'https://api.paypal.com';
168             }
169              
170             # copy config to this object
171 2         6 for (grep { $self->$_ } keys %$config) {
  2         8  
172 2         18 $self->{$_} = $config->{$_};
173             }
174              
175 2 50       12 unless ($self->transaction_id_mapper) {
176             $self->transaction_id_mapper(
177             sub {
178 2     2   29 my ($self, $token, $transaction_id, $cb) = @_;
179 2         8 $app->log->warn("You need to set 'transaction_id_mapper' in Mojolicious::Plugin::PayPal");
180 2   66     65 $self->$cb('', $self->{transaction_id_map}{$token} //= $transaction_id);
181             }
182 2         17 );
183             }
184              
185             $app->helper(
186             paypal => sub {
187 5     5   45760 my $c = shift;
188 5 100       22 return $self unless @_;
189 3         15 my $method = sprintf '%s_payment', shift;
190 3         17 $self->$method($c, @_);
191 3         25 return $c;
192             }
193 2         29 );
194             }
195              
196             sub _add_routes {
197 1     1   2 my ($self, $app) = @_;
198 1         7 my $r = $app->routes;
199             my $payments = $self->{payments}
200 1   50     12 ||= {}; # just here for debug purposes, may change without warning
201              
202 1         4 $self->base_url('/paypal');
203              
204 1         16 $r->post('/paypal/v1/oauth2/token' => {template => 'paypal/v1/oauth2/token', format => 'json'});
205              
206             $r->post(
207             '/paypal/v1/payments/payment' => sub {
208 1     1   3852 my $self = shift;
209 1         3 my $token = 'EC-60U79048BN7719609';
210 1         6 $payments->{$token} = $self->req->json;
211 1         487 $self->render('paypal/v1/payments/payment', token => $token, format => 'json');
212             }
213 1         407 );
214              
215             $r->get('/paypal/webscr')->to(
216             cb => sub {
217 1     1   12941 my $self = shift;
218 1   50     5 my $token = $self->param('token') || 'missing';
219 1         382 $payments->{CR87QHB7JTRSC} = $payments->{$token}; # payer_id = CR87QHB7JTRSC
220 1         6 $self->render('paypal/webscr', format => 'html', payment => $payments->{$token});
221             }
222 1         314 );
223              
224             $r->post('/paypal/v1/payments/payment/:transaction_id/execute')->to(
225             cb => sub {
226 1     1   3622 my $self = shift;
227 1   50     5 my $payer_id = $self->req->json->{payer_id} || 'missing';
228             $self->render(
229             'paypal/v1/payments/payment/execute',
230 1         163 payment => $payments->{$payer_id},
231             format => 'json'
232             );
233             }
234 1         255 );
235              
236 1         470 push @{$app->renderer->classes}, __PACKAGE__;
  1         7  
237             }
238              
239             sub _error {
240 1     1   4 my ($self, $err) = @_;
241 1         13 my $res = Mojolicious::Plugin::PayPal::Res->new;
242 1         10 $res->code(400);
243 1         19 $res->param(message => $err);
244 1         132 $res->param(source => __PACKAGE__);
245 1         43 $res;
246             }
247              
248             sub _extract_error {
249 0     0   0 my ($self, $res, $e) = @_;
250 0         0 my $err = ''; # TODO
251              
252 0         0 $res->code(500);
253 0   0     0 $res->param(message => $err // $e);
254 0 0       0 $res->param(source => $err ? $self->base_url : __PACKAGE__);
255 0         0 $res;
256             }
257              
258             sub _get_access_token {
259 1     1   11 my ($self, $c, $cb) = @_;
260 1         3 my $token_url = $self->_url('/v1/oauth2/token');
261 1         5 my %headers = ('Accept' => 'application/json', 'Accept-Language' => 'en_US');
262              
263 1         5 $token_url->userinfo(join ':', $self->client_id, $self->secret);
264 1         15 warn "[MOJO_PAYPAL] Token URL $token_url\n" if DEBUG == 2;
265              
266             $c->delay(
267             sub {
268 1     1   169 my ($delay) = @_;
269 1         5 $self->_ua->post(
270             $token_url, \%headers,
271             form => {grant_type => 'client_credentials'},
272             $delay->begin
273             );
274             },
275             sub {
276 1     1   16159 my ($delay, $tx) = @_;
277 1   50     3 my $json = eval { $tx->res->json } || {};
278              
279 1   50     327 $json->{access_token} //= '';
280 1         8 $self->$cb($self->{access_token} = $json->{access_token}, $tx);
281             },
282 1         10 );
283             }
284              
285             # https://developer.paypal.com/webapps/developer/docs/integration/direct/make-your-first-call/
286             sub _make_request_with_token {
287 2     2   1229 my ($self, $c, $method, $url, $body, $cb) = @_;
288 2         8 my %headers = ('Content-Type' => 'application/json');
289              
290             $c->delay(
291             sub { # get token unless we have it
292 2     2   360 my ($delay) = @_;
293 2 100       10 return $delay->pass($self->{access_token}, undef) if $self->{access_token};
294 1         4 return $self->_get_access_token($c, $delay->begin);
295             },
296             sub { # abort or make request with token
297 2     2   109 my ($delay, $token, $tx) = @_;
298 2 50       7 return $self->$cb($tx) unless $token;
299 2         7 $headers{Authorization} = "Bearer $token";
300 2         4 warn "[MOJO_PAYPAL] Authorization: Bearer $token\n" if DEBUG;
301 2         9 return $self->_ua->$method($url, \%headers, $body, $delay->begin);
302             },
303             sub { # get token if it has expired
304 2     2   11545 my ($delay, $tx) = @_;
305 2 50       8 return $self->_get_access_token($c, $delay->begin) if $tx->res->code == 401;
306 2         22 return $delay->pass(undef, $tx); # success
307             },
308             sub { # return or retry request with new token
309 2     2   428 my ($delay, $token, $tx) = @_;
310 2 50       11 return $self->$cb($tx) unless $token; # return success or error $tx
311 0         0 $headers{Authorization} = "Bearer $token";
312 0         0 warn "[MOJO_PAYPAL] Authorization: Bearer $token\n" if DEBUG;
313 0         0 return $self->_ua->$method($url, \%headers, $body, $cb);
314             },
315 2         31 );
316             }
317              
318             sub _url {
319 4     4   17 my $url = Mojo::URL->new($_[0]->base_url . $_[1]);
320 4         221 warn "[MOJO_PAYPAL] URL $url\n" if DEBUG;
321 4         7 $url;
322             }
323              
324             {
325              
326             package Mojolicious::Plugin::PayPal::Res;
327 2     2   17 use Mojo::Base 'Mojo::Message::Response';
  2         4  
  2         9  
328 27     27   446 sub param { shift->body_params->param(@_) }
329             }
330              
331             package Mojolicious::Plugin::PayPal;
332              
333             1;
334              
335             =encoding utf8
336              
337             =head1 NAME
338              
339             Mojolicious::Plugin::PayPal - Make payments using PayPal
340              
341             =head1 VERSION
342              
343             0.08
344              
345             =head1 DESCRIPTION
346              
347             L is a plugin for the L web
348             framework which allow you to do payments using L.
349              
350             This module is EXPERIMENTAL. The API can change at any time. Let me know
351             if you are using it.
352              
353             See also L.
354              
355             =head1 SYNOPSIS
356              
357             use Mojolicious::Lite;
358              
359             plugin PayPal => {
360             secret => '...',
361             client_id => '...',
362             };
363              
364             # register a payment and send the visitor to PayPal payment terminal
365             post '/checkout' => sub {
366             my $c = shift;
367             my %payment = (
368             amount => $c->param('amount'),
369             description => 'Some description',
370             );
371              
372             $c->delay(
373             sub {
374             my ($delay) = @_;
375             $c->paypal(register => \%payment, $delay->begin);
376             },
377             sub {
378             my ($delay, $res) = @_;
379             return $c->render(text => "Ooops!", status => $res->code) unless $res->code == 302;
380             # store $res->param('transaction_id');
381             $c->redirect_to($res->headers->location);
382             },
383             );
384             };
385              
386             # after redirected back from PayPal payment terminal
387             get '/checkout' => sub {
388             my $c = shift;
389              
390             $c->delay(
391             sub {
392             my ($delay) = @_;
393             $c->paypal(process => {}, $delay->begin);
394             },
395             sub {
396             my ($delay, $res) = @_;
397             return $c->render(text => $res->param("message"), status => $res->code) unless $res->code == 200;
398             return $c->render(text => "yay!");
399             },
400             );
401             };
402              
403              
404             =head2 Transaction ID mapper
405              
406             You should provide a L. Here is an example code on how to do that:
407              
408             $app->paypal->transaction_id_mapper(sub {
409             my ($self, $token, $transaction_id, $cb) = @_;
410              
411             if($transaction_id) {
412             eval { My::DB->store_transaction_id($token => $transaction_id); };
413             $self->$cb($@, $transaction_id);
414             }
415             else {
416             my $transaction_id = eval { My::DB->get_transaction_id($token)); };
417             $self->$cb($@, $transaction_id);
418             }
419             });
420              
421             =head1 ATTRIBUTES
422              
423             =head2 base_url
424              
425             $str = $self->base_url;
426              
427             This is the location to PayPal payment solution. Will be set to
428             L if the mojolicious application mode is
429             "production" or L.
430              
431             =head2 client_id
432              
433             $str = $self->client_id;
434              
435             The value used as username when fetching the the access token.
436             This can be found in "Applications tab" in the PayPal Developer site.
437              
438             =head2 currency_code
439              
440             $str = $self->currency_code;
441              
442             The currency code. Default is "USD".
443              
444             =head2 transaction_id_mapper
445              
446             $code = $self->transaction_id_mapper;
447              
448             Holds a code used to find the transaction ID, after user has been redirected
449             back from PayPal terminal page.
450              
451             NOTE! The default callback provided by this module does not scale and will
452             not work in a multi-process environment, such as running under C
453             or using a load balancer. You should therefor provide your own backend
454             solution. See L for example code.
455              
456             =head2 secret
457              
458             $str = $self->secret;
459              
460             The value used as password when fetching the the access token.
461             This can be found in "Applications tab" in the PayPal Developer site.
462              
463             =head1 HELPERS
464              
465             =head2 paypal
466              
467             $self = $c->paypal;
468             $c = $c->paypal($method => @args);
469              
470             Returns this instance unless any args have been given or calls one of the
471             available L instead. C<$method> need to be without "_payment" at
472             the end. Example:
473              
474             $c->paypal(register => { ... }, sub {
475             my ($c, $res) = @_;
476             # ...
477             });
478              
479             =head1 METHODS
480              
481             =head2 process_payment
482              
483             $self = $self->process_payment(
484             $c,
485             {
486             token => $str, # default to $c->param("token")
487             payer_id => $str, # default to $c->param("PayerID")
488             },
489             sub {
490             my ($self, $res) = @_;
491             },
492             );
493              
494             This is used to process the payment after a user has been redirected back
495             from the PayPal terminal.
496              
497             See L
498             for details.
499              
500             =head2 register_payment
501              
502             $self = $self->register_payment(
503             $c,
504             {
505             amount => $num, # 99.90, not 9990
506             redirect_url => $str, # default to current request URL
507             # ...
508             },
509             sub {
510             my ($self, $res) = @_;
511             },
512             );
513              
514             The L method is used to send the required payment details
515             to PayPal which will later be approved by the user after being redirected
516             to the PayPal terminal page.
517              
518             Useful C<$res> values:
519              
520             =over 4
521              
522             =item * $res->code
523              
524             Set to 302 on success.
525              
526             =item * $res->param("transaction_id")
527              
528             Only set on success. An ID identifying this transaction. Generated by PayPal.
529              
530             =item * $res->headers->location
531              
532             Only set on success. This holds a URL to the PayPal terminal page, which
533             you will redirect the user to after storing the transaction ID and other
534             customer related details.
535              
536             =back
537              
538             =head2 register
539              
540             $app->plugin(PayPal => \%config);
541              
542             Called when registering this plugin in the main L application.
543              
544             =head1 COPYRIGHT AND LICENSE
545              
546             Copyright (C) 2014, Jan Henning Thorsen
547              
548             This program is free software, you can redistribute it and/or modify it under
549             the terms of the Artistic License version 2.0.
550              
551             =head1 AUTHOR
552              
553             Jan Henning Thorsen - C
554              
555             =head1 CONTRIBUTORS
556              
557             Yu Pan - C
558              
559             =cut
560              
561             __DATA__