This is not required for our initial version, but is important to think about. Most solutions have some kind of support for free trials. It starts with a trial_interval in the billing schedule. For example, a monthly schedule where the 1st month is a trial. That ensures that we don't charge the customer for the first cycle.

I've done some research.

1) The trial period is the period before the billing cycles start. No invoice.
2) Intervals often don't match.
So, a monthly prepaid service might have a "14 day" trial. Then, close to the expiry of the 14 days, the customer is notified and given a chance to cancel their subscription. If they don't, we automatically start billing (create the first billing cycle and recurring order, in case of prepayment charge the card).

Original GitHub conversation: https://github.com/bojanz/commerce_recurring/issues/3

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

bojanz created an issue. See original summary.

brunodbo’s picture

One question here is whether to require a customer to enter their payment details when starting the trial (e.g., Stripe allows for this, while Braintree doesn't). Would it be possible to make this be an option in the trial period configuration?

Hubbs’s picture

I'm looking forward to this, but in the meantime, you can potentially use the promotions module with this patch applied to create a one time discount of 100%. It applies the discount one during initial checkout but not for any additional recurring billing.

However, with that patch there is a bug I've found where the new setting only works for the user/1 supperadmin. Hopefully, that can be resolved soon, then this can be an OK workaround for the time being.

nikathone’s picture

@hubbs how do you checkout free order then? I was talking to Matt and we thought that this might be possible in case https://www.drupal.org/project/commerce/issues/2856583 cover a situation to create payment method but not create payment when order is free.

Hubbs’s picture

@nikathone My tests using the discount method I mentioned were going through because the tax was still being calculated, and therefore my orders weren't $0. I incorrectly assumed that it would work on a $0 scenario. Sorry about that. So with that said, it looks like we would also need the patch you've mentioned. :S

mike82’s picture

I think it should not be only considered a free trial, but a free trial is only a special case of a different first period. I have a lot of experience with billing systems and the current implementation is not considering a lot of possible cases for subscriptions.
The general approach is to allow an initial period with different first time price and start the regular subscription afterwards. Also both periods should support offsets. A free trial period is therefore only a special case, of this with price 0. If you think of a 14 day trial as you mentioned, it doesn't need to be for free. You could have 14 days for 10$ trial and afterwards a regular subscription of 30$ per month.

bojanz’s picture

@mike82
I think we need to special case free trials VS differently-priced-initial-periods because of the "no invoice" rule. A differently priced first period would still be invoiced.

nikathone’s picture

Thinking about free trial using commerce_license. I came up with 10 steps which I think would allow me to get it working with current code, few patches and custom codes:

1. Create a free-trial role
2. create a free-trial role license plugin
3. create and configure a product and product variation to hold license type, role and expiration data.
4. Product variation price is free and no subscription or recurring involved
5. Apply patch at https://www.drupal.org/project/commerce/issues/2856583 to support free orders checkout
6. Alternatively I can find a way to create and place orders related to free trial programatically and skip the checkout process altogether
7. Apply patch at https://www.drupal.org/project/commerce_license/issues/2879258 to allow license expiration
8. Write custom code to prevent user with any other role license and subscription to be able to checkout or view this free trial product
9. When a user purchased my other subscriptions cancel the existing free trial license
10. Make sure that even users with expired subscription can't have access to free trial
jsacksick’s picture

Here's my first attempt at this:

  1. A new subscription state was added ("trial"), as well as a new transition ("trial_canceled", from "trial" to "canceled"
  2. Added 2 new methods to SubscriptionTypeBase: onSubscriptionTrialCancel() and onSubscriptionTrialStart(). I don't think we need onSubscriptionTrialEnd() since as soon as the trial ends, the subscription is set to active and RecurringOrderManager::ensureOrder() is called
  3. I added 2 new fields to subscriptions: "trial_starts" and "trial_ends" as well as getters/setters for those fields.
  4. I expanded the tests
  5. I added a new Advancedqueue job type plugin which is there to ensure orders for trial subscriptions when the trial ends.
  6. I've decided to go with "Trial period" rather than "Trial interval" as it's supposed to be a one time thing.
  7. I made changes to InitialOrderProcessor although I'm not 100% sure that's what needs to happen there...
  8. I created a dumb copy of "BillingPeriod", "TrialPeriod", we could reuse the BillingPeriod class since the content is 100% identical but it kind of feels wrong to re-use BillingPeriod as it could be confusing.
  9. The trial period is always "Rolling".

I'm wondering if I should update generateFirstBillingPeriod() to take into account the trial period...
We're probably still missing a few things here and there...
@bojanz: thoughts?

Status: Needs review » Needs work
jsacksick’s picture

Status: Needs work » Needs review
FileSize
46.89 KB
2.34 KB

Attempt to fix the functional tests.

jsacksick’s picture

So I made some changes following a discussion I had with @bojanz today and after #2927164: Add code for activating pending subscriptions got committed.

  • I removed TrialPeriod and the patch is now making use of the existing BillingPeriod object.
  • I removed RecurringOrderEnsure and slightly modified SubscriptionActivate to support "trial" subscriptions, and I'm now saving the subcription right after applying the transition.
  • Added a "trial_activate" transition (From "trial" to "active").
  • I updated the query in Cron.php to retrieve "pending" subscriptions as well as "trial" subscriptions.
  • Made some other minor changes.

Unfortunately, I'm having troubles creating the interdiff, so I can't provide it atm.

bojanz’s picture

Great work! This seems ready to go, but I'll do another more in-depth review later today.

bojanz’s picture

Status: Needs review » Needs work
+    // Query for pending subscriptions with a "starts" timestamp in the past.
+    // as well as "trial" subscriptions with a past "trial_ends" timestamp.
+    $subscription_storage = $this->entityTypeManager->getStorage('commerce_subscription');
+    $subscription_query = $subscription_storage->getQuery();
+    $request_time = $this->time->getRequestTime();
+    $or_condition = $subscription_query->orConditionGroup();
+
+    // Build the "pending" conditions.
+    $pending_conditions = $subscription_query->andConditionGroup();
+    $pending_conditions
+      ->condition('state','pending')
+      ->condition('starts', $request_time, '<');
+    $or_condition->condition($pending_conditions);
+
+    // Build the "trial" conditions.
+    $trial_conditions = $subscription_query->andConditionGroup();
+    $trial_conditions
+      ->condition('state', 'trial')
+      ->condition('trial_ends', $request_time, '<=');
+    $or_condition->condition($trial_conditions);

We said that we'd also populate "starts" when populating "trial_starts" and "trial_ends".
Aside from being a good idea, it also allows us to significantly simplify this query (even removing the need to build it in a helper method), since we'd always check just starts and the state.

+    trial_period:
+      type: mapping
+      label: 'Trial period'
+      mapping:
+        allow_trials:
+          type: boolean
+          label: 'Allow trials'
+        number:
+          type: integer
+          label: Number
+        unit:
+          type: string
+          label: Unit

It is odd that we store allow_trials, we usually don't do that.
I would expect trial_period to be NULL if trials are not allowed. Then ->allowsTrials() would just check if trial_period is empty.

+  /**
+   * Build the query for getting the subscription IDS to activate.
+   */

IDs

+  /**
+   * Checks whether or not the billing schedule allows trials.
+   *

"or not" is not needed nor usually said.

    * Acts on a subscription after it has been activated.
+   * Detecting that a trial just ended can be achieved by checking the original
+   * subscription state ($subscription->original->getState()->value == 'trial').
    *
    * Called before the subscription and recurring order are saved.

There's usually a newline after the one-line method description.

     // The createPayment() call might throw a decline exception, which is
     // supposed to be handled by the caller, to allow for dunning.
-    $payment_gateway_plugin->createPayment($payment);
+      $payment_gateway_plugin->createPayment($payment);

Looks accidental.

porchlight’s picture

Piggybacking on bojanz comment -- without the subscription "start" date filled out, the SubscriptionListBuilder crashes when calling getStartDate(). So if we are going to set the start value when somebody signs up, then problem solved. But if not, then we should throw in a shorthand if/else like used for the end_date column in the list builder buildRow() function. Awesome progress though! Aside from that small issue everything else seems to be working as expected. Still testing more though...

bojanz’s picture

Final point: we need to rename trial_period to trial_interval.

Reasons:
1) Programming languages, systems, APIs can't agree what is an interval VS what is a period. In commerce_recurring an interval is "5 days" while a period is "Jan 1st - Jan 6th". This matches how PHP itself names these concepts. So, trial_period is wrong because it doesn't have a start date and an end date.

This mismatch is obvious in this part of the patch:

+    $interval = new Interval($this->configuration['trial_period']['number'], $this->configuration['trial_period']['unit']);

2) Having trial_interval (number, unit) matches interval (number, unit). Jonathan's argument was that it was a bit confusing cause trials don't actually repeat, but other systems seem to have the same parallel (period / trial period, interval / trial period). For example, Recurly: https://dev.recurly.com/docs/create-plan

arthur.baghdasar’s picture

Cannot get this to work.

I've created a free trial for 1 hour in billing schedules form and set the billing to be prepaid, added a product and variation with that.

But when trying to purchase that item, I get free trail 0USD to pay, it doesn't ask me for any payment method to fill.
The order was created, but the actual subscription no.

Maybe I'm missing something.

bojanz’s picture

This patch can't make it possible to collect a payment method for a free order.
That's a related Commerce issue: #2871483: Add checkout settings for payment method behavior.

jsacksick’s picture

@bojanz: Thanks for your feedbacks.

I'm now setting the Subscription "starts" timestamp on order place.
I'm wondering if we shouldn't modify SubscriptionActivate to re-set the "starts" timestamp to the current date...

Additionally, as pointed out in #17, we're not capturing the payment for free trials since the order total is 0.

I'm setting "trial_interval" to an empty array by default (instead of NULL because the schema defines it as "mapping" which means setting it to NULL wouldn't work.

jsacksick’s picture

Minor fixes that I forgot in the previous patch.

bojanz’s picture

Status: Needs review » Needs work

Looks almost ready!

I don't see why SubscriptionActivate would mess with the started date.

+    // Defaults the trial interval to the interval if empty.
+    $trial_interval = !empty($trial_interval) ? $trial_interval : $this->configuration['interval'];

$trial_interval is never defined before this. Assuming that it wants to check for $this->configuration['trial_interval']

+            'label' => t('Free trial'),
+            'amount' => $order_item->getAdjustedTotalPrice()->multiply('-1'),

getTotalPrice() should be fine enough, I don't think we need getAdjustedTotalPrice?

+          // Prepaid subscriptions need to be prorated so that the customer
+          // pays only for the portion of the period that they'll get.
+          $unit_price = $order_item->getUnitPrice();
+          $billing_period = $billing_schedule->getPlugin()->generateFirstBillingPeriod($start_date);
+          $partial_billing_period = new BillingPeriod($start_date, $billing_period->getEndDate());
+          $prorater = $billing_schedule->getProrater();
+          $prorated_unit_price = $prorater->prorateOrderItem($order_item, $partial_billing_period, $billing_period);
+          if (!$prorated_unit_price->equals($unit_price)) {
+            $difference = $unit_price->subtract($prorated_unit_price);
+            $order_item->addAdjustment(new Adjustment([
+              'type' => 'subscription',
+              'label' => t('Proration'),
+              'amount' => $difference->multiply('-1'),
+              'source_id' => $billing_schedule->id(),
+            ]));
+          }

Looks like this too wasn't updated for per-total adjustments, which means we need to fix it (could be in a followup though, with test coverage).

jsacksick’s picture

Status: Needs work » Needs review
FileSize
42.78 KB
2.91 KB

Would it be ok to do this when we prorate?

'amount' => $difference->multiply($order_item->getQuantity())->multiply('-1'),

If we need to change the code to prorate on the total instead, then maybe we should focus on that in a follow-up issue.

  • bojanz committed 13c32cc on 8.x-1.x authored by jsacksick
    Issue #2919596 by jsacksick, bojanz: Add support for free trials
    
bojanz’s picture

Status: Needs review » Fixed

I have committed #22 with a few improvements:

1) Moved the trial_starts and trial_ends fields (and their getters/setters) before the starts/ends fields, since conceptually the trial period happens before the first real period. Updated tests and other method orderings to reflect this.

2) Added a comment to generateTrialPeriod:

  public function generateTrialPeriod(DrupalDateTime $start_date) {
    // Trial periods are always rolling (starting from the given start date).
    $interval = new Interval($this->configuration['trial_interval']['number'], $this->configuration['trial_interval']['unit']);
    return new BillingPeriod($start_date, $interval->add($start_date));
  }

3) Modified OrderSubscriber to set the trial starts from the $trial_period, and not from $start_date.
That way the plugin has more freedom over what to return.

See you in #2871483: Add checkout settings for payment method behavior and other recurring issues!

arthur.baghdasar’s picture

Hi thank you all for your time and work.

I've tested the new version of 8.x-1.x branch and here is what I've got.

I've created a free interval for 1 hour and a product variation with that billing schedule.

Once I've purchased that product, an order has been placed and a new subscription added marked as "Trial".
After waiting for an hour Activate subscription job run the subscription became active, but I do not see client billed for that,

I expected to see new recurring order marked as completed as my billing schedule was configured to be pre paid.

Note:To get to the trial sbscriptions I've update commerce to the latest dev and commented out this part at
modules/contrib/commerce/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInformation.php:146

 public function buildPaneForm(array $pane_form, FormStateInterface $form_state, array &$complete_form) {
    if ($this->order->isPaid() /*|| $this->order->getTotalPrice()->isZero()*/) {
      // No payment is needed if the order is free or has already been paid.
      // In that case, collect just the billing information.
      $pane_form['#title'] = $this->t('Billing information');
      $pane_form = $this->buildBillingProfileForm($pane_form, $form_state);
      return $pane_form;
    }
arthur.baghdasar’s picture

After half an hour running the cron order appeared suddanly as paid, Thank you for your efforts.

  • jsacksick committed 24dc6d5 on 8.x-1.x
    Issue #2919596: The trial_starts field shouldn't be required.
    

  • jsacksick committed 2a3263c on 8.x-1.x
    Issue #2919596 followup by jsacksick: Fix the adjustment label used for...

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

bojanz’s picture

Adding #3029058: Complete the free trial implementation to related issues, which completed the logic.