From 644c84def3caf712043f029bf2e01cd97dfa9f81 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Wed, 7 Oct 2026 12:48:05 +0200 Subject: [PATCH] feat(ledger): shop.paypal.required makes a missing or failing PayPal import throw Without credentials the PayPal import was a silent no-op, so an app could lose its PayPal fees from revenue without anyone noticing. With shop.paypal.required on, a missing account, missing migration or a failed account import throws (after every account was tried), which the host's scheduler failure handling can alert on. Default off. Co-Authored-By: Claude Opus 5.5 (1M context) --- config/shop.php | 8 +++++++ docs/06-ledger.md | 2 ++ .../ShopImportPaypalLedgerCommand.php | 21 ++++++++++++++-- tests/Feature/Ledger/PaypalLedgerTest.php | 24 +++++++++++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/config/shop.php b/config/shop.php index e5471f2..cec81a1 100644 --- a/config/shop.php +++ b/config/shop.php @@ -122,6 +122,14 @@ return [ 'paypal' => [ 'base_url' => env('PAYPAL_BASE_URL', 'https://api-m.paypal.com'), + /* + * When true, shop:import-paypal-ledger throws if no account has + * credentials or an account's import fails, so a scheduler failure + * alert fires instead of PayPal fees silently going uncounted. Off by + * default: an app without PayPal stays quiet. + */ + 'required' => false, + 'accounts' => [ [ 'name' => 'default', diff --git a/docs/06-ledger.md b/docs/06-ledger.md index ea8c44e..1a559b4 100644 --- a/docs/06-ledger.md +++ b/docs/06-ledger.md @@ -20,6 +20,8 @@ PAYPAL_CLIENT_ID=... PAYPAL_SECRET=... ``` +Set `shop.paypal.required` to true where PayPal must be counted: the import then throws when no account has credentials or an import fails, so the scheduler's failure handling reports it instead of the fees silently going uncounted. + More accounts go into `shop.paypal.accounts` (publish the config), one entry each with `name`, `client_id`, `secret` and an optional `base_url` (`https://api-m.sandbox.paypal.com` for sandbox). ## How PayPal rows count diff --git a/src/Console/Commands/ShopImportPaypalLedgerCommand.php b/src/Console/Commands/ShopImportPaypalLedgerCommand.php index 82bfdd5..d396b10 100644 --- a/src/Console/Commands/ShopImportPaypalLedgerCommand.php +++ b/src/Console/Commands/ShopImportPaypalLedgerCommand.php @@ -30,20 +30,31 @@ class ShopImportPaypalLedgerCommand extends Command $accounts = array_values(array_filter($accounts, fn ($a) => $a['name'] === $only)); } + $required = (bool) config('shop.paypal.required', false); + if (! $accounts) { - $this->info('No PayPal account configured (shop.paypal.accounts), nothing to import.'); + $message = 'No PayPal account configured (shop.paypal.accounts), nothing to import.'; + if ($required) { + throw new \RuntimeException($message . ' shop.paypal.required is on: set PAYPAL_CLIENT_ID and PAYPAL_SECRET.'); + } + $this->info($message); return self::SUCCESS; } if (! $paypal->hasProviderColumns()) { - $this->error('The ledger table has no provider/account columns yet. Run the laravel-shop migrations first.'); + $message = 'The ledger table has no provider/account columns yet. Run the laravel-shop migrations first.'; + if ($required) { + throw new \RuntimeException($message); + } + $this->error($message); return self::FAILURE; } $dry = (bool) $this->option('dry-run'); $failed = false; + $errors = []; foreach ($accounts as $account) { try { @@ -54,6 +65,7 @@ class ShopImportPaypalLedgerCommand extends Command $result = $paypal->import($account, $since, $dry); } catch (\Throwable $e) { $this->error(sprintf('[%s] %s', $account['name'], $e->getMessage())); + $errors[] = sprintf('[%s] %s', $account['name'], $e->getMessage()); $failed = true; continue; @@ -94,6 +106,11 @@ class ShopImportPaypalLedgerCommand extends Command } } + // Every account was tried; now make a required import's failure loud. + if ($failed && $required) { + throw new \RuntimeException('PayPal ledger import failed: ' . implode('; ', $errors)); + } + return $failed ? self::FAILURE : self::SUCCESS; } } diff --git a/tests/Feature/Ledger/PaypalLedgerTest.php b/tests/Feature/Ledger/PaypalLedgerTest.php index c7113a7..abb3762 100644 --- a/tests/Feature/Ledger/PaypalLedgerTest.php +++ b/tests/Feature/Ledger/PaypalLedgerTest.php @@ -256,6 +256,30 @@ class PaypalLedgerTest extends TestCase Http::assertNotSent(fn ($request) => $request->method() !== 'GET' && ! str_ends_with($request->url(), '/v1/oauth2/token')); } + #[Test] + public function a_required_import_throws_when_unconfigured_or_failing(): void + { + config()->set('shop.paypal.required', true); + config()->set('shop.paypal.accounts', [['name' => 'default', 'client_id' => null, 'secret' => null]]); + + try { + $this->artisan('shop:import-paypal-ledger')->run(); + $this->fail('an unconfigured required import must throw'); + } catch (\RuntimeException $e) { + $this->assertStringContainsString('PAYPAL_CLIENT_ID', $e->getMessage()); + } + + config()->set('shop.paypal.accounts', [['name' => 'main', 'client_id' => 'id', 'secret' => 'bad']]); + Http::fake(['*/v1/oauth2/token' => Http::response(['error' => 'invalid_client', 'error_description' => 'Client Authentication failed'], 401)]); + + try { + $this->artisan('shop:import-paypal-ledger')->run(); + $this->fail('a failing required import must throw'); + } catch (\RuntimeException $e) { + $this->assertStringContainsString('Client Authentication failed', $e->getMessage()); + } + } + #[Test] public function command_without_accounts_is_a_no_op(): void {