File Coverage

blib/lib/Mojolicious/Plugin/PayPal.pm
Criterion Covered Total %
statement 12 12 100.0
branch n/a
condition 1 2 50.0
subroutine 4 4 100.0
pod n/a
total 17 18 94.4


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::PayPal;
2 2     2   1454 use Mojo::Base 'Mojolicious::Plugin';
  2         8  
  2         18  
3 2     2   463 use Mojo::JSON 'j';
  2         5  
  2         145  
4 2     2   16 use Mojo::UserAgent;
  2         5  
  2         26  
5 2   50 2   96 use constant DEBUG => $ENV{MOJO_PAYPAL_DEBUG} || 0;
  2         4  
  2         5031  
6              
7              
8             our $VERSION = '0.07';
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             my ($self, $c, $args, $cb) = @_;
19             my %body;
20              
21             $args->{cancel} //= $c->param('return_url') ? 0 : 1;
22             $args->{token} ||= $c->param('token')
23             or return $self->$cb($self->_error('token missing in input'));
24             $args->{payer_id} ||= $c->param('PayerID')
25             or return $self->$cb($self->_error('PayerID missing in input'));
26              
27             %body = (payer_id => $args->{payer_id});
28              
29             $c->delay(
30             sub {
31             my ($delay) = @_;
32             $self->transaction_id_mapper->($self, $args->{token}, undef, $delay->begin);
33             },
34             sub {
35             my ($delay, $err, $transaction_id) = @_;
36             return $self->$cb($self->_error($err)) if $err;
37             my $url = $self->_url("/v1/payments/payment/$transaction_id/execute");
38             $delay->pass($transaction_id);
39             $self->_make_request_with_token($c, post => $url, j(\%body), $delay->begin);
40             },
41             sub {
42             my ($delay, $transaction_id, $tx) = @_;
43             my $res = Mojolicious::Plugin::PayPal::Res->new($tx->res);
44              
45             $res->code(0) unless $res->code;
46             $res->param(transaction_id => $transaction_id);
47              
48             if ($args->{cancel}) {
49             $res->param(message => 'Payment cancelled.');
50             $res->param(source => $self->base_url);
51             $res->code(205);
52             return $self->$cb($res);
53             }
54              
55             local $@;
56             eval {
57             my $json = $res->json;
58             my $token;
59              
60             $json->{id} or die 'No transaction ID in response from PayPal';
61             $json->{state} eq 'approved' or die $json->{state};
62              
63             while (my ($key, $value) = each %{$json->{payer}{payer_info} || {}}) {
64             $res->param("payer_$key" => $value);
65             }
66              
67             $res->param(payer_id => $args->{payer_id});
68             $res->param(state => $json->{state});
69             $res->param(transaction_id => $json->{id});
70             $res->code(200);
71             $self->$cb($res);
72             1;
73             } or do {
74             warn "[MOJO_PAYPAL] ! $@" if DEBUG;
75             $self->$cb($self->_extract_error($res, $@));
76             };
77             },
78             );
79              
80             $self;
81             }
82              
83             sub register_payment {
84             my ($self, $c, $args, $cb) = @_;
85             my $register_url = $self->_url('/v1/payments/payment');
86             my $redirect_url = Mojo::URL->new($args->{redirect_url} ||= $c->req->url->to_abs);
87             my %body;
88              
89             $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             {total => $args->{amount}, currency => $args->{currency_code} || $self->currency_code,},
103             },
104             ],
105             );
106              
107             $c->delay(
108             sub {
109             my ($delay) = @_;
110             $self->_make_request_with_token($c, post => $register_url, j(\%body), $delay->begin);
111             },
112             sub {
113             my ($delay, $tx) = @_;
114             my $res = Mojolicious::Plugin::PayPal::Res->new($tx->res);
115              
116             $res->code(0) unless $res->code;
117              
118             local $@;
119             eval {
120             my $json = $res->json;
121             my $token;
122              
123             $json->{id} or die 'No transaction ID in response from PayPal';
124             $json->{state} eq 'created' or die $json->{state};
125              
126             for my $link (@{$json->{links}}) {
127             my $key = "$link->{rel}_url";
128             $key =~ s!_url_url$!_url!;
129             $res->param($key => $link->{href});
130             }
131              
132             $token = Mojo::URL->new($res->param('approval_url'))->query->param('token');
133              
134             $res->param(state => $json->{state});
135             $res->param(transaction_id => $json->{id});
136             $res->headers->location($res->param('approval_url'));
137             $res->code(302);
138             $delay->pass($res);
139             $self->transaction_id_mapper->($self, $token => $json->{id}, $delay->begin);
140             1;
141             } or do {
142             warn "[MOJO_PAYPAL] ! $@" if DEBUG;
143             $delay->pass($self->_extract_error($res, $@));
144             };
145             },
146             sub {
147             my ($delay, $res, $err, $id) = @_;
148              
149             return $self->$cb($self->_error($err)) if $err;
150             return $self->$cb($res);
151             },
152             );
153              
154             $self;
155             }
156              
157             sub register {
158             my ($self, $app, $config) = @_;
159              
160             # self contained
161             if (ref $config->{secret}) {
162             $self->_add_routes($app);
163             $self->_ua->server->app($app);
164             $config->{secret} = ${$config->{secret}};
165             }
166             elsif ($app->mode eq 'production') {
167             $config->{base_url} ||= 'https://api.paypal.com';
168             }
169              
170             # copy config to this object
171             for (grep { $self->$_ } keys %$config) {
172             $self->{$_} = $config->{$_};
173             }
174              
175             unless ($self->transaction_id_mapper) {
176             $self->transaction_id_mapper(
177             sub {
178             my ($self, $token, $transaction_id, $cb) = @_;
179             $app->log->warn("You need to set 'transaction_id_mapper' in Mojolicious::Plugin::PayPal");
180             $self->$cb('', $self->{transaction_id_map}{$token} //= $transaction_id);
181             }
182             );
183             }
184              
185             $app->helper(
186             paypal => sub {
187             my $c = shift;
188             return $self unless @_;
189             my $method = sprintf '%s_payment', shift;
190             $self->$method($c, @_);
191             return $c;
192             }
193             );
194             }
195              
196             sub _add_routes {
197             my ($self, $app) = @_;
198             my $r = $app->routes;
199             my $payments = $self->{payments}
200             ||= {}; # just here for debug purposes, may change without warning
201              
202             $self->base_url('/paypal');
203              
204             $r->post('/paypal/v1/oauth2/token' => {template => 'paypal/v1/oauth2/token', format => 'json'});
205              
206             $r->post(
207             '/paypal/v1/payments/payment' => sub {
208             my $self = shift;
209             my $token = 'EC-60U79048BN7719609';
210             $payments->{$token} = $self->req->json;
211             $self->render('paypal/v1/payments/payment', token => $token, format => 'json');
212             }
213             );
214              
215             $r->get('/paypal/webscr')->to(
216             cb => sub {
217             my $self = shift;
218             my $token = $self->param('token') || 'missing';
219             $payments->{CR87QHB7JTRSC} = $payments->{$token}; # payer_id = CR87QHB7JTRSC
220             $self->render('paypal/webscr', format => 'html', payment => $payments->{$token});
221             }
222             );
223              
224             $r->post('/paypal/v1/payments/payment/:transaction_id/execute')->to(
225             cb => sub {
226             my $self = shift;
227             my $payer_id = $self->req->json->{payer_id} || 'missing';
228             $self->render(
229             'paypal/v1/payments/payment/execute',
230             payment => $payments->{$payer_id},
231             format => 'json'
232             );
233             }
234             );
235              
236             push @{$app->renderer->classes}, __PACKAGE__;
237             }
238              
239             sub _error {
240             my ($self, $err) = @_;
241             my $res = Mojolicious::Plugin::PayPal::Res->new;
242             $res->code(400);
243             $res->param(message => $err);
244             $res->param(source => __PACKAGE__);
245             $res;
246             }
247              
248             sub _extract_error {
249             my ($self, $res, $e) = @_;
250             my $err = ''; # TODO
251              
252             $res->code(500);
253             $res->param(message => $err // $e);
254             $res->param(source => $err ? $self->base_url : __PACKAGE__);
255             $res;
256             }
257              
258             sub _get_access_token {
259             my ($self, $cb) = @_;
260             my $token_url = $self->_url('/v1/oauth2/token');
261             my %headers = ('Accept' => 'application/json', 'Accept-Language' => 'en_US');
262              
263             $token_url->userinfo(join ':', $self->client_id, $self->secret);
264             warn "[MOJO_PAYPAL] Token URL $token_url\n" if DEBUG == 2;
265              
266             $c->delay(
267             sub {
268             my ($delay) = @_;
269             $self->_ua->post(
270             $token_url, \%headers,
271             form => {grant_type => 'client_credentials'},
272             $delay->begin
273             );
274             },
275             sub {
276             my ($delay, $tx) = @_;
277             my $json = eval { $tx->res->json } || {};
278              
279             $json->{access_token} //= '';
280             $self->$cb($self->{access_token} = $json->{access_token}, $tx);
281             },
282             );
283             }
284              
285             # https://developer.paypal.com/webapps/developer/docs/integration/direct/make-your-first-call/
286             sub _make_request_with_token {
287             my ($self, $c, $method, $url, $body, $cb) = @_;
288             my %headers = ('Content-Type' => 'application/json');
289              
290             $c->delay(
291             sub { # get token unless we have it
292             my ($delay) = @_;
293             return $delay->pass($self->{access_token}, undef) if $self->{access_token};
294             return $self->_get_access_token($delay->begin);
295             },
296             sub { # abort or make request with token
297             my ($delay, $token, $tx) = @_;
298             return $self->$cb($tx) unless $token;
299             $headers{Authorization} = "Bearer $token";
300             warn "[MOJO_PAYPAL] Authorization: Bearer $token\n" if DEBUG;
301             return $self->_ua->$method($url, \%headers, $body, $delay->begin);
302             },
303             sub { # get token if it has expired
304             my ($delay, $tx) = @_;
305             return $self->_get_access_token($delay->begin) if $tx->res->code == 401;
306             return $delay->pass(undef, $tx); # success
307             },
308             sub { # return or retry request with new token
309             my ($delay, $token, $tx) = @_;
310             return $self->$cb($tx) unless $token; # return success or error $tx
311             $headers{Authorization} = "Bearer $token";
312             warn "[MOJO_PAYPAL] Authorization: Bearer $token\n" if DEBUG;
313             return $self->_ua->$method($url, \%headers, $body, $cb);
314             },
315             );
316             }
317              
318             sub _url {
319             my $url = Mojo::URL->new($_[0]->base_url . $_[1]);
320             warn "[MOJO_PAYPAL] URL $url\n" if DEBUG;
321             $url;
322             }
323              
324             {
325              
326             package Mojolicious::Plugin::PayPal::Res;
327             use Mojo::Base 'Mojo::Message::Response';
328             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.07
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__