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:
parent
945d7e70aa
commit
644c84def3
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue