Skip to content

Commit df3a235

Browse files
Fix idempotent first payment webhooks
1 parent d0c1be3 commit df3a235

4 files changed

Lines changed: 150 additions & 42 deletions

File tree

src/FirstPayment/FirstPaymentHandler.php

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ class FirstPaymentHandler
2121
/** @var \Illuminate\Support\Collection */
2222
protected $actions;
2323

24+
/** @var bool */
25+
protected $alreadyProcessed = false;
26+
2427
/**
2528
* FirstPaymentHandler constructor.
2629
*
@@ -40,7 +43,20 @@ public function __construct(MolliePayment $molliePayment)
4043
*/
4144
public function execute()
4245
{
46+
$this->alreadyProcessed = false;
47+
4348
$order = DB::transaction(function () {
49+
$localPayment = Cashier::$paymentModel::query()
50+
->where('mollie_payment_id', $this->molliePayment->id)
51+
->lockForUpdate()
52+
->first();
53+
54+
if ($localPayment?->order) {
55+
$this->alreadyProcessed = true;
56+
57+
return $localPayment->order;
58+
}
59+
4460
$this->owner->mollie_mandate_id = $this->molliePayment->mandateId;
4561
$this->owner->save();
4662

@@ -53,7 +69,7 @@ public function execute()
5369

5470
// It's possible a payment from Cashier v1 is not yet tracked in the Cashier database.
5571
// In that case we create a record here.
56-
$localPayment = Cashier::$paymentModel::findByMolliePaymentOrCreate(
72+
$localPayment = $localPayment ?: Cashier::$paymentModel::findByMolliePaymentOrCreate(
5773
$this->molliePayment,
5874
$this->owner,
5975
$this->actions->all()
@@ -68,11 +84,23 @@ public function execute()
6884
return $order;
6985
});
7086

71-
event(new MandateUpdated($this->owner, $this->molliePayment));
87+
if (! $this->alreadyProcessed) {
88+
event(new MandateUpdated($this->owner, $this->molliePayment));
89+
}
7290

7391
return $order;
7492
}
7593

94+
/**
95+
* Determine if execute() returned an already processed first payment.
96+
*
97+
* @return bool
98+
*/
99+
public function wasAlreadyProcessed()
100+
{
101+
return $this->alreadyProcessed;
102+
}
103+
76104
/**
77105
* Fetch the owner model using the mandate payment metadata.
78106
*

src/Http/Controllers/FirstPaymentWebhookController.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,13 @@ public function handleWebhook(Request $request)
2424

2525
if ($payment) {
2626
if ($payment->isPaid()) {
27-
$order = (new FirstPaymentHandler($payment))->execute();
27+
$handler = new FirstPaymentHandler($payment);
28+
$order = $handler->execute();
29+
30+
if ($handler->wasAlreadyProcessed()) {
31+
return new Response(null, 200);
32+
}
33+
2834
$payment->webhookUrl = route('webhooks.mollie.aftercare');
2935

3036
/** @var UpdateMolliePayment $updateMolliePayment */

tests/SubscriptionBuilder/FirstPaymentSubscriptionBuilderTest.php

Lines changed: 81 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -132,45 +132,7 @@ public function handlesAPaidFirstPayment()
132132

133133
Event::fake();
134134

135-
$molliePayment = new MolliePayment(new MollieApiClient);
136-
$molliePayment->id = 'tr_unique_payment_id';
137-
$molliePayment->paidAt = Carbon::now()->toIso8601String();
138-
$molliePayment->mandateId = 'mdt_unique_mandate_id';
139-
$molliePayment->amount = (object) [
140-
'currency' => 'EUR',
141-
'value' => '0.05',
142-
];
143-
$molliePayment->metadata = json_decode(json_encode([
144-
'owner' => [
145-
'type' => get_class($this->user),
146-
'id' => 1,
147-
],
148-
'actions' => [
149-
[
150-
'handler' => StartSubscription::class,
151-
'description' => 'Monthly payment',
152-
'subtotal' => [
153-
'value' => '0.00',
154-
'currency' => 'EUR',
155-
],
156-
'taxPercentage' => 20,
157-
'plan' => 'monthly-10-1',
158-
'name' => 'default',
159-
'quantity' => 1,
160-
'nextPaymentAt' => now()->addDays(12)->toIso8601String(),
161-
'trialUntil' => now()->addDays(5)->toIso8601String(),
162-
],
163-
[
164-
'handler' => AddGenericOrderItem::class,
165-
'description' => 'Test mandate payment',
166-
'subtotal' => [
167-
'value' => '0.04',
168-
'currency' => 'EUR',
169-
],
170-
'taxPercentage' => 20,
171-
],
172-
],
173-
]));
135+
$molliePayment = $this->getPaidFirstPayment();
174136

175137
Cashier::$paymentModel::createFromMolliePayment($molliePayment, $this->user);
176138

@@ -208,6 +170,41 @@ public function handlesAPaidFirstPayment()
208170
$this->assertSame(Order::first()->items->first()->description_extra_lines[0], 'From 2019-01-01 to 2019-02-01');
209171
}
210172

173+
#[Test]
174+
public function ignoresADuplicatePaidFirstPaymentWebhook()
175+
{
176+
Event::fake();
177+
178+
$molliePayment = $this->getPaidFirstPayment();
179+
180+
Cashier::$paymentModel::createFromMolliePayment($molliePayment, $this->user);
181+
182+
$this->withMockedGetMolliePayment(2, $molliePayment);
183+
$this->withMockedGetMollieMandateAccepted(2);
184+
$this->withMockedGetMollieCustomer(2);
185+
$this->withMockedUpdateMolliePayment();
186+
187+
$firstResponse = $this->post(route('webhooks.mollie.first_payment', [
188+
'id' => 'tr_unique_payment_id',
189+
]));
190+
191+
$secondResponse = $this->post(route('webhooks.mollie.first_payment', [
192+
'id' => 'tr_unique_payment_id',
193+
]));
194+
195+
$firstResponse->assertStatus(200);
196+
$secondResponse->assertStatus(200);
197+
198+
$this->assertSame(1, Order::count());
199+
$this->assertSame(1, Order::query()->where('mollie_payment_id', 'tr_unique_payment_id')->count());
200+
$this->assertSame(1, Cashier::$paymentModel::count());
201+
$this->assertNotNull(Cashier::$paymentModel::first()->order_id);
202+
203+
Event::assertDispatched(OrderProcessed::class, 1);
204+
Event::assertDispatched(FirstPaymentPaid::class, 1);
205+
Event::assertDispatched(SubscriptionStarted::class, 1);
206+
}
207+
211208
#[Test]
212209
public function testWithCouponNoTrialValidatesCoupon()
213210
{
@@ -270,6 +267,51 @@ public function testHandlesNoTrialMode()
270267
);
271268
}
272269

