-
-
Notifications
You must be signed in to change notification settings - Fork 51
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add command to list products and variants (#80)
* Add command to list products and variants * wip * Fix code styling * apply Lemon Squeezy sort to rendered result * wip * tests: add tests to required configs * wip --------- Co-authored-by: heyjorgedev <[email protected]> Co-authored-by: Dinis Esteves <[email protected]> Co-authored-by: Dries Vints <[email protected]>
- Loading branch information
1 parent
940bd53
commit dfc6b10
Showing
4 changed files
with
336 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,188 @@ | ||
<?php | ||
|
||
namespace LemonSqueezy\Laravel\Console; | ||
|
||
use Illuminate\Console\Command; | ||
use Illuminate\Http\Client\Response; | ||
use Illuminate\Support\Arr; | ||
use Illuminate\Support\Facades\Validator; | ||
use LemonSqueezy\Laravel\LemonSqueezy; | ||
|
||
use function Laravel\Prompts\error; | ||
use function Laravel\Prompts\spin; | ||
|
||
class ListProductsCommand extends Command | ||
{ | ||
/** | ||
* The name and signature of the console command. | ||
* | ||
* @var string | ||
*/ | ||
protected $signature = 'lmsqueezy:products | ||
{product? : The ID of the product to list variants for.} | ||
'; | ||
|
||
/** | ||
* The console command description. | ||
* | ||
* @var string | ||
*/ | ||
protected $description = 'Lists all products and their variants.'; | ||
|
||
public function handle(): int | ||
{ | ||
if (! $this->validate()) { | ||
return Command::FAILURE; | ||
} | ||
|
||
$storeResponse = spin(fn () => $this->fetchStore(), '🍋 Fetching store information...'); | ||
$store = $storeResponse->json('data.attributes'); | ||
|
||
$productId = $this->argument('product'); | ||
|
||
if ($productId) { | ||
return $this->handleProduct($store, $productId); | ||
} | ||
|
||
return $this->handleProducts($store); | ||
} | ||
|
||
protected function validate(): bool | ||
{ | ||
$validator = Validator::make(config('lemon-squeezy'), [ | ||
'api_key' => [ | ||
'required', | ||
], | ||
'store' => [ | ||
'required', | ||
], | ||
], [ | ||
'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 handleProduct(array $store, string $productId): int | ||
{ | ||
$response = spin( | ||
fn () => LemonSqueezy::api( | ||
'GET', | ||
sprintf('products/%s', $productId), | ||
['include' => 'variants'] | ||
), | ||
'🍋 Fetching product information...' | ||
); | ||
|
||
$product = $response->json('data'); | ||
|
||
$this->newLine(); | ||
$this->displayTitle(); | ||
$this->newLine(); | ||
|
||
$this->displayProduct($product); | ||
|
||
$variants = collect($response->json('included')) | ||
->filter(fn ($item) => $item['type'] === 'variants') | ||
->sortBy('sort'); | ||
|
||
$variants->each(fn (array $variant) => $this->displayVariant( | ||
$variant, | ||
Arr::get($store, 'currency'), | ||
$variants->count() > 1 | ||
)); | ||
|
||
$this->newLine(); | ||
|
||
return Command::SUCCESS; | ||
} | ||
|
||
protected function handleProducts(array $store): int | ||
{ | ||
$productsResponse = spin( | ||
fn () => LemonSqueezy::api( | ||
'GET', | ||
'products', | ||
[ | ||
'include' => 'variants', | ||
'filter[store_id]' => config('lemon-squeezy.store_id'), | ||
'page[size]' => 100, | ||
] | ||
), | ||
'🍋 Fetching products information...', | ||
); | ||
|
||
$products = collect($productsResponse->json('data')); | ||
|
||
$this->newLine(); | ||
$this->displayTitle(); | ||
$this->newLine(); | ||
|
||
$products->each(function ($product) use ($productsResponse, $store) { | ||
$this->displayProduct($product); | ||
|
||
$variantIds = collect(Arr::get($product, 'relationships.variants.data'))->pluck('id'); | ||
$variants = collect($productsResponse->json('included')) | ||
->filter(fn ($item) => $item['type'] === 'variants') | ||
->filter(fn ($item) => $variantIds->contains($item['id'])) | ||
->sortBy('sort'); | ||
|
||
$variants->each(fn ($variant) => $this->displayVariant( | ||
$variant, | ||
Arr::get($store, 'currency'), | ||
$variants->count() > 1 | ||
)); | ||
|
||
$this->newLine(); | ||
}); | ||
|
||
return Command::SUCCESS; | ||
} | ||
|
||
protected function displayTitle(): void | ||
{ | ||
$this->components->twoColumnDetail('<fg=gray>Product/Variant</>', '<fg=gray>ID</>'); | ||
} | ||
|
||
protected function displayProduct(array $product): void | ||
{ | ||
$this->components->twoColumnDetail( | ||
sprintf('<fg=green;options=bold>%s</>', Arr::get($product, 'attributes.name')), | ||
Arr::get($product, 'id') | ||
); | ||
} | ||
|
||
protected function displayVariant(array $variant, string $currency, bool $hidePending = false): void | ||
{ | ||
if (Arr::get($variant, 'attributes.status') === 'pending' && $hidePending) { | ||
return; | ||
} | ||
|
||
$name = Arr::get($variant, 'attributes.name'); | ||
|
||
$price = LemonSqueezy::formatAmount( | ||
Arr::get($variant, 'attributes.price'), | ||
$currency, | ||
); | ||
|
||
$id = Arr::get($variant, 'id'); | ||
|
||
$this->components->twoColumnDetail(sprintf('%s <fg=gray>%s</>', $name, $price), $id); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
<?php | ||
|
||
use Illuminate\Support\Facades\Http; | ||
use LemonSqueezy\Laravel\LemonSqueezy; | ||
|
||
beforeEach(function () { | ||
Http::fake([ | ||
LemonSqueezy::API.'/stores/fake' => Http::response([ | ||
'data' => [ | ||
'id' => 'fake', | ||
'attributes' => [ | ||
'name' => 'Fake Store', | ||
'currency' => 'EUR', | ||
], | ||
], | ||
]), | ||
LemonSqueezy::API.'/products/fake?*' => Http::response([ | ||
'data' => [ | ||
'id' => 'fake', | ||
'attributes' => [ | ||
'name' => 'Fake Product', | ||
], | ||
'relationships' => [ | ||
'variants' => [ | ||
'data' => [ | ||
['id' => '123'], | ||
], | ||
], | ||
], | ||
], | ||
'included' => [ | ||
[ | ||
'id' => '123', | ||
'type' => 'variants', | ||
'attributes' => [ | ||
'name' => 'Fake Variant', | ||
'price' => 999, | ||
], | ||
], | ||
], | ||
]), | ||
LemonSqueezy::API.'/products?*' => Http::response([ | ||
'data' => [ | ||
[ | ||
'id' => '1', | ||
'attributes' => [ | ||
'name' => 'Pro', | ||
], | ||
'relationships' => [ | ||
'variants' => [ | ||
'data' => [ | ||
['id' => '123'], | ||
], | ||
], | ||
], | ||
], | ||
[ | ||
'id' => '2', | ||
'attributes' => [ | ||
'name' => 'Test', | ||
], | ||
'relationships' => [ | ||
'variants' => [ | ||
'data' => [ | ||
['id' => '321'], | ||
['id' => '456'], | ||
], | ||
], | ||
], | ||
], | ||
], | ||
'included' => [ | ||
[ | ||
'id' => '123', | ||
'type' => 'variants', | ||
'attributes' => [ | ||
'name' => 'Default', | ||
'price' => 999, | ||
], | ||
], | ||
[ | ||
'id' => '321', | ||
'type' => 'variants', | ||
'attributes' => [ | ||
'name' => 'Monthly', | ||
'price' => 929, | ||
], | ||
], | ||
[ | ||
'id' => '456', | ||
'type' => 'variants', | ||
'attributes' => [ | ||
'name' => 'Yearly', | ||
'price' => 939, | ||
], | ||
], | ||
], | ||
]), | ||
]); | ||
}); | ||
|
||
it('can list products', function () { | ||
$this->artisan('lmsqueezy:products') | ||
|
||
// First Product | ||
->expectsOutputToContain('Pro') | ||
->expectsOutputToContain('Default €9.99') | ||
|
||
// Second Product | ||
->expectsOutputToContain('Test') | ||
->expectsOutputToContain('Monthly €9.29') | ||
->expectsOutputToContain('Yearly €9.39') | ||
|
||
->assertSuccessful(); | ||
}); | ||
|
||
it('can query a specific product', function () { | ||
$this->artisan('lmsqueezy:products', ['product' => 'fake']) | ||
->expectsOutputToContain('Fake Product') | ||
->expectsOutputToContain('Fake Variant €9.99') | ||
->assertSuccessful(); | ||
}); | ||
|
||
it('fails when api key is missing', function () { | ||
config()->set('lemon-squeezy.api_key', null); | ||
|
||
$this->artisan('lmsqueezy:products') | ||
->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:products') | ||
->expectsOutputToContain('Lemon Squeezy store ID not set. You can add it to your .env file as LEMON_SQUEEZY_STORE.'); | ||
}); |