This commit is contained in:
Mark Gregory
2026-09-30 13:24:33 +01:00
parent bf5357cbcf
commit 5bb4e43665
32 changed files with 2185 additions and 236 deletions
@@ -11,68 +11,181 @@ 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
{
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) {
} catch (Throwable $e) {
report($e);
return response()->json(['message' => 'Invalid webhook signature.'], 400);
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);
$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'));
$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' => 'Webhook event has no stable identifier.',
], 400);
}
$payloadHash = hash('sha256', $raw);
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;
}
});
} catch (\Throwable $e) {
report($e);
/*
* Paddle can deliver several related events concurrently.
*
* 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 (
$provider,
$externalID,
$eventType,
$payload,
$payloadHash,
$provisioner
): void {
/*
* Insert the event if this is its first delivery.
*
* Once present, lock the row so duplicate deliveries of
* the same event cannot provision concurrently.
*/
LicenseWebhookEvent::firstOrCreate(
[
'provider' => $provider,
'external_event_id' => $externalID,
],
[
'event_type' => $eventType,
'payload_sha256' => $payloadHash,
'payload' => $payload,
]
);
return response()->json(['message' => 'Webhook could not be processed.'], 500);
$event = LicenseWebhookEvent::query()
->where('provider', $provider)
->where('external_event_id', $externalID)
->lockForUpdate()
->firstOrFail();
if ($event->processed_at) {
return;
}
if (! hash_equals(
(string) $event->payload_sha256,
$payloadHash
)) {
throw new RuntimeException(
'Webhook ID was reused with a different payload.'
);
}
try {
$provisioner->provision(
$provider,
$payload
);
$event->processed_at = now();
$event->processing_error = null;
$event->save();
} catch (Throwable $e) {
/*
* 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->processing_error = mb_substr(
$e->getMessage(),
0,
4000
);
$event->save();
throw $e;
}
},
5
);
} catch (Throwable $e) {
report($e);
return response()->json([
'message' => 'Webhook could not be processed.',
], 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 deliberately happens outside the webhook transaction.
*
* 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);
}
return response()->json(['ok' => true]);
return response()->json([
'ok' => true,
]);
}
}
@@ -17,10 +17,10 @@ final class CheckoutController extends Controller
}
$plans = [
'personal-monthly' => ['name' => 'Personal', 'billing' => 'Monthly', 'display_price' => '£1.99 / month', 'price_id' => $paddle['personal_monthly_price_id'], 'activation_limit' => 1],
'personal-annual' => ['name' => 'Personal', 'billing' => 'Annual', 'display_price' => '£14.99 / year', 'price_id' => $paddle['personal_annual_price_id'], 'activation_limit' => 1],
'professional-monthly' => ['name' => 'Professional', 'billing' => 'Monthly', 'display_price' => '£3.99 / month', 'price_id' => $paddle['professional_monthly_price_id'], 'activation_limit' => 3],
'professional-annual' => ['name' => 'Professional', 'billing' => 'Annual', 'display_price' => '£29.99 / year', 'price_id' => $paddle['professional_annual_price_id'], 'activation_limit' => 3],
'personal-monthly' => ['name' => 'Personal', 'billing' => 'Monthly', 'display_price' => '£2.99 / month', 'price_id' => $paddle['personal_monthly_price_id'], 'activation_limit' => 1],
'personal-annual' => ['name' => 'Personal', 'billing' => 'Annual', 'display_price' => '£24.99 / year', 'price_id' => $paddle['personal_annual_price_id'], 'activation_limit' => 1],
'professional-monthly' => ['name' => 'Professional', 'billing' => 'Monthly', 'display_price' => '£4.99 / month', 'price_id' => $paddle['professional_monthly_price_id'], 'activation_limit' => 3],
'professional-annual' => ['name' => 'Professional', 'billing' => 'Annual', 'display_price' => '£44.99 / year', 'price_id' => $paddle['professional_annual_price_id'], 'activation_limit' => 3],
];
foreach ($plans as $plan) {
@@ -7,15 +7,14 @@ use App\Models\License;
use App\Models\LicenseDeliveryOutbox;
use App\Models\LicenseProduct;
use App\Services\Payments\PaddleClient;
use App\Services\Licensing\LicenseDeliveryService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
use RuntimeException;
final class PortalController extends Controller
{
public function __construct(private PaddleClient $paddle) {}
public function __construct(private PaddleClient $paddle, private LicenseDeliveryService $delivery) {}
private function owned(Request $request)
{
@@ -116,9 +115,11 @@ final class PortalController extends Controller
return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']);
}
$plain = Crypt::decryptString($out->encrypted_license_key);
Mail::raw("Your SonarDesk licence key is:\n\n{$plain}\n\nKeep this key private.", fn ($m) => $m->to($request->user()->email)->subject('Your SonarDesk licence key'));
$out->update(['delivered_at' => now()]);
$out->update([
'recipient_email' => strtolower($request->user()->email),
'delivered_at' => null,
]);
$this->delivery->deliverPending(1);
return back()->with('status', 'Licence key sent to your verified email address.');
}