update to provisioner code
This commit is contained in:
@@ -14,52 +14,171 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class PaymentWebhookController extends Controller
|
||||
{
|
||||
public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner, LicenseDeliveryService $delivery): JsonResponse
|
||||
{
|
||||
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
|
||||
'paddle' => app(PaddleWebhookVerifier::class),
|
||||
'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (! $verifier) {
|
||||
return response()->json(['message' => 'Unknown webhook provider.'], 404);
|
||||
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);
|
||||
return response()->json([
|
||||
'message' => 'Invalid webhook signature.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($provider, $externalID, $eventType, $payload, $raw, $provisioner) {
|
||||
$event = LicenseWebhookEvent::firstOrCreate(['provider' => $provider, 'external_event_id' => $externalID], ['event_type' => $eventType, 'payload_sha256' => hash('sha256', $raw), 'payload' => $payload]);
|
||||
if ($event->processed_at) {
|
||||
return;
|
||||
}
|
||||
if (! hash_equals($event->payload_sha256, hash('sha256', $raw))) {
|
||||
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) {
|
||||
$event->update(['processing_error' => mb_substr($e->getMessage(), 0, 4000)]);
|
||||
throw $e;
|
||||
}
|
||||
});
|
||||
$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),
|
||||
@@ -69,15 +188,21 @@ final class PaymentWebhookController extends Controller
|
||||
], 500);
|
||||
}
|
||||
|
||||
// Delivery is intentionally outside the webhook transaction. A temporary mail
|
||||
// outage must not roll back an otherwise valid Paddle event. Pending outbox
|
||||
// rows remain available for portal resend or a later webhook delivery attempt.
|
||||
/*
|
||||
* 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]);
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user