laravel / cashier-stripe

Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.
https://laravel.com/docs/billing
MIT License
2.37k stars 672 forks source link

[Discussion] Calculating subscription renewal date #361

Closed luoshiben closed 5 years ago

luoshiben commented 7 years ago

I need to display the date on which a user's subscription will renew. However, there doesn't seem to be a built-in way to get this information via Cashier. Therefore, I attempted to solve the problem myself but have come to the conclusion that there is not a reliable way to calculate the renewal date of a subscription based on the way Cashier handles subscription data. I'll explain further, and would greatly appreciate feedback or comments if I'm totally missing something.

To illustrate the scenario and problem, here's how I went about creating a solution, only to discover that it doesn't always work. Assuming plan information was stored locally or retrieved (and cached locally) from Stripe it would seem that you could simply calculate the renewal date based on the $user->subscription($planName)->created_at date. For example, here's some code I wrote to try to determine the renewal date:

protected function getRenewalDate(Subscription $subscription)
{
    if($subscription->onTrial()) {
        return $subscription->trial_ends_at->addSecond();
    }

    $plan = Plan::find($subscription->stripe_plan);
    $renewsOn = null;
    if($plan->interval == 'month') {
        $renewsOn = $this->calcMonthlyRenewal($subscription->created_at);
    } elseif($plan->interval == 'year') {
        $renewsOn = $this->calcYearlyRenewal($subscription->created_at);
    }
    return $renewsOn;
}

protected function calcMonthlyRenewal($subscribedDate) {
    $now = Carbon::now('UTC');
    $months = $subscribedDate->diffInMonths($now) + 1;
    $renewsDate = $subscribedDate->copy()->addMonths($months);
    if($renewsDate->month > $now->month && $renewsDate->day != $subscribedDate->day) {
        $renewsDate = $renewsDate->subMonth()
            ->lastOfMonth()
            ->addHours($subscribedDate->hour)
            ->addMinutes($subscribedDate->minute)
            ->addSeconds($subscribedDate->second);
    }

    return $renewsDate;
}

protected function calcYearlyRenewal($subscribedDate) {
    $years = $subscribedDate->diffInYears(Carbon::now('UTC'));
    $renewsDate = $subscribedDate->addYears($years+1);

    return $renewsDate;
}

These methods basically just add the appropriate amount of months or years, based on the plan's interval, to the subscription created_at date to figure out when billing next occurs. Seems great, right? Sadly there's a problem!

Let's assume that the user originally created a subscription for the first time with a plan that had a monthly interval. For the entire life of the subscription the code above will work just fine. However, now imagine that the user upgrades at some point to plan with an annual interval ($user->subscription($planName)->swap($newPlanName)). Cashier simply updates the stripe_plan field in the database, and of course the updated_at field is set appropriately also when the record is touched. This operation just obliterated our ability to follow the chain of events, and to know exactly when the subscription plan was started. We can't use the created_at date to determine when the annual plan will renew, because that date is based on when the subscription was created, not when the associated plan was started for the user. The next thought would be to use the updated_at date, but again we're foiled because this value could be changed for reasons other than just switching plans, like cancelling the subscription or changing quantity, etc. Essentially, Cashier doesn't store data in a way that allows us to calculate the subscription renewal date.

One way I can think of to fix this would be to store a plan_created_at (or something similar) date on the subscription. That way, if the subscription's plan changes we have a record of when that happened.

I'd love thoughts, validation, or holes poked in my assumptions and understandings here. If this problem is real, maybe we can decide on a best way to solve the problem and I'll work on a PR. I'm sure many others also need renewal date functionality! Thanks, all.

scottgrayson commented 7 years ago

@luoshiben I need to do this too. I wonder if it could be done with an observer....

  1. add renews_at column to subscriptions table
  2. Observer pseudocode:
    
    use Stripe\StripePHP;
    use Cashier\Subscription;

