added beads and fixes for paddle checkout

This commit is contained in:
Codex Agent
2025-12-22 14:13:26 +01:00
parent c947e638eb
commit f9016fb8ab
11 changed files with 522 additions and 1 deletions

View File

@@ -14,13 +14,17 @@ use App\Models\Tenant;
use App\Models\User;
use App\Services\Checkout\CheckoutAssignmentService;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleTransactionService;
use App\Support\CheckoutRoutes;
use App\Support\Concerns\PresentsPackages;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Inertia\Inertia;
@@ -250,7 +254,14 @@ class CheckoutController extends Controller
public function sessionStatus(
CheckoutSessionStatusRequest $request,
CheckoutSession $session,
CheckoutSessionService $sessions,
CheckoutAssignmentService $assignment,
PaddleTransactionService $transactions,
): JsonResponse {
$this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions);
$session->refresh();
return response()->json([
'status' => $session->status,
'completed_at' => optional($session->completed_at)->toIso8601String(),
@@ -327,4 +338,151 @@ class CheckoutController extends Controller
return $price <= 0;
}
private function attemptPaddleRecovery(
CheckoutSession $session,
CheckoutSessionService $sessions,
CheckoutAssignmentService $assignment,
PaddleTransactionService $transactions
): void {
if ($session->provider !== CheckoutSession::PROVIDER_PADDLE) {
return;
}
if (in_array($session->status, [
CheckoutSession::STATUS_COMPLETED,
CheckoutSession::STATUS_FAILED,
CheckoutSession::STATUS_CANCELLED,
], true)) {
return;
}
$metadata = $session->provider_metadata ?? [];
$lastPollAt = $metadata['paddle_poll_at'] ?? null;
$now = now();
if ($lastPollAt) {
try {
$lastPoll = Carbon::parse($lastPollAt);
if ($lastPoll->diffInSeconds($now) < 15) {
return;
}
} catch (\Throwable) {
// Ignore invalid timestamps.
}
}
$checkoutId = $metadata['paddle_checkout_id'] ?? $session->paddle_checkout_id ?? null;
$transactionId = $metadata['paddle_transaction_id'] ?? $session->paddle_transaction_id ?? null;
if (! $checkoutId && ! $transactionId) {
Log::info('[Checkout] Paddle recovery missing checkout reference, falling back to custom data scan', [
'session_id' => $session->id,
]);
}
$metadata['paddle_poll_at'] = $now->toIso8601String();
$session->forceFill([
'provider_metadata' => $metadata,
])->save();
try {
$transaction = $transactionId ? $transactions->retrieve($transactionId) : null;
if (! $transaction && $checkoutId) {
$transaction = $transactions->findByCheckoutId($checkoutId);
}
if (! $transaction) {
$transaction = $transactions->findByCustomData([
'checkout_session_id' => $session->id,
'package_id' => (string) $session->package_id,
'tenant_id' => (string) $session->tenant_id,
]);
}
} catch (PaddleException $exception) {
Log::warning('[Checkout] Paddle recovery failed', [
'session_id' => $session->id,
'checkout_id' => $checkoutId,
'transaction_id' => $transactionId,
'status' => $exception->status(),
'message' => $exception->getMessage(),
]);
return;
} catch (\Throwable $exception) {
Log::warning('[Checkout] Paddle recovery failed', [
'session_id' => $session->id,
'checkout_id' => $checkoutId,
'transaction_id' => $transactionId,
'message' => $exception->getMessage(),
]);
return;
}
if (! $transaction) {
Log::info('[Checkout] Paddle recovery: transaction not found', [
'session_id' => $session->id,
'checkout_id' => $checkoutId,
'transaction_id' => $transactionId,
]);
return;
}
$status = strtolower((string) ($transaction['status'] ?? ''));
$transactionId = $transactionId ?: ($transaction['id'] ?? null);
if ($transactionId && $session->paddle_transaction_id !== $transactionId) {
$session->forceFill([
'paddle_transaction_id' => $transactionId,
])->save();
}
if ($status === 'completed') {
$sessions->markProcessing($session, [
'paddle_status' => $status,
'paddle_transaction_id' => $transactionId,
'paddle_recovered_at' => $now->toIso8601String(),
]);
$assignment->finalise($session, [
'source' => 'paddle_poll',
'provider' => CheckoutSession::PROVIDER_PADDLE,
'provider_reference' => $transactionId,
'payload' => $transaction,
]);
$sessions->markCompleted($session, $now);
Log::info('[Checkout] Paddle session recovered via API', [
'session_id' => $session->id,
'checkout_id' => $checkoutId,
'transaction_id' => $transactionId,
]);
return;
}
if (in_array($status, ['failed', 'cancelled', 'canceled'], true)) {
$sessions->markFailed($session, 'paddle_'.$status);
Log::info('[Checkout] Paddle transaction failed', [
'session_id' => $session->id,
'checkout_id' => $checkoutId,
'transaction_id' => $transactionId,
'status' => $status,
]);
return;
}
Log::info('[Checkout] Paddle transaction pending', [
'session_id' => $session->id,
'checkout_id' => $checkoutId,
'transaction_id' => $transactionId,
'status' => $status,
]);
}
}

View File

@@ -14,5 +14,6 @@ class VerifyCsrfToken extends Middleware
protected $except = [
'api/v1/photos/*/like',
'api/v1/events/*/upload',
'paddle/webhook',
];
}
}

View File

@@ -33,6 +33,87 @@ class PaddleTransactionService
];
}
/**
* @return array<string, mixed>
*/
public function retrieve(string $transactionId): array
{
$response = $this->client->get("/transactions/{$transactionId}");
$transaction = Arr::get($response, 'data');
return is_array($transaction) ? $transaction : (is_array($response) ? $response : []);
}
/**
* @return array<string, mixed>|null
*/
public function findByCheckoutId(string $checkoutId): ?array
{
$response = $this->client->get('/transactions', [
'checkout_id' => $checkoutId,
'order_by' => '-created_at',
]);
$transactions = Arr::get($response, 'data', []);
if (! is_array($transactions) || $transactions === []) {
return null;
}
$first = $transactions[0] ?? null;
return is_array($first) ? $first : null;
}
/**
* @param array<string, string|int|null> $criteria
* @return array<string, mixed>|null
*/
public function findByCustomData(array $criteria, int $limit = 20): ?array
{
$payload = array_filter([
'order_by' => '-created_at',
'per_page' => max(1, min($limit, 50)),
], static fn ($value) => $value !== null && $value !== '');
$response = $this->client->get('/transactions', $payload);
$transactions = Arr::get($response, 'data', []);
if (! is_array($transactions) || $transactions === []) {
return null;
}
foreach ($transactions as $transaction) {
if (! is_array($transaction)) {
continue;
}
$customData = Arr::get($transaction, 'custom_data', Arr::get($transaction, 'customData', []));
if (! is_array($customData) || $customData === []) {
continue;
}
$matches = true;
foreach ($criteria as $key => $value) {
if ($value === null || $value === '') {
continue;
}
$candidate = $customData[$key] ?? null;
if ((string) $candidate !== (string) $value) {
$matches = false;
break;
}
}
if ($matches) {
return $transaction;
}
}
return null;
}
/**
* Issue a refund for a Paddle transaction.
*