Guard Paddle sync mapping
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-02 21:47:14 +01:00
parent 77b7af13d4
commit 8b445ae998
3 changed files with 85 additions and 2 deletions

View File

@@ -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)"}

View File

@@ -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 = [

View File

@@ -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);