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) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-10-07 12:48:05 +02:00
parent 945d7e70aa
commit 644c84def3
4 changed files with 53 additions and 2 deletions

View File

@ -122,6 +122,14 @@ return [
'paypal' => [ 'paypal' => [
'base_url' => env('PAYPAL_BASE_URL', 'https://api-m.paypal.com'), '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' => [ 'accounts' => [
[ [
'name' => 'default', 'name' => 'default',

View File

@ -20,6 +20,8 @@ PAYPAL_CLIENT_ID=...
PAYPAL_SECRET=... 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). 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 ## How PayPal rows count

View File

@ -30,20 +30,31 @@ class ShopImportPaypalLedgerCommand extends Command
$accounts = array_values(array_filter($accounts, fn ($a) => $a['name'] === $only)); $accounts = array_values(array_filter($accounts, fn ($a) => $a['name'] === $only));
} }
$required = (bool) config('shop.paypal.required', false);
if (! $accounts) { 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; return self::SUCCESS;
} }
if (! $paypal->hasProviderColumns()) { 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; return self::FAILURE;
} }
$dry = (bool) $this->option('dry-run'); $dry = (bool) $this->option('dry-run');
$failed = false; $failed = false;
$errors = [];
foreach ($accounts as $account) { foreach ($accounts as $account) {
try { try {
@ -54,6 +65,7 @@ class ShopImportPaypalLedgerCommand extends Command
$result = $paypal->import($account, $since, $dry); $result = $paypal->import($account, $since, $dry);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->error(sprintf('[%s] %s', $account['name'], $e->getMessage())); $this->error(sprintf('[%s] %s', $account['name'], $e->getMessage()));
$errors[] = sprintf('[%s] %s', $account['name'], $e->getMessage());
$failed = true; $failed = true;
continue; 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; return $failed ? self::FAILURE : self::SUCCESS;
} }
} }

View File

@ -256,6 +256,30 @@ class PaypalLedgerTest extends TestCase
Http::assertNotSent(fn ($request) => $request->method() !== 'GET' && ! str_ends_with($request->url(), '/v1/oauth2/token')); 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] #[Test]
public function command_without_accounts_is_a_no_op(): void public function command_without_accounts_is_a_no_op(): void
{ {