Migrate billing from Paddle to Lemon Squeezy
This commit is contained in:
@@ -24,7 +24,7 @@ class CouponPreviewController extends Controller
|
||||
|
||||
$package = Package::findOrFail($data['package_id']);
|
||||
|
||||
if (! $package->paddle_price_id) {
|
||||
if (! $package->lemonsqueezy_variant_id) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('marketing.coupon.errors.package_not_configured'),
|
||||
]);
|
||||
|
||||
@@ -36,7 +36,7 @@ class GiftVoucherCheckoutController extends Controller
|
||||
|
||||
if (! $checkout['checkout_url']) {
|
||||
throw ValidationException::withMessages([
|
||||
'tier_key' => __('Unable to create Paddle checkout.'),
|
||||
'tier_key' => __('Unable to create Lemon Squeezy checkout.'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -46,19 +46,19 @@ class GiftVoucherCheckoutController extends Controller
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'checkout_id' => ['nullable', 'string', 'required_without_all:transaction_id,code'],
|
||||
'transaction_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'],
|
||||
'code' => ['nullable', 'string', 'required_without_all:checkout_id,transaction_id'],
|
||||
'checkout_id' => ['nullable', 'string', 'required_without_all:order_id,code'],
|
||||
'order_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'],
|
||||
'code' => ['nullable', 'string', 'required_without_all:checkout_id,order_id'],
|
||||
]);
|
||||
|
||||
$voucherQuery = GiftVoucher::query();
|
||||
|
||||
if (! empty($data['checkout_id'])) {
|
||||
$voucherQuery->where('paddle_checkout_id', $data['checkout_id']);
|
||||
$voucherQuery->where('lemonsqueezy_checkout_id', $data['checkout_id']);
|
||||
}
|
||||
|
||||
if (! empty($data['transaction_id'])) {
|
||||
$voucherQuery->orWhere('paddle_transaction_id', $data['transaction_id']);
|
||||
if (! empty($data['order_id'])) {
|
||||
$voucherQuery->orWhere('lemonsqueezy_order_id', $data['order_id']);
|
||||
}
|
||||
|
||||
if (! empty($data['code'])) {
|
||||
|
||||
@@ -9,7 +9,7 @@ use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -18,7 +18,7 @@ use Illuminate\Validation\ValidationException;
|
||||
class PackageController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PaddleCheckoutService $paddleCheckout,
|
||||
private readonly LemonSqueezyCheckoutService $lemonsqueezyCheckout,
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
) {}
|
||||
|
||||
@@ -53,7 +53,7 @@ class PackageController extends Controller
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'type' => 'required|in:endcustomer,reseller',
|
||||
'payment_method' => 'required|in:paddle',
|
||||
'payment_method' => 'required|in:lemonsqueezy',
|
||||
'event_id' => 'nullable|exists:events,id', // For endcustomer
|
||||
'success_url' => 'nullable|url',
|
||||
'return_url' => 'nullable|url',
|
||||
@@ -79,7 +79,7 @@ class PackageController extends Controller
|
||||
{
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'paddle_transaction_id' => 'required|string',
|
||||
'lemonsqueezy_order_id' => 'required|string',
|
||||
]);
|
||||
|
||||
$package = Package::findOrFail($request->package_id);
|
||||
@@ -89,14 +89,14 @@ class PackageController extends Controller
|
||||
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
|
||||
}
|
||||
|
||||
$provider = 'paddle';
|
||||
$provider = 'lemonsqueezy';
|
||||
|
||||
DB::transaction(function () use ($request, $package, $tenant, $provider) {
|
||||
PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'provider' => $provider,
|
||||
'provider_id' => $request->input('paddle_transaction_id'),
|
||||
'provider_id' => $request->input('lemonsqueezy_order_id'),
|
||||
'price' => $package->price,
|
||||
'type' => 'endcustomer_event',
|
||||
'purchased_at' => now(),
|
||||
@@ -161,7 +161,7 @@ class PackageController extends Controller
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function createPaddleCheckout(Request $request): JsonResponse
|
||||
public function createLemonSqueezyCheckout(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
@@ -181,15 +181,15 @@ class PackageController extends Controller
|
||||
throw ValidationException::withMessages(['user' => 'User context missing.']);
|
||||
}
|
||||
|
||||
if (! $package->paddle_price_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||
if (! $package->lemonsqueezy_variant_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']);
|
||||
}
|
||||
|
||||
$session = $this->sessions->createOrResume($user, $package, [
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY);
|
||||
|
||||
$now = now();
|
||||
|
||||
@@ -211,14 +211,14 @@ class PackageController extends Controller
|
||||
],
|
||||
];
|
||||
|
||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
|
||||
$checkout = $this->lemonsqueezyCheckout->createCheckout($tenant, $package, $payload);
|
||||
|
||||
$session->forceFill([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||
'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||
'lemonsqueezy_checkout_id' => $checkout['id'] ?? null,
|
||||
'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null,
|
||||
])),
|
||||
])->save();
|
||||
|
||||
@@ -239,7 +239,7 @@ class PackageController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url');
|
||||
$checkoutUrl = data_get($session->provider_metadata ?? [], 'lemonsqueezy_checkout_url');
|
||||
|
||||
return response()->json([
|
||||
'status' => $session->status,
|
||||
@@ -297,11 +297,11 @@ class PackageController extends Controller
|
||||
|
||||
private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||
{
|
||||
if (! $package->paddle_price_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||
if (! $package->lemonsqueezy_variant_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']);
|
||||
}
|
||||
|
||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
|
||||
$checkout = $this->lemonsqueezyCheckout->createCheckout($tenant, $package, [
|
||||
'success_url' => $request->input('success_url'),
|
||||
'return_url' => $request->input('return_url'),
|
||||
'metadata' => array_filter([
|
||||
|
||||
@@ -13,7 +13,7 @@ class EventAddonCatalogController extends Controller
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$addons = collect($this->catalog->all())
|
||||
->filter(fn (array $addon) => ! empty($addon['price_id']))
|
||||
->filter(fn (array $addon) => ! empty($addon['variant_id']))
|
||||
->map(fn (array $addon, string $key) => array_merge($addon, ['key' => $key]))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
@@ -4,10 +4,9 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleCustomerPortalService;
|
||||
use App\Services\Paddle\PaddleCustomerService;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezySubscriptionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -16,9 +15,8 @@ use Illuminate\Support\Facades\Log;
|
||||
class TenantBillingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PaddleTransactionService $paddleTransactions,
|
||||
private readonly PaddleCustomerService $paddleCustomers,
|
||||
private readonly PaddleCustomerPortalService $portalSessions,
|
||||
private readonly LemonSqueezyOrderService $orders,
|
||||
private readonly LemonSqueezySubscriptionService $subscriptions,
|
||||
) {}
|
||||
|
||||
public function transactions(Request $request): JsonResponse
|
||||
@@ -32,20 +30,15 @@ class TenantBillingController extends Controller
|
||||
], 404);
|
||||
}
|
||||
|
||||
if (! $tenant->paddle_customer_id) {
|
||||
try {
|
||||
$this->paddleCustomers->ensureCustomerId($tenant);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Failed to resolve Paddle customer for tenant', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'message' => 'Failed to resolve Paddle customer.',
|
||||
], 502);
|
||||
}
|
||||
if (! $tenant->lemonsqueezy_customer_id) {
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'meta' => [
|
||||
'next' => null,
|
||||
'previous' => null,
|
||||
'has_more' => false,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$cursor = $request->query('cursor');
|
||||
@@ -60,16 +53,16 @@ class TenantBillingController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->paddleTransactions->listForCustomer($tenant->paddle_customer_id, $query);
|
||||
$result = $this->orders->listForCustomer($tenant->lemonsqueezy_customer_id, $query);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Failed to load Paddle transactions', [
|
||||
Log::warning('Failed to load Lemon Squeezy transactions', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'message' => 'Failed to load Paddle transactions.',
|
||||
'message' => 'Failed to load Lemon Squeezy transactions.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
@@ -143,68 +136,64 @@ class TenantBillingController extends Controller
|
||||
], 404);
|
||||
}
|
||||
|
||||
$customerId = null;
|
||||
$subscriptionId = null;
|
||||
|
||||
try {
|
||||
$customerId = $this->paddleCustomers->ensureCustomerId($tenant);
|
||||
$subscriptionId = $tenant->getActiveResellerPackage()?->lemonsqueezy_subscription_id;
|
||||
if (! $subscriptionId) {
|
||||
return response()->json([
|
||||
'message' => 'No active subscription found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
Log::debug('Creating Paddle customer portal session', [
|
||||
Log::debug('Fetching Lemon Squeezy subscription portal URL', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'paddle_customer_id' => $customerId,
|
||||
'paddle_environment' => config('paddle.environment'),
|
||||
'paddle_base_url' => config('paddle.base_url'),
|
||||
'lemonsqueezy_subscription_id' => $subscriptionId,
|
||||
]);
|
||||
|
||||
$session = $this->portalSessions->createSession($customerId);
|
||||
$subscription = $this->subscriptions->retrieve($subscriptionId);
|
||||
} catch (\Throwable $exception) {
|
||||
$context = [
|
||||
'tenant_id' => $tenant->id,
|
||||
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id,
|
||||
'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id,
|
||||
'lemonsqueezy_subscription_id' => $subscriptionId ?? null,
|
||||
'error' => $exception->getMessage(),
|
||||
'paddle_environment' => config('paddle.environment'),
|
||||
'paddle_base_url' => config('paddle.base_url'),
|
||||
];
|
||||
|
||||
if ($exception instanceof PaddleException) {
|
||||
$context['paddle_status'] = $exception->status();
|
||||
$context['paddle_error_code'] = Arr::get($exception->context(), 'error.code');
|
||||
$context['paddle_error_message'] = Arr::get($exception->context(), 'error.message');
|
||||
$context['paddle_error_detail'] = Arr::get($exception->context(), 'error.detail');
|
||||
$context['paddle_error_doc_url'] = Arr::get($exception->context(), 'error.documentation_url');
|
||||
$context['paddle_request_id'] = Arr::get($exception->context(), 'meta.request_id');
|
||||
$context['paddle_errors'] = Arr::get($exception->context(), 'error.errors');
|
||||
if ($exception instanceof LemonSqueezyException) {
|
||||
$context['lemonsqueezy_status'] = $exception->status();
|
||||
$context['lemonsqueezy_error'] = Arr::get($exception->context(), 'errors.0');
|
||||
$context['lemonsqueezy_errors'] = Arr::get($exception->context(), 'errors');
|
||||
$context['lemonsqueezy_request_id'] = Arr::get($exception->context(), 'meta.request_id');
|
||||
}
|
||||
|
||||
Log::warning('Failed to create Paddle customer portal session', [
|
||||
Log::warning('Failed to fetch Lemon Squeezy subscription portal URL', [
|
||||
...$context,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Failed to create Paddle customer portal session.',
|
||||
'message' => 'Failed to fetch Lemon Squeezy subscription portal URL.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
$url = Arr::get($session, 'data.urls.general.overview')
|
||||
?? Arr::get($session, 'data.urls.general')
|
||||
?? Arr::get($session, 'urls.general.overview')
|
||||
?? Arr::get($session, 'urls.general');
|
||||
$url = $this->subscriptions->portalUrl($subscription)
|
||||
?? $this->subscriptions->updatePaymentMethodUrl($subscription);
|
||||
|
||||
if (! $url) {
|
||||
$sessionData = Arr::get($session, 'data');
|
||||
$sessionUrls = Arr::get($session, 'data.urls') ?? Arr::get($session, 'urls');
|
||||
$sessionData = Arr::get($subscription, 'data');
|
||||
$sessionUrls = Arr::get($subscription, 'attributes.urls');
|
||||
|
||||
Log::warning('Paddle customer portal session missing URL', [
|
||||
Log::warning('Lemon Squeezy subscription missing portal URL', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id,
|
||||
'paddle_environment' => config('paddle.environment'),
|
||||
'paddle_base_url' => config('paddle.base_url'),
|
||||
'session_keys' => array_keys($session),
|
||||
'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id,
|
||||
'lemonsqueezy_subscription_id' => $subscriptionId ?? null,
|
||||
'subscription_keys' => array_keys($subscription),
|
||||
'session_data_keys' => is_array($sessionData) ? array_keys($sessionData) : null,
|
||||
'session_url_keys' => is_array($sessionUrls) ? array_keys($sessionUrls) : null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Paddle customer portal session missing URL.',
|
||||
'message' => 'Lemon Squeezy subscription missing portal URL.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ 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\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||
use App\Support\CheckoutRequestContext;
|
||||
use App\Support\CheckoutRoutes;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
@@ -74,9 +74,9 @@ class CheckoutController extends Controller
|
||||
'error' => $facebookError,
|
||||
'profile' => $facebookProfile,
|
||||
],
|
||||
'paddle' => [
|
||||
'environment' => config('paddle.environment'),
|
||||
'client_token' => config('paddle.client_token'),
|
||||
'lemonsqueezy' => [
|
||||
'store_id' => config('lemonsqueezy.store_id'),
|
||||
'test_mode' => config('lemonsqueezy.test_mode', false),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -271,9 +271,9 @@ class CheckoutController extends Controller
|
||||
CheckoutSession $session,
|
||||
CheckoutSessionService $sessions,
|
||||
CheckoutAssignmentService $assignment,
|
||||
PaddleTransactionService $transactions,
|
||||
LemonSqueezyOrderService $orders,
|
||||
): JsonResponse {
|
||||
$this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions);
|
||||
$this->attemptLemonSqueezyRecovery($session, $sessions, $assignment, $orders);
|
||||
|
||||
$session->refresh();
|
||||
|
||||
@@ -288,56 +288,56 @@ class CheckoutController extends Controller
|
||||
CheckoutSession $session,
|
||||
CheckoutSessionService $sessions,
|
||||
CheckoutAssignmentService $assignment,
|
||||
PaddleTransactionService $transactions,
|
||||
LemonSqueezyOrderService $orders,
|
||||
): JsonResponse {
|
||||
$validated = $request->validated();
|
||||
$transactionId = $validated['transaction_id'] ?? null;
|
||||
$orderId = $validated['order_id'] ?? null;
|
||||
$checkoutId = $validated['checkout_id'] ?? null;
|
||||
|
||||
$metadata = $session->provider_metadata ?? [];
|
||||
$metadataUpdated = false;
|
||||
|
||||
if ($transactionId) {
|
||||
$session->paddle_transaction_id = $transactionId;
|
||||
$metadata['paddle_transaction_id'] = $transactionId;
|
||||
if ($orderId) {
|
||||
$session->lemonsqueezy_order_id = $orderId;
|
||||
$metadata['lemonsqueezy_order_id'] = $orderId;
|
||||
$metadataUpdated = true;
|
||||
}
|
||||
|
||||
if ($checkoutId) {
|
||||
$metadata['paddle_checkout_id'] = $checkoutId;
|
||||
$metadata['lemonsqueezy_checkout_id'] = $checkoutId;
|
||||
$metadataUpdated = true;
|
||||
}
|
||||
|
||||
if ($metadataUpdated) {
|
||||
$metadata['paddle_client_event_at'] = now()->toIso8601String();
|
||||
$metadata['lemonsqueezy_client_event_at'] = now()->toIso8601String();
|
||||
$session->provider_metadata = $metadata;
|
||||
$session->save();
|
||||
}
|
||||
|
||||
if (app()->environment('local')
|
||||
&& $session->provider === CheckoutSession::PROVIDER_PADDLE
|
||||
&& $session->provider === CheckoutSession::PROVIDER_LEMONSQUEEZY
|
||||
&& ! in_array($session->status, [
|
||||
CheckoutSession::STATUS_COMPLETED,
|
||||
CheckoutSession::STATUS_FAILED,
|
||||
CheckoutSession::STATUS_CANCELLED,
|
||||
], true)
|
||||
&& ($transactionId || $checkoutId)
|
||||
&& ($orderId || $checkoutId)
|
||||
) {
|
||||
$sessions->markProcessing($session, array_filter([
|
||||
'paddle_status' => 'completed',
|
||||
'paddle_transaction_id' => $transactionId,
|
||||
'paddle_local_confirmed_at' => now()->toIso8601String(),
|
||||
'lemonsqueezy_status' => 'paid',
|
||||
'lemonsqueezy_order_id' => $orderId,
|
||||
'lemonsqueezy_local_confirmed_at' => now()->toIso8601String(),
|
||||
]));
|
||||
|
||||
$assignment->finalise($session, [
|
||||
'source' => 'paddle_local',
|
||||
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||
'provider_reference' => $transactionId ?? $checkoutId,
|
||||
'source' => 'lemonsqueezy_local',
|
||||
'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
|
||||
'provider_reference' => $orderId ?? $checkoutId,
|
||||
]);
|
||||
|
||||
$sessions->markCompleted($session);
|
||||
} else {
|
||||
$this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions);
|
||||
$this->attemptLemonSqueezyRecovery($session, $sessions, $assignment, $orders);
|
||||
}
|
||||
|
||||
$session->refresh();
|
||||
@@ -419,13 +419,13 @@ class CheckoutController extends Controller
|
||||
return $price <= 0;
|
||||
}
|
||||
|
||||
private function attemptPaddleRecovery(
|
||||
private function attemptLemonSqueezyRecovery(
|
||||
CheckoutSession $session,
|
||||
CheckoutSessionService $sessions,
|
||||
CheckoutAssignmentService $assignment,
|
||||
PaddleTransactionService $transactions
|
||||
LemonSqueezyOrderService $orders
|
||||
): void {
|
||||
if ($session->provider !== CheckoutSession::PROVIDER_PADDLE) {
|
||||
if ($session->provider !== CheckoutSession::PROVIDER_LEMONSQUEEZY) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -438,7 +438,7 @@ class CheckoutController extends Controller
|
||||
}
|
||||
|
||||
$metadata = $session->provider_metadata ?? [];
|
||||
$lastPollAt = $metadata['paddle_poll_at'] ?? null;
|
||||
$lastPollAt = $metadata['lemonsqueezy_poll_at'] ?? null;
|
||||
$now = now();
|
||||
|
||||
if ($lastPollAt) {
|
||||
@@ -452,39 +452,31 @@ class CheckoutController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$checkoutId = $metadata['paddle_checkout_id'] ?? $session->paddle_checkout_id ?? null;
|
||||
$transactionId = $metadata['paddle_transaction_id'] ?? $session->paddle_transaction_id ?? null;
|
||||
$checkoutId = $metadata['lemonsqueezy_checkout_id'] ?? $session->lemonsqueezy_checkout_id ?? null;
|
||||
$orderId = $metadata['lemonsqueezy_order_id'] ?? $session->lemonsqueezy_order_id ?? null;
|
||||
|
||||
if (! $checkoutId && ! $transactionId) {
|
||||
Log::info('[Checkout] Paddle recovery missing checkout reference, falling back to custom data scan', [
|
||||
if (! $checkoutId && ! $orderId) {
|
||||
Log::info('[Checkout] Lemon Squeezy recovery missing checkout reference', [
|
||||
'session_id' => $session->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$metadata['paddle_poll_at'] = $now->toIso8601String();
|
||||
$metadata['lemonsqueezy_poll_at'] = $now->toIso8601String();
|
||||
$session->forceFill([
|
||||
'provider_metadata' => $metadata,
|
||||
])->save();
|
||||
|
||||
try {
|
||||
$transaction = $transactionId ? $transactions->retrieve($transactionId) : null;
|
||||
$order = $orderId ? $orders->retrieve($orderId) : null;
|
||||
|
||||
if (! $transaction && $checkoutId) {
|
||||
$transaction = $transactions->findByCheckoutId($checkoutId);
|
||||
if (! $order && $checkoutId) {
|
||||
$order = $orders->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', [
|
||||
} catch (LemonSqueezyException $exception) {
|
||||
Log::warning('[Checkout] Lemon Squeezy recovery failed', [
|
||||
'session_id' => $session->id,
|
||||
'checkout_id' => $checkoutId,
|
||||
'transaction_id' => $transactionId,
|
||||
'order_id' => $orderId,
|
||||
'status' => $exception->status(),
|
||||
'message' => $exception->getMessage(),
|
||||
'context' => $exception->context(),
|
||||
@@ -492,77 +484,77 @@ class CheckoutController extends Controller
|
||||
|
||||
return;
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('[Checkout] Paddle recovery failed', [
|
||||
Log::warning('[Checkout] Lemon Squeezy recovery failed', [
|
||||
'session_id' => $session->id,
|
||||
'checkout_id' => $checkoutId,
|
||||
'transaction_id' => $transactionId,
|
||||
'order_id' => $orderId,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $transaction) {
|
||||
Log::info('[Checkout] Paddle recovery: transaction not found', [
|
||||
if (! $order) {
|
||||
Log::info('[Checkout] Lemon Squeezy recovery: order not found', [
|
||||
'session_id' => $session->id,
|
||||
'checkout_id' => $checkoutId,
|
||||
'transaction_id' => $transactionId,
|
||||
'order_id' => $orderId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$status = strtolower((string) ($transaction['status'] ?? ''));
|
||||
$transactionId = $transactionId ?: ($transaction['id'] ?? null);
|
||||
$status = strtolower((string) data_get($order, 'attributes.status', ''));
|
||||
$resolvedOrderId = $orderId ?: data_get($order, 'id');
|
||||
|
||||
if ($transactionId && $session->paddle_transaction_id !== $transactionId) {
|
||||
if ($resolvedOrderId && $session->lemonsqueezy_order_id !== $resolvedOrderId) {
|
||||
$session->forceFill([
|
||||
'paddle_transaction_id' => $transactionId,
|
||||
'lemonsqueezy_order_id' => $resolvedOrderId,
|
||||
])->save();
|
||||
}
|
||||
|
||||
if ($status === 'completed') {
|
||||
if (in_array($status, ['paid', 'completed'], true)) {
|
||||
$sessions->markProcessing($session, [
|
||||
'paddle_status' => $status,
|
||||
'paddle_transaction_id' => $transactionId,
|
||||
'paddle_recovered_at' => $now->toIso8601String(),
|
||||
'lemonsqueezy_status' => $status,
|
||||
'lemonsqueezy_order_id' => $resolvedOrderId,
|
||||
'lemonsqueezy_recovered_at' => $now->toIso8601String(),
|
||||
]);
|
||||
|
||||
$assignment->finalise($session, [
|
||||
'source' => 'paddle_poll',
|
||||
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||
'provider_reference' => $transactionId,
|
||||
'payload' => $transaction,
|
||||
'source' => 'lemonsqueezy_poll',
|
||||
'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
|
||||
'provider_reference' => $resolvedOrderId,
|
||||
'payload' => $order,
|
||||
]);
|
||||
|
||||
$sessions->markCompleted($session, $now);
|
||||
|
||||
Log::info('[Checkout] Paddle session recovered via API', [
|
||||
Log::info('[Checkout] Lemon Squeezy session recovered via API', [
|
||||
'session_id' => $session->id,
|
||||
'checkout_id' => $checkoutId,
|
||||
'transaction_id' => $transactionId,
|
||||
'order_id' => $resolvedOrderId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array($status, ['failed', 'cancelled', 'canceled'], true)) {
|
||||
$sessions->markFailed($session, 'paddle_'.$status);
|
||||
if (in_array($status, ['failed', 'cancelled', 'canceled', 'refunded', 'voided'], true)) {
|
||||
$sessions->markFailed($session, 'lemonsqueezy_'.$status);
|
||||
|
||||
Log::info('[Checkout] Paddle transaction failed', [
|
||||
Log::info('[Checkout] Lemon Squeezy order failed', [
|
||||
'session_id' => $session->id,
|
||||
'checkout_id' => $checkoutId,
|
||||
'transaction_id' => $transactionId,
|
||||
'order_id' => $resolvedOrderId,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('[Checkout] Paddle transaction pending', [
|
||||
Log::info('[Checkout] Lemon Squeezy order pending', [
|
||||
'session_id' => $session->id,
|
||||
'checkout_id' => $checkoutId,
|
||||
'transaction_id' => $transactionId,
|
||||
'order_id' => $resolvedOrderId,
|
||||
'status' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2,27 +2,27 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\Paddle\PaddleCheckoutRequest;
|
||||
use App\Http\Requests\LemonSqueezy\LemonSqueezyCheckoutRequest;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
|
||||
use App\Support\CheckoutRequestContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PaddleCheckoutController extends Controller
|
||||
class LemonSqueezyCheckoutController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PaddleCheckoutService $checkout,
|
||||
private readonly LemonSqueezyCheckoutService $checkout,
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
private readonly CouponService $coupons,
|
||||
) {}
|
||||
|
||||
public function create(PaddleCheckoutRequest $request): JsonResponse
|
||||
public function create(LemonSqueezyCheckoutRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
@@ -35,8 +35,8 @@ class PaddleCheckoutController extends Controller
|
||||
|
||||
$package = Package::findOrFail((int) $data['package_id']);
|
||||
|
||||
if (! $package->paddle_price_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||
if (! $package->lemonsqueezy_variant_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']);
|
||||
}
|
||||
|
||||
$session = $this->sessions->createOrResume($user, $package, array_merge(
|
||||
@@ -46,7 +46,7 @@ class PaddleCheckoutController extends Controller
|
||||
]
|
||||
));
|
||||
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY);
|
||||
|
||||
$now = now();
|
||||
|
||||
@@ -59,44 +59,10 @@ class PaddleCheckoutController extends Controller
|
||||
])->save();
|
||||
|
||||
$couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? '')));
|
||||
$discountId = null;
|
||||
|
||||
if ($couponCode !== '') {
|
||||
$preview = $this->coupons->preview($couponCode, $package, $tenant);
|
||||
$this->sessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
|
||||
$discountId = $preview['coupon']->paddle_discount_id;
|
||||
}
|
||||
|
||||
if ($request->boolean('inline') && $discountId === null) {
|
||||
$metadata = array_merge($session->provider_metadata ?? [], [
|
||||
'mode' => 'inline',
|
||||
]);
|
||||
|
||||
$session->forceFill([
|
||||
'provider_metadata' => $metadata,
|
||||
])->save();
|
||||
|
||||
return response()->json([
|
||||
'checkout_session_id' => $session->id,
|
||||
'mode' => 'inline',
|
||||
'items' => [
|
||||
[
|
||||
'priceId' => $package->paddle_price_id,
|
||||
'quantity' => 1,
|
||||
],
|
||||
],
|
||||
'custom_data' => [
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'package_id' => (string) $package->id,
|
||||
'checkout_session_id' => (string) $session->id,
|
||||
'legal_version' => $session->legal_version,
|
||||
'accepted_terms' => '1',
|
||||
],
|
||||
'customer' => array_filter([
|
||||
'email' => $user->email,
|
||||
'name' => trim(($user->first_name ?? '').' '.($user->last_name ?? '')) ?: ($user->name ?? null),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
$checkout = $this->checkout->createCheckout($tenant, $package, [
|
||||
@@ -108,15 +74,17 @@ class PaddleCheckoutController extends Controller
|
||||
'legal_version' => $session->legal_version,
|
||||
'accepted_terms' => true,
|
||||
],
|
||||
'discount_id' => $discountId,
|
||||
'discount_code' => $couponCode ?: null,
|
||||
'customer_email' => $user?->email,
|
||||
'customer_name' => trim(($user?->first_name ?? '').' '.($user?->last_name ?? '')) ?: ($user?->name ?? null),
|
||||
]);
|
||||
|
||||
$session->forceFill([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||
'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||
'lemonsqueezy_checkout_id' => $checkout['id'] ?? null,
|
||||
'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null,
|
||||
])),
|
||||
])->save();
|
||||
|
||||
@@ -2,35 +2,32 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PaddleReturnController extends Controller
|
||||
class LemonSqueezyReturnController extends Controller
|
||||
{
|
||||
public function __construct(private readonly PaddleTransactionService $transactions) {}
|
||||
public function __construct(private readonly LemonSqueezyOrderService $orders) {}
|
||||
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
$transactionId = $this->resolveTransactionId($request);
|
||||
$orderId = $this->resolveOrderId($request);
|
||||
$fallback = $this->resolveFallbackUrl();
|
||||
|
||||
if (! $transactionId) {
|
||||
if (! $orderId) {
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
try {
|
||||
$transaction = $this->transactions->retrieve($transactionId);
|
||||
} catch (PaddleException $exception) {
|
||||
Log::warning('Paddle return failed to load transaction', [
|
||||
'transaction_id' => $transactionId,
|
||||
$order = $this->orders->retrieve($orderId);
|
||||
} catch (LemonSqueezyException $exception) {
|
||||
Log::warning('Lemon Squeezy return failed to load order', [
|
||||
'order_id' => $orderId,
|
||||
'error' => $exception->getMessage(),
|
||||
'status' => $exception->status(),
|
||||
]);
|
||||
@@ -38,10 +35,10 @@ class PaddleReturnController extends Controller
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
$customData = $this->extractCustomData($transaction);
|
||||
$status = Str::lower((string) ($transaction['status'] ?? ''));
|
||||
$customData = $this->extractCustomData($order);
|
||||
$status = Str::lower((string) Arr::get($order, 'attributes.status', ''));
|
||||
$successUrl = $customData['success_url'] ?? null;
|
||||
$cancelUrl = $customData['cancel_url'] ?? $customData['return_url'] ?? null;
|
||||
$cancelUrl = $customData['return_url'] ?? null;
|
||||
|
||||
$target = $this->isSuccessStatus($status) ? $successUrl : $cancelUrl;
|
||||
$target = $this->resolveSafeRedirect($target, $fallback);
|
||||
@@ -49,11 +46,10 @@ class PaddleReturnController extends Controller
|
||||
return redirect()->to($target);
|
||||
}
|
||||
|
||||
protected function resolveTransactionId(Request $request): ?string
|
||||
protected function resolveOrderId(Request $request): ?string
|
||||
{
|
||||
$candidate = $request->query('_ptxn')
|
||||
?? $request->query('ptxn')
|
||||
?? $request->query('transaction_id');
|
||||
$candidate = $request->query('order_id')
|
||||
?? $request->query('order');
|
||||
|
||||
if (! is_string($candidate) || $candidate === '') {
|
||||
return null;
|
||||
@@ -68,33 +64,19 @@ class PaddleReturnController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $transaction
|
||||
* @param array<string, mixed> $order
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function extractCustomData(array $transaction): array
|
||||
protected function extractCustomData(array $order): array
|
||||
{
|
||||
$customData = Arr::get($transaction, 'custom_data', []);
|
||||
$customData = Arr::get($order, 'attributes.custom_data', []);
|
||||
|
||||
if (! is_array($customData)) {
|
||||
$customData = [];
|
||||
}
|
||||
|
||||
$legacy = Arr::get($transaction, 'customData');
|
||||
if (is_array($legacy)) {
|
||||
$customData = array_merge($customData, $legacy);
|
||||
}
|
||||
|
||||
$metadata = Arr::get($transaction, 'metadata');
|
||||
if (is_array($metadata)) {
|
||||
$customData = array_merge($customData, $metadata);
|
||||
}
|
||||
|
||||
return $customData;
|
||||
return is_array($customData) ? $customData : [];
|
||||
}
|
||||
|
||||
protected function isSuccessStatus(string $status): bool
|
||||
{
|
||||
return in_array($status, ['completed', 'paid'], true);
|
||||
return in_array($status, ['paid', 'completed'], true);
|
||||
}
|
||||
|
||||
protected function resolveSafeRedirect(?string $target, string $fallback): string
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PaddleWebhookController extends Controller
|
||||
class LemonSqueezyWebhookController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CheckoutWebhookService $webhooks,
|
||||
@@ -22,7 +22,7 @@ class PaddleWebhookController extends Controller
|
||||
{
|
||||
try {
|
||||
if (! $this->verify($request)) {
|
||||
Log::warning('Paddle webhook signature verification failed');
|
||||
Log::warning('Lemon Squeezy webhook signature verification failed');
|
||||
|
||||
return response()->json(['status' => 'invalid'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
@@ -33,29 +33,27 @@ class PaddleWebhookController extends Controller
|
||||
return response()->json(['status' => 'ignored'], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
|
||||
$eventType = $payload['event_type'] ?? null;
|
||||
$eventId = $payload['event_id'] ?? $payload['id'] ?? data_get($payload, 'data.id');
|
||||
$eventType = $payload['meta']['event_name'] ?? $request->headers->get('X-Event-Name');
|
||||
$eventId = $payload['meta']['event_id'] ?? $payload['data']['id'] ?? null;
|
||||
$webhookEvent = $this->recorder->recordReceived(
|
||||
'paddle',
|
||||
'lemonsqueezy',
|
||||
$eventId ? (string) $eventId : null,
|
||||
$eventType ? (string) $eventType : null,
|
||||
);
|
||||
$handled = false;
|
||||
|
||||
$this->logDev('Paddle webhook received', [
|
||||
$this->logDev('Lemon Squeezy webhook received', [
|
||||
'event_type' => $eventType,
|
||||
'checkout_id' => data_get($payload, 'data.checkout_id'),
|
||||
'transaction_id' => data_get($payload, 'data.id'),
|
||||
'has_billing_signature' => (string) $request->headers->get('Paddle-Signature', '') !== '',
|
||||
'has_legacy_signature' => (string) $request->headers->get('Paddle-Webhook-Signature', '') !== '',
|
||||
'order_id' => data_get($payload, 'data.id'),
|
||||
'has_signature' => (string) $request->headers->get('X-Signature', '') !== '',
|
||||
]);
|
||||
|
||||
if ($eventType) {
|
||||
$handled = $this->webhooks->handlePaddleEvent($payload);
|
||||
$handled = $this->webhooks->handleLemonSqueezyEvent($payload);
|
||||
$handled = $this->addonWebhooks->handle($payload) || $handled;
|
||||
}
|
||||
|
||||
Log::info('Paddle webhook processed', [
|
||||
Log::info('Lemon Squeezy webhook processed', [
|
||||
'event_type' => $eventType,
|
||||
'handled' => $handled,
|
||||
]);
|
||||
@@ -71,13 +69,13 @@ class PaddleWebhookController extends Controller
|
||||
} catch (\Throwable $exception) {
|
||||
$eventId = $this->captureWebhookException($exception);
|
||||
|
||||
Log::error('Paddle webhook processing failed', [
|
||||
Log::error('Lemon Squeezy webhook processing failed', [
|
||||
'message' => $exception->getMessage(),
|
||||
'event_type' => (string) $request->json('event_type'),
|
||||
'event_type' => (string) data_get($request->json()->all(), 'meta.event_name'),
|
||||
'sentry_event_id' => $eventId,
|
||||
]);
|
||||
|
||||
$this->logDev('Paddle webhook error payload', $this->reducePayload($request->json()->all()));
|
||||
$this->logDev('Lemon Squeezy webhook error payload', $this->reducePayload($request->json()->all()));
|
||||
|
||||
if (isset($webhookEvent)) {
|
||||
$this->recorder->markFailed($webhookEvent, $exception->getMessage());
|
||||
@@ -89,85 +87,33 @@ class PaddleWebhookController extends Controller
|
||||
|
||||
protected function verify(Request $request): bool
|
||||
{
|
||||
$secret = config('paddle.webhook_secret');
|
||||
$secret = config('lemonsqueezy.webhook_secret');
|
||||
|
||||
if (! $secret) {
|
||||
// Allow processing in sandbox or when secret not configured
|
||||
return true;
|
||||
}
|
||||
|
||||
$billingSignature = (string) $request->headers->get('Paddle-Signature', '');
|
||||
|
||||
if ($billingSignature !== '') {
|
||||
$parts = $this->parseSignatureHeader($billingSignature);
|
||||
$timestamp = $parts['ts'] ?? null;
|
||||
$hash = $parts['h1'] ?? null;
|
||||
|
||||
if (! $timestamp || ! $hash) {
|
||||
$this->logDev('Paddle webhook signature missing parts', [
|
||||
'has_timestamp' => (bool) $timestamp,
|
||||
'has_hash' => (bool) $hash,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = $request->getContent();
|
||||
$expected = hash_hmac('sha256', $timestamp.':'.$payload, $secret);
|
||||
|
||||
$valid = hash_equals($expected, $hash);
|
||||
if (! $valid) {
|
||||
$this->logDev('Paddle webhook signature mismatch (billing)', [
|
||||
'timestamp' => $timestamp,
|
||||
]);
|
||||
}
|
||||
|
||||
return $valid;
|
||||
}
|
||||
|
||||
$payload = $request->getContent();
|
||||
$signature = (string) $request->headers->get('Paddle-Webhook-Signature', '');
|
||||
$signature = (string) $request->headers->get('X-Signature', '');
|
||||
|
||||
if ($signature === '') {
|
||||
$this->logDev('Paddle webhook missing signature header', [
|
||||
'header' => 'Paddle-Webhook-Signature',
|
||||
$this->logDev('Lemon Squeezy webhook missing signature header', [
|
||||
'header' => 'X-Signature',
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = $request->getContent();
|
||||
$expected = hash_hmac('sha256', $payload, $secret);
|
||||
|
||||
$valid = hash_equals($expected, $signature);
|
||||
if (! $valid) {
|
||||
$this->logDev('Paddle webhook signature mismatch (legacy)', []);
|
||||
$this->logDev('Lemon Squeezy webhook signature mismatch', []);
|
||||
}
|
||||
|
||||
return $valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function parseSignatureHeader(string $header): array
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach (explode(',', $header) as $chunk) {
|
||||
$chunk = trim($chunk);
|
||||
if ($chunk === '' || ! str_contains($chunk, '=')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$key, $value] = array_map('trim', explode('=', $chunk, 2));
|
||||
if ($key !== '' && $value !== '') {
|
||||
$parts[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
@@ -177,7 +123,7 @@ class PaddleWebhookController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('[PaddleWebhook] '.$message, $context);
|
||||
Log::info('[LemonSqueezyWebhook] '.$message, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,12 +132,11 @@ class PaddleWebhookController extends Controller
|
||||
protected function reducePayload(array $payload): array
|
||||
{
|
||||
return array_filter([
|
||||
'event_type' => $payload['event_type'] ?? null,
|
||||
'transaction_id' => data_get($payload, 'data.id'),
|
||||
'checkout_id' => data_get($payload, 'data.checkout_id'),
|
||||
'status' => data_get($payload, 'data.status'),
|
||||
'customer_id' => data_get($payload, 'data.customer_id'),
|
||||
'has_custom_data' => is_array(data_get($payload, 'data.custom_data')),
|
||||
'event_type' => data_get($payload, 'meta.event_name'),
|
||||
'order_id' => data_get($payload, 'data.id'),
|
||||
'status' => data_get($payload, 'data.attributes.status'),
|
||||
'customer_id' => data_get($payload, 'data.attributes.customer_id'),
|
||||
'has_custom_data' => is_array(data_get($payload, 'meta.custom_data')),
|
||||
], static fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\Models\TenantPackage;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
|
||||
use App\Support\CheckoutRequestContext;
|
||||
use App\Support\CheckoutRoutes;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
@@ -41,7 +41,7 @@ class MarketingController extends Controller
|
||||
|
||||
public function __construct(
|
||||
private readonly CheckoutSessionService $checkoutSessions,
|
||||
private readonly PaddleCheckoutService $paddleCheckout,
|
||||
private readonly LemonSqueezyCheckoutService $lemonsqueezyCheckout,
|
||||
private readonly CouponService $coupons,
|
||||
private readonly GiftVoucherCheckoutService $giftVouchers,
|
||||
) {}
|
||||
@@ -194,14 +194,14 @@ class MarketingController extends Controller
|
||||
return redirect('/event-admin')->with('success', __('marketing.packages.free_assigned'));
|
||||
}
|
||||
|
||||
if (! $package->paddle_price_id) {
|
||||
Log::warning('Package missing Paddle price id', ['package_id' => $package->id]);
|
||||
if (! $package->lemonsqueezy_variant_id) {
|
||||
Log::warning('Package missing Lemon Squeezy variant id', ['package_id' => $package->id]);
|
||||
|
||||
return redirect()->route('packages', [
|
||||
'locale' => app()->getLocale(),
|
||||
'highlight' => $package->slug,
|
||||
])
|
||||
->with('error', __('marketing.packages.paddle_not_configured'));
|
||||
->with('error', __('marketing.packages.lemonsqueezy_not_configured'));
|
||||
}
|
||||
|
||||
$session = $this->checkoutSessions->createOrResume($user, $package, array_merge(
|
||||
@@ -211,7 +211,7 @@ class MarketingController extends Controller
|
||||
]
|
||||
));
|
||||
|
||||
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY);
|
||||
|
||||
$now = now();
|
||||
|
||||
@@ -223,20 +223,17 @@ class MarketingController extends Controller
|
||||
'legal_version' => $this->resolveLegalVersion(),
|
||||
])->save();
|
||||
|
||||
$appliedDiscountId = null;
|
||||
|
||||
if ($couponCode) {
|
||||
try {
|
||||
$preview = $this->coupons->preview($couponCode, $package, $tenant);
|
||||
$this->checkoutSessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
|
||||
$appliedDiscountId = $preview['coupon']->paddle_discount_id;
|
||||
$request->session()->forget('marketing.checkout.coupon');
|
||||
} catch (ValidationException $exception) {
|
||||
$request->session()->flash('coupon_error', $exception->errors()['code'][0] ?? __('marketing.coupon.errors.generic'));
|
||||
}
|
||||
}
|
||||
|
||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
|
||||
$checkout = $this->lemonsqueezyCheckout->createCheckout($tenant, $package, [
|
||||
'success_url' => route('marketing.success', [
|
||||
'locale' => app()->getLocale(),
|
||||
'packageId' => $package->id,
|
||||
@@ -252,15 +249,15 @@ class MarketingController extends Controller
|
||||
'accepted_terms' => (bool) $session->accepted_terms_at,
|
||||
'accepted_waiver' => $requiresWaiver && (bool) $session->digital_content_waiver_at,
|
||||
],
|
||||
'discount_id' => $appliedDiscountId,
|
||||
'discount_code' => $couponCode,
|
||||
]);
|
||||
|
||||
$session->forceFill([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||
'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||
'lemonsqueezy_checkout_id' => $checkout['id'] ?? null,
|
||||
'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null,
|
||||
])),
|
||||
])->save();
|
||||
|
||||
@@ -268,7 +265,7 @@ class MarketingController extends Controller
|
||||
|
||||
if (! $redirectUrl) {
|
||||
throw ValidationException::withMessages([
|
||||
'paddle' => __('marketing.packages.paddle_checkout_failed'),
|
||||
'lemonsqueezy' => __('marketing.packages.lemonsqueezy_checkout_failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ class TestCheckoutController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function simulatePaddle(
|
||||
public function simulateLemonSqueezy(
|
||||
Request $request,
|
||||
CheckoutWebhookService $webhooks,
|
||||
CheckoutSession $session
|
||||
@@ -70,13 +70,13 @@ class TestCheckoutController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'event_type' => ['nullable', 'string'],
|
||||
'transaction_id' => ['nullable', 'string'],
|
||||
'order_id' => ['nullable', 'string'],
|
||||
'status' => ['nullable', 'string'],
|
||||
'checkout_id' => ['nullable', 'string'],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$eventType = $validated['event_type'] ?? 'transaction.completed';
|
||||
$eventType = $validated['event_type'] ?? 'order_created';
|
||||
$metadata = array_merge([
|
||||
'tenant_id' => $session->tenant_id,
|
||||
'package_id' => $session->package_id,
|
||||
@@ -84,16 +84,21 @@ class TestCheckoutController extends Controller
|
||||
], $validated['metadata'] ?? []);
|
||||
|
||||
$payload = [
|
||||
'event_type' => $eventType,
|
||||
'data' => array_filter([
|
||||
'id' => $validated['transaction_id'] ?? ('txn_'.Str::uuid()),
|
||||
'status' => $validated['status'] ?? 'completed',
|
||||
'meta' => [
|
||||
'event_name' => $eventType,
|
||||
'custom_data' => $metadata,
|
||||
'checkout_id' => $validated['checkout_id'] ?? $session->provider_metadata['paddle_checkout_id'] ?? 'chk_'.Str::uuid(),
|
||||
],
|
||||
'data' => array_filter([
|
||||
'id' => $validated['order_id'] ?? ('order_'.Str::uuid()),
|
||||
'attributes' => array_filter([
|
||||
'status' => $validated['status'] ?? 'paid',
|
||||
'checkout_id' => $validated['checkout_id'] ?? $session->provider_metadata['lemonsqueezy_checkout_id'] ?? 'chk_'.Str::uuid(),
|
||||
'custom_data' => $metadata,
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
$handled = $webhooks->handlePaddleEvent($payload);
|
||||
$handled = $webhooks->handleLemonSqueezyEvent($payload);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
|
||||
@@ -7,7 +7,7 @@ use App\Models\EventPackage;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Tenant;
|
||||
use App\Notifications\Customer\WithdrawalConfirmed;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -36,7 +36,7 @@ class WithdrawalController extends Controller
|
||||
|
||||
public function confirm(
|
||||
WithdrawalConfirmRequest $request,
|
||||
PaddleTransactionService $transactions,
|
||||
LemonSqueezyOrderService $orders,
|
||||
string $locale
|
||||
): RedirectResponse {
|
||||
$user = $request->user();
|
||||
@@ -60,10 +60,10 @@ class WithdrawalController extends Controller
|
||||
->with('error', __('marketing.withdrawal.errors.not_eligible', [], $locale));
|
||||
}
|
||||
|
||||
$transactionId = $this->resolveTransactionId($purchase);
|
||||
$orderId = $this->resolveOrderId($purchase);
|
||||
|
||||
if (! $transactionId) {
|
||||
Log::warning('Withdrawal missing Paddle transaction reference.', [
|
||||
if (! $orderId) {
|
||||
Log::warning('Withdrawal missing Lemon Squeezy order reference.', [
|
||||
'purchase_id' => $purchase->id,
|
||||
'provider' => $purchase->provider,
|
||||
]);
|
||||
@@ -74,11 +74,11 @@ class WithdrawalController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$transactions->refund($transactionId, ['reason' => 'withdrawal']);
|
||||
$orders->refund($orderId, ['reason' => 'withdrawal']);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Withdrawal refund failed', [
|
||||
'purchase_id' => $purchase->id,
|
||||
'transaction_id' => $transactionId,
|
||||
'order_id' => $orderId,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
@@ -94,13 +94,13 @@ class WithdrawalController extends Controller
|
||||
$withdrawalMeta = array_merge($withdrawalMeta, [
|
||||
'confirmed_at' => $confirmedAt->toIso8601String(),
|
||||
'confirmed_by' => $user?->id,
|
||||
'transaction_id' => $transactionId,
|
||||
'order_id' => $orderId,
|
||||
]);
|
||||
|
||||
$metadata['withdrawal'] = $withdrawalMeta;
|
||||
|
||||
$purchase->forceFill([
|
||||
'provider_id' => $transactionId,
|
||||
'provider_id' => $orderId,
|
||||
'refunded' => true,
|
||||
'metadata' => $metadata,
|
||||
])->save();
|
||||
@@ -127,7 +127,7 @@ class WithdrawalController extends Controller
|
||||
->with('package')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'endcustomer_event')
|
||||
->where('provider', 'paddle')
|
||||
->where('provider', 'lemonsqueezy')
|
||||
->where('refunded', false)
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('id')
|
||||
@@ -151,7 +151,7 @@ class WithdrawalController extends Controller
|
||||
$reasons[] = 'type';
|
||||
}
|
||||
|
||||
if ($purchase->provider !== 'paddle') {
|
||||
if ($purchase->provider !== 'lemonsqueezy') {
|
||||
$reasons[] = 'provider';
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ class WithdrawalController extends Controller
|
||||
$reasons[] = 'refunded';
|
||||
}
|
||||
|
||||
if (! $this->resolveTransactionId($purchase)) {
|
||||
if (! $this->resolveOrderId($purchase)) {
|
||||
$reasons[] = 'missing_reference';
|
||||
}
|
||||
|
||||
@@ -224,13 +224,13 @@ class WithdrawalController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveTransactionId(PackagePurchase $purchase): ?string
|
||||
private function resolveOrderId(PackagePurchase $purchase): ?string
|
||||
{
|
||||
if ($purchase->provider === 'paddle' && $purchase->provider_id) {
|
||||
if ($purchase->provider === 'lemonsqueezy' && $purchase->provider_id) {
|
||||
return (string) $purchase->provider_id;
|
||||
}
|
||||
|
||||
return data_get($purchase->metadata, 'paddle_transaction_id');
|
||||
return data_get($purchase->metadata, 'lemonsqueezy_order_id');
|
||||
}
|
||||
|
||||
private function deactivateTenantPackage(Tenant $tenant, PackagePurchase $purchase): void
|
||||
|
||||
Reference in New Issue
Block a user