270+
protected function getPaidFirstPayment(): MolliePayment
271+
{
272+
$molliePayment = new MolliePayment(new MollieApiClient);
273+
$molliePayment->id = 'tr_unique_payment_id';
274+
$molliePayment->paidAt = Carbon::now()->toIso8601String();
275+
$molliePayment->mandateId = 'mdt_unique_mandate_id';
276+
$molliePayment->amount = (object) [
277+
'currency' => 'EUR',
278+
'value' => '0.05',
279+
];
280+
$molliePayment->metadata = json_decode(json_encode([
281+
'owner' => [
282+
'type' => get_class($this->user),
283+
'id' => 1,
284+
],
285+
'actions' => [
286+
[
287+
'handler' => StartSubscription::class,
288+
'description' => 'Monthly payment',
289+
'subtotal' => [
290+
'value' => '0.00',
291+
'currency' => 'EUR',
292+
],
293+
'taxPercentage' => 20,
294+
'plan' => 'monthly-10-1',
295+
'name' => 'default',
296+
'quantity' => 1,
297+
'nextPaymentAt' => now()->addDays(12)->toIso8601String(),
298+
'trialUntil' => now()->addDays(5)->toIso8601String(),
299+
],
300+
[
301+
'handler' => AddGenericOrderItem::class,
302+
'description' => 'Test mandate payment',
303+
'subtotal' => [
304+
'value' => '0.04',
305+
'currency' => 'EUR',
306+
],
307+
'taxPercentage' => 20,
308+
],
309+
],
310+
]));
311+
312+
return $molliePayment;
313+
}
314+
273315
/**
274316
* @return \Laravel\Cashier\SubscriptionBuilder\FirstPaymentSubscriptionBuilder
275317
*/

tests/UpdatePaymentMethod/UpdatePaymentMethodTest.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,38 @@ public function canUpdatePaymentMethod()
6666
});
6767
}
6868

69+
#[Test]
70+
public function doesNotAddBalanceAgainForADuplicateFirstPayment()
71+
{
72+
$owner = User::factory()->create([
73+
'mollie_mandate_id' => 'mdt_unique_mandate_id',
74+
]);
75+
76+
$newPayment = $this->getNewMandatePaymentStub();
77+
Cashier::$paymentModel::createFromMolliePayment($newPayment, $owner);
78+
79+
Event::fake();
80+
81+
$firstHandler = new FirstPaymentHandler($newPayment);
82+
$firstOrder = $firstHandler->execute();
83+
84+
$secondHandler = new FirstPaymentHandler($newPayment);
85+
$secondOrder = $secondHandler->execute();
86+
87+
$owner = $owner->fresh();
88+
89+
$this->assertFalse($firstHandler->wasAlreadyProcessed());
90+
$this->assertTrue($secondHandler->wasAlreadyProcessed());
91+
$this->assertTrue($firstOrder->is($secondOrder));
92+
$this->assertTrue($owner->hasCredit());
93+
$this->assertMoneyEURCents(100, $owner->credit('EUR')->money());
94+
$this->assertEquals(1, $owner->orderItems()->count());
95+
$this->assertEquals(1, $owner->orders()->count());
96+
$this->assertEquals($firstOrder->id, Cashier::$paymentModel::first()->order_id);
97+
98+
Event::assertDispatched(MandateUpdated::class, 1);
99+
}
100+
69101
protected function getNewMandatePaymentStub(): Payment
70102
{
71103
$newPayment = new Payment(new MollieApiClient());

0 commit comments

Comments
 (0)