Skip to content

Commit b145669

Browse files
author
root
committed
Add Pagarme webhook handler
1 parent d4c8599 commit b145669

11 files changed

Lines changed: 276 additions & 4 deletions

File tree

routes/api.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use Kaninstein\MultiAcquirerCheckout\Presentation\Http\Controllers\CheckoutController;
55
use Kaninstein\MultiAcquirerCheckout\Presentation\Http\Controllers\BoletoBarcodeController;
66
use Kaninstein\MultiAcquirerCheckout\Presentation\Http\Controllers\FeeController;
7+
use Kaninstein\MultiAcquirerCheckout\Presentation\Http\Controllers\PagarmeWebhookController;
78

89
$prefix = (string) config('multi-acquirer.routes.prefix', 'api/multi-acquirer');
910
$middleware = (array) config('multi-acquirer.routes.middleware', ['api']);
@@ -14,4 +15,5 @@
1415
Route::post('/checkout', [CheckoutController::class, 'process']);
1516
Route::post('/fees', [FeeController::class, 'calculate']);
1617
Route::get('/boleto/barcode', BoletoBarcodeController::class);
18+
Route::post('/webhooks/pagarme', PagarmeWebhookController::class);
1719
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
namespace Kaninstein\MultiAcquirerCheckout\Application\Services;
4+
5+
use Illuminate\Contracts\Events\Dispatcher;
6+
use Kaninstein\MultiAcquirerCheckout\Infrastructure\Repositories\Contracts\PaymentRepositoryInterface;
7+
8+
final readonly class PagarmeWebhookService
9+
{
10+
public function __construct(
11+
private PaymentRepositoryInterface $payments,
12+
private Dispatcher $events,
13+
) {}
14+
15+
/**
16+
* @param array<string, mixed> $payload
17+
* @return array{status:string}
18+
*/
19+
public function handle(array $payload): array
20+
{
21+
$eventType = is_string($payload['type'] ?? null) ? (string) $payload['type'] : null;
22+
/** @var array<string,mixed> $data */
23+
$data = is_array($payload['data'] ?? null) ? (array) $payload['data'] : $payload;
24+
25+
if (! $eventType) {
26+
return ['status' => 'ignored'];
27+
}
28+
29+
$gatewayTransactionId = $this->extractGatewayTransactionId($eventType, $data);
30+
if ($gatewayTransactionId === null) {
31+
return ['status' => 'ignored'];
32+
}
33+
34+
$payment = $this->payments->findByGatewayTransactionId($gatewayTransactionId);
35+
if ($payment === null) {
36+
return ['status' => 'ignored'];
37+
}
38+
39+
if ($payment->status->isFinal()) {
40+
return ['status' => 'ignored'];
41+
}
42+
43+
$webhookMeta = [
44+
'last_event' => $eventType,
45+
'received_at' => now()->toISOString(),
46+
];
47+
48+
$payment->metadata = [
49+
...$payment->metadata,
50+
'webhook' => $webhookMeta,
51+
];
52+
53+
if ($eventType === 'charge.paid' || $eventType === 'charge.payment_succeeded' || $eventType === 'order.paid') {
54+
$payment->markPaid();
55+
} elseif ($eventType === 'charge.pending' || $eventType === 'charge.waiting_payment' || $eventType === 'order.pending') {
56+
// Keep pending; only record metadata.
57+
} elseif ($eventType === 'charge.failed' || $eventType === 'charge.payment_failed') {
58+
$payment->fail($this->extractFailureReason($data) ?? 'Payment failed');
59+
} elseif ($eventType === 'charge.refunded') {
60+
$payment->refund();
61+
} elseif ($eventType === 'charge.canceled' || $eventType === 'order.canceled') {
62+
$payment->cancel();
63+
} else {
64+
return ['status' => 'ignored'];
65+
}
66+
67+
$this->payments->save($payment);
68+
69+
if (config('multi-acquirer.events.dispatch_domain_events', true)) {
70+
foreach ($payment->releaseEvents() as $event) {
71+
$this->events->dispatch($event);
72+
}
73+
}
74+
75+
return ['status' => 'success'];
76+
}
77+
78+
/**
79+
* @param array<string,mixed> $data
80+
*/
81+
private function extractFailureReason(array $data): ?string
82+
{
83+
$last = is_array($data['last_transaction'] ?? null) ? (array) $data['last_transaction'] : null;
84+
$gatewayResponse = $last && is_array($last['gateway_response'] ?? null) ? (array) $last['gateway_response'] : null;
85+
$errors = $gatewayResponse && is_array($gatewayResponse['errors'] ?? null) ? (array) $gatewayResponse['errors'] : [];
86+
$first = is_array($errors[0] ?? null) ? (array) $errors[0] : null;
87+
88+
$message = $first['message'] ?? null;
89+
90+
return is_string($message) && $message !== '' ? $message : null;
91+
}
92+
93+
/**
94+
* @param array<string,mixed> $data
95+
*/
96+
private function extractGatewayTransactionId(string $eventType, array $data): ?string
97+
{
98+
if (str_starts_with($eventType, 'charge.')) {
99+
$id = $data['id'] ?? null;
100+
return is_string($id) && $id !== '' ? $id : null;
101+
}
102+
103+
if (str_starts_with($eventType, 'order.')) {
104+
$charges = is_array($data['charges'] ?? null) ? (array) $data['charges'] : [];
105+
$firstCharge = is_array($charges[0] ?? null) ? (array) $charges[0] : [];
106+
$id = $firstCharge['id'] ?? null;
107+
108+
return is_string($id) && $id !== '' ? $id : null;
109+
}
110+
111+
return null;
112+
}
113+
}
114+

