File Coverage

blib/lib/Mojolicious/Plugin/PayPal.pm
Criterion Covered Total %
statement 165 182 90.6
branch 28 50 56.0
condition 14 34 41.1
subroutine 31 32 96.8
pod 3 3 100.0
total 241 301 80.0


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