If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
Go create a huge coupon - like for $500. Call this one SUPER_SHEEP
!
Ok, this coupon is awesome - so let's add it to an order.
Perfect! As you can see, the cart is already smart enough to return the total as $0, instead of a negative number. Way to go cart!
But, uh, what's this checkout form still doing over here? Why should I need to enter my credit card info? This order is free!
Look, how you handle free orders is up to you. If you still want to require a credit card, you can do that. The customer won't be charged, but the card will be on file for renewals.
But that's not for me: I want to make it as easy as possible for this user to checkout free.
The first step, is to hide this checkout form! In checkout.html.twig
, find the
_cardForm.html.twig
include, and wrap it in if cart.totalWithDiscount > 0
:
... lines 1 - 19 | |
{% block body %} | |
<div class="nav-space-checkout"> | |
<div class="container"> | |
<div class="row"> | |
... lines 24 - 83 | |
<div class="col-xs-12 col-sm-6"> | |
{% if cart.totalWithDiscount > 0 %} | |
{{ include('order/_cardForm.html.twig') }} | |
{% else %} | |
... lines 88 - 92 | |
{% endif %} | |
</div> | |
</div> | |
</div> | |
</div> | |
{% endblock %} |
Else, create a really simple form that submits right back to this URL. Give it
method="POST"
and a submit button that invites the user to "Checkout for Free":
... lines 1 - 19 | |
{% block body %} | |
<div class="nav-space-checkout"> | |
<div class="container"> | |
<div class="row"> | |
... lines 24 - 83 | |
<div class="col-xs-12 col-sm-6"> | |
{% if cart.totalWithDiscount > 0 %} | |
{{ include('order/_cardForm.html.twig') }} | |
{% else %} | |
<form action="" method="POST"> | |
<button type="submit" class="btn btn-lg btn-danger"> | |
Checkout for Free! | |
</button> | |
</form> | |
{% endif %} | |
</div> | |
</div> | |
</div> | |
</div> | |
{% endblock %} |
If you refresh now, boom! Checkout form gone.
But that's not quite everything we need to do. Thanks to the discount, Stripe
will already know that it doesn't need to charge the user, and so, the customer
doesn't need to have a card. But, the funny thing is, up until now, our OrderController
logic is expecting a stripeToken
to always be submitted:
... lines 1 - 11 | |
class OrderController extends BaseController | |
{ | |
... lines 14 - 48 | |
public function checkoutAction(Request $request) | |
{ | |
... lines 51 - 53 | |
if ($request->isMethod('POST')) { | |
$token = $request->request->get('stripeToken'); | |
... lines 56 - 68 | |
} | |
... lines 70 - 77 | |
} | |
... lines 79 - 166 | |
} | |
... lines 168 - 169 |
Remember that's the token that we get back from Stripe, after submitting the
credit card information to them. We then pass that to chargeCustomer()
and attach
it to the Customer:
... lines 1 - 11 | |
class OrderController extends BaseController | |
{ | |
... lines 14 - 120 | |
private function chargeCustomer($token) | |
{ | |
... lines 123 - 125 | |
if (!$user->getStripeCustomerId()) { | |
$stripeCustomer = $stripeClient->createCustomer($user, $token); | |
} else { | |
$stripeCustomer = $stripeClient->updateCustomerCard($user, $token); | |
} | |
... lines 131 - 165 | |
} | |
} | |
... lines 168 - 169 |
But now, our code needs to be smart enough to not try to attach the token to the Customer for free orders. At the top, add a sanity check: if there is no token, and the shopping cart's total with discount is not free... well, we have a problem! Throw a clear exception: the order is non-free... but we're missing the payment token!
... lines 1 - 11 | |
class OrderController extends BaseController | |
{ | |
... lines 14 - 120 | |
private function chargeCustomer($token) | |
{ | |
if (!$token && $this->get('shopping_cart')->getTotalWithDiscount() > 0) { | |
throw new \Exception('Somehow the order is non-free, but we have no token!?'); | |
} | |
... lines 126 - 174 | |
} | |
} | |
... lines 177 - 178 |
Next, when we call createCustomer()
, we pass in the $token
. Open StripeClient
and find that method:
... lines 1 - 8 | |
class StripeClient | |
{ | |
... lines 11 - 19 | |
public function createCustomer(User $user, $paymentToken) | |
{ | |
$customer = \Stripe\Customer::create([ | |
... line 23 | |
'source' => $paymentToken, | |
]); | |
... lines 26 - 31 | |
} | |
... lines 33 - 191 | |
} |
Hmm. Now, $paymentToken
might be blank. But Stripe will be really angry if we
try to attach an empty source to the Customer. Instead of doing this all at once,
add a new $data
array variable, and move the email
key into it. Then, pass
$data
to the create()
call:
... lines 1 - 8 | |
class StripeClient | |
{ | |
... lines 11 - 19 | |
public function createCustomer(User $user, $paymentToken) | |
{ | |
$data = [ | |
'email' => $user->getEmail(), | |
]; | |
... lines 25 - 29 | |
$customer = \Stripe\Customer::create($data); | |
... lines 31 - 36 | |
} | |
... lines 38 - 201 | |
} |
You know what's next: if $paymentToken
is not blank, add a source
key to
$data
set to $paymentToken
:
... lines 1 - 8 | |
class StripeClient | |
{ | |
... lines 11 - 19 | |
public function createCustomer(User $user, $paymentToken) | |
{ | |
$data = [ | |
'email' => $user->getEmail(), | |
]; | |
if ($paymentToken) { | |
$data['source'] = $paymentToken; | |
} | |
$customer = \Stripe\Customer::create($data); | |
... lines 31 - 36 | |
} | |
... lines 38 - 201 | |
} |
We're done here.
But we have the same problem in updateCustomerCard()
. Let's fix this here:
if $stripeToken
, then update the customer's card:
... lines 1 - 11 | |
class OrderController extends BaseController | |
{ | |
... lines 14 - 120 | |
private function chargeCustomer($token) | |
{ | |
... lines 123 - 129 | |
if (!$user->getStripeCustomerId()) { | |
$stripeCustomer = $stripeClient->createCustomer($user, $token); | |
} else { | |
// don't need to update it if the order is fre | |
if ($token) { | |
$stripeCustomer = $stripeClient->updateCustomerCard($user, $token); | |
} else { | |
... line 137 | |
} | |
} | |
... lines 140 - 174 | |
} | |
} | |
... lines 177 - 178 |
Else, we do need to fetch the \Stripe\Customer
object - we use it below.
In StripeClient
, add a new method to do this: public function findCustomer()
with a User
argument:
... lines 1 - 8 | |
class StripeClient | |
{ | |
... lines 11 - 48 | |
public function findCustomer(User $user) | |
{ | |
... line 51 | |
} | |
... lines 53 - 201 | |
} |
Then, return the timeless \Stripe\Customer::retrieve($user->getStripeCustomerId())
:
... lines 1 - 8 | |
class StripeClient | |
{ | |
... lines 11 - 48 | |
public function findCustomer(User $user) | |
{ | |
return \Stripe\Customer::retrieve($user->getStripeCustomerId()); | |
} | |
... lines 53 - 201 | |
} |
In the controller, call that: $stripeCustomer = $stripeClient->findCustomer($user)
:
... lines 1 - 11 | |
class OrderController extends BaseController | |
{ | |
... lines 14 - 120 | |
private function chargeCustomer($token) | |
{ | |
... lines 123 - 129 | |
if (!$user->getStripeCustomerId()) { | |
$stripeCustomer = $stripeClient->createCustomer($user, $token); | |
} else { | |
// don't need to update it if the order is fre | |
if ($token) { | |
$stripeCustomer = $stripeClient->updateCustomerCard($user, $token); | |
} else { | |
$stripeCustomer = $stripeClient->findCustomer($user); | |
} | |
} | |
... lines 140 - 174 | |
} | |
} | |
... lines 177 - 178 |
Ok, I'm feeling good! The last trouble spot is in updateCardDetails()
. Open
SubscriptionHelper
:
... lines 1 - 8 | |
class SubscriptionHelper | |
{ | |
... lines 11 - 104 | |
public function updateCardDetails(User $user, \Stripe\Customer $stripeCustomer) | |
{ | |
$cardDetails = $stripeCustomer->sources->data[0]; | |
$user->setCardBrand($cardDetails->brand); | |
$user->setCardLast4($cardDetails->last4); | |
$this->em->persist($user); | |
$this->em->flush($user); | |
} | |
... lines 113 - 131 | |
} |
Oh yea - this method looks at the sources
key on the Customer to get the card brand
and last four digits. In our app, every Customer has exactly one card, so we use the 0
key. But guess what! Not anymore: a customer might have zero cards.
So we just need to code defensively: add an if statement: if !$stripeCustomer->sources->data
,
just return: there's no card details to update:
... lines 1 - 8 | |
class SubscriptionHelper | |
{ | |
... lines 11 - 104 | |
public function updateCardDetails(User $user, \Stripe\Customer $stripeCustomer) | |
{ | |
if (!$stripeCustomer->sources->data) { | |
// the customer may not have a card on file | |
return; | |
} | |
... lines 111 - 116 | |
} | |
... lines 118 - 136 | |
} |
Ok, we're done! A free checkout and a normal checkout are almost the same. The only difference is that you don't have a Stripe token, so you can't attach that to your Customer.
Refresh the checkout page and, "Checkout for Free".
It works! In Stripe, find our Customer. There is no new payment, but there is an Invoice for $0 and an active subscription. The Invoice shows off the discount.
Thanks to this change, it's now possible for a Customer to not have any cards attached in Stripe. And yea know what? This creates a new problem in a totally unrelated part of the process: webhooks.
But, it's no big deal. Open WebhookController
and find the invoice.payment_failed
section. Wait! Woh! Before that - oh geez - fix my horrible typo: invoice.payment_succeeded
:
... lines 1 - 9 | |
class WebhookController extends BaseController | |
{ | |
... lines 12 - 14 | |
public function stripeWebhookAction(Request $request) | |
{ | |
... lines 17 - 43 | |
switch ($stripeEvent->type) { | |
... lines 45 - 51 | |
case 'invoice.payment_succeeded': | |
... lines 53 - 78 | |
} | |
... lines 80 - 81 | |
} | |
... lines 83 - 102 | |
} |
This is why you must test your webhooks!
Anyways, back to invoice.payment_failed
. Our entire reason for handling this
webhook is so that we can send the user an email to tell them that we're having a
problem charging their card. We didn't actually do the work, but that email would
probably sound like this:
Hey friend! So, we're having a problem charging your card. If you need to update it, go to your account page and add the new details there.
But what will happen if a user checks out for free, but with a subscription? After their first month, Stripe will not be able to charge them, and it will trigger this webhook.
In those cases, the email should really have some different text, like:
Yo amigo! I hope you enjoyed your free month. If you want to continue, you can add a credit card to your account page.
So to know which language to use, first fetch the Stripe Customer by saying
$this->get('stripe_client')->findCustomer($user)
:
... lines 1 - 9 | |
class WebhookController extends BaseController | |
{ | |
... lines 12 - 14 | |
public function stripeWebhookAction(Request $request) | |
{ | |
... lines 17 - 43 | |
switch ($stripeEvent->type) { | |
... lines 45 - 62 | |
case 'invoice.payment_failed': | |
... lines 64 - 65 | |
if ($stripeSubscriptionId) { | |
... lines 67 - 68 | |
if ($stripeEvent->data->object->attempt_count == 1) { | |
$user = $subscription->getUser(); | |
$stripeCustomer = $this->get('stripe_client') | |
->findCustomer($user->getStripeCustomerId()); | |
... lines 73 - 77 | |
} | |
} | |
... lines 80 - 84 | |
} | |
... lines 86 - 87 | |
} | |
... lines 89 - 108 | |
} |
Now we can create a new variable, called $hasCardOnFile
. Set that to a count
of $stripeCustomer->sources->data
and check if it's greater than zero:
... lines 1 - 9 | |
class WebhookController extends BaseController | |
{ | |
... lines 12 - 14 | |
public function stripeWebhookAction(Request $request) | |
{ | |
... lines 17 - 43 | |
switch ($stripeEvent->type) { | |
... lines 45 - 62 | |
case 'invoice.payment_failed': | |
... lines 64 - 65 | |
if ($stripeSubscriptionId) { | |
... lines 67 - 68 | |
if ($stripeEvent->data->object->attempt_count == 1) { | |
$user = $subscription->getUser(); | |
$stripeCustomer = $this->get('stripe_client') | |
->findCustomer($user->getStripeCustomerId()); | |
$hasCardOnFile = count($stripeCustomer->sources->data) > 0; | |
// todo - send the user an email about the problem | |
// use hasCardOnFile to customize this | |
} | |
} | |
... lines 80 - 84 | |
} | |
... lines 86 - 87 | |
} | |
... lines 89 - 108 | |
} |
Now, you can use that variable to write the most uplifting, majestic, and encouraging payment failed emails that the world has ever seen.
"Houston: no signs of life"
Start the conversation!
// composer.json
{
"require": {
"php": ">=5.5.9, <7.4",
"symfony/symfony": "3.1.*", // v3.1.10
"doctrine/orm": "^2.5", // v2.7.2
"doctrine/doctrine-bundle": "^1.6", // 1.6.8
"doctrine/doctrine-cache-bundle": "^1.2", // 1.3.0
"symfony/swiftmailer-bundle": "^2.3", // v2.6.2
"symfony/monolog-bundle": "^2.8", // v2.12.1
"symfony/polyfill-apcu": "^1.0", // v1.3.0
"sensio/distribution-bundle": "^5.0", // v5.0.22
"sensio/framework-extra-bundle": "^3.0.2", // v3.0.26
"incenteev/composer-parameter-handler": "^2.0", // v2.1.2
"friendsofsymfony/user-bundle": "~2.0.1", // v2.0.1
"stof/doctrine-extensions-bundle": "^1.2", // v1.2.2
"stripe/stripe-php": "^3.15", // v3.23.0
"doctrine/doctrine-migrations-bundle": "^1.1", // v1.2.1
"phpunit/phpunit": "^5.5", // 5.7.20
"composer/package-versions-deprecated": "^1.11" // 1.11.99
},
"require-dev": {
"sensio/generator-bundle": "^3.0", // v3.1.4
"symfony/phpunit-bridge": "^3.0", // v3.3.0
"hautelook/alice-bundle": "^1.3", // v1.4.1
"doctrine/data-fixtures": "^1.2" // v1.2.2
}
}