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.

Webhook: Payment Failed!

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

Ok, there's just one more webhook we need to worry about, and it's the easiest one: invoice.payment_failed. Send a test webhook for this event.

Refresh RequestBin to check it out.

This webhook type is important for only one reason: to send your user an email so that they know we're having problems charging their card. That's it! We're already using a different webhook to actually cancel their subscription if the failures continue.

This has almost the same body as the invoice.payment_succeeded event: the embedded object is an invoice and if that invoice is related to a subscription, it has a subscription property.

That means that in WebhookController, this is a pretty easy one to handle. Add a new case for invoice.payment_failed:

... lines 1 - 8
class WebhookController extends BaseController
{
... lines 11 - 13
public function stripeWebhookAction(Request $request)
{
... lines 16 - 31
switch ($stripeEvent->type) {
... lines 33 - 50
case 'invoice.payment_failed':
... lines 52 - 62
break;
... lines 64 - 66
}
... lines 68 - 69
}
... lines 71 - 90
}

Then, start just like before: grab the $stripeSubscriptionId. Then, add an if statement - just in case this invoice has no subscription:

... lines 1 - 8
class WebhookController extends BaseController
{
... lines 11 - 13
public function stripeWebhookAction(Request $request)
{
... lines 16 - 31
switch ($stripeEvent->type) {
... lines 33 - 50
case 'invoice.payment_failed':
$stripeSubscriptionId = $stripeEvent->data->object->subscription;
if ($stripeSubscriptionId) {
... lines 55 - 60
}
break;
... lines 64 - 66
}
... lines 68 - 69
}
... lines 71 - 90
}

What to do when a Payment Fails?

Earlier, we talked about what happens when a payment fails. It depends on your Subscription settings in Stripe, but ultimately, Stripe will attempt to charge the card a few times, and then cancel the subscription.

You could send your user an email each time Stripe tries to charge their card and fails, but that'll probably be a bit annoying. So, I like to send an email only after the first attempt fails.

To know if this webhook is being fired after the first, second or third attempt, use a field called attempt_count. If this equals one, send an email. In the controller, add if $stripeEvent->data->object->attempt_count == 1, then send them an email. Well, I'll leave that step to you guys:

... lines 1 - 8
class WebhookController extends BaseController
{
... lines 11 - 13
public function stripeWebhookAction(Request $request)
{
... lines 16 - 31
switch ($stripeEvent->type) {
... lines 33 - 50
case 'invoice.payment_failed':
$stripeSubscriptionId = $stripeEvent->data->object->subscription;
if ($stripeSubscriptionId) {
... lines 55 - 56
if ($stripeEvent->data->object->attempt_count == 1) {
... line 58
// todo - send the user an email about the problem
}
}
break;
... lines 64 - 66
}
... lines 68 - 69
}
... lines 71 - 90
}

If you need to know which user the subscription belongs to, first fetch the Subscription from the database by using our findSubscription() method. Then, add $user = $subscription->getUser():

... lines 1 - 8
class WebhookController extends BaseController
{
... lines 11 - 13
public function stripeWebhookAction(Request $request)
{
... lines 16 - 31
switch ($stripeEvent->type) {
... lines 33 - 50
case 'invoice.payment_failed':
$stripeSubscriptionId = $stripeEvent->data->object->subscription;
if ($stripeSubscriptionId) {
$subscription = $this->findSubscription($stripeSubscriptionId);
if ($stripeEvent->data->object->attempt_count == 1) {
$user = $subscription->getUser();
// todo - send the user an email about the problem
}
}
break;
... lines 64 - 66
}
... lines 68 - 69
}
... lines 71 - 90
}

I like this webhook - it's easy! And actually, we're done with webhooks! Except for preventing replay attacks... which is important, but painless.

Leave a comment!

2
Login or Register to join the conversation
Default user avatar
Default user avatar Chris | posted 4 years ago | edited

Hey guys,

maybe you can help me :) At the moment I do setup my webhooks, and I'm following this lecture.

I understand the logic that we query only for invoices where actually a subscription was involved.

But why do we look for $stripeEvent->data->object->subscription?

In Stripe I created a test webhook and it returned at this field a value of null. Contrary, it returned within the invoice items array a subscription item.

