Files
SonarDesk-Licencing-Server/app/Http/Controllers/Api/PaymentWebhookController.php
T

209 lines
6.8 KiB
PHP
Executable File

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\LicenseWebhookEvent;
use App\Services\Licensing\LicenseDeliveryService;
use App\Services\Payments\LemonSqueezyWebhookVerifier;
use App\Services\Payments\PaddleWebhookVerifier;
use App\Services\Payments\PurchaseProvisioner;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class PaymentWebhookController extends Controller
{
public function __invoke(
string $provider,
Request $request,
PurchaseProvisioner $provisioner,
LicenseDeliveryService $delivery
): JsonResponse {
$raw = (string) $request->getContent();
$verifier = match ($provider) {
'paddle' => app(PaddleWebhookVerifier::class),
'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),
default => null,
};
if (! $verifier) {
return response()->json([
'message' => 'Unknown webhook provider.',
], 404);
}
try {
$verifier->verify($request, $raw);
} catch (\Throwable $e) {
report($e);
return response()->json([
'message' => 'Invalid webhook signature.',
], 400);
}
try {
$payload = json_decode(
$raw,
true,
flags: JSON_THROW_ON_ERROR
);
} catch (\Throwable) {
return response()->json([
'message' => 'Invalid JSON.',
], 400);
}
$externalID = (string) (
$provider === 'paddle'
? ($payload['event_id'] ?? '')
: ($payload['meta']['event_name'] ?? '')
.':'
.($payload['data']['id'] ?? '')
);
$eventType = (string) (
$provider === 'paddle'
? ($payload['event_type'] ?? 'unknown')
: ($payload['meta']['event_name'] ?? 'unknown')
);
if ($externalID === '') {
return response()->json([
'message' => 'Webhook event has no stable identifier.',
], 400);
}
try {
/*
* Paddle can deliver several related webhook events at almost
* exactly the same time.
*
* Laravel will automatically retry the entire transaction when
* MySQL reports a deadlock / serialization failure.
*
* Five attempts gives short-lived webhook contention time to
* resolve without weakening any database constraints.
*/
DB::transaction(
function () use (
$provider,
$externalID,
$eventType,
$payload,
$raw,
$provisioner
): void {
/*
* firstOrCreate protects against Paddle retrying the exact
* same event ID.
*
* lockForUpdate prevents two workers handling the same
* webhook event concurrently after the row exists.
*/
LicenseWebhookEvent::firstOrCreate(
[
'provider' => $provider,
'external_event_id' => $externalID,
],
[
'event_type' => $eventType,
'payload_sha256' => hash('sha256', $raw),
'payload' => $payload,
]
);
$event = LicenseWebhookEvent::query()
->where('provider', $provider)
->where('external_event_id', $externalID)
->lockForUpdate()
->firstOrFail();
/*
* Already successfully processed.
*
* Returning here makes duplicate delivery of the exact
* same Paddle event idempotent.
*/
if ($event->processed_at !== null) {
return;
}
/*
* An external event ID must never be reused with different
* content.
*/
$incomingHash = hash('sha256', $raw);
if (! hash_equals(
(string) $event->payload_sha256,
$incomingHash
)) {
throw new \RuntimeException(
'Webhook ID was reused with a different payload.'
);
}
try {
$provisioner->provision(
$provider,
$payload
);
$event->update([
'processed_at' => now(),
'processing_error' => null,
]);
} catch (\Throwable $e) {
/*
* This update is part of the same transaction.
*
* If processing fails and the transaction rolls back,
* Paddle can retry the webhook normally.
*/
$event->update([
'processing_error' => mb_substr(
$e->getMessage(),
0,
4000
),
]);
throw $e;
}
},
5
);
} catch (\Throwable $e) {
report($e);
return response()->json([
'message' => 'Webhook could not be processed.',
'exception' => get_class($e),
'error' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
], 500);
}
/*
* Delivery is intentionally outside the webhook transaction.
*
* A temporary mail outage must not roll back an otherwise valid
* payment event. Pending outbox rows remain available for portal
* resend or a later webhook delivery attempt.
*/
try {
$delivery->deliverPending(10);
} catch (\Throwable $e) {
report($e);
}
return response()->json([
'ok' => true,
]);
}
}