File Coverage

blib/lib/Business/Stripe/WebCheckout.pm
Criterion Covered Total %
statement 15 164 9.1
branch 0 74 0.0
condition 0 33 0.0
subroutine 5 17 29.4
pod 11 11 100.0
total 31 299 10.3


line stmt bran cond sub pod time code
1             package Business::Stripe::WebCheckout;
2            
3             # TODO - Pre release
4             #
5             # TODO - Post release
6             #
7             # 12-04-21 - Improve obtaining success/cancel URLs from environment
8             # 14-04-21 - Add P&P
9             # 16-04-21 - Properly implement live testing without real Stripe keys
10             #
11            
12 1     1   63615 use HTTP::Tiny;
  1         42754  
  1         30  
13 1     1   603 use JSON::PP;
  1         14392  
  1         97  
14 1     1   640 use Data::Dumper;
  1         5677  
  1         86  
15            
16 1     1   11 use strict;
  1         2  
  1         22  
17 1     1   6 use warnings;
  1         2  
  1         1740  
18            
19             our $VERSION = '1.2';
20             $VERSION = eval $VERSION;
21            
22             sub new {
23 0     0 1   my $class = shift;
24 0           my %attrs = @_;
25            
26 0           my @products;
27 0           $attrs{'trolley'} = \@products;
28            
29 0   0       $attrs{'currency'} //= 'GBP';
30            
31 0           $attrs{'error'} = '';
32            
33 0   0       $attrs{'cancel-url'} //= "$ENV{'REQUEST_SCHEME'}://$ENV{'HTTP_HOST'}$ENV{'SCRIPT_NAME'}";
34 0   0       $attrs{'success-url'} //= "$ENV{'REQUEST_SCHEME'}://$ENV{'HTTP_HOST'}$ENV{'SCRIPT_NAME'}";
35 0 0 0       $attrs{'error'} = 'cancel-url and success-url cannot be derived from the environment and need to be provided' unless ($attrs{'cancel-url'} and $attrs{'success-url'});
36            
37             # This is changed during testing only
38 0   0       $attrs{'url'} //= 'https://api.stripe.com/v1/checkout/sessions';
39            
40 0 0 0       $attrs{'error'} = 'Public API key provided is not a valid key' if $attrs{'api-public'} and $attrs{'api-public'} !~ /^pk_/;
41 0 0         $attrs{'error'} = 'Secret API key provided is not a valid key' unless $attrs{'api-secret'} =~ /^sk_/;
42 0 0 0       $attrs{'error'} = 'Secret API key provided as Public key' if $attrs{'api-public'} and $attrs{'api-public'} =~ /^sk_/;
43 0 0         $attrs{'error'} = 'Public API key provided as Secret key' if $attrs{'api-secret'} =~ /^pk_/;
44 0 0         $attrs{'error'} = 'Secret API key is too short' unless length $attrs{'api-secret'} > 100;
45 0 0         $attrs{'error'} = 'Secret API key is missing' unless $attrs{'api-secret'};
46            
47 0           return bless \%attrs, $class;
48             }
49            
50             sub success {
51 0     0 1   my $self = shift;
52 0           return !$self->{'error'};
53             }
54            
55             sub error {
56 0     0 1   my $self = shift;
57 0           return $self->{'error'};
58             }
59            
60             sub add_product {
61 0     0 1   my ($self, %product) = @_;
62 0           $self->{'error'} = '';
63            
64 0 0 0       unless ($product{'price'} > 0 and $product{'price'} !~ /\./) {
65 0           $self->{'error'} = 'Invalid price. Price is an integer of the lowest currency unit';
66 0           return;
67             }
68 0 0 0       unless ($product{'qty'} > 0 and $product{'qty'} !~ /\./) {
69 0           $self->{'error'} = 'Invalid qty. Qty is a positive integer';
70 0           return;
71             }
72            
73 0 0         unless ($product{'name'}) {
74 0           $self->{'error'} = 'No product name supplied';
75 0           return;
76             }
77 0           $self->{'intent'} = undef;
78             # Update existing Product by ID
79 0           foreach my $prod(@{$self->{'trolley'}}) {
  0            
80 0 0         if ($prod->{'id'} eq $product{'id'}) {
81 0           foreach my $field('name', 'description', 'qty', 'price') {
82 0           $prod->{$field} = $product{$field};
83             }
84 0           return scalar @{$self->{'trolley'}};
  0            
85             }
86             }
87            
88 0           my $new_product;
89 0           foreach my $field('id', 'name', 'description', 'qty', 'price') {
90 0           $new_product->{$field} = $product{$field};
91             }
92 0           push @{$self->{'trolley'}}, $new_product;
  0            
93             }
94            
95             sub list_products {
96 0     0 1   my $self = shift;
97 0           my @products;
98 0           foreach my $prod(@{$self->{'trolley'}}) {
  0            
99 0           push @products, $prod->{'id'};
100             }
101 0           return @products;
102             }
103            
104             sub get_product {
105 0     0 1   my ($self, $id) = @_;
106 0           $self->{'error'} = '';
107            
108 0 0         unless ($id) {
109 0           $self->{'error'} = 'Product ID missing';
110 0           return;
111             }
112            
113 0           foreach my $prod(@{$self->{'trolley'}}) {
  0            
114 0 0         if ($prod->{'id'} eq $id) {
115 0           return $prod;
116             }
117             }
118 0           $self->{'error'} = "Product ID $id not found";
119             }
120            
121             sub delete_product {
122 0     0 1   my ($self, $id) = @_;
123 0           $self->{'error'} = '';
124            
125 0 0         unless ($id) {
126 0           $self->{'error'} = 'Product ID missing';
127 0           return;
128             }
129            
130 0           for (my $i = 0; $i < scalar @{$self->{'trolley'}}; $i++) {
  0            
131 0 0         if (${$self->{'trolley'}}[$i]->{'id'} eq $id) {
  0            
132 0           $self->{'intent'} = undef;
133 0           splice @{$self->{'trolley'}}, $i, 1;
  0            
134 0           return scalar @{$self->{'trolley'}};
  0            
135             }
136             }
137 0           $self->{'error'} = "Product ID $id not found";
138             }
139            
140             # Private method called internally by get_intent and get_intent_id
141             # Attempts to obtain a new session intent from Stripe
142             # Returns existing session if it exists and Trolley hasn't changed
143             sub _create_intent {
144 0     0     my $self = shift;
145            
146 0 0         if ($self->{'intent'}) {
147 0           return $self->{'intent'};
148             }
149            
150 0   0       $self->{'reference'} //= __PACKAGE__;
151            
152 0           my $http = HTTP::Tiny->new;
153             my $headers = {
154 0           'Authorization' => 'Bearer ' . $self->{'api-secret'},
155             'Stripe-Version' => '2020-08-27',
156             };
157            
158             # Update URL and headers during stripe-live tests
159 0 0         if ($self->{'url'} =~ /^https:\/\/www\.boddison\.com/) {
160 0           $headers->{'BODTEST'} = __PACKAGE__ . " v$VERSION";
161             $headers->{'Authorization'} = undef,
162 0 0         $self->{'url'} .= '?fail' if $self->{'api-test-fail'};
163             }
164            
165 0           my $vars = {
166             'headers' => $headers,
167             'agent' => 'Perl-WebCheckout/$VERSION',
168             };
169             my $payload = {
170             'cancel_url' => $self->{'cancel-url'},
171             'success_url' => $self->{'success-url'},
172             'payment_method_types[0]' => 'card',
173             'mode' => 'payment',
174 0           'client_reference_id' => $self->{'reference'},
175             };
176 0 0         $payload->{'customer_email'} = $self->{'email'} if $self->{'email'};
177 0 0         if ($self->{'getShipping'}) {
178 0           $payload->{'shipping_address_collection[allowed_countries][0]'} = $self->{'getShipping'};
179             }
180            
181 0           my $i = 0;
182 0           foreach my $prod(@{$self->{'trolley'}}) {
  0            
183 0           $payload->{"line_items[$i][currency]"} = $self->{'currency'};
184 0           $payload->{"line_items[$i][name]"} = $prod->{'name'};
185 0 0         $payload->{"line_items[$i][description]"} = $prod->{'description'} if $prod->{'description'};
186 0           $payload->{"line_items[$i][quantity]"} = $prod->{'qty'};
187 0           $payload->{"line_items[$i][amount]"} = $prod->{'price'};
188 0           $i++;
189             }
190            
191 0           my $response = $http->post_form($self->{'url'}, $payload, $vars);
192            
193 0           $self->{'error'} = '';
194 0 0         if ($response->{'success'}) {
195 0           $self->{'intent'} = $response->{'content'};
196             } else {
197 0           my $content = $response->{'content'};
198 0           eval {
199 0           $content = decode_json($response->{'content'});
200             };
201 0 0         if ($@) {
202 0           $self->{'error'} = $content;
203             } else {
204 0           $self->{'error'} = $content->{'error'}->{'message'};
205             }
206             }
207             }
208            
209             sub get_intent {
210 0     0 1   my ($self, %attrs) = @_;
211            
212 0 0         $self->{'reference'} = $attrs{'reference'} if $attrs{'reference'};
213 0 0         $self->{'email'} = $attrs{'email'} if $attrs{'email'};
214            
215 0           $self->{'error'} = '';
216 0           return $self->_create_intent;
217             }
218            
219             sub get_intent_id {
220 0     0 1   my ($self, %attrs) = @_;
221            
222 0 0         $self->{'reference'} = $attrs{'reference'} if $attrs{'reference'};
223 0 0         $self->{'email'} = $attrs{'email'} if $attrs{'email'};
224            
225 0           $self->{'error'} = '';
226 0           my $intent = $self->_create_intent;
227 0 0         if ($self->{'error'}) {
228 0           return $intent;
229             } else {
230 0           return decode_json($intent)->{'id'};
231             }
232             }
233            
234             sub get_ids {
235 0     0 1   my ($self, %attrs) = @_;
236            
237 0 0         $self->{'public-key'} = $attrs{'public-key'} if $attrs{'public-key'};
238            
239 0           $self->{'error'} = '';
240 0 0         unless ($self->{'api-public'}) {
241 0           $self->{'error'} = 'Required Public API Key missing';
242 0           return;
243             }
244            
245 0 0         $self->{'reference'} = $attrs{'reference'} if $attrs{'reference'};
246 0 0         $self->{'email'} = $attrs{'email'} if $attrs{'email'};
247            
248 0           my $intent_id = $self->get_intent_id;
249            
250 0           my %result;
251 0 0         if ($self->{'error'}) {
252 0           $result{'status'} = 'error';
253 0           $result{'message'} = $self->{'error'};
254             } else {
255 0           $result{'status'} = 'success';
256 0           $result{'api-key'} = $self->{'api-public'};
257 0           $result{'session'} = $intent_id;
258             }
259            
260 0 0         $attrs{'format'} = 'text' unless $attrs{'format'};
261 0 0         return encode_json(\%result) if lc($attrs{'format'}) eq 'json';
262 0   0       return $result{'message'} || "$result{'api-key'}:$result{'session'}";
263             }
264            
265             sub checkout {
266 0     0 1   my $self = shift;
267            
268 0           my $data = $self->get_ids( 'format' => 'text', @_);
269            
270 0 0         return if $self->{'error'};
271            
272 0           my ($key, $session) = split /:/, $data;
273            
274 0 0 0       unless ($key and $session) {
275 0           $self->{'error'} = 'Error getting key and session';
276 0           return;
277             }
278            
279 0           return <<"END_HTML";
280             Content-type: text/html
281            
282            
283            
284            
285            
292            
293            
294             END_HTML
295            
296             }
297            
298             1;
299            
300             __END__