Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This course is archived!
This tutorial uses an older version of Symfony of the stripe-php SDK. The majority of the concepts are still valid, though there *are* differences. We've done our best to add notes & comments that describe these changes.

Displaying All the Invoice Details

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

To see all the details for each invoice, we need an "invoice show" page. Head to ProfileController and add a new method for this: showInvoiceAction(). Give it a URL: /profile/invoices/{invoiceId}. And a name: account_invoice_show, and then add the $invoiceId argument:

... lines 1 - 14
class ProfileController extends BaseController
{
... lines 17 - 178
/**
* @Route("/profile/invoices/{invoiceId}", name="account_invoice_show")
*/
public function showInvoiceAction($invoiceId)
{
... lines 184 - 189
}
}

Before doing anything else, copy that route name, head back to the account template and fill in the href by printing path() then pasting the route name. For the second argument, pass in the wildcard: invoiceId set to invoice.id, which will be the Stripe invoice ID:

... lines 1 - 67
{% block body %}
<div class="nav-space">
<div class="container">
<div class="row">
<div class="col-xs-6">
... lines 73 - 88
<table class="table">
<tbody>
... lines 91 - 148
<tr>
<th>Invoices</th>
<td>
<div class="list-group">
{% for invoice in invoices %}
<a href="{{ path('account_invoice_show', {invoiceId: invoice.id}) }}" class="list-group-item">
Date: {{ invoice.date|date('Y-m-d') }}
<span class="label label-success pull-right">${{ invoice.amount_due/100 }} </span>
</a>
{% endfor %}
</div>
</td>
</tr>
</tbody>
</table>
</div>
... lines 166 - 174
</div>
</div>
</div>
{% endblock %}
... lines 179 - 180

Fetch One Invoice's Data

Back in the controller, our work here is pretty simple: we'll just ask Stripe for this one Invoice. In StripeClient, we don't have a method that returns just one invoice, so let's add one: public function findInvoice() with an $invoiceId argument. Inside, return the elegant \Stripe\Invoice::retrieve($invoiceId):

... lines 1 - 8
class StripeClient
{
... lines 11 - 224
public function findInvoice($invoiceId)
{
return \Stripe\Invoice::retrieve($invoiceId);
}
}

Love it!

In the controller, use this: $stripeInvoice = $this->get('stripe_client')->findInvoice() with $invoiceId:

... lines 1 - 14
class ProfileController extends BaseController
{
... lines 17 - 181
public function showInvoiceAction($invoiceId)
{
$stripeInvoice = $this->get('stripe_client')
->findInvoice($invoiceId);
... lines 186 - 189
}
}

To make things really nice, you'll probably want to wrap this in a try-catch block: if there's a 404 error from Stripe, you'll want to catch that exception and throw the normal $this->createNotFoundException(). That'll cause our site to return a 404 error, instead of 500 error.

Finally, render a new template: how about profile/invoice.html.twig. Pass this an invoice variable set to $stripeInvoice:

... lines 1 - 14
class ProfileController extends BaseController
{
... lines 17 - 181
public function showInvoiceAction($invoiceId)
{
$stripeInvoice = $this->get('stripe_client')
->findInvoice($invoiceId);
return $this->render('profile/invoice.html.twig', array(
'invoice' => $stripeInvoice
));
}
}

Rendering Invoice Details

Instead of creating that template by hand, let's take a shortcut. If you downloaded the "start" code from the site, you should have a tutorial/ directory with an invoice.html.twig file inside. Copy that and paste it into your profile/ templates directory:

{% extends 'base.html.twig' %}
{% import _self as macros %}
{% macro currency(rawStripeAmount) %}
{% if rawStripeAmount < 0 %}-{% endif %}${{ (rawStripeAmount/100)|abs }}
{% endmacro %}
{% block body %}
<div class="nav-space">
<div class="container">
<div class="row">
<div class="col-xs-6">
<h1>Invoice {{ invoice.date|date('Y-m-d') }}</h1>
<table class="table">
<thead>
<tr>
<th>To</th>
{# or put company information here #}
<th>{{ app.user.email }}</th>
</tr>
<tr>
<th>Invoice Number</th>
<th>
{{ invoice.id }}
</th>
</tr>
</thead>
</table>
<table class="table table-striped">
<tbody>
{% if invoice.starting_balance %}
<tr>
<td>Starting Balance</td>
<td>
{{ macros.currency(invoice.starting_balance) }}
</td>
</tr>
{% endif %}
{% for lineItem in invoice.lines.data %}
<tr>
<td>
{% if lineItem.description %}
{{ lineItem.description }}
{% elseif (lineItem.plan) %}
Subscription to {{ lineItem.plan.name }}
{% endif %}
</td>
<td>
{{ macros.currency(lineItem.amount) }}
</td>
</tr>
{% endfor %}
{% if invoice.discount %}
<tr>
<td>Discount: {{ invoice.discount.coupon.id }}</td>
<td>
{{ macros.currency(invoice.discount.coupon.amount_off * -1) }}
</td>
</tr>
{% endif %}
<tr>
<th>Total</th>
<th>
{{ macros.currency(invoice.amount_due) }}
</th>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

Before we look deeper at this, let's make sure it works! Refresh the profile page, then click into one of the discounted invoices. Score! It's got all the important stuff: the subscription, the discount and the total at the bottom.

Here's the deal: there is an infinite number of ways to render an invoice. But the tricky part is understanding all the different pieces that you need to include. Let's take a look at invoice.html.twig to see what it's doing.

The Components of an Invoice

First, the Invoice has a starting_balance field:

... lines 1 - 7
{% block body %}
<div class="nav-space">
<div class="container">
<div class="row">
<div class="col-xs-6">
... lines 14 - 31
<table class="table table-striped">
<tbody>
{% if invoice.starting_balance %}
<tr>
<td>Starting Balance</td>
<td>
{{ macros.currency(invoice.starting_balance) }}
</td>
</tr>
{% endif %}
... lines 42 - 71
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

This answers the question: "how much was the customer's account balance before charging this invoice?" If the balance is positive, then this was used to discount the invoice before charging the customer. By printing it here, it'll help explain the total.

Tip

It's possible that not all of the Customer's balance was used. You could also use the ending_balance field to check.

Second, since each charge is a line item, we can loop through each one and print its details. But, each line item might be for an individual product or for a subscription. It's a little weird, but I've found that the best way to handle this is to check to see if lineItem.description is set:

... lines 1 - 7
{% block body %}
<div class="nav-space">
<div class="container">
<div class="row">
<div class="col-xs-6">
... lines 14 - 31
<table class="table table-striped">
<tbody>
... lines 34 - 41
{% for lineItem in invoice.lines.data %}
<tr>
<td>
{% if lineItem.description %}
{{ lineItem.description }}
{% elseif (lineItem.plan) %}
Subscription to {{ lineItem.plan.name }}
{% endif %}
</td>
<td>
{{ macros.currency(lineItem.amount) }}
</td>
</tr>
{% endfor %}
... lines 56 - 71
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

If it is set, then print it. In that case, this line item is either an individual product - in which case the description is the product's name - or it's a proration subscription line item that's created when a user changes between plans. In that case, the description is really nice: it explains exactly what this charge or credit means.

But if the description is blank, this is for a normal subscription charge. Print out "Subscription to" and then lineItem.plan.name.

In both cases, for the amount, print lineItem.amount. Oh, that macros.currency() thing is a macro I setup that helps manage negative numbers and adds the $ sign:

... line 1
{% import _self as macros %}
{% macro currency(rawStripeAmount) %}
{% if rawStripeAmount < 0 %}-{% endif %}${{ (rawStripeAmount/100)|abs }}
{% endmacro %}
... lines 7 - 79

After the line items, there's just one more thing to worry about: discounts! We already know that you can create Coupons and attach them to a Customer at checkout. When a Coupon has been used, it's known as a "discount" on the invoice. Let's print the coupon's ID and the amount off thanks to the coupon:

... lines 1 - 7
{% block body %}
<div class="nav-space">
<div class="container">
<div class="row">
<div class="col-xs-6">
... lines 14 - 31
<table class="table table-striped">
<tbody>
... lines 34 - 56
{% if invoice.discount %}
<tr>
<td>Discount: {{ invoice.discount.coupon.id }}</td>
<td>
{{ macros.currency(invoice.discount.coupon.amount_off * -1) }}
</td>
</tr>
{% endif %}
... lines 65 - 71
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

If you want to support Coupons for both a set amount off and a percentage off, you'll need to do a little bit more work here.

Finally, at the bottom: print the total by using the amount_due field. After taking everything above into account, this should be the amount they were charged:

... lines 1 - 7
{% block body %}
<div class="nav-space">
<div class="container">
<div class="row">
<div class="col-xs-6">
... lines 14 - 31
<table class="table table-striped">
<tbody>
... lines 34 - 65
<tr>
<th>Total</th>
<th>
{{ macros.currency(invoice.amount_due) }}
</th>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

So... Store Invoices Locally?

Ok! Once you know which fields to render, it's not too bad. But this approach has one big downside: we don't have any of the invoice data in our database: we're relying on a third party to store everything. It also means that it'll be a little bit harder to query or report on invoice data. And finally, the invoice pages may load a little slow, since we're waiting for an API request to Stripe to finish.

If you do want to store invoices locally, it's not too much more work. Of course, you'll need an invoices table with whatever columns are important for you to store: like the amount charged, and maybe some discount details.

That's simple enough, but how and when would we populate this? Webhooks! Specifically, the invoice.created webhook: just respond to this and create a "copy" in your database of whatever info you want. You'll also want to listen to invoice.updated to catch any changes to an invoice, like when it goes from unpaid to paid.

If that's important to you, go for it!

Ahhhh, now we really did it! We've made it to the end. This stuff is tough, but you should feel empowered. Creating a payment system is about more than just accepting credit cards, it's about giving your customers a great, bug-free, surprise-free and joyful experience. Go out there and make that a reality!

And as always, if you have any questions, ask us in the comments.

Seeya guys next time!

Leave a comment!

11
Login or Register to join the conversation
Kiuega Avatar

Hello ! I just finished training!
I have a few questions for the rest.

1) Could I create a panel to be able to add subscription plans?

