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' => [
|
'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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue