diff --git a/src/FirstPayment/FirstPaymentHandler.php b/src/FirstPayment/FirstPaymentHandler.php index 7e965d5..0977dab 100644 --- a/src/FirstPayment/FirstPaymentHandler.php +++ b/src/FirstPayment/FirstPaymentHandler.php @@ -21,6 +21,9 @@ class FirstPaymentHandler /** @var \Illuminate\Support\Collection */ protected $actions; + /** @var bool */ + protected $alreadyProcessed = false; + /** * FirstPaymentHandler constructor. * @@ -40,7 +43,20 @@ public function __construct(MolliePayment $molliePayment) */ public function execute() { + $this->alreadyProcessed = false; + $order = DB::transaction(function () { + $localPayment = Cashier::$paymentModel::query() + ->where('mollie_payment_id', $this->molliePayment->id) + ->lockForUpdate() + ->first(); + + if ($localPayment?->order) { + $this->alreadyProcessed = true; + + return $localPayment->order; + } + $this->owner->mollie_mandate_id = $this->molliePayment->mandateId; $this->owner->save(); @@ -53,7 +69,7 @@ public function execute() // It's possible a payment from Cashier v1 is not yet tracked in the Cashier database. // In that case we create a record here. - $localPayment = Cashier::$paymentModel::findByMolliePaymentOrCreate( + $localPayment = $localPayment ?: Cashier::$paymentModel::findByMolliePaymentOrCreate( $this->molliePayment, $this->owner, $this->actions->all() @@ -68,11 +84,23 @@ public function execute() return $order; }); - event(new MandateUpdated($this->owner, $this->molliePayment)); + if (! $this->alreadyProcessed) { + event(new MandateUpdated($this->owner, $this->molliePayment)); + } return $order; } + /** + * Determine if execute() returned an already processed first payment. + * + * @return bool + */ + public function wasAlreadyProcessed() + { + return $this->alreadyProcessed; + } + /** * Fetch the owner model using the mandate payment metadata. * diff --git a/src/Http/Controllers/FirstPaymentWebhookController.php b/src/Http/Controllers/FirstPaymentWebhookController.php index 0ac47d9..39e15af 100644 --- a/src/Http/Controllers/FirstPaymentWebhookController.php +++ b/src/Http/Controllers/FirstPaymentWebhookController.php @@ -24,7 +24,13 @@ public function handleWebhook(Request $request) if ($payment) { if ($payment->isPaid()) { - $order = (new FirstPaymentHandler($payment))->execute(); + $handler = new FirstPaymentHandler($payment); + $order = $handler->execute(); + + if ($handler->wasAlreadyProcessed()) { + return new Response(null, 200); + } + $payment->webhookUrl = route('webhooks.mollie.aftercare'); /** @var UpdateMolliePayment $updateMolliePayment */ diff --git a/tests/SubscriptionBuilder/FirstPaymentSubscriptionBuilderTest.php b/tests/SubscriptionBuilder/FirstPaymentSubscriptionBuilderTest.php index 3e34d59..caeaa01 100644 --- a/tests/SubscriptionBuilder/FirstPaymentSubscriptionBuilderTest.php +++ b/tests/SubscriptionBuilder/FirstPaymentSubscriptionBuilderTest.php @@ -132,45 +132,7 @@ public function handlesAPaidFirstPayment() Event::fake(); - $molliePayment = new MolliePayment(new MollieApiClient); - $molliePayment->id = 'tr_unique_payment_id'; - $molliePayment->paidAt = Carbon::now()->toIso8601String(); - $molliePayment->mandateId = 'mdt_unique_mandate_id'; - $molliePayment->amount = (object) [ - 'currency' => 'EUR', - 'value' => '0.05', - ]; - $molliePayment->metadata = json_decode(json_encode([ - 'owner' => [ - 'type' => get_class($this->user), - 'id' => 1, - ], - 'actions' => [ - [ - 'handler' => StartSubscription::class, - 'description' => 'Monthly payment', - 'subtotal' => [ - 'value' => '0.00', - 'currency' => 'EUR', - ], - 'taxPercentage' => 20, - 'plan' => 'monthly-10-1', - 'name' => 'default', - 'quantity' => 1, - 'nextPaymentAt' => now()->addDays(12)->toIso8601String(), - 'trialUntil' => now()->addDays(5)->toIso8601String(), - ], - [ - 'handler' => AddGenericOrderItem::class, - 'description' => 'Test mandate payment', - 'subtotal' => [ - 'value' => '0.04', - 'currency' => 'EUR', - ], - 'taxPercentage' => 20, - ], - ], - ])); + $molliePayment = $this->getPaidFirstPayment(); Cashier::$paymentModel::createFromMolliePayment($molliePayment, $this->user); @@ -208,6 +170,41 @@ public function handlesAPaidFirstPayment() $this->assertSame(Order::first()->items->first()->description_extra_lines[0], 'From 2019-01-01 to 2019-02-01'); } + #[Test] + public function ignoresADuplicatePaidFirstPaymentWebhook() + { + Event::fake(); + + $molliePayment = $this->getPaidFirstPayment(); + + Cashier::$paymentModel::createFromMolliePayment($molliePayment, $this->user); + + $this->withMockedGetMolliePayment(2, $molliePayment); + $this->withMockedGetMollieMandateAccepted(2); + $this->withMockedGetMollieCustomer(2); + $this->withMockedUpdateMolliePayment(); + + $firstResponse = $this->post(route('webhooks.mollie.first_payment', [ + 'id' => 'tr_unique_payment_id', + ])); + + $secondResponse = $this->post(route('webhooks.mollie.first_payment', [ + 'id' => 'tr_unique_payment_id', + ])); + + $firstResponse->assertStatus(200); + $secondResponse->assertStatus(200); + + $this->assertSame(1, Order::count()); + $this->assertSame(1, Order::query()->where('mollie_payment_id', 'tr_unique_payment_id')->count()); + $this->assertSame(1, Cashier::$paymentModel::count()); + $this->assertNotNull(Cashier::$paymentModel::first()->order_id); + + Event::assertDispatched(OrderProcessed::class, 1); + Event::assertDispatched(FirstPaymentPaid::class, 1); + Event::assertDispatched(SubscriptionStarted::class, 1); + } + #[Test] public function testWithCouponNoTrialValidatesCoupon() { @@ -270,6 +267,51 @@ public function testHandlesNoTrialMode() ); } + protected function getPaidFirstPayment(): MolliePayment + { + $molliePayment = new MolliePayment(new MollieApiClient); + $molliePayment->id = 'tr_unique_payment_id'; + $molliePayment->paidAt = Carbon::now()->toIso8601String(); + $molliePayment->mandateId = 'mdt_unique_mandate_id'; + $molliePayment->amount = (object) [ + 'currency' => 'EUR', + 'value' => '0.05', + ]; + $molliePayment->metadata = json_decode(json_encode([ + 'owner' => [ + 'type' => get_class($this->user), + 'id' => 1, + ], + 'actions' => [ + [ + 'handler' => StartSubscription::class, + 'description' => 'Monthly payment', + 'subtotal' => [ + 'value' => '0.00', + 'currency' => 'EUR', + ], + 'taxPercentage' => 20, + 'plan' => 'monthly-10-1', + 'name' => 'default', + 'quantity' => 1, + 'nextPaymentAt' => now()->addDays(12)->toIso8601String(), + 'trialUntil' => now()->addDays(5)->toIso8601String(), + ], + [ + 'handler' => AddGenericOrderItem::class, + 'description' => 'Test mandate payment', + 'subtotal' => [ + 'value' => '0.04', + 'currency' => 'EUR', + ], + 'taxPercentage' => 20, + ], + ], + ])); + + return $molliePayment; + } + /** * @return \Laravel\Cashier\SubscriptionBuilder\FirstPaymentSubscriptionBuilder */ diff --git a/tests/UpdatePaymentMethod/UpdatePaymentMethodTest.php b/tests/UpdatePaymentMethod/UpdatePaymentMethodTest.php index a8165f1..c49ed53 100644 --- a/tests/UpdatePaymentMethod/UpdatePaymentMethodTest.php +++ b/tests/UpdatePaymentMethod/UpdatePaymentMethodTest.php @@ -66,6 +66,38 @@ public function canUpdatePaymentMethod() }); } + #[Test] + public function doesNotAddBalanceAgainForADuplicateFirstPayment() + { + $owner = User::factory()->create([ + 'mollie_mandate_id' => 'mdt_unique_mandate_id', + ]); + + $newPayment = $this->getNewMandatePaymentStub(); + Cashier::$paymentModel::createFromMolliePayment($newPayment, $owner); + + Event::fake(); + + $firstHandler = new FirstPaymentHandler($newPayment); + $firstOrder = $firstHandler->execute(); + + $secondHandler = new FirstPaymentHandler($newPayment); + $secondOrder = $secondHandler->execute(); + + $owner = $owner->fresh(); + + $this->assertFalse($firstHandler->wasAlreadyProcessed()); + $this->assertTrue($secondHandler->wasAlreadyProcessed()); + $this->assertTrue($firstOrder->is($secondOrder)); + $this->assertTrue($owner->hasCredit()); + $this->assertMoneyEURCents(100, $owner->credit('EUR')->money()); + $this->assertEquals(1, $owner->orderItems()->count()); + $this->assertEquals(1, $owner->orders()->count()); + $this->assertEquals($firstOrder->id, Cashier::$paymentModel::first()->order_id); + + Event::assertDispatched(MandateUpdated::class, 1); + } + protected function getNewMandatePaymentStub(): Payment { $newPayment = new Payment(new MollieApiClient());