ZfrCash is a high level Zend Framework 2 module that simplify how you handle payments. It internally uses Stripe as the payment gateway, using ZfrStripe.
Here are a few features of what ZfrCash allows:
- Provides clean interfaces to represent a Stripe customer and a billable object (object that could receive a subscription).
- Clean services to create customers, cards, discounts, plans and subscriptions.
- Provides a basic listener that keep in sync subscriptions, cards and discounts.
- Easily extensible through events
- ZF2: >= 2.2
- Doctrine ORM: >= 2.5 (we are using some new features that only ship with Doctrine ORM 2.5)
- DoctrineORMModule: >= 0.9
- ZfrStripe
While we only use Doctrine Common interfaces, I suspect it won't work with Doctrine ODM as it needs things like entity resolvers.
To install ZfrCash, use composer:
php composer.phar require zfr/zfr-cash:~1.0
Enable ZfrCash in your application.config.php
, then copy the file
vendor/zfr/zfr-cash/config/zfr_cash.local.php.dist
to the
config/autoload
directory of your application (don't forget to remove the
.dist
extension from the file name!).
Before diving into ZfrCash, you need to be familiar with some concepts that are used throughout this module:
-
Customer: a Customer (any object that implements
ZfrCash\Entity\CustomerInterface
) is a customer in sense of Stripe. A customer is associated to a Stripe customer identifier, a credit card (optional) and a discount (optional). -
Billable object: once you have a customer, you can create one or multiple recurring subscriptions. ZfrCash introduces the concept of a billable object (any object that implements the
ZfrCash\Entity\BillableInterface
interface). A billable object is simply an object that holds a subscription (which, in turns, holds a payer - a customer -).
ZfrCash is flexible enough to allow a lot of different use cases. Here are multiple examples
If your business is based on one Stripe subscription per user (the user itself subscribes to a plan), then you could make your user implements the two ZfrCash interfaces:
use ZfrCash\Entity\BillableInterface;
use ZfrCash\Entity\BillableTrait;
use ZfrCash\Entity\CustomerInterface;
use ZfrCash\Entity\CustomerTrait;
class User implements CustomerInterface, BillableInterface
{
use CustomerTrait;
use BillableTrait;
}
The two traits comes with sane default mapping.
Stripe supports multiple subscriptions, and ZfrCash makes it easy to support this use case. For instance, you may want to price per project, each new project resulting in a new, separate subscription (but paid by the same person). In this case, the billable object will be the project, will the user will stay the Customer.
The User now only implements CustomerInterface:
use ZfrCash\Entity\CustomerInterface;
use ZfrCash\Entity\CustomerTrait;
class User implements CustomerInterface
{
use CustomerTrait;
}
While the project implements the BillableInterface:
use ZfrCash\Entity\BillableInterface;
use ZfrCash\Entity\BillableTrait;
class Project implements BillableInterface
{
use BillableTrait;
}
While ZfrCash tries to do as much as possible automatically, it requires some configuration on your end.
The first thing you need is to copy the zfr_cash.local.php.dist
file to your application/autoload
folder. There
is one mandatory option:
object_manager
: if you are using Doctrine ORM, there are great chances that you should specifydoctrine.entitymanager.orm_default
.
In your application, add the following config:
return [
'doctrine' => [
'entity_resolver' => [
'orm_default' => [
'resolvers' => [
CustomerInterface::class => YourCustomerClass::class,
BillableInterface::class => YourBillableClass::class
]
]
]
]
]
This actually maps the two ZfrCash interfaces to concrete implementations in your code.
For ZfrCash to work properly, you must create two custom Doctrine repositories:
- The repository for the class implementing
CustomerInterface
must implementsZfrCash\Repository\CustomerRepositoryInterface
. - The repository for the class implementing
BillableInterface
must implementsZfrCash\Repository\BillableRepositoryInterface
To create a custom repository, you need to set the repositoryClass
option in your Doctrine 2 mapping. Here is an
example for a User class implementing the CustomerInterface
:
/**
* @ORM\Entity(repositoryClass="User\Repository\UserRepository")
*/
class User implements CustomerInterface
{
use CustomerTrait;
}
Where your repository implements the given interface:
namespace User\Repository;
use Doctrine\ORM\EntityRepository;
use ZfrCash\Repository\CustomerRepositoryInterface;
class UserRepository extends EntityRepository implements CustomerRepositoryInterface
{
public function findOneByStripeId($stripeId)
{
return $this->findOneBy(['stripeId' => $stripeId]);
}
}
Do the same for the billable object (that may be actually the same repository if you use the one subscription per customer architecture).
By default, ZfrCash creates two routes that will listen to some events triggered by Stripe.
/stripe/test-listener
: will listen to test events/stripe/live-listener
: will listen to live events
You can change the URL if you want, but those are sane defaults. ZfrCash is smart enough to detect the if the incoming events match the given route. For instance, if a live event reaches a test listener, it will do nothing (it uses the API key prefix to see if it matches).
Whenever ZfrCash receives an event from Stripe, it will do an additional API request to Stripe to validate the
webhook and ensure no one is trying to hack you. However, if your application run into a controlled environment (for
instance if you filter incoming requests by Stripe IPs), you can disable this behaviour by setting the
validate_webhooks
option to false in your config:
return [
'zfr_cash' => [
'validate_webhooks' => false
]
];
By default, ZfrCash will listen to the following events, and take some actions:
customer.discount.created
,customer.discount.updated
,customer.discount.deleted
: ZfrCash synchronizes the various discount events (both for subscription discounts and customer discounts). This means you can create/update/remove a discount right into the Stripe UI, and it will be automatically persisted into your database. Alternatively, you can also create/update/remove a discount in your code, using services.customer.card.updated
orcustomer.source.updated
: since January 2015, Stripe can automatically updates your Stripe customers' card, without them to manually update their card. As a consequence, ZfrCash automatically update the card. Alternatively, you can create or remove a card into your code. If you are using an API version newer or equal than 2015-02-18, you must usecustomer.source.updated
, otherwisecustomer.card.updated
.customer.subscription.updated
,customer.subscription.deleted
: when your subscription renews, ZfrCash automatically updates the various subscription properties (likecurrent_period_start
andcurrent_period_end
). It also deletes the subscription for your database if it is deleted from Stripe. Alternatively, you can create, update and remove subscriptions into your code using services.plan.created
,plan.updated
,plan.deleted
: whenever you create, update or delete a plan from Stripe, it is automatically added into your database.
If you don't want ZfrCash to keep your database in sync, you can disable this behaviour by setting the
register_listeners
to false in your config:
return [
'zfr_cash' => [
'register_listeners' => false
]
];
Now that ZfrCash is configured, we need to configure Stripe so that it correctly sends the events into your application. To do that, go to your Stripe dashbord.
In the top right screen, click on your account name, and "Account Settings". Open the "Webhooks" tab.
You can add either test listener or live listener. Click on "Add URL". Carefully select the right mode, and
enter the right URL. For instance, for Live URL: https://www.mysite.com/stripe/live-listener
.
We do not recommend you to send all the events, as Stripe is quite chatty, it can add some stress to your server.
At the minimum, we recommend you to listen to the events that ZfrCash listen. Some other interesting events
include: invoice.payment_succeeded
, invoice.payment_failed
... In a later section, you will learn how you can
hook your own code.
Before going to production, be sure to try your code in test mode, and see if ZfrCash reacts correctly in your case.
While ZfrCash only provides behaviour for basic events, Stripe sends a lot of other events. For instance, you may
want to send an email whenever a payment for a recurring payment fail. To do that, you must listen to the
ZfrCash\Event\WebhookEvent::WEBHOOK_RECEIVED
event.
The first step is to create your listener class:
namespace Application\Listener;
use ZfrCash\Controller\WebhookListenerController;
use ZfrCash\Event\WebhookEvent;
class CustomStripeListener extends AbstractListenerAggregate
{
public function attachAggregate(EventManagerInterface $eventManager)
{
$sharedManager = $eventManager->getSharedManager();
$sharedManager->attach(WebhookListenerController::class, WebhookEvent::WEBHOOK_RECEIVED, [$this, 'handleStripeEvent']);
}
/**
* @param WebhookEvent $event
*/
public function handleStripeEvent(WebhookEvent $event)
{
$stripeEvent = $event->getStripeEvent(); // This is the full Stripe event
switch ($stripeEvent['type']) {
case 'invoice.payment_failed':
// Do something...
break;
}
}
}
Finally, you need to register your listener. In your Module.php class:
public function onBootstrap(EventInterface $event)
{
/* @var $application \Zend\Mvc\Application */
$application = $event->getTarget();
$serviceManager = $application->getServiceManager();
$eventManager = $application->getEventManager();
$eventManager->attach(new CustomStripeListener());
}
You can retrieve the CustomerService using the ZfrCash\Service\CustomerService
key in your service manager.
The customer service is a built-in service that allows you to create a Stripe customer. This service automatically creates a Stripe customer on Stripe, and save the various properties in your database. You can optionally create a customer with a card and/or discount in one call.
Most of the time, the customer will be your user class. That's why ZfrCash expects that you pass it an object
implementing ZfrCash\Entity\CustomerInterface
. Here is a simple usage:
class UserController extends AbstractActionController
{
public function createAction()
{
// Create your user... that implements CustomerInterface
$cardToken = $this->params()->fromQuery('card_token');
$discount = $this->params()->fromQuery('discount');
$user = $this->customerService->create($user, [
'card' => $cardToken, // If Stripe API version is older than 2015-02-18
'source' => $cardToken, // If Stripe API version is newer or equal than 2015-02-18
'discount' => $discount,
'email' => $user->getEmail()
]);
}
}
Supported options are:
email
: set the email Stripe attributedescription
: set the description Stripe attributecard
: can either be a card token (created using Stripe.JS) or a full hash containing card properties. This must be used in place ofsource
for API version of Stripe older than 2015-02-18.source
: can either be a card token (created using Stripe.JS) or a full hash containing card properties. This must be used in place ofcard
for API version of Stripe newer or equal than 2015-02-18.coupon
: a coupon to attach to the customermetadata
: a key value that set the metadata Stripe attributesidempotency_key
: a key that is used to prevent an operation for being executed twice
All of those properties are optional.
If you have a customer Stripe ID, you can retrieve the full customer using a Stripe identifier:
$customer = $this->customerService->getByStripeAd('cus_abc');
The card service allows you to create and remove a card from a customer.
You can retrieve the CardService using the ZfrCash\Service\CardService
key in your service manager.
If you want to replace the default credit card of a customer, you can use the attachToCustomer
method. It will
automatically delete the previous card both from Stripe and your database, and attach the new one:
$card = $cardService->attachToCustomer($customer, $cardToken);
// $card is the new card
The second parameter can either be a card token (created using Stripe.JS) or a hash of card attributes.
You can remove a card (both from Stripe and your database) using the remove
method:
$cardService->remove($card);
The subscription service is used to create, update and remove any subscription.
You can retrieve the SubscriptionService using the ZfrCash\Service\SubscriptionService
key in your service manager.
The main operation is to create a subscription. A subscription is paid by a subscription, for a billable resource (which may be the same, if you have one subscription per customer). The method accepts a customer, a billable, a plan, and options. Supported options are:
tax_percent
: allow to set a tax that will be applied in addition of normal plan pricequantity
: set a quantity for the plancoupon
: set a coupon for the given subscription onlytrial_end
: a DateTime that represents that allows to manually set an trial dateapplication_fee_percent
: if you are creating subscription on behalf of other through Stripe Connectbilling_cycle_anchor
: a DateTime that defines when to start the recurring paymentsmetadata
: any pair of metadataidempotency_key
: a key that is used to prevent an operation for being executed twice
For instance:
$subscription = $subscriptionService->create($customer, $billable, $plan, [
'quantity' => 2,
'trial_end' => (new DateTime())->modify('+7 days')
]);
Internally, ZfrCash will create the subscription on Stripe, save it in your database, and make the different connections between the payer, the subscription and the billable resource.
Note: the
idempotency_key
is a new feature that Stripe recently added, and that ZfrCash already supports. Basically, it allows to prevent an operation from being executed twice. For instance, let's say that you are using the subscription service to create a subscription in a delayed job executed by a worker. The job is executed, the subscription service correctly create the subscription (and the customer starts paying), but the job just fails after that for any reason (a HTTP call has timed out, the server has been shut down...). The job is therefore automatically reinserted for later processing... but the problem is that the subscription will be created again, and your customer will pay twice! To avoid this issue, you can pass anidempotency_key
(that can be anything, but in this example, the unique identifier of the job is a good candidate). During 24 hours, if you try to create a subscription with the exact same idempotency key, Stripe will return the exact same response, without create a new subscription each time!
You can cancel a subscription through the service. The method accepts an optional second parameter (false by default), that allows to cancel the subscription at the end of the current period, instead of stopping it now (the default).
If the cancel is set to cancel the subscription now, it will automatically remove it from your database.
You can also modify an existing subscription, either the plan or the quantity:
// Update the plan
$anotherPlan = ...;
$subscription = $this->subscriptionService->modifyPlan($subscription, $anotherPlan);
// Update the quantity
$subscription = $this->subscriptionService->modifyQuantity($subscription, 4);
As always, ZfrCash will make the API call to Stripe, and update your database.
The service also has several getters you can use:
getById
: get a subscription by its IDgetByStripeId
: get the subscription by its Stripe IDgetByCustomer
: get all the subscriptions for the given customer
The plan service allows you to update and remove a plan.
You can retrieve the PlanService using the ZfrCash\Service\PlanService
key in your service manager.
You can use this method to update the plan name (not recommended) or the metadata plan. The metadata plan (up to 20 key/values) can be used for things like having plan limits encoded in the API.
ZfrCash does not allow to remove plans from database. Instead, it justs allow to deactivate a plan. The reason is that you may still have subscription link to a plan, and removing it may corrupt your data.
Instead, when you deactivate a plan (or when you remove it from Stripe and that ZfrCash handles the event), it is just soft-deleted.
When you use ZfrCash for the first time, you may have plans already created on Stripe. Instead of manually creating
all your plans into your database, you can use the syncFromStripe
method. It will retrieve all the created plans
from your Stripe account, and create them locally in your database.
Whenever a new plan is imported or created from an event, it is deactivated by default (to avoid leaking and make a newly created plan already visible by your customers). You are responsible for activating it yourself.
The customer discount service allows you to handle customer discount (in Stripe, a discount created for a customer will be applied to ALL recurring payments).
You can retrieve the CustomerDiscountService using the ZfrCash\Service\CustomerDiscountService
key in your service
manager.
You can create a new discount for a customer by passing a coupon code. It will automatically make the call to Stripe, and update your database. If the customer already has a coupon, it will update it instead:
$discount = $this->customerDiscountService->createForCustomer($customer, 'COUPON_15');
You can update the coupon of an existing discount using the changeCoupon
method. It will automatically make the
API call to Stripe, and update your database:
$discount = $this->customerDiscountService->changeCoupon($discount, 'COUPON_30');
Finally, you can remove an existing coupon. This will delete it from Stripe and your database:
$this->customerDiscountService->remove($discount);
Finally, the service offers several getters you can use:
getById
: get the discount by its idgetByCustomer
: get the discount of a given customer
The subscription discount service allows you to handle subscription discount (in Stripe, a discount created for a subscription will ONLY be applied for recurring payments of a given subscription).
You can retrieve the SubscriptionDiscountService using the ZfrCash\Service\SubscriptionDiscountService
key in
your service manager.
You can create a new discount for a subscription by passing a coupon code. It will automatically make the call to Stripe, and update your database. If the subscription already has a coupon, it will update it instead:
$discount = $this->subscriptionDiscountService->createForSubscription($customer, 'COUPON_15');
You can update the coupon of an existing discount using the changeCoupon
method. It will automatically make the
API call to Stripe, and update your database:
$discount = $this->subscriptionDiscountService->changeCoupon($discount, 'COUPON_30');
Finally, you can remove an existing coupon. This will delete it from Stripe and your database:
$this->subscriptionDiscountService->remove($discount);
Finally, the service offers several getters you can use:
getById
: get the discount by its idgetBySubscription
: get the discount of a given subscription
ZfrCash comes with a validator for european VAT numbers (VIES). For instance, if you have an input filter, you can add the validator:
$inputFilter->add([
'name' => 'tax_number',
'required' => false,
'validators' => [
['name' => ViesValidator::class]
]
]);
By default, the validator only follow rules for correct format, but do not enforce the real existence of the VAT
number. This needs to do an additional call to the VIES webservice. To enable this, you can use the option
check_existence
:
$inputFilter->add([
'name' => 'tax_number',
'required' => false,
'validators' => [
[
'name' => ViesValidator::class,
'options' => ['check_existence' => true]
]
]
]);
However, please note that from my experience, the VIES webservice is HIGHLY unreliable and often fails.