diff --git a/README.md b/README.md index 2522d58..924c25c 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,9 @@ Command | Description --- | --- `php artisan lmsqueezy:products` | List all available products with their variants and prices `php artisan lmsqueezy:products 12345` | List a specific product by its ID with its variants and prices +`php artisan lmsqueezy:licenses 12345` | List licenses generated for a given product ID +`php artisan lmsqueezy:licenses -p 3 -s 20` | List the paginated result of all generated licenses +`php artisan lmsqueezy:licenses --order=1234 --status=active` | List active licenses for a given order ID ## Checkouts diff --git a/src/Console/ListLicensesCommand.php b/src/Console/ListLicensesCommand.php new file mode 100644 index 0000000..aa1c3cf --- /dev/null +++ b/src/Console/ListLicensesCommand.php @@ -0,0 +1,175 @@ +validate()) { + return Command::FAILURE; + } + + $storeResponse = spin(fn () => $this->fetchStore(), '🍋 Fetching store information...'); + $store = $storeResponse->json('data.attributes'); + + return $this->handleLicenses($store); + } + + protected function validate(): bool + { + $arr = array_merge( + config('lemon-squeezy'), + ['page' => $this->option('page')], + ['size' => $this->option('size')] + ); + $validator = Validator::make($arr, [ + 'api_key' => [ + 'required', + ], + 'store' => [ + 'required', + ], + 'page' => [ + 'nullable', 'numeric', 'min:1', + ], + 'size' => [ + 'nullable', 'numeric', 'min:1', 'max:100' + ] + ], [ + 'api_key.required' => 'Lemon Squeezy API key not set. You can add it to your .env file as LEMON_SQUEEZY_API_KEY.', + 'store.required' => 'Lemon Squeezy store ID not set. You can add it to your .env file as LEMON_SQUEEZY_STORE.', + ]); + + if ($validator->passes()) { + return true; + } + + $this->newLine(); + + foreach ($validator->errors()->all() as $error) { + error($error); + } + + return false; + } + + protected function fetchStore(): Response + { + return LemonSqueezy::api('GET', sprintf('stores/%s', config('lemon-squeezy.store'))); + } + + protected function handleLicenses(array $store): int + { + $licensesResponse = spin( + fn () => LemonSqueezy::api( + 'GET', + sprintf('license-keys'), + [ + 'filter[store_id]' => config('lemon-squeezy.store'), + 'page[size]' => (int)$this->option('size'), + 'page[number]' => (int)$this->option('page'), + 'filter[product_id]' => $this->argument('product'), + 'filter[order_id]' => $this->option('order'), + 'filter[status]' => $this->option('status'), + ] + ), + '🍋 Fetching licenses...', + ); + + $currPage = $licensesResponse->json('meta.page.currentPage'); + $lastPage = $licensesResponse->json('meta.page.lastPage'); + + if ($lastPage > 1 && $currPage <= $lastPage) { + info(sprintf('Showing page %d of %d', $currPage, $lastPage)); + } + + $licenses = collect($licensesResponse->json('data')); + $licenses->each(function ($license) use ($licensesResponse, $store) { + $this->displayLicense($license, $this->option('long')); + + $this->newLine(); + }); + + return Command::SUCCESS; + } + + private function displayStatus(array $license): string + { + $status = Arr::get($license, 'attributes.status_formatted'); + $limit = Arr::get($license, 'attributes.activation_limit') ?? '0'; + $usage = Arr::get($license, 'attributes.activation_usage') ?? '0'; + + return "${status} (${usage}/${limit})"; + } + + private function displayProductInfo(array $license): void + { + $productId = Arr::get($license, 'attributes.product_id'); + $variantId = Arr::get($license, 'attributes.variant_id') ?? 'None'; + + $this->components->twoColumnDetail( + 'Product:Variant', + "${productId}:${variantId}" + ); + } + + private function displayCustomer(array $license): void + { + $customerName = Arr::get($license, 'attributes.user_name'); + $customerEmail = Arr::get($license, 'attributes.user_email'); + + $this->components->twoColumnDetail( + 'Customer', + "${customerName} [${customerEmail}]" + ); + } + + protected function displayLicense(array $license, bool $long): void + { + $key = Arr::get($license, $long ? 'attributes.key' : 'attributes.key_short'); + $orderId = Arr::get($license, 'attributes.order_id'); + + $this->components->twoColumnDetail( + sprintf('%s', $key), + $this->displayStatus($license) + ); + $this->displayProductInfo($license); + $this->displayCustomer($license); + $this->components->twoColumnDetail( + 'Order ID', + "${orderId}" + ); + } +} diff --git a/src/LemonSqueezyServiceProvider.php b/src/LemonSqueezyServiceProvider.php index cd6ecb7..59da598 100644 --- a/src/LemonSqueezyServiceProvider.php +++ b/src/LemonSqueezyServiceProvider.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use LemonSqueezy\Laravel\Console\ListenCommand; +use LemonSqueezy\Laravel\Console\ListLicensesCommand; use LemonSqueezy\Laravel\Console\ListProductsCommand; use LemonSqueezy\Laravel\Http\Controllers\WebhookController; @@ -87,6 +88,7 @@ protected function bootCommands(): void if ($this->app->runningInConsole()) { $this->commands([ ListenCommand::class, + ListLicensesCommand::class, ListProductsCommand::class, ]); } diff --git a/tests/Feature/Commands/ListLicensesCommandTest.php b/tests/Feature/Commands/ListLicensesCommandTest.php new file mode 100644 index 0000000..b42a2a0 --- /dev/null +++ b/tests/Feature/Commands/ListLicensesCommandTest.php @@ -0,0 +1,159 @@ + Http::response([ + 'data' => [ + 'id' => 'fake', + 'attributes' => [ + 'name' => 'Fake Store', + 'currency' => 'EUR', + ], + ], + ]), + LemonSqueezy::API.'/stores/other' => Http::response([ + 'data' => [ + 'id' => 'other', + 'attributes' => [ + 'name' => 'Other Fake Store', + 'currency' => 'EUR', + ], + ], + ]), + LemonSqueezy::API.'/license-keys?*' => Http::response([ + 'data' => [ + [ + 'id' => '1', + 'attributes' => [ + 'key' => '0766391F-31BA-4508-8528-887A901C6262', + 'key_short' => 'XXXX-887A901C6262', + 'activation_limit' => '10', + 'activation_usage' => '2', + 'product_id' => '12345', + 'order_id' => 'foo', + 'variant_id' => '67890', + 'status' => 'active', + 'user_name' => 'John Doe', + 'user_email' => 'john.doe@example.com', + ], + ], + [ + 'id' => '2', + 'attributes' => [ + 'key' => '42FF3EE3-D03B-48DC-8C2F-F447677BF33F', + 'key_short' => 'XXXX-F447677BF33F', + 'activation_limit' => '6', + 'activation_usage' => '0', + 'product_id' => '12345', + 'order_id' => 'bar', + 'status' => 'active', + 'user_name' => 'Jane Doe', + 'user_email' => 'jane.doe@example.com', + ], + ], + [ + 'id' => '3', + 'attributes' => [ + 'key' => '9A631ABD-7F1D-4890-B211-1280B8A52A1F', + 'key_short' => 'XXXX-1280B8A52A1F', + 'activation_usage' => '0', + 'product_id' => '67890', + 'variant_id' => '12345', + 'order_id' => 'foo', + 'status' => 'disabled', + 'user_name' => 'John Doe', + 'user_email' => 'john.doe@example.com', + ], + ] + ], + ]), + ]); +}); + +it('can list licenses', function () { + $this->artisan('lmsqueezy:licenses') + + // First License + ->expectsOutputToContain('XXXX-887A901C6262') + ->expectsOutputToContain('12345:67890') + ->expectsOutputToContain('John Doe [john.doe@example.com]') + + // Second License + ->expectsOutputToContain('XXXX-F447677BF33F') + ->expectsOutputToContain('12345:None') + ->expectsOutputToContain('Jane Doe [jane.doe@example.com]') + + // Third License + ->expectsOutputToContain('XXXX-1280B8A52A1F') + ->expectsOutputToContain('67890:12345') + + ->assertSuccessful(); +}); + +it('can query licenses for a specific status', function () { + $this->artisan('lmsqueezy:licenses', ['--status' => 'disabled']) + ->expectsOutputToContain('XXXX-1280B8A52A1F') + ->expectsOutputToContain('XXXX-887A901C6262') + ->doesntExpectOutput('XXXX-F447677BF33F') + + ->assertSuccessful(); +}); + +it('can query licenses for a specific product', function () { + $this->artisan('lmsqueezy:licenses', ['product' => '12345']) + ->doesntExpectOutput('XXXX-1280B8A52A1F') + ->expectsOutputToContain('XXXX-887A901C6262') + ->expectsOutputToContain('XXXX-F447677BF33F') + + ->assertSuccessful(); +}); + +it('can query licenses for a specific order', function () { + $this->artisan('lmsqueezy:licenses', ['--order' => 'bar']) + ->doesntExpectOutput('XXXX-1280B8A52A1F') + ->doesntExpectOutput('XXXX-887A901C6262') + ->expectsOutputToContain('XXXX-F447677BF33F') + + ->assertSuccessful(); +}); + +it('can display the full license key', function () { + $this->artisan('lmsqueezy:licenses', ['-l' => true]) + ->expectsOutputToContain('9A631ABD-7F1D-4890-B211-1280B8A52A1F') + ->doesntExpectOutput('XXXX-1280B8A52A1F') + + ->assertSuccessful(); +}); + +it('can display the requested page', function () { + $this->artisan('lmsqueezy:licenses', ['-p' => '2', '-s' => '2']) + ->doesntExpectOutput('XXXX-887A901C6262') + ->doesntExpectOutput('XXXX-F447677BF33F') + ->expectsOutputToContain('XXXX-1280B8A52A1F') + + ->assertSuccessful(); +}); + +it('fails when api key is missing', function () { + config()->set('lemon-squeezy.api_key', null); + + $this->artisan('lmsqueezy:licenses') + ->expectsOutputToContain('Lemon Squeezy API key not set. You can add it to your .env file as LEMON_SQUEEZY_API_KEY.'); +}); + +it('fails when store is missing', function () { + config()->set('lemon-squeezy.store', null); + + $this->artisan('lmsqueezy:licenses') + ->expectsOutputToContain('Lemon Squeezy store ID not set. You can add it to your .env file as LEMON_SQUEEZY_STORE.'); +}); + +it('returns correct products based on the store id', function () { + config()->set('lemon-squeezy.store', 'other'); + + $this->artisan('lmsqueezy:licenses') + ->doesntExpectOutput('XXXX-887A901C6262'); +});