update to provisioner code

This commit is contained in:
Mark Gregory
2026-09-30 12:55:32 +01:00
parent 6850a00e76
commit 58eec7da9a
2 changed files with 378 additions and 172 deletions
@@ -11,6 +11,8 @@ use App\Services\Payments\PurchaseProvisioner;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Throwable;
final class PaymentWebhookController extends Controller
{
@@ -36,7 +38,7 @@ final class PaymentWebhookController extends Controller
try {
$verifier->verify($request, $raw);
} catch (\Throwable $e) {
} catch (Throwable $e) {
report($e);
return response()->json([
@@ -50,7 +52,7 @@ final class PaymentWebhookController extends Controller
true,
flags: JSON_THROW_ON_ERROR
);
} catch (\Throwable) {
} catch (Throwable) {
return response()->json([
'message' => 'Invalid JSON.',
], 400);
@@ -76,16 +78,16 @@ final class PaymentWebhookController extends Controller
], 400);
}
$payloadHash = hash('sha256', $raw);
try {
/*
* Paddle can deliver several related webhook events at almost
* exactly the same time.
* Paddle can deliver several related events concurrently.
*
* 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.
* Laravel retries this complete transaction when MySQL reports
* a deadlock. Five attempts gives short-lived lock contention
* enough opportunity to resolve without acknowledging an event
* that was not actually processed.
*/
DB::transaction(
function () use (
@@ -93,15 +95,14 @@ final class PaymentWebhookController extends Controller
$externalID,
$eventType,
$payload,
$raw,
$payloadHash,
$provisioner
): void {
/*
* firstOrCreate protects against Paddle retrying the exact
* same event ID.
* Insert the event if this is its first delivery.
*
* lockForUpdate prevents two workers handling the same
* webhook event concurrently after the row exists.
* Once present, lock the row so duplicate deliveries of
* the same event cannot provision concurrently.
*/
LicenseWebhookEvent::firstOrCreate(
[
@@ -110,7 +111,7 @@ final class PaymentWebhookController extends Controller
],
[
'event_type' => $eventType,
'payload_sha256' => hash('sha256', $raw),
'payload_sha256' => $payloadHash,
'payload' => $payload,
]
);
@@ -121,27 +122,15 @@ final class PaymentWebhookController extends Controller
->lockForUpdate()
->firstOrFail();
/*
* Already successfully processed.
*
* Returning here makes duplicate delivery of the exact
* same Paddle event idempotent.
*/
if ($event->processed_at !== null) {
if ($event->processed_at) {
return;
}
/*
* An external event ID must never be reused with different
* content.
*/
$incomingHash = hash('sha256', $raw);
if (! hash_equals(
(string) $event->payload_sha256,
$incomingHash
$payloadHash
)) {
throw new \RuntimeException(
throw new RuntimeException(
'Webhook ID was reused with a different payload.'
);
}
@@ -152,31 +141,30 @@ final class PaymentWebhookController extends Controller
$payload
);
$event->update([
'processed_at' => now(),
'processing_error' => null,
]);
} catch (\Throwable $e) {
$event->processed_at = now();
$event->processing_error = null;
$event->save();
} 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.
* This assignment is useful when the surrounding
* transaction eventually succeeds. If processing
* throws, the transaction itself is rolled back and
* Paddle will retry the event.
*/
$event->update([
'processing_error' => mb_substr(
$e->getMessage(),
0,
4000
),
]);
$event->processing_error = mb_substr(
$e->getMessage(),
0,
4000
);
$event->save();
throw $e;
}
},
5
);
} catch (\Throwable $e) {
} catch (Throwable $e) {
report($e);
return response()->json([
@@ -189,15 +177,15 @@ final class PaymentWebhookController extends Controller
}
/*
* Delivery is intentionally outside the webhook transaction.
* Delivery deliberately happens 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.
* A temporary SMTP failure must not undo a successfully processed
* payment event. The outbox retains anything that still needs
* delivery.
*/
try {
$delivery->deliverPending(10);
} catch (\Throwable $e) {
} catch (Throwable $e) {
report($e);
}