Subscription::saving(function (Subscription $sub) { $stripeSubscription = Stripe::getSubscription($sub->stripe_id) $sub->renews_at = $stripeSubscription->period_end }



Hopefully the save() method is called everytime a payment succeeds in the cashier webhook controller. Otherwise this Subscription::saving method won't fire. 

 If no one posts with a better solution, i'll try this within the next few days
luoshiben commented 7 years ago

@scottgrayson Nice to hear I'm not alone. =)

Using an observer to keep the logic tucked neatly away is definitely a good idea. However, I'm not sure that storing a renews_at day solves the problem here. The subscription may renew every month or every year or on some other interval, but your code base will never know about it since Stripe processes all of that. In other words, there'd be no event to cause the Subscription model to trigger the saving event to keep the renews_at field updated. I suppose you could create a webhook to listen for charge events and update the renews_at date there, but that seems like a lot of moving pieces (and potential for failure) just to figure out the next time someone is going to be billed.

I ended up adding a plan_created_at field, and my code just keeps that datetime value updated whenever a subscription is created or the plan on the subscription is changed. This could probably be done using an observer as you suggest. Then, when I need to know when the subscription renews I just use the plan_created_at date and the plan's interval to calculate the next renewal date.

scottgrayson commented 7 years ago

@luoshiben Stripe sends an invoice.payment_succeeded webhook everytime a subscription payment succeeds.

https://stripe.com/docs/api#event_types-invoice.payment_succeeded

You could extend the cashier webhook controller to capture that and update the renewal date by getting the subscription's period_end from stripe. Then you would not need the observer anymore. I did this before when i did not use cashier

luoshiben commented 7 years ago

@scottgrayson Right, this scenario could be handled through webhooks. However, I personally feel that using webhooks isn't the best overall solution because: 1. it assumes that Cashier's underlying payment provider/driver will always use webhooks, and 2. it also relies on a lot of "moving parts" for something that really should be understood by Cashier natively. For now, and assuming the code base is using Stripe, maybe webhooks are as good a solution as any. I just feel that if Cashier stored a small piece of additional data then it could help with providing this information right out of the box.

davehenke commented 6 years ago

Since cashier uses Stripe-php as the underlying request framework, you can make use of that directly. I was able to retrieve current_period_end from the Subscription Stripe object for a test subscription I made using this:

use Stripe\Stripe;
use Stripe\Subscription;
use MyApp\User;
class MyClass {
    public function test()
    {
        $user = User::find(26);
        $subscriptionId = $user->subscription('main')->stripe_id;
        $apiKey = config('services.stripe.secret');
        Stripe::setApiKey($apiKey);

        $subscription = Subscription::retrieve($subscriptionId);
        return $subscription->current_period_end;
    }
}

I am trying to determine what the best way to hold this data might be. The naive and lazy side of me would really just like to make this call anytime my app decides it needs to know the value instead of attempting to store it and then managing it myself.

garygreen commented 6 years ago

Is this still a problem? I don't think it is a major problem now because Cashier now stores a ends_at which automatically updates when a subscription is cancelled (either manually or by failed payment after X attempts and notified by webhook)

If you need to get the renewal date before the subscription is cancelled, you can just make a call to Stripe to get the subscription details and use the current_period_end which is essentially the renewal date. Store it as you like.

It would be nice though if Cashier could add a new method to create() that returns the Subscription object from Stripe - that way you wouldn't need to double up on API calls to get the information again and store extra data you need. Or just go ahead and add a renews_at column baked in by default based on what Stripe returned from current_period_end to begin with, that would be nice.

garygreen commented 6 years ago

If your interested, I made a console command which synchronises the renewal date for all your subscription (e.g. the start of when Stripe when next create an invoice/bill the customer). It's a shame Cashier doesn't set this internally when creating a subscription, as you often rely on it to know when to downgrade an account / stop services to a user. Hopefully this helps you 👍

https://gist.github.com/garygreen/fb4dc0288e2c57f9af015968ff7019bb

driesvints commented 5 years ago

You can indeed use the ends_at to know when a subscription period ends and starts again.

shez1983 commented 5 years ago

unless i am mistaken @driesvints ends_at is null by default and only has something when users cancels.. for an active subsc you dont actually KNOW when the reneweal is because you dont know when the subsc actually started...

in one of my project, i have to send user ANNUAL reminder before the annual subsc expires and at the moment i am not sure how to do it - i used updated_at because i figured the only reason updated_at would change is when a payment actually happened so i can look at that add 11 months and then send them a reminder... but other dev isnt happy with me using updated_at.. as the model could change in other ways (i dont think so? and had considered when it would change and couldnt think of any obvious flow where it would change other than when a new payment is taken?) but then the updated_at wouldnt change for next year's payment... so i am back to square 1

driesvints commented 5 years ago

@shez1983 you can use $user->subscription()->asStripeSubscription() and then call ->current_period_end or any other api key for the subscription object. This should give you every info you'll need.

shez1983 commented 5 years ago

ideally cashier should save this info... @driesvints because i am doing a cronjob that runs a console command each day.. so it will result in repeated calls to get same info...

driesvints commented 5 years ago

@shez1983 you can add a migration to add these fields and update the subscription after you've created one. Then add a webhook to keep things in sync. We can't start adding every single field for every Stripe entity to the DB scheme because it would also mean keeping things in sync all the time with what changes in Stripe. You can retrieve the values as I said above. You can cache them or save them in your own DB storage if you want.

shez1983 commented 5 years ago

@driesvints its this attitude that puts people off.. for whats it worth thats exactly what i will be doing but i would have thought that when a subs expires is an IMPORTANT thing for your application. no one is asking you to add EVERY single field..

driesvints commented 5 years ago

Hey @shez1983. I'm sorry you feel that way. Having a large open source project like Cashier means we get feature requests and requests to add additional functionality on a very regular basis. While we would gladly try to help everyone out it's simple not possible for us to account for everyone's use-case or situation.

To further clarify why I don't see it as ideal to add this particularly field: whenever a subscription is updated or swapped the current_period_end and current_period_start values could change. This means that we'll have to update these in every situation that a subscription is modified. With Cashier we only try to save the basic necessities in order to make Cashier do its job. For every extra info, it's easy to simply retrieve the extra info from the Stripe object.

I'm sorry if I was a little harsh in my above remark, I meant no harm. Hope you can figure out a solution for your use-case.

NassimRehali15 commented 5 years ago

https://gist.github.com/NassimRehali15/415939b46a70dfc0cb92175bdb467116

ReckeDJ commented 3 years ago

@driesvints sorry for opening this again, but it is somewhat related to this issue. We need to catch the invoice.payment_succeeded Stripe event. Because this event is handled in Cashier by default, is it an option to add a simple InvoicePaymentSucceeded event in Cashier? Of course I can override the function and add it myself, but I thought this could be a good trigger for the use case above too.

ReckeDJ commented 3 years ago

@driesvints Nevermind. just found the Laravel\Cashier\Events\WebhookHandled event! So I guess that is the best option to handle every specific Stripe event that I need :)

devilslane-com commented 7 months ago

For anyone looking to do this quickly on the Subscription model:

    public function renews_at () : Carbon
    {
        $sub_dom = $this->created_at->format ('j');

        return intval (now()->format ('j')) < intval ($sub_dom) ?
            Carbon::createFromDate (now()->year, now()->month, $sub_dom)
            :
            Carbon::createFromDate (now()->year, now()->month, $sub_dom)->addMonths(1);
    }