2) What happens to customers if I change the information on a plan? (Like the price or the trial period or the plan id). Will this create errors?

3) Can a malicious user send any type of request? Like a refund request or a dispute?

Thank you !

Reply

Hey Kiuega !

> Hello ! I just finished training!

Nice work! And thanks for sticking with us despite the tutorial using older an old API :). I hope it was still very useful!

> 1) Could I create a panel to be able to add subscription plans?

Yep definitely! There is a "plan" API. We have very few, specific plans (monthly, yearly, team monthly, team yearly [more or less]) and our UI is specifically built around this small set. That's why we didn't make them dynamic. But you can definitely use the Stripe API to create "plans".

> 2) What happens to customers if I change the information on a plan?

Good question :). Reference: https://stripe.com/docs/bil...

Once a plan has been created, only the metadata and nickname can be modified; the amount, currency, and interval are fixed. Should you need to change any of these parameters, a new plan must be created instead. This restriction avoids the potential surprise and confusion that would arise from having different customers on the same plan but at different price points.

So basically, this is not allowed :). And that's probably a good thing - imagine how complex it would be if you changed a plan's price!

> 3) Can a malicious user send any type of request? Like a refund request or a dispute?

They cannot. The reason is that using the Stripe API requires you to set your private key - https://symfonycasts.com/sc...

Your public key *is* something that's embedded publicly (like on your checkout page) and that allows the AJAX request to be made in JavaScript to send the credit card info. However, no other API calls can be made without having the private key. For example, if someone tried to make an API request to make a refund, they won't have your private key and so they will get rejected. So, keep that private key secret ;).

By the way, this is one of the nice things about Stripe: they are experts in payment & security, and they make it VERY difficult to do something that would leave your site insecure.

Cheers!

Reply
Kiuega Avatar

Super thank you for all this information! Awesome !

Yep definitely! There is a "plan" API. We have very few, specific plans (monthly, yearly, team monthly, team yearly [more or less]) and our UI is specifically built around this small set. That's why we didn't make them dynamic. But you can definitely use the Stripe API to create "plans".

Super that! Yes it is true that using the Stripe API is simpler, but imagine if we really create a large component to be able to manage everything from our application. It would be very useful for our own projects but also if we work for clients who would like to benefit from it. It is the bet that I gave myself in any case. Currently I have no job and therefore I spend my time coding to gain experience and to be noticed. Doing such a panel will definitely benefit me

> 2) What happens to customers if I change the information on a plan?

Good question :). Reference: https://stripe.com/docs/bil...

Usually you can only change the name and the trial period. It should have no impact

> 3) Can a malicious user send any type of request? Like a refund request or a dispute?

They cannot.

Okay awesome !

Reply

Hello, I followed the tutorial, everything works well for the majority of customers, my for some customers the subscription does not succeed:

  • The source is not attached, I see this event in Stripe:

`"message": "This customer has no attached payment source",

"type": "invalid_request_error"`

