Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/Billable.php
Original file line number Diff line number Diff line change
Expand Up @@ -502,11 +502,22 @@ public function pendingMollieMandate()
/**
* Checks whether the Mollie mandate is still valid. If not, clears it.
*
* When $acceptPending is true, a pending mandate is treated as valid. This is
* used by the first-payment flow where Mollie has already collected the
* payment and the mandate is guaranteed to finalize (see issue #289). For
* recurring/off-session charges the default strict behavior is preserved.
*
* @return bool
*
* @throws \Laravel\Cashier\Exceptions\MandateIsNotYetFinalizedException
*/
public function validateMollieMandate()
public function validateMollieMandate(bool $acceptPending = false)
{
if ($this->pendingMollieMandate()) {
if ($acceptPending) {
return true;
}

throw new MandateIsNotYetFinalizedException();
}

Expand All @@ -523,10 +534,11 @@ public function validateMollieMandate()
* @return bool
*
* @throws \Laravel\Cashier\Exceptions\InvalidMandateException
* @throws \Laravel\Cashier\Exceptions\MandateIsNotYetFinalizedException
*/
public function guardMollieMandate()
public function guardMollieMandate(bool $acceptPending = false)
{
throw_unless($this->validateMollieMandate(), new InvalidMandateException());
throw_unless($this->validateMollieMandate($acceptPending), new InvalidMandateException());

return true;
}
Expand Down
6 changes: 6 additions & 0 deletions src/FirstPayment/Actions/StartSubscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,12 @@ public function execute()
$this->builder()->nextPaymentAt($this->plan->interval()->getEndOfNextSubscriptionCycle());
}

// The first payment has already been collected by Mollie, so the mandate
// is guaranteed to finalize even when it is still pending at this point
// (e.g. PayPal or Belfius, where finalization can take up to 72h and
// exceeds Mollie's webhook retry window). See issue #289.
$this->builder()->acceptPendingMandate();

// Create the subscription, scheduling the next payment
$subscription = $this->builder()->create();

Expand Down
21 changes: 20 additions & 1 deletion src/SubscriptionBuilder/MandatedSubscriptionBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ class MandatedSubscriptionBuilder implements Contract
/** @var bool */
protected $validateCoupon = true;

/** @var bool */
protected $pendingMandateAccepted = false;

/**
* Create a new subscription builder instance.
*
Expand Down Expand Up @@ -93,7 +96,7 @@ public function __construct(Model $owner, string $name, string $plan)
*/
public function create()
{
$this->owner->guardMollieMandate();
$this->owner->guardMollieMandate($this->pendingMandateAccepted);
$now = now();

return DB::transaction(function () use ($now) {
Expand Down Expand Up @@ -242,4 +245,20 @@ public function skipCouponHandling()

return $this;
}

/**
* Treat a pending Mollie mandate as valid when creating the subscription.
*
* Used by the first-payment flow: Mollie has already collected the payment
* and guarantees the mandate will finalize, even when finalization takes
* longer than the webhook retry window (PayPal, Belfius). See issue #289.
*
* @return $this
*/
public function acceptPendingMandate(bool $accept = true)
{
$this->pendingMandateAccepted = $accept;

return $this;
}
}
25 changes: 25 additions & 0 deletions tests/BillableTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,31 @@ public function throwExceptionIfMandateIsInPendingState()
$user->newSubscriptionForMandateId('mdt_unique_mandate_id', 'main', 'monthly-10-1')->create();
}

#[Test]
public function validateMollieMandateAcceptsPendingWhenOptedIn()
{
// Regression test for issue #289: callers in the first-payment flow can
// opt in to treating a pending mandate as valid, because Mollie has
// already collected the payment and guarantees finalization.
$this->withMockedGetMollieCustomer();
$this->withMockedGetMollieMandatePending();
$user = $this->getMandatedUser(false);

$this->assertTrue($user->validateMollieMandate(acceptPending: true));
}

#[Test]
public function validateMollieMandateStillRejectsPendingByDefault()
{
$this->expectException(MandateIsNotYetFinalizedException::class);

$this->withMockedGetMollieCustomer();
$this->withMockedGetMollieMandatePending();
$user = $this->getMandatedUser(false);

$user->validateMollieMandate();
}

#[Test]
public function returnsDefaultSubscriptionBuilderIfOwnerHasValidMandateId()
{
Expand Down
28 changes: 28 additions & 0 deletions tests/Charge/ManageChargesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@

namespace Laravel\Cashier\Tests\Charge;

use Laravel\Cashier\Charge\ChargeItem;
use Laravel\Cashier\Charge\FirstPaymentChargeBuilder;
use Laravel\Cashier\Charge\MandatedChargeBuilder;
use Laravel\Cashier\Exceptions\MandateIsNotYetFinalizedException;
use Laravel\Cashier\Tests\BaseTestCase;
use Laravel\Cashier\Tests\Fixtures\User;
use Money\Currency;
use Money\Money;
use PHPUnit\Framework\Attributes\Test;

class ManageChargesTest extends BaseTestCase
Expand All @@ -30,4 +34,28 @@ public function useNewMandatedCharge()

$this->assertInstanceOf(MandatedChargeBuilder::class, $owner->newCharge());
}

