paddel setup new verifier

This commit is contained in:
Mark Gregory
2026-09-09 12:36:34 +01:00
parent 1540be5551
commit 7c8b5fabd6
5 changed files with 197 additions and 0 deletions
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Contracts\View\View;
use RuntimeException;
final class CheckoutController extends Controller
{
public function __invoke(): View
{
$paddle = config('rsync-studio.paddle');
$token = trim((string) ($paddle['client_side_token'] ?? ''));
if ($token === '') {
throw new RuntimeException('PADDLE_CLIENT_SIDE_TOKEN is not configured.');
}
$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],
];
foreach ($plans as $plan) {
if (trim((string) $plan['price_id']) === '') {
throw new RuntimeException('One or more Paddle price IDs are not configured.');
}
}
return view('checkout.buy', [
'plans' => $plans,
'paddleToken' => $token,
'paddleEnvironment' => (string) ($paddle['environment'] ?? 'sandbox'),
]);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Services\Licensing;
use App\Models\LicenseDeliveryOutbox;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
final class LicenseDeliveryService
{
public function deliverPending(int $limit = 20): int
{
$delivered = 0;
$pending = LicenseDeliveryOutbox::query()
->whereNull('delivered_at')
->whereNotNull('recipient_email')
->where('recipient_email', '!=', '')
->oldest()
->limit(max(1, min($limit, 100)))
->get();
foreach ($pending as $outbox) {
$plain = Crypt::decryptString($outbox->encrypted_license_key);
$recipient = strtolower(trim((string) $outbox->recipient_email));
if ($recipient === '') continue;
Mail::raw(
"Thank you for choosing Rsync Studio.\n\nYour licence key is:\n\n{$plain}\n\nOpen Rsync Studio → Settings → Licence and enter this key to activate your Mac.\n\nKeep this key private. You can manage activations from the Rsync Studio licence portal.",
fn ($message) => $message->to($recipient)->subject('Your Rsync Studio licence key')
);
$outbox->update(['delivered_at' => now()]);
$delivered++;
}
return $delivered;
}
}
+91
View File
@@ -0,0 +1,91 @@
@extends('layouts.app')
@section('content')
<div class="hero-card">
<span class="eyebrow">Rsync Studio</span>
<h1>Choose your licence</h1>
<p class="lead">Every subscription starts with a 7-day free trial. Cancel before the trial ends and you won't be charged.</p>
@if($paddleEnvironment === 'sandbox')<div class="sandbox-badge">Sandbox checkout — no real payment is taken</div>@endif
</div>
<div class="pricing-grid">
@foreach($plans as $code => $plan)
<article class="price-card {{ str_starts_with($code, 'professional') ? 'featured' : '' }}">
<div>
<div class="plan-heading">
<div>
<h2>{{ $plan['name'] }}</h2>
<div class="muted">{{ $plan['billing'] }} billing</div>
</div>
@if($plan['billing'] === 'Annual')<span class="pill">Best value</span>@endif
</div>
<div class="price" data-price-display="{{ $plan['price_id'] }}" data-billing="{{ strtolower($plan['billing']) }}">{{ $plan['display_price'] }}</div>
<ul class="features">
<li>7-day free trial</li>
<li>{{ $plan['activation_limit'] }} active Mac{{ $plan['activation_limit'] === 1 ? '' : 's' }}</li>
<li>Secure online activation</li>
<li>Licence management portal</li>
</ul>
</div>
<button class="checkout-button" type="button" data-price-id="{{ $plan['price_id'] }}" data-plan="{{ $plan['name'] }} {{ $plan['billing'] }}">Start free trial</button>
</article>
@endforeach
</div>
<div class="card checkout-status" id="checkout-status" hidden>
<h2>Purchase received</h2>
<p>Paddle is confirming your subscription. Your Rsync Studio licence key will be sent to the email used at checkout. You can then activate it in <strong>Rsync Studio → Settings → Licence</strong>.</p>
<p class="muted">If the email takes a moment, wait for the Paddle confirmation and check your junk folder. You can also create a portal account with the same email address.</p>
<a class="btn" href="{{ route('register') }}">Create portal account</a>
</div>
<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
<script>
(() => {
const token = @json($paddleToken);
const environment = @json($paddleEnvironment);
if (environment === 'sandbox') Paddle.Environment.set('sandbox');
Paddle.Initialize({
token,
eventCallback(event) {
if (event && event.name === 'checkout.completed') {
document.getElementById('checkout-status').hidden = false;
document.getElementById('checkout-status').scrollIntoView({behavior: 'smooth', block: 'center'});
}
}
});
const priceElements = new Map(
Array.from(document.querySelectorAll('[data-price-display]')).map(element => [element.dataset.priceDisplay, element])
);
const previewItems = Array.from(priceElements.keys()).map(priceId => ({priceId, quantity: 1}));
Paddle.PricePreview({items: previewItems}).then(result => {
for (const item of (result?.data?.details?.lineItems ?? [])) {
const element = priceElements.get(item?.price?.id);
const formatted = item?.formattedTotals?.subtotal ?? item?.formattedUnitTotals?.subtotal;
if (!element || !formatted) continue;
const suffix = element.dataset.billing === 'annual' ? ' / year' : ' / month';
element.textContent = formatted + suffix;
}
}).catch(() => {
// Keep the server-rendered GBP fallback if Paddle pricing preview is unavailable.
});
document.querySelectorAll('.checkout-button').forEach(button => {
button.addEventListener('click', () => {
Paddle.Checkout.open({
items: [{priceId: button.dataset.priceId, quantity: 1}],
settings: {
displayMode: 'overlay',
theme: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
allowLogout: false
},
customData: {
source: 'rsync-studio-buy-page',
plan: button.dataset.plan
}
});
});
});
})();
</script>
@endsection
+3
View File
@@ -0,0 +1,3 @@
@extends('layouts.app') @section('content')
<div class="card"><h1>Rsync Studio Support</h1><p>For licence recovery, create or sign in to your portal account using the same email address used at checkout.</p><p><a class="btn" href="{{ route('portal.index') }}">Open licence portal</a></p><p class="muted">For activation problems, include the app version and the exact error message. Never send SSH passwords, private keys, or your full licence token.</p></div>
@endsection
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
final class CheckoutPageTest extends TestCase
{
public function test_buy_page_contains_all_four_configured_paddle_prices(): void
{
config()->set('rsync-studio.paddle.client_side_token', 'test_example');
config()->set('rsync-studio.paddle.environment', 'sandbox');
config()->set('rsync-studio.paddle.personal_monthly_price_id', 'pri_personal_monthly');
config()->set('rsync-studio.paddle.personal_annual_price_id', 'pri_personal_annual');
config()->set('rsync-studio.paddle.professional_monthly_price_id', 'pri_professional_monthly');
config()->set('rsync-studio.paddle.professional_annual_price_id', 'pri_professional_annual');
$this->get('/buy')
->assertOk()
->assertSee('7-day free trial')
->assertSee('pri_personal_monthly')
->assertSee('pri_personal_annual')
->assertSee('pri_professional_monthly')
->assertSee('pri_professional_annual')
->assertSee('test_example');
}
}