src/Domain/Payment/Entities/Payment.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
use Illuminate\Support\Str;
66
use Kaninstein\MultiAcquirerCheckout\Domain\Payment\Events\PaymentAuthorized;
7+
use Kaninstein\MultiAcquirerCheckout\Domain\Payment\Events\PaymentCanceled;
78
use Kaninstein\MultiAcquirerCheckout\Domain\Payment\Events\PaymentCreated;
89
use Kaninstein\MultiAcquirerCheckout\Domain\Payment\Events\PaymentFailed;
910
use Kaninstein\MultiAcquirerCheckout\Domain\Payment\Events\PaymentPaid;
11+
use Kaninstein\MultiAcquirerCheckout\Domain\Payment\Events\PaymentRefunded;
1012
use Kaninstein\MultiAcquirerCheckout\Domain\Payment\ValueObjects\Customer;
1113
use Kaninstein\MultiAcquirerCheckout\Domain\Payment\ValueObjects\Money;
1214
use Kaninstein\MultiAcquirerCheckout\Domain\Payment\ValueObjects\PaymentMethod;
@@ -74,6 +76,18 @@ public function fail(string $reason): void
7476
$this->recordEvent(new PaymentFailed($this->id, $reason));
7577
}
7678

79+
public function cancel(): void
80+
{
81+
$this->status = PaymentStatus::CANCELED;
82+
$this->recordEvent(new PaymentCanceled($this->id, $this->gatewayTransactionId));
83+
}
84+
85+
public function refund(): void
86+
{
87+
$this->status = PaymentStatus::REFUNDED;
88+
$this->recordEvent(new PaymentRefunded($this->id, $this->gatewayTransactionId));
89+
}
90+
7791
/**
7892
* @return array<string, mixed>
7993
*/
@@ -92,4 +106,3 @@ public function toArray(): array
92106
];
93107
}
94108
}
95-
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
namespace Kaninstein\MultiAcquirerCheckout\Domain\Payment\Events;
4+
5+
final readonly class PaymentCanceled
6+
{
7+
public function __construct(
8+
public string $paymentId,
9+
public ?string $gatewayTransactionId = null,
10+
) {}
11+
}
12+
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
namespace Kaninstein\MultiAcquirerCheckout\Domain\Payment\Events;
4+
5+
final readonly class PaymentRefunded
6+
{
7+
public function __construct(
8+
public string $paymentId,
9+
public ?string $gatewayTransactionId = null,
10+
) {}
11+
}
12+

src/Infrastructure/Repositories/Contracts/PaymentRepositoryInterface.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ interface PaymentRepositoryInterface
99
public function save(Payment $payment): void;
1010

1111
public function findById(string $id): ?Payment;
12-
}
1312

