Files
api/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php
T
Jeppe B c795df4aad Add invoice period review workflow (#336)
Improve the superuser invoice-period review API, stale-preview protection, queue visibility, review blockers, and e-conomic eligibility.
2026-08-02 19:20:50 +02:00

690 lines
24 KiB
PHP

<?php
app_require('classes/response.php');
app_require('routes/InvoicingPeriodRoute.php');
use classes\response;
use routes\InvoicingPeriodRoute;
function invoicing_period_pagination_invoke(string $method, array $args = []): mixed
{
$reflection = new ReflectionClass(InvoicingPeriodRoute::class);
$target = $reflection->getMethod($method);
return $target->invokeArgs(null, $args);
}
function invoicing_period_customer_card(
int $customerNumber,
string $customerName,
array $transactions,
bool $requiresAction = false,
array $extra = []
): array {
return array_merge([
'id' => $customerNumber,
'customer_number' => $customerNumber,
'customer_name' => $customerName,
'requires_action' => $requiresAction,
'transactions' => $transactions,
'meta' => [],
'queue' => [
'has_active_job' => false,
'statuses' => [],
'invoice_collection_ids' => [],
'is_action_blocked' => false,
],
'draft' => [
'has_valid_draft' => false,
'invoice_collection_ids' => [],
'is_action_blocked' => false,
],
], $extra);
}
function invoicing_period_transaction(array $overrides = []): array
{
return array_merge([
'id' => 9001,
'date' => '2026-04-10 12:00:00',
'amount' => 125.5,
'booked' => false,
'department_id' => 1,
'customer_number' => 1001,
'reference' => 'REF-9001',
'po' => 'PO-9001',
'notes' => 'Gate note',
'reg_1' => 'AB12345',
'reg_2' => '',
'reg_3' => '',
'excluded' => false,
'invoice_collection_id' => 3001,
'queue_status' => null,
'queue_job_id' => null,
], $overrides);
}
it('detects paginated period mode only when pagination parameters are present', function (): void {
global $response;
$previousResponse = $GLOBALS['response'] ?? null;
$previousGet = $_GET;
$previousMethod = $_SERVER['REQUEST_METHOD'] ?? null;
$previousQueryString = $_SERVER['QUERY_STRING'] ?? null;
$response = new response();
try {
$_SERVER['REQUEST_METHOD'] = 'GET';
$_GET = [
'dateFrom' => '2026-04-01',
'dateTo' => '2026-04-30',
];
expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toBeNull();
$_GET['flagTab'] = 'yellow';
expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toMatchArray([
'flagTab' => 'yellow',
]);
unset($_GET['flagTab']);
$_SERVER['QUERY_STRING'] = 'reviewState=blocked&reviewState=ready&departmentId=4%2C8';
$_GET['reviewState'] = 'ready';
$_GET['departmentId'] = '4,8';
expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toMatchArray([
'reviewStates' => ['blocked', 'ready'],
'departmentIds' => [4, 8],
]);
$_SERVER['QUERY_STRING'] = '';
unset($_GET['reviewState'], $_GET['departmentId']);
$_GET['page'] = '2';
expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toMatchArray([
'periodView' => 'all',
'page' => 2,
'limit' => 100,
'search' => '',
'includeRequiresAction' => true,
'includeBooked' => true,
]);
} finally {
$_GET = $previousGet;
if ($previousMethod === null) {
unset($_SERVER['REQUEST_METHOD']);
} else {
$_SERVER['REQUEST_METHOD'] = $previousMethod;
}
if ($previousQueryString === null) {
unset($_SERVER['QUERY_STRING']);
} else {
$_SERVER['QUERY_STRING'] = $previousQueryString;
}
if ($previousResponse === null) {
unset($GLOBALS['response']);
} else {
$response = $previousResponse;
}
}
});
it('normalizes workflow filters and deterministic review sorting', function (): void {
$options = invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[
'reviewState' => ['blocked,ready', 'unknown'],
'severity' => 'red,green,invalid',
'invoiceState' => 'open,economic_booked',
'departmentId' => ['4,8', '0', '-1'],
'sort' => 'priority',
'direction' => 'DESC',
]]);
expect($options)->toMatchArray([
'reviewStates' => ['blocked', 'ready'],
'severities' => ['red', 'green'],
'invoiceStates' => ['open', 'economic_booked'],
'departmentIds' => [4, 8],
'sort' => 'priority',
'direction' => 'desc',
]);
});
it('normalizes period pagination options and clamps invalid page and limit values', function (): void {
$options = invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[
'periodView' => 'not-a-view',
'page' => '-4',
'limit' => '500',
'search' => ' Nordic ',
'includeRequiresAction' => '0',
'includeBooked' => 'false',
'flagTab' => 'invalid-tab',
]]);
expect($options)->toBe([
'periodView' => 'all',
'page' => 1,
'limit' => 500,
'search' => 'Nordic',
'flagTab' => 'all',
'reviewStates' => [],
'severities' => [],
'invoiceStates' => [],
'departmentIds' => [],
'sort' => 'customer_name',
'direction' => 'asc',
'includeRequiresAction' => false,
'includeBooked' => false,
]);
expect(invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[
'limit' => '900',
]]))->toMatchArray([
'limit' => 500,
]);
expect(invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[
'limit' => 'all',
]]))->toMatchArray([
'limit' => 'all',
]);
expect(invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[
'periodView' => 'invoice_per_order',
'page' => '3',
'limit' => '0',
'flagTab' => 'yellow',
]]))->toMatchArray([
'periodView' => 'invoice_per_order',
'page' => 3,
'limit' => 100,
'flagTab' => 'yellow',
]);
});
it('slices only the active period view and keeps exact full-result type counts', function (): void {
$period = [
'dateFrom' => '2026-04-01 00:00:00',
'dateTo' => '2026-04-30 23:59:59',
'types' => [
'all' => [
invoicing_period_customer_card(1001, 'Alpha Transport', [
invoicing_period_transaction(['id' => 11, 'customer_number' => 1001, 'amount' => 50]),
], false),
invoicing_period_customer_card(1002, 'Beta Transport', [
invoicing_period_transaction([
'id' => 12,
'customer_number' => 1002,
'amount' => 75,
'booked' => true,
'excluded' => true,
'reference' => 'REF-BETA',
'po' => 'PO-BETA',
'reg_1' => 'BB22222',
]),
], true),
invoicing_period_customer_card(1003, 'Gamma Transport', [
invoicing_period_transaction([
'id' => 13,
'customer_number' => 1003,
'amount' => 125,
'booked' => true,
]),
], false, [
'draft' => [
'has_valid_draft' => true,
'invoice_collection_ids' => [3013],
'is_action_blocked' => true,
],
]),
],
'fixed_pricing' => [
invoicing_period_customer_card(1002, 'Beta Transport', [], true, [
'meta' => [
'fixed_pricing' => [
'price' => 999,
],
],
]),
],
'invoice_per_order' => [
invoicing_period_customer_card(1001, 'Alpha Transport', [], true, [
'flags' => [
['source' => 'manual', 'status' => 'active'],
],
]),
],
],
];
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 2,
'limit' => 1,
'search' => '',
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect($result['pagination'])->toMatchArray([
'page' => 2,
'per_page' => 1,
'total' => 3,
'search' => '',
]);
expect($result['period']['types']['all'])->toHaveCount(1);
expect($result['period']['types']['all'][0])->toMatchArray([
'customer_number' => 1002,
'customer_name' => 'Beta Transport',
'requires_action' => true,
'meta' => [
'fixed_pricing' => [
'price' => 999,
],
],
]);
expect($result['period']['types']['all'][0]['transactions'][0])->toMatchArray([
'id' => 12,
'amount' => 75,
'booked' => true,
'excluded' => true,
'reference' => 'REF-BETA',
'po' => 'PO-BETA',
'reg_1' => 'BB22222',
]);
expect($result['period']['types']['fixed_pricing'])->toBe([]);
expect($result['period']['type_counts']['all'])->toBe([
'requires_action' => 1,
'draft' => 1,
'manual_flags' => 0,
'automatic_flags' => 0,
'completed' => 1,
'total' => 3,
]);
expect($result['period']['type_totals']['all'])->toBe([
'total' => 1174.0,
'booked' => 125.0,
'not_booked' => 1049.0,
]);
expect($result['period']['type_totals']['fixed_pricing'])->toBe([
'total' => 999.0,
'booked' => 0.0,
'not_booked' => 999.0,
]);
expect($result['period']['type_counts']['invoice_per_order']['manual_flags'])->toBe(1);
});
it('returns the entire active period view when the limit is all', function (): void {
$period = [
'dateFrom' => '2026-04-01 00:00:00',
'dateTo' => '2026-04-30 23:59:59',
'types' => [
'all' => [
invoicing_period_customer_card(1101, 'Alpha', [
invoicing_period_transaction(['id' => 31, 'customer_number' => 1101]),
]),
invoicing_period_customer_card(1102, 'Beta', [
invoicing_period_transaction(['id' => 32, 'customer_number' => 1102]),
]),
invoicing_period_customer_card(1103, 'Gamma', [
invoicing_period_transaction(['id' => 33, 'customer_number' => 1103]),
]),
],
],
];
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 3,
'limit' => 'all',
'search' => '',
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect($result['pagination'])->toMatchArray([
'page' => 1,
'per_page' => 'all',
'total' => 3,
]);
expect(array_column($result['period']['types']['all'], 'customer_number'))->toBe([1101, 1102, 1103]);
});
it('searches customer fields and order fields at the customer-card level', function (): void {
$period = [
'dateFrom' => '2026-04-01 00:00:00',
'dateTo' => '2026-04-30 23:59:59',
'types' => [
'all' => [
invoicing_period_customer_card(2001, 'Solaris Fleet', [
invoicing_period_transaction([
'id' => 21,
'customer_number' => 2001,
'reference' => 'REF-KEEP',
'po' => 'PO-KEEP',
]),
]),
invoicing_period_customer_card(2002, 'Nordic Logistics', [
invoicing_period_transaction([
'id' => 22,
'customer_number' => 2002,
'reference' => 'MISS',
'po' => 'PO-777',
'notes' => 'Driver waits at gate',
'reg_1' => 'CD33333',
]),
invoicing_period_transaction([
'id' => 23,
'customer_number' => 2002,
'reference' => 'SECOND-LINE',
]),
]),
],
],
];
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'search' => 'po-777',
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect($result['pagination']['total'])->toBe(1);
expect($result['period']['types']['all'])->toHaveCount(1);
expect($result['period']['types']['all'][0]['customer_number'])->toBe(2002);
expect($result['period']['types']['all'][0]['transactions'])->toHaveCount(2);
$registrationMatch = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'search' => 'cd33333',
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect($registrationMatch['period']['types']['all'][0]['customer_name'])->toBe('Nordic Logistics');
});
it('applies requires-action and booked visibility filters before counting and slicing', function (): void {
$period = [
'dateFrom' => '2026-04-01 00:00:00',
'dateTo' => '2026-04-30 23:59:59',
'types' => [
'all' => [
invoicing_period_customer_card(3001, 'Needs Action', [
invoicing_period_transaction(['customer_number' => 3001, 'booked' => false]),
], true),
invoicing_period_customer_card(3002, 'Already Booked', [
invoicing_period_transaction(['customer_number' => 3002, 'booked' => true]),
], false),
invoicing_period_customer_card(3003, 'Still Open', [
invoicing_period_transaction(['customer_number' => 3003, 'booked' => false]),
], false),
],
],
];
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'search' => '',
'includeRequiresAction' => false,
'includeBooked' => false,
]]);
expect($result['pagination']['total'])->toBe(1);
expect($result['period']['type_counts']['all']['total'])->toBe(1);
expect($result['period']['types']['all'][0]['customer_number'])->toBe(3003);
});
it('filters period flag tabs using active customer-scoped flag counts', function (): void {
$period = [
'dateFrom' => '2026-04-01 00:00:00',
'dateTo' => '2026-04-30 23:59:59',
'types' => [
'all' => [
invoicing_period_customer_card(4001, 'Yellow Order Flag', [
invoicing_period_transaction(['customer_number' => 4001, 'id' => 41]),
], false, [
'flags' => [
[
'source' => 'automatic',
'status' => 'active',
'target_type' => 'order',
'order_id' => 41,
],
],
]),
invoicing_period_customer_card(4002, 'Ignored Manual Flag', [
invoicing_period_transaction(['customer_number' => 4002, 'id' => 42]),
], false, [
'flags' => [
[
'source' => 'manual',
'status' => 'ignored',
'target_type' => 'customer',
],
],
]),
invoicing_period_customer_card(4003, 'Red Customer Flag', [
invoicing_period_transaction(['customer_number' => 4003, 'id' => 43]),
], false, [
'flags' => [
[
'source' => 'manual',
'status' => 'active',
'target_type' => 'customer',
],
],
]),
],
],
];
$yellowResult = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'search' => '',
'flagTab' => 'yellow',
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect($yellowResult['pagination']['total'])->toBe(1);
expect(array_column($yellowResult['period']['types']['all'], 'customer_number'))->toBe([4001]);
$noneResult = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'search' => '',
'flagTab' => 'none',
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect($noneResult['pagination']['total'])->toBe(1);
expect(array_column($noneResult['period']['types']['all'], 'customer_number'))->toBe([4002]);
expect($noneResult['period']['type_counts']['all'])->toMatchArray([
'manual_flags' => 1,
'automatic_flags' => 1,
'total' => 3,
]);
});
it('derives review workflow metadata, facets and priority order without persisted review state', function (): void {
$period = [
'types' => [
'all' => [
invoicing_period_customer_card(5105, 'Completed', [
invoicing_period_transaction([
'customer_number' => 5105,
'booked' => true,
'invoice_state' => 'economic_booked',
'department_id' => 5,
]),
]),
invoicing_period_customer_card(5104, 'Ready', [
invoicing_period_transaction([
'customer_number' => 5104,
'invoice_state' => 'open',
'department_id' => 4,
]),
]),
invoicing_period_customer_card(5103, 'Queued', [
invoicing_period_transaction(['customer_number' => 5103, 'invoice_state' => 'closed']),
], false, [
'queue' => [
'has_active_job' => true,
'job_ids' => [71],
'statuses' => ['pending'],
'is_action_blocked' => true,
],
]),
invoicing_period_customer_card(5102, 'Attention', [
invoicing_period_transaction(['customer_number' => 5102, 'invoice_state' => 'economic_draft']),
], false, [
'flags' => [['source' => 'automatic', 'status' => 'active']],
]),
invoicing_period_customer_card(5101, 'Blocked', [
invoicing_period_transaction(['customer_number' => 5101, 'invoice_state' => 'open']),
], false, [
'flags' => [['source' => 'manual', 'status' => 'active']],
'invoice_collections' => [[
'id' => 9101,
'state' => 'open',
'external_id' => 'DRAFT-ERROR-9101',
'error_message' => 'E-conomic rejected VAT code',
]],
]),
],
],
];
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'sort' => 'priority',
'direction' => 'asc',
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect(array_column($result['period']['types']['all'], 'customer_number'))
->toBe([5101, 5102, 5103, 5104, 5105]);
expect($result['period']['types']['all'][0]['review'])->toMatchArray([
'state' => 'blocked',
'severity' => 'red',
'next_action' => 'resolve_manual_flags',
'is_actionable' => true,
'counts' => [
'active_manual_flags' => 1,
'active_automatic_flags' => 0,
'collection_errors' => 1,
'active_queue_jobs' => 0,
'booked_transactions' => 0,
'unbooked_transactions' => 1,
],
]);
expect(array_column($result['pagination']['facets']['review_states'], 'value'))
->toBe(['attention', 'blocked', 'completed', 'queued', 'ready']);
expect($result['pagination']['order'])->toBe(['field' => 'priority', 'direction' => 'asc']);
});
it('filters workflow facets and searches collection identifiers and error details', function (): void {
$period = [
'types' => [
'all' => [
invoicing_period_customer_card(5201, 'Collection Failure', [
invoicing_period_transaction([
'customer_number' => 5201,
'invoice_state' => 'open',
'department_id' => 12,
]),
], false, [
'invoice_collections' => [[
'id' => 44001,
'invoice_collection_id' => 44001,
'external_id' => 'EXT-REVIEW-7788',
'booked_invoice_id' => null,
'error_message' => 'VAT account missing',
'state' => 'open',
]],
]),
invoicing_period_customer_card(5202, 'Other Customer', [
invoicing_period_transaction([
'customer_number' => 5202,
'booked' => true,
'invoice_state' => 'economic_booked',
'department_id' => 13,
]),
]),
],
],
];
$searched = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'search' => 'vat account missing',
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect(array_column($searched['period']['types']['all'], 'customer_number'))->toBe([5201]);
$filtered = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'reviewStates' => ['completed'],
'severities' => ['green'],
'invoiceStates' => ['economic_booked'],
'departmentIds' => [13],
'sort' => 'customer_number',
'direction' => 'asc',
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect($filtered['pagination']['total'])->toBe(1)
->and($filtered['period']['types']['all'][0]['customer_number'])->toBe(5202)
->and($filtered['pagination']['filters'])->toMatchArray([
'reviewState' => ['completed'],
'severity' => ['green'],
'invoiceState' => ['economic_booked'],
'departmentId' => [13],
]);
});
it('blocks review from aggregate manual counts when restricted flag details are absent', function (): void {
$period = [
'types' => [
'all' => [
invoicing_period_customer_card(5301, 'Restricted Flag Details', [
invoicing_period_transaction(['customer_number' => 5301]),
], false, [
'flag_counts' => ['manual' => 2, 'automatic' => 0, 'total' => 2],
]),
],
],
];
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect($result['period']['types']['all'][0])->not->toHaveKey('flags')
->and($result['period']['types']['all'][0]['review'])->toMatchArray([
'state' => 'blocked',
'severity' => 'red',
'next_action' => 'resolve_manual_flags',
]);
});