Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Discussion] Calculating subscription renewal date #361

Closed
luoshiben opened this issue Dec 21, 2016 · 18 comments
Closed

[Discussion] Calculating subscription renewal date #361

luoshiben opened this issue Dec 21, 2016 · 18 comments

Comments

@luoshiben
Copy link
Contributor

luoshiben commented Dec 21, 2016

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
Copy link

@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
Copy link
Contributor Author

@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
Copy link

scottgrayson commented Feb 22, 2017

@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
Copy link
Contributor Author

@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
Copy link

davehenke commented Oct 14, 2017

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
Copy link

garygreen commented Feb 12, 2018

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
Copy link

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
Copy link
Member

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

@shez1983
Copy link

shez1983 commented Oct 16, 2018

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
Copy link
Member

driesvints commented Oct 16, 2018

@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
Copy link

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
Copy link
Member

@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
Copy link

@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
Copy link
Member

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
Copy link

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

@ReckeDJ
Copy link

ReckeDJ commented Nov 18, 2020

@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
Copy link

ReckeDJ commented Nov 18, 2020

@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
Copy link

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);
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

9 participants