Skip to content
This repository has been archived by the owner on Apr 19, 2022. It is now read-only.

Commit

Permalink
Add PHP Slim server (#60)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
youssef-stripe authored and thorsten-stripe committed Apr 15, 2019
1 parent 69521f4 commit 37d278f
Show file tree
Hide file tree
Showing 9 changed files with 532 additions and 0 deletions.
4 changes: 4 additions & 0 deletions server/php/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/vendor/
/logs/*
composer.lock
settings.ini
21 changes: 21 additions & 0 deletions server/php/.htaccess
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<IfModule mod_rewrite.c>
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]
</IfModule>
63 changes: 63 additions & 0 deletions server/php/README.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions server/php/composer.json
Original file line number Diff line number Diff line change
@@ -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": "[email protected]"
}
],
"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"
}

}
198 changes: 198 additions & 0 deletions server/php/index.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
<?php
use Slim\Http\Request;
use Slim\Http\Response;
use Stripe\Stripe;
use Store\Inventory;
use Store\Shipping;

// Due to a bug in the PHP embedded server, URLs containing a dot don't work
// This will fix the missing variable in that case
if (PHP_SAPI == 'cli-server') {
$_SERVER['SCRIPT_NAME'] = 'index.php';
}

require __DIR__ . '/vendor/autoload.php';

// Instantiate the app
$settings = require __DIR__ . '/settings.php';
$app = new \Slim\App($settings);

// Instantiate the logger as a dependency
$container = $app->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();
16 changes: 16 additions & 0 deletions server/php/settings.ini.example
Original file line number Diff line number Diff line change
@@ -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
56 changes: 56 additions & 0 deletions server/php/settings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php
$iniFilename = __DIR__ . '/settings.ini';
if (!is_file($iniFilename)) {
die('Missing settings.ini file.');
}

$settings = parse_ini_file($iniFilename);
if (!$settings) {
die('Unable to read settings.ini file. Please check file format and read access.');
}

return [
'settings' => [
'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']
]
],
];
Loading

0 comments on commit 37d278f

Please sign in to comment.