209 lines
6.8 KiB
PHP
Executable File
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,
|
|
]);
|
|
}
|
|
}
|