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 <jb@truckwash.dk>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jeppe B
2026-08-05 12:55:06 +00:00
committed by GitHub
co-authored by Jeppe Bundgaard Copilot
parent 59107a6bb2
commit bd2ff1be9a
2 changed files with 14 additions and 1 deletions
@@ -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;
@@ -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'));