paddel setup
This commit is contained in:
@@ -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.
|
||||
@@ -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()); }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user