From bd2ff1be9a95f85bec8016b5a91e568bd0936132 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Wed, 5 Aug 2026 14:55:06 +0200 Subject: [PATCH] Fix false 409 snapshot expiry in invoice-period tree preview (#350) ## Problem `POST /superuser/invoicing/period/tree-actions/preview` could return `409 Invoice-period snapshot is missing or expired` during normal UI flows when a snapshot binding aged out before the user triggered the action. ## Fix - introduce a dedicated snapshot cache TTL (`SNAPSHOT_BINDING_TTL_SECONDS`) - keep preview cache TTL unchanged (`PREVIEW_TTL_SECONDS`) - use the longer snapshot TTL for actor/customer snapshot binding writes This preserves existing safety because snapshot bindings are still revalidated against fresh revision data before use. ## Tests - `vendor/bin/pest tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php --colors=never` - `vendor/bin/pest tests/Api/CollectedInvoiceBulkActionsApiTest.php --colors=never` (suite present; skipped without `RUN_API_TESTS=1`) Co-authored-by: Jeppe Bundgaard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../invoice_collection_bulk_action_service.php | 3 ++- .../InvoiceCollectionBulkActionSafetyTest.php | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/services/nginx/app/classes/invoice_collection_bulk_action_service.php b/services/nginx/app/classes/invoice_collection_bulk_action_service.php index 022d3835..eec4db55 100644 --- a/services/nginx/app/classes/invoice_collection_bulk_action_service.php +++ b/services/nginx/app/classes/invoice_collection_bulk_action_service.php @@ -27,6 +27,7 @@ class invoice_collection_bulk_action_service public const ACTION_QUEUE_ECONOMIC = 'queue_economic'; private const PREVIEW_TTL_SECONDS = 600; + private const SNAPSHOT_BINDING_TTL_SECONDS = 7200; private const MAX_COLLECTIONS = 100; private const CONFIRMATION_PHRASES = [ 'da' => 'Bekræft', @@ -347,7 +348,7 @@ class invoice_collection_bulk_action_service (new redis())->setEx( $this->snapshotCacheKey($actorUserId, $revision), $encoded, - self::PREVIEW_TTL_SECONDS + self::SNAPSHOT_BINDING_TTL_SECONDS ); return $binding; diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php index 60caea62..b598678d 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php @@ -32,6 +32,18 @@ it('binds invoice-period tree bulk previews to the actor and cached preview acti ->toContain("'snapshot_revision'"); }); +it('keeps snapshot bindings alive longer than preview caches for invoice-period tree actions', function (): void { + $service = (string)file_get_contents(app_path('classes/invoice_collection_bulk_action_service.php')); + $reflection = new ReflectionClass(invoice_collection_bulk_action_service::class); + $previewTtl = (int)$reflection->getConstant('PREVIEW_TTL_SECONDS'); + $snapshotTtl = (int)$reflection->getConstant('SNAPSHOT_BINDING_TTL_SECONDS'); + + expect($snapshotTtl)->toBeGreaterThan($previewTtl) + ->and($service)->toContain('private const SNAPSHOT_BINDING_TTL_SECONDS = 7200;') + ->toContain('self::SNAPSHOT_BINDING_TTL_SECONDS') + ->toContain("setEx(\n \$this->snapshotCacheKey(\$actorUserId, \$revision),"); +}); + it('previews whole-tree cleanup and merge supersession semantics', function (): void { $service = (string)file_get_contents(app_path('classes/invoice_collection_bulk_action_service.php')); $object = (string)file_get_contents(app_path('objects/collected_order_invoices_o.php'));