This is just an excerpt of the line item's array


      lines: {                                  // The individual line items that make up the invoice. lines is sorted as follows: invoice items in reverse chronological order, followed by the subscription, if any.
        data: [
          {
            id: "sli_00000000000000",             // Unique identifier for the object.
            object: "line_item",                  // String representing the object’s type. Objects of the same type share the same value.
            amount: 2000,                         // The amount, in cents.
            currency: "eur",                      // Three-letter ISO currency code, in lowercase. Must be a supported currency.
            description: "1 × Gold Special (at €20.00 / month)", // An arbitrary string attached to the object. Often useful for displaying to users.
            discountable: true,                   // If true, discounts will apply to this line item. Always false for prorations.
            livemode: false,                      // Whether this is a test line item.
            metadata: {
            },
            period: {                             // The timespan covered by this invoice item.
              end: 1541424401,                    // End of the line item’s billing period
              start: 1538746001                   // Start of the line item’s billing period
            },
plan: {// https://stripe.com/docs/api#plan_object  // The plan of the subscription, if the line item is a subscription or a proration.
              id: "gold_00000000000000",          // Unique identifier for the object.
              object: "plan",                     // String representing the object’s type. Objects of the same type share the same value.
              active: true,                       // Whether the plan is currently available for new subscriptions.
              aggregate_usage: null,
              amount: 2000,                       // The amount in cents to be charged on the interval specified.
              billing_scheme: "per_unit",
              created: 1394752996,                // Time at which the object was created. Measured in seconds since the Unix epoch.
              currency: "eur",                    // Three-letter ISO currency code, in lowercase. Must be a supported currency.
              interval: "month",                  // One of day, week, month or year. The frequency with which a subscription should be billed.
              interval_count: 1,                  // The number of intervals (specified in the interval property) between subscription billings. For example, interval=month and interval_count=3 bills every 3 months.
              livemode: false,                    // Has the value true if the object exists in live mode or the value false if the object exists in test mode.
              metadata: {
              },
              nickname: null,                     // A brief description of the plan, hidden from customers.
              product: "prod_00000000000000",     // The product whose pricing this plan determines.
              tiers: null,
              tiers_mode: null,                   // Defines if the tiering price should be graduated or volume based. In volume-based tiering, the maximum quantity within a period determines the per unit price, in graduated tiering pricing can successively change as the quantity grows.
              transform_usage: null,
              trial_period_days: null,            // Default number of trial days when subscribing a customer to this plan using trial_from_plan=true.
              usage_type: "licensed"
            },

And further below in the invoice event:


      subscription: null,
      subtotal: 0,                                // Total of all subscriptions, invoice items, and prorations on the invoice before any discount is applied.
      tax: null,                                  // The amount of tax included in the total, calculated from tax_percent and the subtotal. If no tax_percent is defined, this value will be null.
      tax_percent: null,                          // This percentage of the subtotal has been added to the total amount of the invoice, including invoice line items and discounts. This field is inherited from the subscription’s tax_percent field, but can be changed before the invoice is paid. This field defaults to null.
      total: 0,                                   // Total after discount.
      webhooks_delivered_at: null                 // The time at which webhooks for this invoice were successfully delivered (if the invoice had no webhooks to deliver, this will match date). Invoice payment is delayed until webhooks are delivered, or until all webhook delivery attempts have been exhausted.
    }
  }

When I look into the API https://stripe.com/docs/api#invoice_object I don't understand the field description of event.data.object.subscription: 'The subscription that this invoice was prepared for, if any.'

According to our setup the subscription filter would not trigger, although within the line Items there is a subscription.

1 Reply

Hey Chris,

Stripe prepare an upcoming invoices for subscriptions before each renew. And I believe that's where that subscription field is set, and that's what "The subscription that this invoice was prepared for, if any" means. But you somehow created a *test* webhook and it returned at this field a value of null that makes sense, i.e. this invoice wasn't prepared automatically by Stripe. I believe that's the root problem why subscription is null. I think you can try to successfully subscribe in test mode, then entering test credit card credentials - the subscription will be created and invoice will be paid. Then you can remove the credit card in the customer and on the next renewal I think you will get an "invoice.payment_failed" webhook and subscription won't be null.

I hope this helps, otherwise, I think you can contact Stripe support directly.

Cheers!

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