Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c8b5fabd6 | ||
|
|
1540be5551 |
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -232,13 +232,13 @@ echo
|
||||
echo "============================================================"
|
||||
|
||||
cat > rsync-studio-paddle-ids.env <<EOF
|
||||
RSYNC_STUDIO_PERSONAL_PRODUCT_ID=$PERSONAL_PRODUCT_ID
|
||||
RSYNC_STUDIO_PERSONAL_MONTHLY_PRICE_ID=$PERSONAL_MONTHLY_ID
|
||||
RSYNC_STUDIO_PERSONAL_ANNUAL_PRICE_ID=$PERSONAL_ANNUAL_ID
|
||||
PADDLE_PERSONAL_PRODUCT_ID=$PERSONAL_PRODUCT_ID
|
||||
PADDLE_PERSONAL_MONTHLY_PRICE_ID=$PERSONAL_MONTHLY_ID
|
||||
PADDLE_PERSONAL_ANNUAL_PRICE_ID=$PERSONAL_ANNUAL_ID
|
||||
|
||||
RSYNC_STUDIO_PROFESSIONAL_PRODUCT_ID=$PRO_PRODUCT_ID
|
||||
RSYNC_STUDIO_PROFESSIONAL_MONTHLY_PRICE_ID=$PRO_MONTHLY_ID
|
||||
RSYNC_STUDIO_PROFESSIONAL_ANNUAL_PRICE_ID=$PRO_ANNUAL_ID
|
||||
PADDLE_PROFESSIONAL_PRODUCT_ID=$PRO_PRODUCT_ID
|
||||
PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=$PRO_MONTHLY_ID
|
||||
PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=$PRO_ANNUAL_ID
|
||||
EOF
|
||||
|
||||
echo
|
||||
|
||||
@@ -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
|
||||
@@ -1 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="{{ csrf_token() }}"><title>{{ $title ?? 'Rsync Studio' }}</title><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;background:#f5f6f8;color:#18202a}nav{background:#111827;color:#fff;padding:14px 24px;display:flex;gap:18px;align-items:center}nav a{color:#fff;text-decoration:none}.wrap{max-width:980px;margin:32px auto;padding:0 20px}.card{background:#fff;border:1px solid #d9dee7;border-radius:12px;padding:22px;margin:16px 0;box-shadow:0 2px 8px #0000000d}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px}.muted{color:#667085}.ok{color:#067647}.bad{color:#b42318}button,.btn{display:inline-block;border:0;border-radius:8px;padding:10px 14px;background:#2563eb;color:#fff;text-decoration:none;cursor:pointer}button.danger{background:#b42318}input{width:100%;box-sizing:border-box;padding:10px;border:1px solid #cbd5e1;border-radius:8px;margin:6px 0 14px}label{font-weight:600}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:10px;border-bottom:1px solid #e5e7eb}.alert{padding:12px;border-radius:8px;background:#ecfdf3;margin-bottom:14px}.error{padding:12px;border-radius:8px;background:#fef3f2;margin-bottom:14px}</style></head><body><nav><strong>Rsync Studio</strong><a href="{{ route('home') }}">Home</a>@auth @if(auth()->user()->hasVerifiedEmail())<a href="{{ route('portal.index') }}">My licences</a>@endif @if(auth()->user()->is_admin)<a href="{{ route('admin.index') }}">Admin</a>@endif <span style="margin-left:auto">{{ auth()->user()->email }}</span><form method="post" action="{{ route('logout') }}">@csrf<button>Sign out</button></form>@else<span style="margin-left:auto"></span><a href="{{ route('login') }}">Sign in</a><a href="{{ route('register') }}">Create account</a>@endauth</nav><main class="wrap">@if(session('status'))<div class="alert">{{ session('status') }}</div>@endif @if($errors->any())<div class="error">{{ $errors->first() }}</div>@endif @yield('content')</main></body></html>
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="{{ csrf_token() }}"><title>{{ $title ?? 'Rsync Studio' }}</title><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;background:#f5f6f8;color:#18202a}nav{background:#111827;color:#fff;padding:14px 24px;display:flex;gap:18px;align-items:center}nav a{color:#fff;text-decoration:none}.hero-card{padding:28px 0 18px}.hero-card h1{font-size:42px;line-height:1.05;margin:8px 0 12px}.eyebrow{text-transform:uppercase;letter-spacing:.12em;font-size:12px;font-weight:800;color:#2563eb}.lead{font-size:18px;line-height:1.55;max-width:720px;color:#475467}.pricing-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;margin:20px 0 28px}.price-card{display:flex;flex-direction:column;justify-content:space-between;gap:24px;background:#fff;border:1px solid #d9dee7;border-radius:16px;padding:24px;box-shadow:0 4px 18px #0000000d}.price-card.featured{border-color:#9dbcf7}.plan-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.plan-heading h2{margin:0 0 5px}.price{font-size:28px;font-weight:800;margin:20px 0}.features{padding-left:20px;line-height:1.9}.pill,.sandbox-badge{display:inline-block;border-radius:999px;padding:5px 9px;font-size:12px;font-weight:700;background:#eff6ff;color:#1d4ed8}.sandbox-badge{margin-top:8px;background:#fff7ed;color:#9a3412}.checkout-button{width:100%;font-size:15px;font-weight:700;padding:12px 16px}.checkout-status{border-color:#86efac}@media(max-width:720px){.pricing-grid{grid-template-columns:1fr}.hero-card h1{font-size:34px}}.wrap{max-width:980px;margin:32px auto;padding:0 20px}.card{background:#fff;border:1px solid #d9dee7;border-radius:12px;padding:22px;margin:16px 0;box-shadow:0 2px 8px #0000000d}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px}.muted{color:#667085}.ok{color:#067647}.bad{color:#b42318}button,.btn{display:inline-block;border:0;border-radius:8px;padding:10px 14px;background:#2563eb;color:#fff;text-decoration:none;cursor:pointer}button.danger{background:#b42318}input{width:100%;box-sizing:border-box;padding:10px;border:1px solid #cbd5e1;border-radius:8px;margin:6px 0 14px}label{font-weight:600}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:10px;border-bottom:1px solid #e5e7eb}.alert{padding:12px;border-radius:8px;background:#ecfdf3;margin-bottom:14px}.error{padding:12px;border-radius:8px;background:#fef3f2;margin-bottom:14px}</style></head><body><nav><strong>Rsync Studio</strong><a href="{{ route('home') }}">Home</a><a href="{{ route('buy') }}">Buy</a>@auth @if(auth()->user()->hasVerifiedEmail())<a href="{{ route('portal.index') }}">My licences</a>@endif @if(auth()->user()->is_admin)<a href="{{ route('admin.index') }}">Admin</a>@endif <span style="margin-left:auto">{{ auth()->user()->email }}</span><form method="post" action="{{ route('logout') }}">@csrf<button>Sign out</button></form>@else<span style="margin-left:auto"></span><a href="{{ route('login') }}">Sign in</a><a href="{{ route('register') }}">Create account</a>@endauth</nav><main class="wrap">@if(session('status'))<div class="alert">{{ session('status') }}</div>@endif @if($errors->any())<div class="error">{{ $errors->first() }}</div>@endif @yield('content')</main></body></html>
|
||||
|
||||
@@ -1 +1 @@
|
||||
@extends('layouts.app') @section('content')<div class="card"><h1>Rsync Studio licensing</h1><p>Manage your Rsync Studio licence, installations and recovery options securely.</p>@guest<a class="btn" href="{{ route('login') }}">Sign in</a>@endguest</div>@endsection
|
||||
@extends('layouts.app') @section('content')<div class="hero-card"><span class="eyebrow">Rsync Studio</span><h1>Professional rsync for macOS.</h1><p class="lead">Create, schedule and monitor secure rsync jobs with a native macOS interface.</p><p><a class="btn" href="{{ route('buy') }}">Start 7-day free trial</a> @guest<a class="btn" style="background:#475467" href="{{ route('login') }}">Sign in</a>@endguest</p></div><div class="card"><h2>Licensing</h2><p>Personal supports one active Mac. Professional supports up to three active Macs. Monthly and annual subscriptions include a 7-day free trial.</p></div>@endsection
|
||||
|
||||
@@ -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
|
||||
@@ -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');});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user