paddel setup

This commit is contained in:
Mark Gregory
2026-09-09 10:20:34 +01:00
parent e7ce20e580
commit ef294842d0
9 changed files with 476 additions and 26 deletions
+64
View File
@@ -0,0 +1,64 @@
# Paddle subscription integration
## Sandbox configuration
Keep the Paddle API key and webhook secret server-side only. Do not put either value in the macOS application or commit them to Git.
Required environment variables:
```dotenv
PADDLE_API_KEY=pdl_sdbx_apikey_...
PADDLE_API_URL=https://sandbox-api.paddle.com
PADDLE_WEBHOOK_SECRET=...
PADDLE_PERSONAL_PRODUCT_ID=pro_01m22q4c3vt7yvtv3h3zw1jbn0
PADDLE_PERSONAL_MONTHLY_PRICE_ID=pri_01m22q4cjx177krcpvdph7e2vy
PADDLE_PERSONAL_ANNUAL_PRICE_ID=pri_01m22q4cs9sr5gshxp6j56nvmh
PADDLE_PROFESSIONAL_PRODUCT_ID=pro_01m22q4d0s6y1557wsq9s7xw8n
PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=pri_01m22q4d6h1yvr1frawtx51gy8
PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=pri_01m22q4dcndcdbzc20txadaz52
```
Then run:
```bash
php artisan migrate
php artisan db:seed --class=Database\\Seeders\\PaddleCatalogSeeder
php artisan config:clear
```
The seeder maps the four Paddle price IDs to licensing entitlements. Personal permits one active Mac; Professional permits three.
## Paddle notification destination
Configure the sandbox notification destination to POST to:
`https://YOUR-HOST/api/v1/webhooks/paddle`
Copy the destination endpoint secret into `PADDLE_WEBHOOK_SECRET`.
Subscribe to at least:
- `subscription.created`
- `subscription.updated`
- `subscription.activated`
- `subscription.trialing`
- `subscription.canceled`
- `subscription.paused`
- `subscription.resumed`
- `subscription.past_due`
- `transaction.completed`
- `transaction.refunded`
## Entitlement behavior
- Trialing and active subscriptions are usable.
- A scheduled end-of-period cancellation stays usable until Paddle changes the subscription to canceled.
- Upgrades/downgrades update the entitlement on the existing licence key.
- Canceled or paused subscriptions revoke the licence.
- Past-due subscriptions retain access through Paddle's current billing period rather than being revoked on the first failed payment.
- Paddle customer details are fetched server-side when the webhook does not carry customer email/name in custom data.
## 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.
+2 -2
View File
@@ -11,8 +11,8 @@ class License extends Model
use HasUuids;
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['license_product_id','license_key_hash','license_key_last4','customer_name','customer_email','edition','activation_limit','max_major_version','expires_at','revoked_at','revoke_reason','payment_provider','external_order_id','external_customer_id'];
protected $casts = ['expires_at'=>'datetime','revoked_at'=>'datetime','activation_limit'=>'integer','max_major_version'=>'integer'];
protected $fillable = ['license_product_id','license_key_hash','license_key_last4','customer_name','customer_email','edition','activation_limit','max_major_version','expires_at','revoked_at','revoke_reason','payment_provider','external_order_id','external_customer_id','external_subscription_id','external_price_id','subscription_status','current_period_ends_at','cancel_scheduled_at'];
protected $casts = ['expires_at'=>'datetime','revoked_at'=>'datetime','activation_limit'=>'integer','max_major_version'=>'integer','current_period_ends_at'=>'datetime','cancel_scheduled_at'=>'datetime'];
public function product(): BelongsTo { return $this->belongsTo(LicenseProduct::class, 'license_product_id'); }
public function activations(): HasMany { return $this->hasMany(LicenseActivation::class); }
public function getIsUsableAttribute(): bool { return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture()); }
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Services\Payments;
use Illuminate\Support\Facades\Http;
final class PaddleClient
{
public function customer(?string $customerID): ?array
{
if (!$customerID || !config('rsync-studio.paddle.api_key')) return null;
$response = Http::baseUrl(config('rsync-studio.paddle.api_url'))
->withToken(config('rsync-studio.paddle.api_key'))
->acceptJson()
->timeout(10)
->get('/customers/'.$customerID);
if (!$response->successful()) return null;
$data = $response->json('data');
return is_array($data) ? $data : null;
}
}
+213 -24
View File
@@ -1,47 +1,236 @@
<?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;
}
}
+8
View File
@@ -11,8 +11,16 @@ return [
'portal_url' => env('RSYNC_STUDIO_PORTAL_URL'),
'support_url' => env('RSYNC_STUDIO_SUPPORT_URL'),
'paddle' => [
'api_key' => env('PADDLE_API_KEY'),
'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),
'personal_product_id' => env('PADDLE_PERSONAL_PRODUCT_ID'),
'personal_monthly_price_id' => env('PADDLE_PERSONAL_MONTHLY_PRICE_ID'),
'personal_annual_price_id' => env('PADDLE_PERSONAL_ANNUAL_PRICE_ID'),
'professional_product_id' => env('PADDLE_PROFESSIONAL_PRODUCT_ID'),
'professional_monthly_price_id' => env('PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID'),
'professional_annual_price_id' => env('PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID'),
],
'lemon_squeezy' => [
'webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET'),
Binary file not shown.
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('licenses', function (Blueprint $table) {
$table->string('external_subscription_id')->nullable()->after('external_customer_id');
$table->string('external_price_id')->nullable()->after('external_subscription_id');
$table->string('subscription_status')->nullable()->after('external_price_id');
$table->timestamp('current_period_ends_at')->nullable()->after('subscription_status');
$table->timestamp('cancel_scheduled_at')->nullable()->after('current_period_ends_at');
$table->unique(['payment_provider', 'external_subscription_id']);
});
}
public function down(): void
{
Schema::table('licenses', function (Blueprint $table) {
$table->dropUnique(['payment_provider', 'external_subscription_id']);
$table->dropColumn([
'external_subscription_id',
'external_price_id',
'subscription_status',
'current_period_ends_at',
'cancel_scheduled_at',
]);
});
}
};
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Database\Seeders;
use App\Models\LicenseProduct;
use Illuminate\Database\Seeder;
class PaddleCatalogSeeder extends Seeder
{
public function run(): void
{
$products = [
['code'=>'personal-monthly','name'=>'Rsync Studio Personal Monthly','edition'=>'Personal','activation_limit'=>1,'external_product_id'=>config('rsync-studio.paddle.personal_product_id'),'external_price_id'=>config('rsync-studio.paddle.personal_monthly_price_id')],
['code'=>'personal-annual','name'=>'Rsync Studio Personal Annual','edition'=>'Personal','activation_limit'=>1,'external_product_id'=>config('rsync-studio.paddle.personal_product_id'),'external_price_id'=>config('rsync-studio.paddle.personal_annual_price_id')],
['code'=>'professional-monthly','name'=>'Rsync Studio Professional Monthly','edition'=>'Professional','activation_limit'=>3,'external_product_id'=>config('rsync-studio.paddle.professional_product_id'),'external_price_id'=>config('rsync-studio.paddle.professional_monthly_price_id')],
['code'=>'professional-annual','name'=>'Rsync Studio Professional Annual','edition'=>'Professional','activation_limit'=>3,'external_product_id'=>config('rsync-studio.paddle.professional_product_id'),'external_price_id'=>config('rsync-studio.paddle.professional_annual_price_id')],
];
foreach ($products as $data) {
if (!$data['external_product_id'] || !$data['external_price_id']) continue;
LicenseProduct::updateOrCreate(
['code'=>$data['code']],
$data + ['payment_provider'=>'paddle','max_major_version'=>config('rsync-studio.default_max_major_version'),'active'=>true]
);
}
}
}
@@ -0,0 +1,105 @@
<?php
namespace Tests\Feature;
use App\Models\License;
use App\Models\LicenseProduct;
use App\Services\Payments\PurchaseProvisioner;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PaddleSubscriptionProvisioningTest extends TestCase
{
use RefreshDatabase;
private function product(string $price, string $edition, int $activations): LicenseProduct
{
return LicenseProduct::create([
'code' => strtolower($edition).'-'.$price,
'name' => $edition,
'edition' => $edition,
'activation_limit' => $activations,
'max_major_version' => 1,
'payment_provider' => 'paddle',
'external_product_id' => $edition === 'Personal' ? 'pro_personal' : 'pro_professional',
'external_price_id' => $price,
'active' => true,
]);
}
private function subscription(string $type, string $status, string $price, string $product, array $extra = []): array
{
return [
'event_type' => $type,
'data' => array_replace_recursive([
'id' => 'sub_test',
'status' => $status,
'customer_id' => 'ctm_test',
'transaction_id' => 'txn_test',
'custom_data' => ['customer_name'=>'Mark','customer_email'=>'mark@example.com'],
'current_billing_period' => ['starts_at'=>'2026-09-09T00:00:00Z','ends_at'=>'2026-10-09T00:00:00Z'],
'scheduled_change' => null,
'items' => [[
'recurring' => true,
'price' => ['id'=>$price,'product_id'=>$product],
]],
], $extra),
];
}
public function test_trialing_subscription_issues_a_usable_license(): void
{
$this->product('pri_personal_monthly', 'Personal', 1);
app(PurchaseProvisioner::class)->provision('paddle', $this->subscription(
'subscription.created', 'trialing', 'pri_personal_monthly', 'pro_personal'
));
$license = License::firstOrFail();
$this->assertSame('Personal', $license->edition);
$this->assertSame('sub_test', $license->external_subscription_id);
$this->assertSame('trialing', $license->subscription_status);
$this->assertTrue($license->is_usable);
}
public function test_upgrade_changes_entitlement_without_changing_license(): void
{
$this->product('pri_personal_monthly', 'Personal', 1);
$this->product('pri_professional_monthly', 'Professional', 3);
$service = app(PurchaseProvisioner::class);
$service->provision('paddle', $this->subscription('subscription.created', 'active', 'pri_personal_monthly', 'pro_personal'));
$id = License::firstOrFail()->id;
$service->provision('paddle', $this->subscription('subscription.updated', 'active', 'pri_professional_monthly', 'pro_professional'));
$license = License::firstOrFail();
$this->assertSame($id, $license->id);
$this->assertSame('Professional', $license->edition);
$this->assertSame(3, $license->activation_limit);
}
public function test_scheduled_cancellation_does_not_revoke_before_effective_date(): void
{
$this->product('pri_personal_monthly', 'Personal', 1);
app(PurchaseProvisioner::class)->provision('paddle', $this->subscription(
'subscription.updated', 'active', 'pri_personal_monthly', 'pro_personal',
['data'=>['scheduled_change'=>['action'=>'cancel','effective_at'=>'2026-10-09T00:00:00Z']]]
));
$license = License::firstOrFail();
$this->assertNull($license->revoked_at);
$this->assertNotNull($license->cancel_scheduled_at);
}
public function test_canceled_subscription_revokes_license(): void
{
$this->product('pri_personal_monthly', 'Personal', 1);
$service = app(PurchaseProvisioner::class);
$service->provision('paddle', $this->subscription('subscription.created', 'active', 'pri_personal_monthly', 'pro_personal'));
$service->provision('paddle', $this->subscription('subscription.canceled', 'canceled', 'pri_personal_monthly', 'pro_personal', ['data'=>['current_billing_period'=>null]]));
$license = License::firstOrFail();
$this->assertNotNull($license->revoked_at);
$this->assertFalse($license->is_usable);
}
}