diff --git a/PADDLE_SUBSCRIPTIONS.md b/PADDLE_SUBSCRIPTIONS.md new file mode 100644 index 0000000..343ace8 --- /dev/null +++ b/PADDLE_SUBSCRIPTIONS.md @@ -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. diff --git a/app/Models/License.php b/app/Models/License.php index e42d8b0..9549d41 100644 --- a/app/Models/License.php +++ b/app/Models/License.php @@ -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()); } diff --git a/app/Services/Payments/PaddleClient.php b/app/Services/Payments/PaddleClient.php new file mode 100644 index 0000000..a84edff --- /dev/null +++ b/app/Services/Payments/PaddleClient.php @@ -0,0 +1,23 @@ +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; + } +} diff --git a/app/Services/Payments/PurchaseProvisioner.php b/app/Services/Payments/PurchaseProvisioner.php index 7cb0c4c..6a3c7fd 100644 --- a/app/Services/Payments/PurchaseProvisioner.php +++ b/app/Services/Payments/PurchaseProvisioner.php @@ -1,47 +1,236 @@ $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; } } diff --git a/config/rsync-studio.php b/config/rsync-studio.php index d500f47..288e409 100644 --- a/config/rsync-studio.php +++ b/config/rsync-studio.php @@ -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'), diff --git a/database/database.sqlite b/database/database.sqlite index 63f9d49..3f4a46a 100644 Binary files a/database/database.sqlite and b/database/database.sqlite differ diff --git a/database/migrations/2026_09_09_100000_add_paddle_subscription_fields.php b/database/migrations/2026_09_09_100000_add_paddle_subscription_fields.php new file mode 100644 index 0000000..ef60b76 --- /dev/null +++ b/database/migrations/2026_09_09_100000_add_paddle_subscription_fields.php @@ -0,0 +1,34 @@ +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', + ]); + }); + } +}; diff --git a/database/seeders/PaddleCatalogSeeder.php b/database/seeders/PaddleCatalogSeeder.php new file mode 100644 index 0000000..71f8e76 --- /dev/null +++ b/database/seeders/PaddleCatalogSeeder.php @@ -0,0 +1,27 @@ +'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] + ); + } + } +} diff --git a/tests/Feature/PaddleSubscriptionProvisioningTest.php b/tests/Feature/PaddleSubscriptionProvisioningTest.php new file mode 100644 index 0000000..a2f9ee5 --- /dev/null +++ b/tests/Feature/PaddleSubscriptionProvisioningTest.php @@ -0,0 +1,105 @@ + 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); + } +}