This commit is contained in:
Mark Gregory
2026-10-19 14:11:28 +01:00
parent 26a2b448a5
commit ac0c5410c1
128 changed files with 5593 additions and 217 deletions
@@ -0,0 +1,40 @@
<?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;
}
}
+22 -18
View File
@@ -1,41 +1,45 @@
<?php
namespace App\Services\Licensing;
use App\Models\License;
use App\Models\LicenseProduct;
use App\Models\LicenseDeliveryOutbox;
use Illuminate\Support\Facades\DB;
use App\Models\LicenseProduct;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
final class LicenseIssuer
{
public function issue(LicenseProduct $product, ?string $name, ?string $email, ?string $provider = null, ?string $orderID = null, ?string $customerID = null, bool $queueDelivery = false): array
{
return DB::transaction(function () use ($product,$name,$email,$provider,$orderID,$customerID,$queueDelivery) {
return DB::transaction(function () use ($product, $name, $email, $provider, $orderID, $customerID, $queueDelivery) {
if ($provider && $orderID) {
$existing = License::where('payment_provider',$provider)->where('external_order_id',$orderID)->first();
if ($existing) return [$existing, null];
$existing = License::where('payment_provider', $provider)->where('external_order_id', $orderID)->first();
if ($existing) {
return [$existing, null];
}
}
$plain = LicenseKey::generate();
$license = License::create([
'license_product_id'=>$product->id,
'license_key_hash'=>LicenseKey::hash($plain),
'license_key_last4'=>substr(LicenseKey::normalize($plain), -4),
'customer_name'=>$name,
'customer_email'=>$email ? strtolower(trim($email)) : null,
'edition'=>$product->edition,
'activation_limit'=>$product->activation_limit,
'max_major_version'=>$product->max_major_version,
'payment_provider'=>$provider,
'external_order_id'=>$orderID,
'external_customer_id'=>$customerID,
'license_product_id' => $product->id,
'license_key_hash' => LicenseKey::hash($plain),
'license_key_last4' => substr(LicenseKey::normalize($plain), -4),
'customer_name' => $name,
'customer_email' => $email ? strtolower(trim($email)) : null,
'edition' => $product->edition,
'activation_limit' => $product->activation_limit,
'max_major_version' => $product->max_major_version,
'payment_provider' => $provider,
'external_order_id' => $orderID,
'external_customer_id' => $customerID,
]);
if ($queueDelivery) {
LicenseDeliveryOutbox::updateOrCreate(
['license_id'=>$license->id],
['encrypted_license_key'=>Crypt::encryptString($plain),'recipient_email'=>$license->customer_email,'delivered_at'=>null]
['license_id' => $license->id],
['encrypted_license_key' => Crypt::encryptString($plain), 'recipient_email' => $license->customer_email, 'delivered_at' => null]
);
}
return [$license, $plain];
});
}
+5 -1
View File
@@ -1,4 +1,5 @@
<?php
namespace App\Services\Licensing;
final class LicenseKey
@@ -7,7 +8,10 @@ final class LicenseKey
{
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$raw = '';
for ($i=0; $i<20; $i++) $raw .= $alphabet[random_int(0, strlen($alphabet)-1)];
for ($i = 0; $i < 20; $i++) {
$raw .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
return implode('-', str_split($raw, 5));
}
+10 -2
View File
@@ -1,4 +1,5 @@
<?php
namespace App\Services\Licensing;
use App\Models\License;
@@ -26,21 +27,28 @@ final class LicenseSigner
ksort($payload, SORT_STRING);
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$sig = sodium_crypto_sign_detached($json, $secret);
return 'RS3.'.self::b64url($json).'.'.self::b64url($sig);
}
public function publicKeyBase64(): string
{
$pair = sodium_crypto_sign_seed_keypair($this->privateSeed());
return base64_encode(sodium_crypto_sign_publickey($pair));
}
private function privateSeed(): string
{
$path = config('rsync-studio.private_key_path');
if (!$path || !is_file($path)) throw new RuntimeException('RSYNC_STUDIO_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
if (! $path || ! is_file($path)) {
throw new RuntimeException('RSYNC_STUDIO_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
}
$decoded = base64_decode(trim((string) file_get_contents($path)), true);
if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) throw new RuntimeException('The Rsync Studio private key must be a Base64-encoded 32-byte Ed25519 seed.');
if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) {
throw new RuntimeException('The Rsync Studio private key must be a Base64-encoded 32-byte Ed25519 seed.');
}
return $decoded;
}
+16 -4
View File
@@ -1,4 +1,5 @@
<?php
namespace App\Services\Licensing;
use RuntimeException;
@@ -8,21 +9,32 @@ final class LicenseTokenVerifier
public function verify(string $token): array
{
$parts = explode('.', trim($token));
if (count($parts) !== 3 || $parts[0] !== 'RS3') throw new RuntimeException('Invalid licence token.');
if (count($parts) !== 3 || $parts[0] !== 'RS3') {
throw new RuntimeException('Invalid licence token.');
}
$payload = $this->decode($parts[1]);
$signature = $this->decode($parts[2]);
$public = base64_decode((string) config('rsync-studio.public_key'), true);
if ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) throw new RuntimeException('Server licence public key is invalid.');
if (!sodium_crypto_sign_verify_detached($signature, $payload, $public)) throw new RuntimeException('Licence token signature is invalid.');
if ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
throw new RuntimeException('Server licence public key is invalid.');
}
if (! sodium_crypto_sign_verify_detached($signature, $payload, $public)) {
throw new RuntimeException('Licence token signature is invalid.');
}
$decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.');
}
private function decode(string $value): string
{
$value = strtr($value, '-_', '+/');
$value .= str_repeat('=', (4 - strlen($value) % 4) % 4);
$out = base64_decode($value, true);
if ($out === false) throw new RuntimeException('Invalid Base64URL value.');
if ($out === false) {
throw new RuntimeException('Invalid Base64URL value.');
}
return $out;
}
}
+8 -3
View File
@@ -1,4 +1,5 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Request;
@@ -10,8 +11,12 @@ final class LemonSqueezyWebhookVerifier implements WebhookVerifier
{
$secret = (string) config('rsync-studio.lemon_squeezy.webhook_secret');
$signature = (string) $request->header('X-Signature');
if ($secret === '' || $signature === '') throw new RuntimeException('Lemon Squeezy webhook verification is not configured.');
$expected = hash_hmac('sha256',$rawBody,$secret);
if (!hash_equals($expected,$signature)) throw new RuntimeException('Lemon Squeezy webhook signature is invalid.');
if ($secret === '' || $signature === '') {
throw new RuntimeException('Lemon Squeezy webhook verification is not configured.');
}
$expected = hash_hmac('sha256', $rawBody, $secret);
if (! hash_equals($expected, $signature)) {
throw new RuntimeException('Lemon Squeezy webhook signature is invalid.');
}
}
}
@@ -0,0 +1,82 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
final class PaddleClient
{
public function customer(?string $customerID): ?array
{
if (! $customerID || ! config('rsync-studio.paddle.api_key')) {
return null;
}
$response = $this->request()->get('/customers/'.$customerID);
if (! $response->successful()) {
return null;
}
$data = $response->json('data');
return is_array($data) ? $data : null;
}
public function updateSubscriptionPrice(string $subscriptionID, string $priceID): array
{
$this->requireWritableConfiguration();
$response = $this->request()->patch('/subscriptions/'.$subscriptionID, [
'proration_billing_mode' => 'prorated_immediately',
'items' => [[
'price_id' => $priceID,
'quantity' => 1,
]],
]);
return $this->subscriptionData($response, 'Paddle could not update the subscription.');
}
public function cancelSubscriptionImmediately(string $subscriptionID): array
{
$this->requireWritableConfiguration();
$response = $this->request()->post('/subscriptions/'.$subscriptionID.'/cancel', [
'effective_from' => 'immediately',
]);
return $this->subscriptionData($response, 'Paddle could not cancel the subscription.');
}
private function request()
{
return Http::baseUrl((string) config('rsync-studio.paddle.api_url'))
->withToken((string) config('rsync-studio.paddle.api_key'))
->acceptJson()
->timeout(15);
}
private function requireWritableConfiguration(): void
{
if (trim((string) config('rsync-studio.paddle.api_key')) === '') {
throw new RuntimeException('Paddle subscription management is not configured.');
}
}
private function subscriptionData(Response $response, string $fallback): array
{
if (! $response->successful()) {
$detail = trim((string) ($response->json('error.detail') ?? ''));
throw new RuntimeException($detail !== '' ? $detail : $fallback);
}
$data = $response->json('data');
if (! is_array($data)) {
throw new RuntimeException($fallback);
}
return $data;
}
}
+20 -6
View File
@@ -1,4 +1,5 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Request;
@@ -10,15 +11,28 @@ final class PaddleWebhookVerifier implements WebhookVerifier
{
$secret = (string) config('rsync-studio.paddle.webhook_secret');
$header = (string) $request->header('Paddle-Signature');
if ($secret === '' || $header === '') throw new RuntimeException('Paddle webhook verification is not configured.');
if ($secret === '' || $header === '') {
throw new RuntimeException('Paddle webhook verification is not configured.');
}
$parts = [];
foreach (explode(';', $header) as $part) { [$k,$v] = array_pad(explode('=',trim($part),2),2,null); if ($k && $v) $parts[$k][]=$v; }
$ts = isset($parts['ts'][0]) ? (int)$parts['ts'][0] : 0;
foreach (explode(';', $header) as $part) {
[$k,$v] = array_pad(explode('=', trim($part), 2), 2, null);
if ($k && $v) {
$parts[$k][] = $v;
}
}
$ts = isset($parts['ts'][0]) ? (int) $parts['ts'][0] : 0;
$signatures = $parts['h1'] ?? [];
$tolerance = max(1,(int)config('rsync-studio.paddle.timestamp_tolerance',300));
if (!$ts || abs(time()-$ts)>$tolerance) throw new RuntimeException('Paddle webhook timestamp is outside the allowed tolerance.');
$tolerance = max(1, (int) config('rsync-studio.paddle.timestamp_tolerance', 300));
if (! $ts || abs(time() - $ts) > $tolerance) {
throw new RuntimeException('Paddle webhook timestamp is outside the allowed tolerance.');
}
$expected = hash_hmac('sha256', $ts.':'.$rawBody, $secret);
foreach ($signatures as $signature) if (hash_equals($expected,$signature)) return;
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return;
}
}
throw new RuntimeException('Paddle webhook signature is invalid.');
}
}
+223 -24
View File
@@ -1,47 +1,246 @@
<?php
namespace App\Services\Payments;
use App\Models\License;
use App\Models\LicenseProduct;
use App\Services\Licensing\LicenseIssuer;
use Illuminate\Support\Carbon;
use RuntimeException;
final class PurchaseProvisioner
{
public function __construct(private LicenseIssuer $issuer) {}
public function __construct(private LicenseIssuer $issuer, private PaddleClient $paddle) {}
public function provision(string $provider, array $event): void
{
$normalized = match ($provider) {
'paddle' => $this->fromPaddle($event),
'lemon-squeezy' => $this->fromLemonSqueezy($event),
default => throw new RuntimeException('Unsupported payment provider.'),
};
if ($normalized['action'] === 'ignore') return;
if ($normalized['action'] === 'revoke') {
License::where('payment_provider',$provider)->where('external_order_id',$normalized['orderID'])->whereNull('revoked_at')->update(['revoked_at'=>now(),'revoke_reason'=>'Payment refunded or reversed']);
if ($provider === 'paddle') {
$this->provisionPaddle($event);
return;
}
$product = LicenseProduct::where('payment_provider',$provider)->where(function($q) use($normalized){ $q->where('external_price_id',$normalized['priceID'])->orWhere('external_product_id',$normalized['productID']); })->where('active',true)->first();
if (!$product) throw new RuntimeException('No active licence product maps to this payment item.');
$this->issuer->issue($product,$normalized['name'],$normalized['email'],$provider,$normalized['orderID'],$normalized['customerID'], true);
if ($provider === 'lemon-squeezy') {
$this->provisionLemonSqueezy($event);
return;
}
throw new RuntimeException('Unsupported payment provider.');
}
private function fromPaddle(array $e): array
private function provisionPaddle(array $event): void
{
$type=(string)($e['event_type']??''); $d=$e['data']??[];
if (in_array($type,['transaction.refunded','transaction.canceled'],true)) return ['action'=>'revoke','orderID'=>(string)($d['id']??''),'priceID'=>'','productID'=>'','name'=>null,'email'=>null,'customerID'=>null];
if ($type!=='transaction.completed') return ['action'=>'ignore'];
$item=$d['items'][0]??[]; $price=$item['price']??[]; $product=$price['product_id']??($item['product_id']??null);
return ['action'=>'issue','orderID'=>(string)($d['id']??''),'priceID'=>(string)($price['id']??($item['price_id']??'')),'productID'=>(string)($product??''),'name'=>$d['custom_data']['customer_name']??null,'email'=>$d['custom_data']['customer_email']??null,'customerID'=>isset($d['customer_id'])?(string)$d['customer_id']:null];
$type = (string) ($event['event_type'] ?? '');
$data = $event['data'] ?? [];
if (str_starts_with($type, 'subscription.')) {
$this->syncPaddleSubscription($type, $data);
return;
}
if ($type === 'transaction.completed') {
$this->syncPaddleTransaction($data);
return;
}
if ($type === 'transaction.refunded') {
$transactionID = (string) ($data['id'] ?? '');
if ($transactionID !== '') {
License::where('payment_provider', 'paddle')
->where('external_order_id', $transactionID)
->whereNull('external_subscription_id')
->whereNull('revoked_at')
->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded']);
}
}
}
private function fromLemonSqueezy(array $e): array
private function syncPaddleSubscription(string $type, array $data): void
{
$type=(string)($e['meta']['event_name']??''); $a=$e['data']['attributes']??[];
$order=(string)($e['data']['id']??($a['order_id']??''));
if (in_array($type,['order_refunded','subscription_expired'],true)) return ['action'=>'revoke','orderID'=>$order,'priceID'=>'','productID'=>'','name'=>null,'email'=>null,'customerID'=>null];
if (!in_array($type,['order_created','subscription_created'],true)) return ['action'=>'ignore'];
return ['action'=>'issue','orderID'=>$order,'priceID'=>(string)($a['variant_id']??''),'productID'=>(string)($a['product_id']??''),'name'=>$a['user_name']??null,'email'=>$a['user_email']??null,'customerID'=>isset($a['customer_id'])?(string)$a['customer_id']:null];
$subscriptionID = (string) ($data['id'] ?? '');
if ($subscriptionID === '') {
throw new RuntimeException('Paddle subscription webhook has no subscription ID.');
}
$status = (string) ($data['status'] ?? '');
$item = $this->firstRecurringItem($data['items'] ?? []);
$price = $item['price'] ?? [];
$priceID = (string) ($price['id'] ?? $item['price_id'] ?? '');
$productID = (string) ($price['product_id'] ?? $item['product_id'] ?? '');
$license = License::where('payment_provider', 'paddle')
->where('external_subscription_id', $subscriptionID)
->first();
if (! $license) {
$product = $this->mappedProduct('paddle', $priceID, $productID);
$custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : [];
$customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null));
[$license] = $this->issuer->issue(
$product,
$this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null),
$this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null),
'paddle',
$this->nullableString($data['transaction_id'] ?? null),
$this->nullableString($data['customer_id'] ?? null),
true,
);
$license->external_subscription_id = $subscriptionID;
} elseif ($priceID !== '' && $priceID !== $license->external_price_id) {
// Upgrades/downgrades change the entitlement attached to the existing key.
$product = $this->mappedProduct('paddle', $priceID, $productID);
$license->license_product_id = $product->id;
$license->edition = $product->edition;
$license->activation_limit = $product->activation_limit;
$license->max_major_version = $product->max_major_version;
}
$periodEnd = $data['current_billing_period']['ends_at'] ?? null;
$scheduled = $data['scheduled_change'] ?? null;
$cancelAt = is_array($scheduled) && ($scheduled['action'] ?? null) === 'cancel'
? ($scheduled['effective_at'] ?? null)
: null;
$license->external_customer_id = $this->nullableString($data['customer_id'] ?? null) ?? $license->external_customer_id;
$license->external_price_id = $priceID !== '' ? $priceID : $license->external_price_id;
$license->subscription_status = $status !== '' ? $status : $license->subscription_status;
$license->current_period_ends_at = $periodEnd ? Carbon::parse($periodEnd) : $license->current_period_ends_at;
$license->cancel_scheduled_at = $cancelAt ? Carbon::parse($cancelAt) : null;
if (in_array($status, ['active', 'trialing'], true)) {
$license->revoked_at = null;
$license->revoke_reason = null;
// Trialing/active subscriptions remain valid through the current period.
$license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : null;
} elseif ($status === 'past_due') {
// Do not instantly kill a licence on the first failed renewal. Preserve the
// period Paddle says is current; a later canceled event ends entitlement.
$license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : ($license->expires_at ?? now()->addDays(7));
} elseif (in_array($status, ['canceled', 'paused'], true)) {
$license->expires_at = now();
$license->revoked_at = now();
$license->revoke_reason = $status === 'paused' ? 'Subscription paused' : 'Subscription canceled';
}
$license->save();
}
private function syncPaddleTransaction(array $data): void
{
$subscriptionID = $this->nullableString($data['subscription_id'] ?? null);
$transactionID = (string) ($data['id'] ?? '');
// Recurring purchases are provisioned from subscription events because those
// events contain the authoritative trial/status/current-period state.
if ($subscriptionID) {
License::where('payment_provider', 'paddle')
->where('external_subscription_id', $subscriptionID)
->whereNull('external_order_id')
->update(['external_order_id' => $transactionID ?: null]);
return;
}
// Keep support for any future one-time Paddle licence product.
$item = $data['items'][0] ?? [];
$price = $item['price'] ?? [];
$priceID = (string) ($price['id'] ?? $item['price_id'] ?? '');
$productID = (string) ($price['product_id'] ?? $item['product_id'] ?? '');
$product = $this->mappedProduct('paddle', $priceID, $productID);
$custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : [];
$customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null));
$this->issuer->issue(
$product,
$this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null),
$this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null),
'paddle',
$transactionID,
$this->nullableString($data['customer_id'] ?? null),
true,
);
}
private function provisionLemonSqueezy(array $event): void
{
$type = (string) ($event['meta']['event_name'] ?? '');
$attributes = $event['data']['attributes'] ?? [];
$order = (string) ($event['data']['id'] ?? $attributes['order_id'] ?? '');
if (in_array($type, ['order_refunded', 'subscription_expired'], true)) {
License::where('payment_provider', 'lemon-squeezy')
->where('external_order_id', $order)
->whereNull('revoked_at')
->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded or subscription expired']);
return;
}
if (! in_array($type, ['order_created', 'subscription_created'], true)) {
return;
}
$product = $this->mappedProduct(
'lemon-squeezy',
(string) ($attributes['variant_id'] ?? ''),
(string) ($attributes['product_id'] ?? ''),
);
$this->issuer->issue(
$product,
$this->nullableString($attributes['user_name'] ?? null),
$this->nullableString($attributes['user_email'] ?? null),
'lemon-squeezy',
$order,
$this->nullableString($attributes['customer_id'] ?? null),
true,
);
}
private function mappedProduct(string $provider, string $priceID, string $productID): LicenseProduct
{
// Price IDs are the authoritative mapping because monthly and annual prices
// intentionally share the same Paddle product ID.
if ($priceID !== '') {
$product = LicenseProduct::where('payment_provider', $provider)
->where('external_price_id', $priceID)
->where('active', true)
->first();
} else {
$product = LicenseProduct::where('payment_provider', $provider)
->where('external_product_id', $productID)
->where('active', true)
->first();
}
if (! $product) {
throw new RuntimeException("No active licence product maps to {$provider} price {$priceID} / product {$productID}.");
}
return $product;
}
private function firstRecurringItem(array $items): array
{
foreach ($items as $item) {
if (($item['recurring'] ?? true) === true) {
return $item;
}
}
return $items[0] ?? [];
}
private function nullableString(mixed $value): ?string
{
if (! is_scalar($value)) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
}
}
+7 -1
View File
@@ -1,4 +1,10 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Request;
interface WebhookVerifier { public function verify(Request $request, string $rawBody): void; }
interface WebhookVerifier
{
public function verify(Request $request, string $rawBody): void;
}