mollie / laravel-cashier-mollie

Official Mollie integration for Laravel Cashier
https://www.cashiermollie.com/
MIT License
137 stars 44 forks source link

Swap with coupon #194

Closed hjeldin closed 1 year ago

hjeldin commented 1 year ago

Hello, we would like to offer our users the ability to swap (immediately) to a more expensive plan and at the same time redeem a coupon. At the moment it doesn't seem possible:

We managed to work around these issues by passing a closure to both restartCycleWithoutModifications (whose name should be changed after this modification) and the swap method on Subscription. The closure uses a reference to the list of orderItems that are going to be processed.

public function restartCycleWithModifications(\Closure $applyNewSettings, ?Carbon $now = null, $invoiceNow = true, \Closure $applyAdditionalItems = null)
{
    $now = $now ?: now();

    return DB::transaction(function () use ($applyNewSettings, $now, $invoiceNow, $applyAdditionalItems) {

        // Wrap up current billing cycle
        $this->removeScheduledOrderItem();
        $reimbursement = $this->reimburseUnusedTime($now);

        $orderItems = (new OrderItemCollection([$reimbursement]))->filter();

        // Apply new subscription settings
        call_user_func($applyNewSettings);

        $onTrial = $this->onTrial();

        if ($onTrial) {

            // Reschedule next cycle's OrderItem using the new subscription settings
            $orderItems[] = $this->scheduleNewOrderItemAt($this->trial_ends_at);
        } else { // Start a new billing cycle using the new subscription settings

            // Reset the billing cycle
            $this->cycle_started_at = $now;
            $this->cycle_ends_at = $now;

            // Create a new OrderItem, starting a new billing cycle
            $orderItems[] = $this->scheduleNewOrderItemAt($now);
        }

        $this->save();

        if($applyAdditionalItems) {
            // Apply new subscription settings
            call_user_func($applyAdditionalItems, array(&$orderItems));
        }

        if (! $onTrial && $invoiceNow) {
            $order = Cashier::$orderModel::createFromItems($orderItems);
            $order->processPayment();
        }
        return $this;
    });
}

public function swap(string $plan, $invoiceNow = true, \Closure $applyAdditionalItems = null)
{
    /** @var Plan $newPlan */
    $newPlan = app(PlanRepository::class)::findOrFail($plan);
    $previousPlan = $this->plan;

    if ($this->cancelled()) {
        $this->cycle_ends_at = $this->ends_at;
        $this->ends_at = null;
    }

    $applyNewSettings = function () use ($newPlan) {
        $this->plan = $newPlan->name();
    };

    $this->restartCycleWithModifications($applyNewSettings, now(), $invoiceNow, $applyAdditionalItems);

    Event::dispatch(new SubscriptionPlanSwapped($this, $previousPlan));

    return $this;
}

The reasoning behind this change is allowing the developer to edit the orderItems before they get packed up in an order and the user is billed.

If we were to push a PR with this functionality would it be accepted? Does anyone have a better idea?

sandervanhooft commented 1 year ago

Hi @hjeldin ,

That's definitely an interesting use case.

Why doesn't $billable->redeemCoupon(...) fit your use case here, right after $subscription->swap(...)?

hjeldin commented 1 year ago

Thanks, i just checked again in our testing environment. Calling $billable->redeemCoupon() after a swap doesn't insert the coupon in the order's items. Actually, it doesn't show up anywhere besides "redeemed_coupons" with a "times_left" = 1. My understanding is that it will be applied after the next renewal (in our case, a year from now). We'd like the discount to be applied immediately and the generated invoice should include it.

hjeldin commented 1 year ago

Hi @sandervanhooft, should i create a pull request with the additional call_user_func or should the combo redeemCoupon/swap be fixed? Did you manage to take a look?

Thanks!

Naoray commented 1 year ago

Hi @hjeldin,

sorry for not getting back to you earlier.

In essence, you are absolutely correct in your observation that coupons are applied during the next renewal of the subscription cycle. However, this also holds true when a subscription swap occurs. To ensure proper functionality with subscription swaps, please follow these steps:

  1. Ensure that your cashier_plans.defaults.order_item_preprocessors configuration includes the ProcessCoupons class (alias of CouponOrderItemPreprocessor).
  2. It's crucial to redeem the coupon first and then proceed with the subscription swap for the user, not the other way around.

Here's a brief overview of how the renewal process is calculated:

  1. All order items that are due are processed through the preprocessors specified in the cashier_plans configuration.
  2. The total amount of all order items for the user is summed up, and an Order Model is created with this combined amount.
  3. A payment is then generated using the data from the Order Model

For coupons to function as intended, the ProcessCoupons (alias of CouponOrderItemPreprocessor) is essential. This preprocessor has the capability to modify, add, or remove order items from the collection used to create the order in step (2). In the case of a coupon, it adds another OrderItem with a negative net amount equal to the coupon's value.

I wrote a test for your specific use case to make 100% sure that it’s working.

Kindly let me know if this explanation resolves your issue. If you have any further questions or concerns, please don't hesitate to ask.

hjeldin commented 1 year ago

Thanks @Naoray, apparently we specified the correct preprocessors in cashier.php, but we were overwriting them with an empty collection in our database-backed plans.