From 37d278f56d87f78745868c02bd722c211e0119aa Mon Sep 17 00:00:00 2001 From: youssef-stripe <47321947+youssef-stripe@users.noreply.github.com> Date: Mon, 15 Apr 2019 17:05:36 +0100 Subject: [PATCH] Add PHP Slim server (#60) * Add PHP implementation of the server * Fixed composer start script * Using Slim built-in method for raw body in webhooks * Minor fixes to README.md * Fixed settings: currency, country and API version * Fixed currency key name in routes * Moved settings to ini file --- server/php/.gitignore | 4 + server/php/.htaccess | 21 ++++ server/php/README.md | 63 ++++++++++ server/php/composer.json | 32 ++++++ server/php/index.php | 198 ++++++++++++++++++++++++++++++++ server/php/settings.ini.example | 16 +++ server/php/settings.php | 56 +++++++++ server/php/store/Inventory.php | 113 ++++++++++++++++++ server/php/store/Shipping.php | 29 +++++ 9 files changed, 532 insertions(+) create mode 100644 server/php/.gitignore create mode 100644 server/php/.htaccess create mode 100644 server/php/README.md create mode 100644 server/php/composer.json create mode 100644 server/php/index.php create mode 100644 server/php/settings.ini.example create mode 100644 server/php/settings.php create mode 100644 server/php/store/Inventory.php create mode 100644 server/php/store/Shipping.php diff --git a/server/php/.gitignore b/server/php/.gitignore new file mode 100644 index 00000000..9c109249 --- /dev/null +++ b/server/php/.gitignore @@ -0,0 +1,4 @@ +/vendor/ +/logs/* +composer.lock +settings.ini diff --git a/server/php/.htaccess b/server/php/.htaccess new file mode 100644 index 00000000..97c5b4c1 --- /dev/null +++ b/server/php/.htaccess @@ -0,0 +1,21 @@ + + RewriteEngine On + + # Some hosts may require you to use the `RewriteBase` directive. + # Determine the RewriteBase automatically and set it as environment variable. + # If you are using Apache aliases to do mass virtual hosting or installed the + # project in a subdirectory, the base path will be prepended to allow proper + # resolution of the index.php file and to redirect to the correct URI. It will + # work in environments without path prefix as well, providing a safe, one-size + # fits all solution. But as you do not need it in this case, you can comment + # the following 2 lines to eliminate the overhead. + RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$ + RewriteRule ^(.*) - [E=BASE:%1] + + # If the above doesn't work you might need to set the `RewriteBase` directive manually, it should be the + # absolute physical path to the directory that contains this htaccess file. + # RewriteBase / + + # RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [QSA,L] + diff --git a/server/php/README.md b/server/php/README.md new file mode 100644 index 00000000..fe2c05fe --- /dev/null +++ b/server/php/README.md @@ -0,0 +1,63 @@ +# Stripe Payments Demo — PHP Server + +This demo uses: +- [Slim](http://www.slimframework.com/) as the API framework. +- [stripe-php](https://github.com/stripe/stripe-php) as the SDK to interact with Stripe's APIs +- [monolog/monolog](https://github.com/Seldaek/monolog) as the logging interface + +## Payments Integration + +- [`settings.php`](settings.php) contains the Stripe account configuration as well as the payment methods accepted in this demo. +- [`index.php`](index.php) contains the routes that interface with Stripe to create PaymentIntents and receive webhook events. +- [`store`](store) contains inventory and shipping utils. + +## Requirements + +You’ll need the following: + +- PHP 5.4 or higher +- Modern browser that supports ES6 (Chrome to see the Payment Request, and Safari to see Apple Pay). +- Stripe account to accept payments ([sign up](https://dashboard.stripe.com/register) for free!) + +## Getting Started + +After cloning the repository, you first have to install the dependencies using composer: + +``` +cd server/php +composer install +``` + +Rename `settings.ini.example` to `settings.ini` and update your [Stripe API keys](https://dashboard.stripe.com/account/apikeys) and any other configuration details you might want to add. + +That's it, you can now run the application by using the PHP built-in server still from the `server/php` directory: + +``` +composer start +``` + +You should now see it running on [`http://localhost:8888/`](http://localhost:8888/) + +Optionally you can change the directory tree to fit your needs, don't forget to: +- change the path to the `public` directory in `settings.php` +- uncomment the `RewriteBase` option in [`.htaccess`](.htaccess) and update the value accordingly if needed + +### Testing Webhooks + +If you want to test [receiving webhooks](https://stripe.com/docs/webhooks), we recommend using ngrok to expose your local server. + +First [download ngrok](https://ngrok.com) and start your PHP application. + +[Run ngrok](https://ngrok.com/docs). Assuming your PHP application is running on the port 8888, you can simply run ngrok in your Terminal in the directory where you downloaded ngrok: + +``` +ngrok http 8888 +``` + +ngrok will display a UI in your terminal telling you the new forwarding address for your PHP app. Use this URL as the URL to be called in your developer [webhooks panel.](https://dashboard.stripe.com/account/webhooks) + +Don't forget to append `/webhook` when you set up your Stripe webhook URL in the Dashboard. Example URL to be called: `http://75795038.ngrok.io/webhook`. + +## Logging + +The application logs webhook events in `server/php/logs`. Make sure your server has write access to that directory. diff --git a/server/php/composer.json b/server/php/composer.json new file mode 100644 index 00000000..09338b90 --- /dev/null +++ b/server/php/composer.json @@ -0,0 +1,32 @@ +{ + "name": "stripe/stripe-payments-demo-php", + "description": "A PHP backend for the Stripe Payments demo shop", + "keywords": ["stripe", "payments", "demo"], + "homepage": "https://github.com/stripe/stripe-payments-demo", + "license": "MIT", + "authors": [ + { + "name": "Youssef Ben Othman", + "email": "youssef@stripe.com" + } + ], + "require": { + "php": ">=5.5.0", + "slim/slim": "^3.1", + "slim/php-view": "^2.0", + "monolog/monolog": "^1.17", + "stripe/stripe-php": "^6.31" + }, + "autoload": { + "psr-4": { + "Store\\": "store/" + } + }, + "config": { + "process-timeout" : 0 + }, + "scripts": { + "start": "php -S localhost:8888 index.php" + } + +} diff --git a/server/php/index.php b/server/php/index.php new file mode 100644 index 00000000..bf3adfa3 --- /dev/null +++ b/server/php/index.php @@ -0,0 +1,198 @@ +getContainer(); +$container['logger'] = function ($c) { + $settings = $c->get('settings')['logger']; + $logger = new Monolog\Logger($settings['name']); + $logger->pushProcessor(new Monolog\Processor\UidProcessor()); + $logger->pushHandler(new Monolog\Handler\StreamHandler($settings['path'], $settings['level'])); + return $logger; +}; + +// Middleware +$app->add(function ($request, $response, $next) { + Stripe::setApiKey($this->get('settings')['stripe']['secretKey']); + $request = $request->withAttribute('staticDir', $this->get('settings')['stripe']['staticDir']); + + return $next($request, $response); +}); + +// Serve the store +$app->get('/', function (Request $request, Response $response, array $args) { + return $response->write(file_get_contents($request->getAttribute('staticDir') . 'index.html')); +}); + +// Serve static assets and images for index.html +$paths = [ + 'javascripts' => 'text/javascript', 'stylesheets' => 'text/css', + 'images' => FILEINFO_MIME_TYPE, + 'images/products' => FILEINFO_MIME_TYPE, + 'images/screenshots' => FILEINFO_MIME_TYPE +]; +$app->get('/{path:' . implode('|', array_keys($paths)) . '}/{file:[^/]+}', + function (Request $request, Response $response, array $args) use ($paths) { + $resource = $request->getAttribute('staticDir') . $args['path'] . '/' . $args['file']; + if (!is_file($resource)) { + $notFoundHandler = $this->get('notFoundHandler'); + return $notFoundHandler($request, $response); + } + + return $response->write(file_get_contents($resource)) + ->withHeader('Content-Type', $paths[$args['path']]); + } +); + +// General config +$app->get('/config', function (Request $request, Response $response, array $args) { + $config = $this->get('settings')['stripe']; + return $response->withJson([ + 'stripePublishableKey' => $config['publishableKey'], + 'stripeCountry' => $config['accountCountry'], + 'country' => $config['defaultCountry'], + 'currency' => $config['shopCurrency'], + 'paymentMethods' => implode($config['paymentMethods'], ', '), + 'shippingOptions' => Shipping::getShippingOptions() + ]); +}); + +// List of fake products on our fake shop +// Used to display the user's cart and calculate the total price +$app->get('/products', function (Request $request, Response $response, array $args) { + return $response->withJson(Inventory::listProducts()); +}); + +// List of fake products on our fake shop +// Used to display the user's cart and calculate the total price +$app->get('/products/{id}', function (Request $request, Response $response, array $args) { + return $response->withJson(Inventory::getProduct($args['id'])); +}); + +// Create the payment intent +// Used when the user starts the checkout flow +$app->post('/payment_intents', function (Request $request, Response $response, array $args) { + $data = $request->getParsedBody(); + try { + $paymentIntent = \Stripe\PaymentIntent::create([ + 'amount' => Inventory::calculatePaymentAmount($data['items']), + 'currency' => $data['currency'], + 'payment_method_types' => $this->get('settings')['stripe']['paymentMethods'] + ]); + + return $response->withJson([ 'paymentIntent' => $paymentIntent ]); + } catch (\Exception $e) { + return $response->withJson([ 'error' => $e->getMessage() ])->withStatus(403); + } +}); + +// Update the total when selected a different shipping option via the payment request API +$app->post('/payment_intents/{id}/shipping_change', function (Request $request, Response $response, array $args) { + $data = $request->getParsedBody(); + $amount = Inventory::calculatePaymentAmount($data['items']); + $amount += Shipping::getShippingCost($data['shippingOption']['id']); + try { + $paymentIntent = \Stripe\PaymentIntent::update($args['id'], [ 'amount' => $amount ]); + return $response->withJson([ 'paymentIntent' => $paymentIntent ]); + } catch (\Exception $e) { + return $response->withJson([ 'error' => $e->getMessage() ])->withStatus(403); + } +}); + +// Fetch the payment intent status +// Used for redirect sources when coming back to the return URL +$app->get('/payment_intents/{id}/status', function (Request $request, Response $response, array $args) { + $paymentIntent = \Stripe\PaymentIntent::retrieve($args['id']); + return $response->withJson([ 'paymentIntent' => [ 'status' => $paymentIntent->status ] ]); +}); + +// Events receiver for payment intents and sources +$app->post('/webhook', function (Request $request, Response $response, array $args) { + $logger = $this->get('logger'); + + $event = $request->getParsedBody(); + + // Parse the message body (and check the signature if possible) + $webhookSecret = $this->get('settings')['stripe']['webhookSecret']; + if ($webhookSecret) { + try { + $event = \Stripe\Webhook::constructEvent( + $request->getBody(), + $request->getHeaderLine('stripe-signature'), + $webhookSecret + ); + } catch (\Exception $e) { + return $response->withJson([ 'error' => $e->getMessage() ])->withStatus(403); + } + } else { + $event = $request->getParsedBody(); + } + + $type = $event['type']; + $object = $event['data']['object']; + + switch ($object['object']) { + case 'payment_intent': + $paymentIntent = $object; + if ($type == 'payment_intent.succeeded') { + // Payment intent successfully completed + $logger->info('🔔 Webhook received! Payment for PaymentIntent ' . + $paymentIntent['id'] . ' succeeded'); + } elseif ($type == 'payment_intent.payment_failed') { + // Payment intent completed with failure + $logger->info('🔔 Webhook received! Payment on source ' . $paymentIntent['last_payment_error']['source']['id'] . + ' for PaymentIntent ' . $paymentIntent['id'] . ' failed'); + } + break; + case 'source': + $source = $object; + if (!isset($source['metadata']['paymentIntent'])) { + // Could be a source from another integration + $logger->info('🔔 Webhook received! Source ' . $source['id'] . + ' did not contain any payment intent in its metadata, ignoring it...'); + continue; + } + + // Retrieve the payment intent this source was created for + $paymentIntent = \Stripe\PaymentIntent::retrieve($source['metadata']['paymentIntent']); + + // Check the source status + if ($source['status'] == 'chargeable') { + // Source is chargeable, use it to confirm the payment intent if possible + if (!in_array($paymentIntent->status, [ 'requires_source', 'requires_payment_method' ])) { + $info = "PaymentIntent {$paymentIntent->id} already has a status of {$paymentIntent->status}"; + $logger->info($info); + return $response->withJson([ 'info' => $info ])->withStatus(200); + } + + $paymentIntent->confirm([ 'source' => $source['id'] ]); + } elseif (in_array($source['status'], [ 'failed', 'canceled' ])) { + // Source failed or has been canceled, cancel the payment intent to let the polling know + $logger->info('🔔 Webhook received! Source ' . $source['id'] . + ' failed or has been canceled, canceling PaymentIntent ' . $paymentIntent->id); + $paymentIntent->cancel(); + } + break; + } + + return $response->withJson([ 'status' => 'success' ])->withStatus(200); +}); + +// Run app +$app->run(); diff --git a/server/php/settings.ini.example b/server/php/settings.ini.example new file mode 100644 index 00000000..a738f981 --- /dev/null +++ b/server/php/settings.ini.example @@ -0,0 +1,16 @@ + +; Find them at https://dashboard.stripe.com/account/apikeys +publishableKey = +secretKey = + +; Find it at https://dashboard.stripe.com/account/webhooks +webhookSecret = + +; Your account country +accountCountry = US + +; Your shop currency +shopCurrency = usd + +; The default country selected for the billing address +defaultCountry = US diff --git a/server/php/settings.php b/server/php/settings.php new file mode 100644 index 00000000..381148b6 --- /dev/null +++ b/server/php/settings.php @@ -0,0 +1,56 @@ + [ + 'displayErrorDetails' => true, // set to false in production + 'addContentLengthHeader' => false, // Allow the web server to send the content-length header + + // Monolog settings + 'logger' => [ + 'name' => 'slim-app', + 'path' => isset($_ENV['docker']) ? 'php://stdout' : __DIR__ . '/logs/app.log', + 'level' => \Monolog\Logger::DEBUG, + ], + + 'stripe' => [ + // You shouldn't have to touch this + 'apiVersion' => '2019-03-14', + + // Update this path if you want to move your public folder + 'staticDir' => __DIR__ . '/../../public/', + + // Adapt these to match your account payments settings + // https://dashboard.stripe.com/account/payments/settings + 'paymentMethods' => [ + // 'ach_credit_transfer', // usd (ACH Credit Transfer payments must be in U.S. Dollars) + 'alipay', // aud, cad, eur, gbp, hkd, jpy, nzd, sgd, or usd. + 'bancontact', // eur (Bancontact must always use Euros) + 'card', // many (https://stripe.com/docs/currencies#presentment-currencies) + 'eps', // eur (EPS must always use Euros) + 'ideal', // eur (iDEAL must always use Euros) + 'giropay', // eur (Giropay must always use Euros) + 'multibanco', // eur (Multibanco must always use Euros) + // 'sepa_debit', // Restricted. See docs for activation details: https://stripe.com/docs/sources/sepa-debit + 'sofort', // eur (SOFORT must always use Euros) + 'wechat' // aud, cad, eur, gbp, hkd, jpy, sgd, or usd. + ], + + // See settings.ini + 'publishableKey' => $settings['publishableKey'], + 'secretKey' => $settings['secretKey'], + 'webhookSecret' => $settings['webhookSecret'], + 'accountCountry' => $settings['accountCountry'], + 'shopCurrency' => $settings['shopCurrency'], + 'defaultCountry' => $settings['defaultCountry'] + ] + ], +]; diff --git a/server/php/store/Inventory.php b/server/php/store/Inventory.php new file mode 100644 index 00000000..45545fee --- /dev/null +++ b/server/php/store/Inventory.php @@ -0,0 +1,113 @@ + [ + 'type' => 'good', 'name'=> 'Increment Magazine', 'attributes' => [ 'issue' ], + 'sku' => [ + 'id' => 'increment-03', + 'product' => 'increment', + 'attributes' => [ 'issue' => 'Issue #3 “Development”' ], + 'price' => 399, 'currency' => 'usd', + 'inventory' => [ 'type' => 'infinite' ] + ] + ], + 'pins' => [ + 'type' => 'good', 'name' => 'Stripe Pins', 'attributes' => [ 'set' ], + 'sku' => [ + 'id' => 'pins-collector', + 'product' => 'pins', + 'attributes' => [ 'set' => 'Collector Set' ], + 'price' => 799, 'currency' => 'usd', + 'inventory' => [ 'type' => 'finite', 'quantity' => 500 ] + ] + ], + 'shirt' => [ + 'type' => 'good', 'name' => 'Stripe Shirt', 'attributes' => ['size', 'gender'], + 'sku' => [ + 'id' => 'shirt-small-woman', + 'product' => 'shirt', + 'attributes' => [ 'size' => 'Small Standard', 'gender' => 'Woman' ], + 'price' => 999, 'currency' => 'usd', + 'inventory' => [ 'type' => 'infinite' ] + ] + ] + ]; + + public static function calculatePaymentAmount($items) { + $total = 0; + foreach ($items as $item) { + $total += self::getSkuPrice($item['parent']) * $item['quantity']; + } + + return $total; + } + + public static function listProducts() { + static $cachedProducts = null; + + if ($cachedProducts) { + return $cachedProducts; + } + + $ids = array_keys(self::$products); + $products = \Stripe\Product::all([ "ids" => $ids ]); + if (count($products->data) === count($ids)) { + $cachedProducts = self::withSkus($products); + return $cachedProducts; + } + + // Products have not been created yet, do it one by one + foreach (self::$products as $id => $product) { + $p = $product; + $p['id'] = $id; + unset($p['sku']); + + \Stripe\Product::create($p); + \Stripe\Sku::create($product['sku']); + } + + $products = \Stripe\Product::all([ "ids" => $ids ]); + if (count($products->data) === count($ids)) { + $cachedProducts = self::withSkus($products); + return $cachedProducts; + } + + // Stripe should already have thrown an Exception but just in case + throw new \RuntimeException("Couldn't retrieve nor create the products in Stripe."); + } + + protected static function withSkus($products) { + foreach ($products->data as $i => $product) { + $products->data[$i]->skus = [ 'data' => [ + \Stripe\Sku::retrieve(self::$products[$product->id]['sku']['id']) + ]]; + } + + return $products; + } + + public static function getSkuPrice($id) { + foreach (self::listProducts()->data as $product) { + if ($product->skus->data[0]->id == $id) { + return $product->skus->data[0]->price; + } + } + + throw new \UnexpectedValueException('Unknown sku ID. Argument passed: ' . $id); + } + + public static function getProduct($id) { + foreach (self::listProducts()->data as $product) { + if ($product->id == $id) { + return $product; + } + } + + throw new \UnexpectedValueException('Unknown product ID. Argument passed: ' . $id); + } +} diff --git a/server/php/store/Shipping.php b/server/php/store/Shipping.php new file mode 100644 index 00000000..616dee07 --- /dev/null +++ b/server/php/store/Shipping.php @@ -0,0 +1,29 @@ + [ 'label' => 'Free Shipping', 'detail' => 'Delivery within 5 days', 'amount' => 0 ], + 'express' => [ 'label' => 'Express Shipping', 'detail' => 'Next day delivery', 'amount' => 500 ] + ]; + + public static function getShippingOptions() { + $shippingOptions = []; + foreach (self::$options as $id => $option) { + $shippingOptions[] = array_merge([ 'id' => $id ], $option); + } + + return $shippingOptions; + } + + public static function getShippingCost($id) { + if (isset(self::$options[$id])) { + return self::$options[$id]['amount']; + } + + throw new \UnexpectedValueException('Unknown shipping option ID. Argument passed: ' . $id); + } +}