diff --git a/README.md b/README.md index 94d999ef..bb10e17a 100644 --- a/README.md +++ b/README.md @@ -568,6 +568,10 @@ An Invoice is available on the Order. Access it using `$event->order->invoice()` #### `OrderPaymentFailed` event The payment for an order has failed. +#### `OrderPaymentFailedDueToInvalidMandate` event +The payment for an order has failed due to an invalid payment mandate. This happens for example when the customer's +credit card has expired. + #### `OrderPaymentPaid` event The payment for an order was successful. diff --git a/src/CashierServiceProvider.php b/src/CashierServiceProvider.php index 512b6240..46a8a295 100644 --- a/src/CashierServiceProvider.php +++ b/src/CashierServiceProvider.php @@ -17,7 +17,7 @@ class CashierServiceProvider extends ServiceProvider { use RegistersMollieInteractions; - const PACKAGE_VERSION = '1.15.0'; + const PACKAGE_VERSION = '1.16.0'; /** * Bootstrap the application services. diff --git a/src/Events/OrderPaymentFailedDueToInvalidMandate.php b/src/Events/OrderPaymentFailedDueToInvalidMandate.php new file mode 100644 index 00000000..2cff5b64 --- /dev/null +++ b/src/Events/OrderPaymentFailedDueToInvalidMandate.php @@ -0,0 +1,27 @@ +order = $order; + } +} diff --git a/src/Order/Order.php b/src/Order/Order.php index 610b589c..fe932512 100644 --- a/src/Order/Order.php +++ b/src/Order/Order.php @@ -12,6 +12,7 @@ use Laravel\Cashier\Events\BalanceTurnedStale; use Laravel\Cashier\Events\OrderCreated; use Laravel\Cashier\Events\OrderPaymentFailed; +use Laravel\Cashier\Events\OrderPaymentFailedDueToInvalidMandate; use Laravel\Cashier\Events\OrderPaymentPaid; use Laravel\Cashier\Events\OrderProcessed; use Laravel\Cashier\Exceptions\InvalidMandateException; @@ -159,7 +160,12 @@ public function processPayment() $this->total_due = $total->subtract($creditUsed)->getAmount(); } - $minimumPaymentAmount = $this->ensureValidMandateAndMinimumPaymentAmountWhenTotalDuePositive(); + try { + $minimumPaymentAmount = $this->ensureValidMandateAndMinimumPaymentAmountWhenTotalDuePositive(); + } catch (InvalidMandateException $e) { + return $this->handlePaymentFailedDueToInvalidMandate(); + } + $totalDue = money($this->total_due, $this->currency); switch (true) { @@ -403,6 +409,40 @@ public function handlePaymentFailed() }); } + /** + * Handles a failed payment for the Order due to an invalid Mollie payment Mandate. + * Restores any credit used to the customer's balance and resets the credits applied to the Order. + * Invokes handlePaymentFailed() on each related OrderItem. + * + * @return $this + */ + public function handlePaymentFailedDueToInvalidMandate() + { + return DB::transaction(function () { + if ($this->creditApplied()) { + $this->owner->addCredit($this->getCreditUsed()); + } + + $this->update([ + 'mollie_payment_id' => null, + 'mollie_payment_status' => 'failed', + 'balance_before' => 0, + 'credit_used' => 0, + 'processed_at' => now(), + ]); + + Event::dispatch(new OrderPaymentFailedDueToInvalidMandate($this)); + + $this->items->each(function (OrderItem $item) { + $item->handlePaymentFailed(); + }); + + $this->owner->clearMollieMandate(); + + return $this; + }); + } + /** * Handles a paid payment for this order. * Invokes handlePaymentPaid() on each related OrderItem. diff --git a/tests/Order/OrderTest.php b/tests/Order/OrderTest.php index a3ee8c42..61e90338 100644 --- a/tests/Order/OrderTest.php +++ b/tests/Order/OrderTest.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Event; use Laravel\Cashier\Events\BalanceTurnedStale; use Laravel\Cashier\Events\OrderCreated; +use Laravel\Cashier\Events\OrderPaymentFailedDueToInvalidMandate; use Laravel\Cashier\Events\OrderProcessed; use Laravel\Cashier\Mollie\Contracts\CreateMolliePayment; use Laravel\Cashier\Mollie\Contracts\GetMollieCustomer; @@ -360,6 +361,58 @@ public function createsAMolliePaymentIfTotalDueIsLargerThanMolliesMinimum() $this->assertDispatchedOrderProcessed($order); } + /** @test */ + public function handlesAnInvalidMandateWhenProcessingThePayment() + { + Event::fake(); + + $this->mock(GetMollieMandate::class, function ($mock) { + $mandate = new Mandate(new MollieApiClient); + $mandate->id = 'mdt_unique_mandate_id'; + $mandate->status = 'invalid'; + $mandate->method = 'directdebit'; + + return $mock->shouldReceive('execute') + ->with('cst_unique_customer_id', 'mdt_unique_mandate_id') + ->once() + ->andReturn($mandate); + }); + + $this->mock(GetMollieCustomer::class, function ($mock) { + $customer = new Customer(new MollieApiClient); + $customer->id = 'cst_unique_customer_id'; + + return $mock->shouldReceive('execute') + ->with('cst_unique_customer_id') + ->once() + ->andReturn($customer); + }); + + $user = $this->getMandatedUser(true, [ + 'id' => 2, + 'mollie_mandate_id' => 'mdt_unique_mandate_id', + 'mollie_customer_id' => 'cst_unique_customer_id', + ]); + + $order = $user->orders()->save(factory(Order::class)->make([ + 'total' => 1025, + 'total_due' => 1025, + 'currency' => 'EUR', + ])); + $this->assertFalse($order->isProcessed()); + $this->assertFalse($user->hasCredit('EUR')); + + $order->processPayment(); + + $this->assertTrue($order->isProcessed()); + $this->assertNull($order->mollie_payment_id); + $this->assertEquals('failed', $order->mollie_payment_status); + + Event::assertDispatched(OrderPaymentFailedDueToInvalidMandate::class, function ($event) use ($order) { + return $order->is($event->order); + }); + } + /** @test */ public function storesOwnerCreditIfTotalIsPositiveAndSmallerThanMolliesMinimum() {