And the customer comes across a 500 error page

Do you know how to avoid this problem?

Thanks !

Reply

Hey Camille,

Hm, you need to figure our why some customers have no attached payment source. Check your customers who have this problem, does they have credit card linked? You can't create a subscription without CC linked to customers, it's required. So, it's your job to make sure customers have credit card attached before creating a subscription for them.

Basically, try to figure out steps to reproduce. There should be some regularity pattern. Then, when you have steps to reproduce - you can research on it more deeply and hopefully find the root of the problem.

I hope this helps!

Cheers!

Reply

Oh, I just saw your answer. Thank you Victor

I do not understand why the customer does not have a card attached to his account (there is no card attached to his account on Stripe) yet the registration page is the same for all customers, for some it works and for others (fortunately minority), it does not work, the card is not attached. The code is therefore the same (the same as in the tutorial) but, for some, the card is not saved ..

At first I thought customers were trying to register without entering a card, but the form cannot be sent if no card has been entered

I'm not sure how to reproduce the problem.

Reply

Hey Camille,

> At first I thought customers were trying to register without entering a
card, but the form cannot be sent if no card has been entered

Are you sure it's not possible? Probably they don't have JS enabled? Try to disable JS in your browser and send the form again. Also, you may want to disable HTML5 validation as well to see if the form could be sent then. So, it might be a client's browser issue. Maybe some of your clients trying to do this registration from Internet Explorer or other bad old browser, that in turn cases the losing of CC credentials... I'm not sure, but that's a valid theory, especially if you use some new JS syntax in your code but does not use Webpack Encore with Babel that transpile that code into legacy code. You need to think about some edge cases that might happen during the registration, looks like in some edge cases CC credentials just not saved, but difficult to say something more without debugging.

I hope those ideas help!

Cheers!

Reply

Yes it helps! Thank you !!

Reply
Default user avatar
Default user avatar Blueblazer172 | posted 5 years ago

Wow I've made it through all 2 Parts :)

Now my last questions will be:

1. What should I do/change to run all in production?
2. What is important to know about the right security in symfony?
3. How should I setup Stripe? Should I Only delete all testdata and change to live mode and change the api keys in the config.yml? or is there something else i should know when switching to live?

You guys are amazing. It is worth spending my time on your tuts, cause they are easy to understand and easy to follow :)
Keep up that great work.

Reply

Hey Blueblazer172!

First, congrats! This were 2 BIG tutorials... I remember well when I recorded them :).

1) There's nothing specific to Stripe that you'll need to change in production, besides changing your Stripe API key to be your production key, and making sure you're forcing https (which of course you are - since we've been talking about that elsewhere!). If there's anything else, it's specific to Symfony, and it's related to performance. We've got some details about that here: http://symfony.com/doc/curr... - but it's nothing mission critical.

2) Symfony itself is secure (we handle security patches, etc), but of course it doesn't mean that code we write in Symfony is secure :). Thanks to Doctrine, you're already safe from SQL injection attacks (unless you're working around Doctrine in some weird way). And in the tutorial, we've been very sensitive to *never* submit the credit card information to our server. In Symfony, the easiest way to mess up security is... well... to forget to secure sensitive pages. For example, if you have a /admin section... but you forget to check to make sure the user is an admin, then there's your security hole :). Make sure each endpoint is properly secured - that will do a lot. Here in KnpU, we also make sure not to save any personal data we don't need to - e.g. we don't store a user's address anywhere. Obviously, the less data you can store in your server, the better.

3) Yep, the biggest things is to change the API key to live mode (though you'll do this actually in parameters.yml - http://knpuniversity.com/sc.... You'll of course need to make sure your "live mode" is activated on Stripe - iirc they have a few setup things to do that. And finally, don't forget that we setup our Plans in Stripe manually. So the plans will need to be manually setup on production again, with the same plan id's (so that they match our code). You don't need to delete your test data - it's sort of unrelated to changing to "prod" mode.

Oh, one last thing! You should also look into PCI compliance - Stripe makes it easy, but there's some work you might want to look into doing.

Good luck! It's been cool to see your progress!

1 Reply

Thank you for this course and your explications you are amazing !

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial uses an older version of Symfony of the stripe-php SDK. The majority of the concepts are still valid, though there *are* differences. We've done our best to add notes & comments that describe these changes.

What PHP libraries does this tutorial use?

// 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
    }
}
userVoice