#[Test]
public function mandatedChargeStillRejectsPendingMandate()
{
// Regression test for issue #289: while the first-payment flow accepts
// a pending mandate (Mollie has already collected that payment),
// off-session/recurring charges must remain strict — a pending mandate
// offers no payment guarantee for charges initiated by the merchant.
$this->expectException(MandateIsNotYetFinalizedException::class);

$this->withMockedGetMollieCustomer();
$this->withMockedGetMollieMandatePending();
$owner = $this->getMandatedUser(true, [
'mollie_mandate_id' => 'mdt_unique_mandate_id',
'mollie_customer_id' => 'cst_unique_customer_id',
]);

$builder = new MandatedChargeBuilder($owner);
$builder->addItem(new ChargeItem(
$owner,
new Money(1000, new Currency('EUR')),
'Test charge'
))->create();
}
}
66 changes: 66 additions & 0 deletions tests/FirstPayment/Actions/StartSubscriptionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,72 @@ public function canStartSubscriptionWithCouponAndTrial()
$this->assertEquals(20, $scheduledItem->tax_percentage);
}

#[Test]
public function startsSubscriptionWhenMandateIsStillPending()
{
// Regression test for issue #289: PayPal and Belfius mandates can remain
// pending for up to 72h after the first payment, which exceeds Mollie's
// webhook retry window. The first payment is already collected and the
// mandate is guaranteed to finalize, so the subscription must be started.
Carbon::setTestNow('2019-01-29');

$user = $this->getMandatedUser(true);
$this->withMockedGetMollieCustomer();
$this->withMockedGetMollieMandatePending();

$this->assertFalse($user->subscribed('default'));

$action = new StartSubscription(
$user,
'default',
'monthly-10-1'
);

$items = $action->execute();
$user = $user->fresh();

$this->assertTrue($user->subscribed('default'));
$this->assertInstanceOf(OrderItemCollection::class, $items);
$this->assertCount(1, $items);

$subscription = $user->subscription('default');
$this->assertEquals(2, $subscription->orderItems()->count());
}

#[Test]
public function startsSubscriptionWithTrialDaysWhenMandateIsStillPending()
{
// Regression test for issue #289: covers the trial branch of
// MandatedSubscriptionBuilder::create() to ensure the pending-mandate
// opt-in also applies when the subscription starts on a trial.
$user = $this->getMandatedUser(true, ['tax_percentage' => 20]);
$this->withMockedGetMollieCustomer();
$this->withMockedGetMollieMandatePending();

$this->assertFalse($user->subscribed('default'));

$action = new StartSubscription(
$user,
'default',
'monthly-10-1'
);

$action->trialDays(5);

$items = $action->execute();
$user = $user->fresh();

$this->assertTrue($user->subscribed('default'));
$this->assertTrue($user->onTrial());
$this->assertInstanceOf(OrderItemCollection::class, $items);
$this->assertCount(1, $items);

$subscription = $user->subscription('default');
$this->assertEquals(2, $subscription->orderItems()->count());
$this->assertCarbon(now()->addDays(5), $subscription->cycle_ends_at);
$this->assertCarbon(now()->addDays(5), $subscription->trial_ends_at);
}

/**
* Check if the action can be built using the payload, and then can return the same payload.
*
Expand Down
70 changes: 70 additions & 0 deletions tests/FirstPayment/FirstPaymentHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Events\MandateUpdated;
use Laravel\Cashier\FirstPayment\Actions\AddBalance;
use Laravel\Cashier\FirstPayment\Actions\StartSubscription;
use Laravel\Cashier\FirstPayment\FirstPaymentHandler;
use Laravel\Cashier\Tests\BaseTestCase;
use Laravel\Cashier\Tests\Fixtures\User;
Expand Down Expand Up @@ -85,6 +86,75 @@ public function handlesMolliePayments()
});
}

#[Test]
public function startsSubscriptionWhenMandateIsStillPending()
{
// End-to-end regression for issue #289: this exercises the path
// FirstPaymentWebhookController → FirstPaymentHandler::execute()
// → StartSubscription::execute() → MandatedSubscriptionBuilder::create()
// with a pending mandate, which is the exact scenario reported for
// PayPal and Belfius first payments.
Event::fake();
$this->withConfiguredPlans();
$this->withMockedGetMollieCustomer();
$this->withMockedGetMollieMandatePending();

$molliePayment = $this->getStartSubscriptionPaymentStub();

$owner = User::factory()->create([
'id' => $molliePayment->metadata->owner->id,
'mollie_customer_id' => 'cst_unique_customer_id',
]);
Cashier::$paymentModel::createFromMolliePayment($molliePayment, $owner);

$this->assertFalse($owner->subscribed('default'));

$handler = new FirstPaymentHandler($molliePayment);
$order = $handler->execute();

$owner = $owner->fresh();

$this->assertTrue($owner->subscribed('default'));
$this->assertEquals('mdt_unique_mandate_id', $owner->mollie_mandate_id);
$this->assertInstanceOf(Cashier::$orderModel, $order);
$this->assertTrue($order->isProcessed());

Event::assertDispatched(MandateUpdated::class);
}

protected function getStartSubscriptionPaymentStub(): MolliePayment
{
$payment = new MolliePayment(new MollieApiClient);
$payment->sequenceType = 'first';
$payment->id = 'tr_unique_start_subscription_payment_id';
$payment->customerId = 'cst_unique_customer_id';
$payment->mandateId = 'mdt_unique_mandate_id';
$payment->amount = (object) ['value' => '10.00', 'currency' => 'EUR'];
$payment->status = PaymentStatus::PAID;
$payment->metadata = json_decode(json_encode([
'owner' => [
'type' => User::class,
'id' => 1,
],
'actions' => [
[
'handler' => StartSubscription::class,
'description' => 'Monthly payment',
'subtotal' => [
'currency' => 'EUR',
'value' => '10.00',
],
'taxPercentage' => 0,
'plan' => 'monthly-10-1',
'name' => 'default',
'quantity' => 1,
],
],
]));

return $payment;
}

protected function getMandatePaymentStub(): MolliePayment
{
$payment = new MolliePayment(new MollieApiClient());
Expand Down