13+
public function findByGatewayTransactionId(string $gatewayTransactionId): ?Payment;
14+
}

src/Infrastructure/Repositories/Eloquent/EloquentPaymentRepository.php

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,25 @@ public function findById(string $id): ?Payment
4141
return null;
4242
}
4343

44+
return $this->toEntity($model);
45+
}
46+
47+
public function findByGatewayTransactionId(string $gatewayTransactionId): ?Payment
48+
{
49+
/** @var PaymentModel|null $model */
50+
$model = $this->model->newQuery()
51+
->where('gateway_transaction_id', $gatewayTransactionId)
52+
->first();
53+
54+
if (! $model) {
55+
return null;
56+
}
57+
58+
return $this->toEntity($model);
59+
}
60+
61+
private function toEntity(PaymentModel $model): Payment
62+
{
4463
return new Payment(
4564
id: (string) $model->id,
4665
amount: Money::fromCents((int) $model->amount_cents, (string) $model->currency),
@@ -54,4 +73,3 @@ public function findById(string $id): ?Payment
5473
);
5574
}
5675
}
57-

src/Infrastructure/Repositories/InMemory/InMemoryPaymentRepository.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,15 @@ public function findById(string $id): ?Payment
1919
{
2020
return $this->payments[$id] ?? null;
2121
}
22-
}
2322

23+
public function findByGatewayTransactionId(string $gatewayTransactionId): ?Payment
24+
{
25+
foreach ($this->payments as $payment) {
26+
if ($payment->gatewayTransactionId === $gatewayTransactionId) {
27+
return $payment;
28+
}
29+
}
30+
31+
return null;
32+
}
33+
}

src/MultiAcquirerCheckoutServiceProvider.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Illuminate\Support\ServiceProvider;
66
use Kaninstein\MultiAcquirerCheckout\Application\Services\CheckoutService;
7+
use Kaninstein\MultiAcquirerCheckout\Application\Services\PagarmeWebhookService;
78
use Kaninstein\MultiAcquirerCheckout\Domain\Fee\Services\FeeCalculator;
89
use Kaninstein\MultiAcquirerCheckout\Domain\Gateway\Contracts\GatewayInterface;
910
use Kaninstein\MultiAcquirerCheckout\Infrastructure\Gateways\Appmax\AppmaxGateway;
@@ -137,6 +138,7 @@ protected function registerRepositories(): void
137138
protected function registerServices(): void
138139
{
139140
$this->app->singleton(CheckoutService::class);
141+
$this->app->singleton(PagarmeWebhookService::class);
140142
$this->app->singleton(FeeCalculator::class);
141143
}
142144

@@ -149,6 +151,7 @@ public function provides(): array
149151
{
150152
return [
151153
CheckoutService::class,
154+
PagarmeWebhookService::class,
152155
FeeCalculator::class,
153156
GatewayPipeline::class,
154157
PaymentRepositoryInterface::class,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace Kaninstein\MultiAcquirerCheckout\Presentation\Http\Controllers;
4+
5+
use Illuminate\Http\JsonResponse;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Support\Facades\Log;
8+
use Kaninstein\LaravelPagarme\Services\WebhookValidator;
9+
use Kaninstein\MultiAcquirerCheckout\Application\Services\PagarmeWebhookService;
10+
11+
final readonly class PagarmeWebhookController
12+
{
13+
public function __construct(
14+
private PagarmeWebhookService $service,
15+
private WebhookValidator $validator,
16+
) {}
17+
18+
public function __invoke(Request $request): JsonResponse
19+
{
20+
$payload = $request->all();
21+
22+
if ((bool) config('multi-acquirer.webhooks.validate_signature', true)) {
23+
$result = $this->validator->validateWebhook($request);
24+
$valid = (bool) ($result['valid'] ?? false);
25+
26+
if (! $valid) {
27+
Log::warning('Invalid Pagarme webhook signature', [
28+
'reasons' => $result['reasons'] ?? [],
29+
'ip' => $request->ip(),
30+
]);
31+
32+
return response()->json(['error' => 'Invalid webhook'], 401);
33+
}
34+
}
35+
36+
$result = $this->service->handle($payload);
37+
38+
return response()->json($result, 200);
39+
}
40+
}
41+

0 commit comments

Comments
 (0)