From 8b445ae99889367385fa8074a174976c61422e68 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 2 Jan 2026 21:47:14 +0100 Subject: [PATCH] Guard Paddle sync mapping --- .beads/issues.jsonl | 2 +- app/Console/Commands/PaddleSyncPackages.php | 44 +++++++++++++++++++ .../Feature/PaddleSyncPackagesCommandTest.php | 41 ++++++++++++++++- 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index df79f2a..68cb43d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -90,7 +90,7 @@ {"id":"fotospiel-app-pcz","title":"Security review: route/middleware inventory for marketing/API","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:54.409559375+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:00.000669934+01:00","closed_at":"2026-01-01T16:04:00.000669934+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-poe","title":"Security review checklist: Cross-cutting headers/CSRF/rate limits","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:03.2320643+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:03.2320643+01:00"} {"id":"fotospiel-app-q2n","title":"Checkout refactor: wizard foundations + updated steps","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:58.701443698+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:04.313207281+01:00","closed_at":"2026-01-01T16:06:04.313207281+01:00","close_reason":"Completed in codebase (verified)"} -{"id":"fotospiel-app-qlj","title":"Paddle catalog sync: verify legacy packages mapped before auto-sync","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:43.333792314+01:00","created_by":"soeren","updated_at":"2026-01-01T15:59:43.333792314+01:00"} +{"id":"fotospiel-app-qlj","title":"Paddle catalog sync: verify legacy packages mapped before auto-sync","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:43.333792314+01:00","created_by":"soeren","updated_at":"2026-01-02T21:46:52.797515024+01:00","closed_at":"2026-01-02T21:46:52.797515024+01:00","close_reason":"Completed"} {"id":"fotospiel-app-qtn","title":"Security review kickoff mitigations (CORS allowlist, headers, upload hardening, signed URLs)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:46.310873311+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:51.914359487+01:00","closed_at":"2026-01-01T16:09:51.914359487+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-sbs","title":"Compliance tools: data export + retention overrides","description":"GDPR-compliant export requests and retention override workflows for tenants/events.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:16.530289009+01:00","updated_at":"2026-01-02T20:13:31.704875591+01:00","closed_at":"2026-01-02T20:13:31.704875591+01:00","close_reason":"Closed"} {"id":"fotospiel-app-swb","title":"Security review: replace public asset URLs with signed routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:05.610098299+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:11.215921463+01:00","closed_at":"2026-01-01T16:04:11.215921463+01:00","close_reason":"Completed in codebase (verified)"} diff --git a/app/Console/Commands/PaddleSyncPackages.php b/app/Console/Commands/PaddleSyncPackages.php index 97ebae8..a51179b 100644 --- a/app/Console/Commands/PaddleSyncPackages.php +++ b/app/Console/Commands/PaddleSyncPackages.php @@ -15,6 +15,7 @@ class PaddleSyncPackages extends Command {--package=* : Limit sync to the given package IDs or slugs} {--dry-run : Generate payload snapshots without calling Paddle} {--pull : Fetch remote Paddle state instead of pushing local changes} + {--allow-unmapped : Allow sync when packages are missing Paddle product/price IDs} {--queue : Dispatch jobs onto the queue instead of running synchronously}'; protected $description = 'Synchronise local packages with Paddle products and prices.'; @@ -32,6 +33,13 @@ class PaddleSyncPackages extends Command $dryRun = (bool) $this->option('dry-run'); $pull = (bool) $this->option('pull'); $queue = (bool) $this->option('queue'); + $allowUnmapped = (bool) $this->option('allow-unmapped'); + + if (! $pull && ! $allowUnmapped && ! $this->hasPackageFilter()) { + if (! $this->guardUnmappedPackages($packages)) { + return self::FAILURE; + } + } $packages->each(function (Package $package) use ($dryRun, $pull, $queue) { if ($pull) { @@ -82,6 +90,42 @@ class PaddleSyncPackages extends Command return $query->orderByDesc('id')->get(); } + protected function hasPackageFilter(): bool + { + return collect((array) $this->option('package'))->filter()->isNotEmpty(); + } + + protected function guardUnmappedPackages(Collection $packages): bool + { + $unmapped = $packages->filter(fn (Package $package) => blank($package->paddle_product_id) || blank($package->paddle_price_id)); + + if ($unmapped->isEmpty()) { + return true; + } + + $this->error('Unmapped Paddle package IDs detected. Resolve legacy mappings or pass --allow-unmapped.'); + $this->table( + ['ID', 'Slug', 'Missing'], + $unmapped->map(function (Package $package): array { + $missing = []; + if (blank($package->paddle_product_id)) { + $missing[] = 'product_id'; + } + if (blank($package->paddle_price_id)) { + $missing[] = 'price_id'; + } + + return [ + $package->id, + $package->slug, + implode(', ', $missing), + ]; + })->values()->all() + ); + + return false; + } + protected function dispatchSyncJob(Package $package, bool $dryRun, bool $queue): void { $context = [ diff --git a/tests/Feature/PaddleSyncPackagesCommandTest.php b/tests/Feature/PaddleSyncPackagesCommandTest.php index af68e66..8ab13f2 100644 --- a/tests/Feature/PaddleSyncPackagesCommandTest.php +++ b/tests/Feature/PaddleSyncPackagesCommandTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature; use App\Jobs\SyncPackageToPaddle; use App\Models\Package; +use Illuminate\Console\Command; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus as BusFacade; use Tests\TestCase; @@ -14,7 +15,10 @@ class PaddleSyncPackagesCommandTest extends TestCase public function test_command_dispatches_jobs_for_packages(): void { - Package::factory()->count(2)->create(); + Package::factory()->count(2)->create([ + 'paddle_product_id' => 'pro_test', + 'paddle_price_id' => 'pri_test', + ]); BusFacade::fake(); @@ -44,6 +48,41 @@ class PaddleSyncPackagesCommandTest extends TestCase }); } + public function test_command_blocks_bulk_sync_with_unmapped_packages(): void + { + Package::factory()->create([ + 'paddle_product_id' => null, + 'paddle_price_id' => null, + ]); + + BusFacade::fake(); + + $this->artisan('paddle:sync-packages', [ + '--dry-run' => true, + '--queue' => true, + ])->assertExitCode(Command::FAILURE); + + BusFacade::assertNotDispatched(SyncPackageToPaddle::class); + } + + public function test_command_allows_unmapped_packages_when_overridden(): void + { + Package::factory()->create([ + 'paddle_product_id' => null, + 'paddle_price_id' => null, + ]); + + BusFacade::fake(); + + $this->artisan('paddle:sync-packages', [ + '--dry-run' => true, + '--queue' => true, + '--allow-unmapped' => true, + ])->assertExitCode(Command::SUCCESS); + + BusFacade::assertDispatched(SyncPackageToPaddle::class, 1); + } + protected function getJobPackageId(SyncPackageToPaddle $job): int { $reflection = new \ReflectionClass($job);