From 1540be5551a74fa1565faed336ed1bde4efe5840 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 9 Sep 2026 12:35:34 +0100 Subject: [PATCH] paddel setup new verifier --- .env.example | 10 ++++++++++ PADDLE_SUBSCRIPTIONS.md | 7 +++++++ .../Controllers/Api/PaymentWebhookController.php | 9 ++++++++- config/rsync-studio.php | 2 ++ create-rsync-studio-catalog.sh | 12 ++++++------ resources/views/layouts/app.blade.php | 2 +- resources/views/portal/welcome.blade.php | 2 +- routes/web.stage5.php | 3 +++ rsync-studio-paddle-ids.env | 12 ++++++------ 9 files changed, 44 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index ed05089..6b9ff41 100644 --- a/.env.example +++ b/.env.example @@ -72,6 +72,16 @@ RSYNC_STUDIO_DEFAULT_MAX_MAJOR_VERSION=1 RSYNC_STUDIO_PURCHASE_URL= RSYNC_STUDIO_PORTAL_URL= RSYNC_STUDIO_SUPPORT_URL= +PADDLE_API_KEY= +PADDLE_API_URL=https://sandbox-api.paddle.com +PADDLE_CLIENT_SIDE_TOKEN= +PADDLE_ENVIRONMENT=sandbox PADDLE_WEBHOOK_SECRET= PADDLE_WEBHOOK_TOLERANCE=300 +PADDLE_PERSONAL_PRODUCT_ID= +PADDLE_PERSONAL_MONTHLY_PRICE_ID= +PADDLE_PERSONAL_ANNUAL_PRICE_ID= +PADDLE_PROFESSIONAL_PRODUCT_ID= +PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID= +PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID= LEMON_SQUEEZY_WEBHOOK_SECRET= diff --git a/PADDLE_SUBSCRIPTIONS.md b/PADDLE_SUBSCRIPTIONS.md index 343ace8..bb6826d 100644 --- a/PADDLE_SUBSCRIPTIONS.md +++ b/PADDLE_SUBSCRIPTIONS.md @@ -62,3 +62,10 @@ Subscribe to at least: ## Important The API key is required only on the licensing server. Use a sandbox key with the sandbox API URL. Live Paddle products/prices have different IDs and should be configured separately when moving to production. + + +## Hosted sandbox checkout + +The licensing site exposes `/buy`, which initializes Paddle.js with `PADDLE_CLIENT_SIDE_TOKEN` and opens an overlay checkout for the four configured price IDs. Set `PADDLE_ENVIRONMENT=sandbox` while testing and use a `test_...` client-side token. The token is intentionally browser-visible; never use `PADDLE_API_KEY` in frontend code. + +After a subscription webhook provisions a licence, the webhook controller makes a best-effort attempt to deliver pending licence keys by email. Delivery failures do not roll back the verified Paddle event; the encrypted outbox remains available for portal resend. diff --git a/app/Http/Controllers/Api/PaymentWebhookController.php b/app/Http/Controllers/Api/PaymentWebhookController.php index 867d9ef..afb68ce 100644 --- a/app/Http/Controllers/Api/PaymentWebhookController.php +++ b/app/Http/Controllers/Api/PaymentWebhookController.php @@ -6,13 +6,14 @@ use App\Models\LicenseWebhookEvent; use App\Services\Payments\LemonSqueezyWebhookVerifier; use App\Services\Payments\PaddleWebhookVerifier; use App\Services\Payments\PurchaseProvisioner; +use App\Services\Licensing\LicenseDeliveryService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; final class PaymentWebhookController extends Controller { - public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner): 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}; @@ -32,6 +33,12 @@ final class PaymentWebhookController extends Controller catch (\Throwable $e) { $event->update(['processing_error'=>mb_substr($e->getMessage(),0,4000)]); throw $e; } }); } 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. + try { $delivery->deliverPending(10); } catch (\Throwable $e) { report($e); } + return response()->json(['ok'=>true]); } } diff --git a/config/rsync-studio.php b/config/rsync-studio.php index 288e409..9a2b2b8 100644 --- a/config/rsync-studio.php +++ b/config/rsync-studio.php @@ -12,6 +12,8 @@ return [ 'support_url' => env('RSYNC_STUDIO_SUPPORT_URL'), 'paddle' => [ 'api_key' => env('PADDLE_API_KEY'), + 'client_side_token' => env('PADDLE_CLIENT_SIDE_TOKEN'), + 'environment' => env('PADDLE_ENVIRONMENT', env('APP_ENV') === 'production' ? 'production' : 'sandbox'), 'api_url' => env('PADDLE_API_URL', env('APP_ENV') === 'production' ? 'https://api.paddle.com' : 'https://sandbox-api.paddle.com'), 'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'), 'timestamp_tolerance' => (int) env('PADDLE_WEBHOOK_TOLERANCE', 300), diff --git a/create-rsync-studio-catalog.sh b/create-rsync-studio-catalog.sh index 7e913ad..d430c09 100755 --- a/create-rsync-studio-catalog.sh +++ b/create-rsync-studio-catalog.sh @@ -232,13 +232,13 @@ echo echo "============================================================" cat > rsync-studio-paddle-ids.env <{{ $title ?? 'Rsync Studio' }}
@if(session('status'))
{{ session('status') }}
@endif @if($errors->any())
{{ $errors->first() }}
@endif @yield('content')
+{{ $title ?? 'Rsync Studio' }}
@if(session('status'))
{{ session('status') }}
@endif @if($errors->any())
{{ $errors->first() }}
@endif @yield('content')
diff --git a/resources/views/portal/welcome.blade.php b/resources/views/portal/welcome.blade.php index c40dd42..fbff917 100644 --- a/resources/views/portal/welcome.blade.php +++ b/resources/views/portal/welcome.blade.php @@ -1 +1 @@ -@extends('layouts.app') @section('content')

Rsync Studio licensing

Manage your Rsync Studio licence, installations and recovery options securely.

@guestSign in@endguest
@endsection +@extends('layouts.app') @section('content')
Rsync Studio

Professional rsync for macOS.

Create, schedule and monitor secure rsync jobs with a native macOS interface.

Start 7-day free trial @guestSign in@endguest

Licensing

Personal supports one active Mac. Professional supports up to three active Macs. Monthly and annual subscriptions include a 7-day free trial.

@endsection diff --git a/routes/web.stage5.php b/routes/web.stage5.php index 61f0703..44e7b51 100644 --- a/routes/web.stage5.php +++ b/routes/web.stage5.php @@ -4,10 +4,13 @@ use App\Http\Controllers\Auth\AuthenticatedSessionController; use App\Http\Controllers\Auth\EmailVerificationController; use App\Http\Controllers\Auth\PasswordController; use App\Http\Controllers\Auth\RegisteredUserController; +use App\Http\Controllers\CheckoutController; use App\Http\Controllers\Portal\PortalController; use App\Http\Middleware\AdminOnly; use Illuminate\Support\Facades\Route; Route::get('/', fn()=>view('portal.welcome'))->name('home'); +Route::get('/buy', CheckoutController::class)->name('buy'); +Route::get('/support', fn()=>view('support'))->name('support'); Route::middleware('guest')->group(function(){Route::get('/login',[AuthenticatedSessionController::class,'create'])->name('login');Route::post('/login',[AuthenticatedSessionController::class,'store'])->middleware('throttle:login');Route::get('/register',[RegisteredUserController::class,'create'])->name('register');Route::post('/register',[RegisteredUserController::class,'store'])->middleware('throttle:registration');Route::get('/forgot-password',[PasswordController::class,'requestForm'])->name('password.request');Route::post('/forgot-password',[PasswordController::class,'email'])->name('password.email')->middleware('throttle:5,1');Route::get('/reset-password/{token}',[PasswordController::class,'resetForm'])->name('password.reset');Route::post('/reset-password',[PasswordController::class,'reset'])->name('password.update');}); Route::middleware('auth')->group(function(){Route::post('/logout',[AuthenticatedSessionController::class,'destroy'])->name('logout');Route::get('/verify-email',[EmailVerificationController::class,'notice'])->name('verification.notice');Route::get('/verify-email/{id}/{hash}',[EmailVerificationController::class,'verify'])->middleware(['signed','throttle:6,1'])->name('verification.verify');Route::post('/email/verification-notification',[EmailVerificationController::class,'resend'])->middleware('throttle:6,1')->name('verification.send');}); Route::middleware(['auth','verified'])->prefix('portal')->name('portal.')->group(function(){Route::get('/',[PortalController::class,'index'])->name('index');Route::get('/licences/{license}',[PortalController::class,'show'])->name('show');Route::post('/licences/{license}/resend',[PortalController::class,'resend'])->middleware('throttle:3,10')->name('resend');Route::delete('/licences/{license}/activations/{activation}',[PortalController::class,'deactivate'])->name('deactivate');}); diff --git a/rsync-studio-paddle-ids.env b/rsync-studio-paddle-ids.env index f4f38a5..87b3c5f 100644 --- a/rsync-studio-paddle-ids.env +++ b/rsync-studio-paddle-ids.env @@ -1,7 +1,7 @@ -RSYNC_STUDIO_PERSONAL_PRODUCT_ID=pro_01m22q4c3vt7yvtv3h3zw1jbn0 -RSYNC_STUDIO_PERSONAL_MONTHLY_PRICE_ID=pri_01m22q4cjx177krcpvdph7e2vy -RSYNC_STUDIO_PERSONAL_ANNUAL_PRICE_ID=pri_01m22q4cs9sr5gshxp6j56nvmh +PADDLE_PERSONAL_PRODUCT_ID=pro_01m22q4c3vt7yvtv3h3zw1jbn0 +PADDLE_PERSONAL_MONTHLY_PRICE_ID=pri_01m22q4cjx177krcpvdph7e2vy +PADDLE_PERSONAL_ANNUAL_PRICE_ID=pri_01m22q4cs9sr5gshxp6j56nvmh -RSYNC_STUDIO_PROFESSIONAL_PRODUCT_ID=pro_01m22q4d0s6y1557wsq9s7xw8n -RSYNC_STUDIO_PROFESSIONAL_MONTHLY_PRICE_ID=pri_01m22q4d6h1yvr1frawtx51gy8 -RSYNC_STUDIO_PROFESSIONAL_ANNUAL_PRICE_ID=pri_01m22q4dcndcdbzc20txadaz52 +PADDLE_PROFESSIONAL_PRODUCT_ID=pro_01m22q4d0s6y1557wsq9s7xw8n +PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=pri_01m22q4d6h1yvr1frawtx51gy8 +PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=pri_01m22q4dcndcdbzc20txadaz52