Having needed to implement a simple workflow for taking card payments by Stripe and knowing that I need to do the same again soon for a different product set, I have created a module that I think will be useful to other people. Existing modules to connect with Stripe either do not cater for the latest security measures of 3D card payments in Europe or are a wrapper for the Stripe API. Both of which are, of course useful. But I wanted to create something easy to use that can be used for the typical simple workflow required by many small businesses.
This gives a simple workflow of adding products to a 'Trolley' and then sending the user directly to the Stripe hosted checkout. From there, Stripe returns to either a success URL if payment was taken successfully or a cancel URL if, for any reason, the transaction failed.
Could you please provide me with some feedback regarding both the code and the POD ahead of uploading it to CPAN?
I was thinking Business::Stripe::Simple as the module name - is that sensible?
Note - at the moment, the examples in the documentation have not been tested. They will be before uploading!
package Bod::Stripe; # TODO - Pre release # # TODO - Post release # # Improve obtaining success/cancel URLs from environment # Add P&P our $VERSION = '0.1'; use HTTP::Tiny; use JSON::PP; use strict; use warnings; sub new { my $class = shift; my %attrs = @_; my @products; $attrs{'trolley'} = \@products; $attrs{'currency'} //= 'GBP'; $attrs{'error'} = ''; $attrs{'cancel-url'} //= "$ENV{'REQUEST_SCHEME'}://$ENV{'HTTP_HOS +T'}$ENV{'SCRIPT_NAME'}"; $attrs{'success-url'} //= "$ENV{'REQUEST_SCHEME'}://$ENV{'HTTP_HOS +T'}$ENV{'SCRIPT_NAME'}"; $attrs{'error'} = 'cancel-url and success-url cannot be de +rived from the environment and need to be provided' unless ($attrs{'c +ancel-url'} and $attrs{'success-url'}); $attrs{'error'} = 'Public API key provided is not a valid key' if +$attrs{'api-public'} and $attrs{'api-public'} !~ /^pk_/; $attrs{'error'} = 'Secret API key provided is not a valid key' unl +ess $attrs{'api-secret'} =~ /^sk_/; $attrs{'error'} = 'Secret API key provided as Public key' if $attr +s{'api-public'} and $attrs{'api-public'} =~ /^sk_/; $attrs{'error'} = 'Public API key provided as Secret key' if $attr +s{'api-secret'} =~ /^pk_/; $attrs{'error'} = 'Secret API key is too short' unless length $att +rs{'api-secret'} > 100; $attrs{'error'} = 'Secret API key is missing' unless $attrs{'api-s +ecret'}; return bless \%attrs, $class; } sub success { my $self = shift; return !$self->{'error'}; } sub error { my $self = shift; return $self->{'error'}; } sub add_product { my ($self, %product) = @_; $self->{'error'} = ''; unless ($product{'price'} > 0 and $product{'price'} !~ /\./) { $self->{'error'} = 'Invalid price. Price is an integer of the + lowest currency unit'; return; } unless ($product{'qty'} > 0 and $product{'qty'} !~ /\./) { $self->{'error'} = 'Invalid qty. Qty is a positive integer'; return; } unless ($product{'name'}) { $self->{'error'} = 'No product name supplied'; return; } $self->{'intent'} = undef; # Update existing Product by ID foreach my $prod(@{$self->{'trolley'}}) { if ($prod->{'id'} eq $product{'id'}) { foreach my $field('name', 'description', 'qty', 'price') { $prod->{$field} = $product{$field}; } return scalar @{$self->{'trolley'}}; } } my $new_product; foreach my $field('id', 'name', 'description', 'qty', 'price') { $new_product->{$field} = $product{$field}; } push @{$self->{'trolley'}}, $new_product; } sub list_products { my $self = shift; my @products; foreach my $prod(@{$self->{'trolley'}}) { push @products, $prod->{'id'}; } return @products; } sub get_product { my ($self, $id) = @_; $self->{'error'} = ''; unless ($id) { $self->{'error'} = 'Product ID missing'; return; } foreach my $prod(@{$self->{'trolley'}}) { if ($prod->{'id'} eq $id) { return $prod; } } $self->{'error'} = "Product ID $id not found"; } sub delete_product { my ($self, $id) = @_; $self->{'error'} = ''; unless ($id) { $self->{'error'} = 'Product ID missing'; return; } for (my $i = 0; $i < scalar @{$self->{'trolley'}}; $i++) { if (${$self->{'trolley'}}[$i]->{'id'} eq $id) { $self->{'intent'} = undef; splice $self->{'trolley'}, $i, 1; return scalar @{$self->{'trolley'}}; } } $self->{'error'} = "Product ID $id not found"; } # Private method called internally by get_intent and get_intent_id # Attempts to obtain a new session intent from Stripe # Returns existing session if it exists and Trolley hasn't changed sub _create_intent { my $self = shift; if ($self->{'intent'}) { return $self->{'intent'}; } $self->{'reference'} //= __PACKAGE__; my $http = HTTP::Tiny->new; my $headers = { 'Authorization' => 'Bearer ' . $self->{'api-secret'}, }; my $vars = { 'headers' => $headers, }; my $payload = { 'cancel_url' => $self->{'cancel-url'}, 'success_url' => $self->{'success-url'}, 'payment_method_types[0]' => 'card', 'mode' => 'payment', 'client_reference_id' => $self->{'reference'}, }; $payload->{'customer_email'} = $self->{'email'} if $self->{'email' +}; my $i = 0; foreach my $prod(@{$self->{'trolley'}}) { $payload->{"line_items[$i][currency]"} = $self->{'curre +ncy'}; $payload->{"line_items[$i][name]"} = $prod->{'name' +}; $payload->{"line_items[$i][description]"} = $prod->{'descri +ption'} if $prod->{'description'}; $payload->{"line_items[$i][quantity]"} = $prod->{'qty'} +; $payload->{"line_items[$i][amount]"} = $prod->{'price'} +; $i++; } my $response = $http->post_form('https://api.stripe.com/v1/checkou +t/sessions', $payload, $vars); $self->{'error'} = ''; if ($response->{'success'}) { $self->{'intent'} = $response->{'content'}; } else { $self->{'error'} = decode_json($response->{'content'})->{'err +or'}->{'message'}; } } sub get_intent { my ($self, %attrs) = @_; $self->{'reference'} = $attrs{'reference'} if $attrs{'reference'}; $self->{'email'} = $attrs{'email'} if $attrs{'email'}; $self->{'error'} = ''; return $self->_create_intent; } sub get_intent_id { my ($self, %attrs) = @_; $self->{'reference'} = $attrs{'reference'} if $attrs{'reference'}; $self->{'email'} = $attrs{'email'} if $attrs{'email'}; $self->{'error'} = ''; my $intent = $self->_create_intent; if ($self->{'error'}) { return $intent; } else { return decode_json($intent)->{'id'}; } } sub get_ids { my ($self, %attrs) = @_; $self->{'public-key'} = $attrs{'public-key'} if $attrs{'public-key +'}; $self->{'error'} = ''; unless ($self->{'api-public'}) { $self->{'error'} = 'Required Public API Key missing'; return; } $self->{'reference'} = $attrs{'reference'} if $attrs{'reference'}; $self->{'email'} = $attrs{'email'} if $attrs{'email'}; my $intent_id = $self->get_intent_id; my %result; if ($self->{'error'}) { $result{'status'} = 'error'; $result{'message'} = $self->{'error'}; } else { $result{'status'} = 'success'; $result{'api-key'} = $self->{'api-public'}; $result{'session'} = $intent_id; } return encode_json(\%result) if lc($attrs{'format'}) eq 'json'; return $result{'message'} || "$result{'api-key'}:$result{'session' +}"; } sub checkout { my $self = shift; my $data = $self->get_ids( 'format' => 'text', @_); return if $self->{'error'}; my ($key, $session) = split /:/, $data; unless ($key and $session) { $self->{'error'} = 'Error getting key and session'; return; } return <<"END_HTML"; Content-type: text/html <html> <head> <script src="https://js.stripe.com/v3/"></script> <script> var stripe = Stripe('$key'); var result = stripe.redirectToCheckout({sessionId: '$session'}); if (result.error) { alert(result.error.message); } </script> </head> <body> END_HTML } 1; __END__ =pod =encoding UTF-8 =head1 NAME Bod::Stripe - Simple way to implement payments using Stripe hosted che +ckout =head1 SYNOPSIS use Bod::Stripe; my $stripe = Bod::Stripe->new( 'api-secret' => 'sk_test_00000000000000000000000000', ); # Note price is in lowest currency unit (i.e pence or cents not poun +ds or dollars) $stripe->add_product( 'id' => 1, 'name' => 'My product', 'qty' => 4, 'price' => 250, ); foreach my $id($stripe->list_products) { print "$id is " . $stripe->get_product($id)->{'name'} . "\n"; } $stripe->checkout( 'api-public' => 'pk_test_00000000000000000000000000', ); =head1 DESCRIPTION A simple to use interface to the Stripe payment gateway utilising the +Stripe hosted checkout. The only dependencies are the core modules L +<HTTP::Tiny> and L<JSON::PP>. L<Bod::Stripe> has a Trolley into which products are loaded. Once the + Trolley is full of the product(s) in the order, this is passed to th +e Stripe hosted checkout either using Javascript provided by Stripe ( +see L<https://stripe.com/docs/payments/accept-a-payment?integration=c +heckout>), Javascript provided in this document or the B<checkout> ut +ility method that allows a server side script to send the user to Str +ipe invisibly. At present L<Bod::Stripe> only handles simple, one-off payments. Mani +pulation of customers, handling subscriptions and user hosted checkou +t is not supported. However, this implementation makes payment for a + single item or group of items simple to implement. =head2 Keys Stripe provides four API Keys. A Secret and a Publishable (called Pub +lic within L<Bod::Stripe>) for both testing and for live transactions +. When calling the B<new> method it is necessary to provide the Secr +et Key. Before calling the B<checkout> method to redirect the user t +o the Stripe hosted checkout, the Public Key is also required so this + is usually provided to the B<new> method. See L<https://stripe.com/docs/keys> =head2 Workflow The basic workflow for L<Bod::Stripe> is to initially create an instan +ce of the module with at minimum the Secret Key. If using a currency +other than GBP this should also be set at this time. my $stripe = Bod::Stripe->new( 'api-public' => 'pk_test_00000000000000000000000000', 'api-secret' => 'sk_test_00000000000000000000000000', 'currency' => 'USD', ); Next, products are assembled in the Trolley. There are methods to add +, update, remove and list the products in the Trolley. $stripe->add_product( 'id' => 1, 'name' => 'My product', 'qty' => 4, 'price' => 250, ); my @products = $stripe->list_products; Once the Trolley contains all the products, the user is redirected to +the Stripe hosted checkout where they pay for the Trolley. Once this + happens, Stripe returns to your site using one of the URLs provided +delending on whether the payment was successful or not. Where no ret +urn URLs are provide, the script URL is used although in practice thi +s is not usually sufficient and return URLs will be needed. $stripe->checkout; Examples of other ways of redirecting the user to the Stripe hosted ch +eckout and listed in the B<Examples> section. =head1 METHODS =head2 new Bod::Stripe->new('api-secret' => 'sk_test_00000000000000000000000000 +'); The constructor method. The Secret Key is required. The following parameters may be provided: =over 4 =item * C<api-secret> - B<required> The Secret Key. =item * C<api-public> - The Public Key. This would normally be provided to the B<new> method but can be left u +ntil sending the user to the Stripe hosted checkout. =item * C<success-url> =item * C<cancel-url> - The callback URL that Stripe returns to once the payme +nt transaction has completed either successfully or otherwise. If th +ese are not explicitly included, the current script URL is used. Nor +mally these need setting but can be omitted for testing or if the Str +ipe payment dashboard is being relied on to confirm successful paymen +ts. =item * C<currency> - The currency to use for the transaction. The default is + British Pounds Stirling (GBP). This should be a 3 letter currency code supported by Stripe (see L<htt +ps://stripe.com/docs/currencies>). =item * C<reference> - The reference to use for the transaction Defaults to "Bod::Stripe" as this is required by Stripe. =item * C<email> - If provided, this pre-fills the user's email address in the + Stripe hosted checkout. If provided, this is then non editable duri +ng checkout. =back =head2 success Returns true is the last method call was successful =head2 error Returns the last error message or an empty string if B<success> return +ed true =head1 Trolley Methods =head2 add_product Adds a product to the Trolley. Or update the product if an existing B +<id> is provided. A product consists of the following hash entries =over 4 =item * C<id> - B<required> A unique ID for the product. This is not passed to Stripe and is only used by L<Bod::Stripe> to ide +ntify current products. If B<add_product> is called with an existing ID, that product is updat +ed. =item * C<name> - B<required> The name of the product as it will be passed to Stripe. =item * C<description> - B<optional> A one line decsription of the product as it will be passed to Stripe. + This is typically used to specify options such as colour. =item * C<qty> - B<required> The number of the product to add to the Trolly. =item * C<price> - B<required> The price of the product in the lowest currency unit. For example - E +<pound>2.50 would be 250 as it is 250 pence; $10 would be 1000 as it + is 1000 cents. Note that special rules apply to Hungarian Forint (HUF) and Ugandan Sh +illing (UGX) - see L<https://stripe.com/docs/currencies> =back On success, returns the number of products in the Trolley =head2 delete_product(id) Delete the product with the specified id On success, returns the number of products in the Trolley =head2 list_products Returns an array contining the IDs of the products in the Trolley =head2 get_product(id) On success, returns a hash with the product details. Each key of the +hash corresponds to items listed for B<add_product> =head1 Checkout Methods =head3 parameters The following methods all take the following optional parameters. See + B<new> for their descriptions. =over 4 =item * C<reference> =item * C<email> =back =head2 get_intent This method will not normally need calling. Returns the full session intent from Stripe if successful or the Strip +e error otherwise. =head2 get_intent_id Returns the intend_id that needs passing to the Stripe hosted checkout + if successful or the Stripe error otherwise. =head2 get_id In addition to the parameters listed above, this method also accepts t +he following optional parameters =over 4 =item * C<public-api> - See B<new> =item * C<format> - The format of the returned information. Current options a +re JSON or text. The default is text. =back Provides the Public Key and Intent Session ID as these are the two pie +ces of information required by the Javascript provided by Stripe and +te Javacsript provided here. If text output is used (the default) th +e Public Key and Intent Session ID are provided as a colon separated +string. =head2 checkout A simple implementation of redirecting the user to the Stripe hosted c +heckout. Calling this method provides a fully formed HTML document including th +e Content-Type header that can be sent to the users browser. The HTM +L document contains all the Javascript required to sent the user to t +he Stripe hosted checkout transparently. Unless you are building a c +heckout with entirely AJAX calls, you will almost certainly want to u +se this method. =head1 EXAMPLES =head2 1 - Using the Stripe provided Javascript See L<https://stripe.com/docs/payments/accept-a-payment?integration=ch +eckout> =head3 Javascript <html> <head> <title>Buy cool new product</title> <script src="https://js.stripe.com/v3/"></script> </head> <body> <button id="checkout-button">Checkout</button> <script type="text/javascript"> // Create an instance of the Stripe object with your publishab +le API key var stripe = Stripe('pk_test_00000000000000000000000000'); var checkoutButton = document.getElementById('checkout-button' +); checkoutButton.addEventListener('click', function() { // Create a new Checkout Session using the server-side endpo +int you // created in step 3. fetch('https://example.com/cgi-bin/trolley.pl?stripe=getInte +nt', { method: 'POST', }) .then(function(response) { return response.json(); }) .then(function(session) { return stripe.redirectToCheckout({ sessionId: session.id } +); }) .then(function(result) { // If `redirectToCheckout` fails due to a browser or netwo +rk // error, you should display the localized error message t +o your // customer using `error.message`. if (result.error) { alert(result.error.message); } }) .catch(function(error) { console.error('Error:', error); }); }); </script> </body> </html> =head3 Perl trolley.pl use Bod::Stripe; use strict; use CGI; my $cgi = CGI->new; if ($cgi->param('stripe') eq 'getIntent') { my $stripe = Bod::Stripe->new( 'api-public' => 'pk_test_00000000000000000000000000', 'api-secret' => 'sk_test_00000000000000000000000000', 'success-url' => 'https://www.example.com/yippee.html', 'cancel-url' => 'https://www.example.com/ohdear.html', 'reference' => 'My Payment', ); $stripe->add_product( 'id' => 'test', 'name' => 'Expensive Thingy', 'description' => 'Special edition version', 'qty' => 1, 'price' => 50000, ); print "Content-Type: text/json\n\n"; print $stripe->get_intent; } =head2 2 - Simpler Javascript using XHR =head3 Javascript <html> <head> <script src="https://js.stripe.com/v3/"></script> <script> var xhr=new XMLHttpRequest(); function checkout() { xhr.open("POST", "https://www.example.com/cgi-bin/trolley.pl", t +rue); xhr.onreadystatechange=function() { if (xhr.readyState == 4 && xhr.status == 200) { var keys = xhr.response.split(':'); var stripe = Stripe(keys[0]); var result = stripe.redirectToCheckout({ sessionId: keys[1] +}); if (result.error) { alert(result.error.message); } } xhr.send("stripe=getKeys"); } </head> <body> <input type="button" value="Buy Now!" onClick="checkout();"> </body> </html> =head3 Perl - trolley.pl use Bod::Stripe; use strict; use CGI; my $cgi = CGI->new; if ($cgi->param('stripe') eq 'getKeys') { my $stripe = Bod::Stripe->new( 'api-public' => 'pk_test_00000000000000000000000000', 'api-secret' => 'sk_test_00000000000000000000000000', 'success-url' => 'https://www.example.com/yippee.html', 'cancel-url' => 'https://www.example.com/ohdear.html', 'reference' => 'My Payment', ); $stripe->add_product( 'id' => 'test', 'name' => 'Expensive Thingy', 'description' => 'Special edition version', 'qty' => 1, 'price' => 50000, ); print "Content-Type: text/text\n\n"; print $stripe->get_ids; } =head2 3 - Simpest method (no Javascript required) =head3 HTML <html> <head> <title>Simple Checkout</title> </head> <body> <form method="post" action="https://www.example.com/cgi-bin/trolley. +pl"> <input type="hidden" name="stripe" action="checkout"> <label for="qty">How many do you want?</label> <input type="number" name="qty" min="1" name="qty"> <input type="submit" value="Buy Now!"> </form> </body> </html> =head3 Perl - trolley.pl use Bod::Stripe; use strict; use CGI; my $cgi = CGI->new; if ($cgi->param('stripe') eq 'checkout') { my $stripe = Bod::Stripe->new( 'api-public' => 'pk_test_00000000000000000000000000', 'api-secret' => 'sk_test_00000000000000000000000000', 'success-url' => 'https://www.example.com/yippee.html', 'cancel-url' => 'https://www.example.com/ohdear.html', 'reference' => 'My Payment', ); $stripe->add_product( 'id' => 'test', 'name' => 'Expensive Thingy', 'description' => 'Special edition version', 'qty' => $cgi->param('qty'), 'price' => 50000, ); if ($stripe->success) { print $stripe->checkout; } else { # handle errors... } } This last example prints out a fully formed HTML to the browser comple +te with Content-Type header. If other headers are required, such as +Set-Cookie headers, they can be included immediately before printing +calling B<checkout>. =head1 SEE ALSO L<Net::Stripe>, L<Net::Stripe::Simple>, L<Business::Stripe> =head1 AUTHOR =over 4 =item * Ian Boddison <ian@boddison.com> =back =head1 COPYRIGHT AND LICENSE This software is copyright (c) 2021 by Ian Boddison. All rights reserved. This program is free software; you can redistribute it and/or modify i +t under the same terms as Perl itself.
Thank you to the Monks who have helped me develop the skills to get the module this far and to the ones who will give useful feedback.
It is very much appreciated.
UPDATE:
I have run the code through Perl::Critic and it passes at 'stern' but generates warnings at 'harsh'. One thing it has found are tab characters instead of spaces despite changing my editor to use spaces after this discussion. I will take out the tabs that have crept in from copying and pasting a few bits of code.
On the suggestion of Critic, I have moved the declaration of $VERSION to after use strict;. Although I thought that had to be first for the CPAN toolchain?
|
---|