getMethod($method); $target->setAccessible(true); return $target->invokeArgs(null, $args); } function invoicing_period_draft_overlay_draft( int $invoice_collection_id, int $customer_number, bool $is_period_relevant = true ): array { return [ 'invoice_collection_id' => $invoice_collection_id, 'customer_number' => $customer_number, 'created_at' => '2026-04-01 00:00:01', 'closed_at' => '2026-04-01 00:00:01', 'is_period_relevant' => $is_period_relevant, ]; } function invoicing_period_draft_overlay_transaction( int $id, ?int $invoice_collection_id, bool $booked = false, bool $excluded = false, ?string $queue_status = null ): array { return [ 'id' => $id, 'booked' => $booked, 'excluded' => $excluded, 'invoice_collection_id' => $invoice_collection_id, 'queue_status' => $queue_status, 'queue_job_id' => $queue_status === null ? null : 99, ]; } function invoicing_period_draft_overlay_reset_deleted_at_column_cache(): void { $reflection = new ReflectionClass(InvoicingPeriodRoute::class); $property = $reflection->getProperty('collectedOrderInvoicesHasDeletedAtColumn'); $property->setAccessible(true); $property->setValue(null, null); } class InvoicingPeriodDraftOverlayFakeDbResult { public int $num_rows; public function __construct(private array $rows = []) { $this->num_rows = count($rows); } public function fetch_assoc(): ?array { return array_shift($this->rows); } } class InvoicingPeriodDraftOverlayFakeDb { public string $selectSql = ''; public function __construct(private bool $hasDeletedAtColumn) { } public function escape_string(string $value): string { return addslashes($value); } public function query(string $sql): InvoicingPeriodDraftOverlayFakeDbResult|bool { if (str_starts_with($sql, 'SHOW COLUMNS')) { return new InvoicingPeriodDraftOverlayFakeDbResult( $this->hasDeletedAtColumn ? [['Field' => 'deleted_at']] : [] ); } $this->selectSql = $sql; return false; } } it('blocks invoicing when all actionable transactions are backed by valid e-conomic drafts', function (): void { $draft = invoicing_period_draft_overlay_draft(14578, 42424242); $customer = [ 'customer_number' => 42424242, 'customer_name' => 'Draft Customer', 'requires_action' => true, 'transactions' => [ invoicing_period_draft_overlay_transaction(501, 14578), ], ]; $result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [ $customer, [14578 => $draft], [42424242 => [$draft]], ]); expect($result['requires_action'])->toBeFalse(); expect($result['queue'])->toBe([ 'has_active_job' => false, 'statuses' => [], 'invoice_collection_ids' => [], 'is_action_blocked' => false, ]); expect($result['draft'])->toBe([ 'has_valid_draft' => true, 'invoice_collection_ids' => [14578], 'is_action_blocked' => true, ]); }); it('keeps invoicing available when valid drafts only cover part of the actionable work', function (): void { $draft = invoicing_period_draft_overlay_draft(2001, 43434343); $customer = [ 'customer_number' => 43434343, 'customer_name' => 'Mixed Draft Customer', 'requires_action' => true, 'transactions' => [ invoicing_period_draft_overlay_transaction(601, 2001), invoicing_period_draft_overlay_transaction(602, null), ], ]; $result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [ $customer, [2001 => $draft], [43434343 => [$draft]], ]); expect($result['requires_action'])->toBeTrue(); expect($result['draft'])->toBe([ 'has_valid_draft' => true, 'invoice_collection_ids' => [2001], 'is_action_blocked' => false, ]); }); it('excludes errored, booked, deleted, and missing-external-id invoice collections at query time', function (): void { $hadDb = array_key_exists('db', $GLOBALS); $originalDb = $GLOBALS['db'] ?? null; $fakeDb = new InvoicingPeriodDraftOverlayFakeDb(true); invoicing_period_draft_overlay_reset_deleted_at_column_cache(); $GLOBALS['db'] = $fakeDb; try { invoicing_period_draft_overlay_invoke('getValidCollectedInvoiceDraftOverlay', [ [ 'all' => [ [ 'customer_number' => 45454545, 'transactions' => [ invoicing_period_draft_overlay_transaction(701, 3001), ], 'meta' => [ 'fixed_pricing' => [ 'price' => 1200, ], ], ], ], ], '2026-04-01', '2026-04-30', ]); } finally { if ($hadDb) { $GLOBALS['db'] = $originalDb; } else { unset($GLOBALS['db']); } invoicing_period_draft_overlay_reset_deleted_at_column_cache(); } expect($fakeDb->selectSql)->toContain('deleted_at IS NULL'); expect($fakeDb->selectSql)->toContain('processor = 1'); expect($fakeDb->selectSql)->toContain('external_id IS NOT NULL'); expect($fakeDb->selectSql)->toContain("external_id <> ''"); expect($fakeDb->selectSql)->toContain('booked_invoice_id IS NULL'); expect($fakeDb->selectSql)->toContain('error_message IS NULL'); }); it('still checks valid drafts when the collection table has no deleted marker column', function (): void { $hadDb = array_key_exists('db', $GLOBALS); $originalDb = $GLOBALS['db'] ?? null; $fakeDb = new InvoicingPeriodDraftOverlayFakeDb(false); invoicing_period_draft_overlay_reset_deleted_at_column_cache(); $GLOBALS['db'] = $fakeDb; try { invoicing_period_draft_overlay_invoke('getValidCollectedInvoiceDraftOverlay', [ [ 'all' => [ [ 'customer_number' => 12345679, 'transactions' => [ invoicing_period_draft_overlay_transaction(61415, 17389), ], ], ], ], '2026-05-11', '2026-05-11', ]); } finally { if ($hadDb) { $GLOBALS['db'] = $originalDb; } else { unset($GLOBALS['db']); } invoicing_period_draft_overlay_reset_deleted_at_column_cache(); } expect($fakeDb->selectSql)->not->toContain('deleted_at IS NULL'); expect($fakeDb->selectSql)->toContain('id IN (17389)'); expect($fakeDb->selectSql)->toContain('processor = 1'); expect($fakeDb->selectSql)->toContain('error_message IS NULL'); }); it('blocks fixed-pricing and subscription customer-level work when a relevant valid draft exists', function (): void { $draft = invoicing_period_draft_overlay_draft(3001, 45454545, true); $customer = [ 'customer_number' => 45454545, 'customer_name' => 'Subscription Draft Customer', 'requires_action' => true, 'transactions' => [], 'meta' => [ 'fixed_pricing' => [ 'price' => 1200, ], ], ]; $result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [ $customer, [], [45454545 => [$draft]], ]); expect($result['requires_action'])->toBeFalse(); expect($result['draft'])->toBe([ 'has_valid_draft' => true, 'invoice_collection_ids' => [3001], 'is_action_blocked' => true, ]); }); it('keeps queue blocking ahead of the draft label when all work is covered by queue or draft state', function (): void { $draft = invoicing_period_draft_overlay_draft(5002, 46464646, true); $customer = [ 'customer_number' => 46464646, 'customer_name' => 'Queue And Draft Customer', 'requires_action' => true, 'transactions' => [ invoicing_period_draft_overlay_transaction(801, 5001, false, false, 'QUEUED'), invoicing_period_draft_overlay_transaction(802, 5002), ], 'queue' => [ 'has_active_job' => true, 'statuses' => ['QUEUED'], 'invoice_collection_ids' => [5001], 'is_action_blocked' => false, ], ]; $result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [ $customer, [5002 => $draft], [46464646 => [$draft]], ]); expect($result['requires_action'])->toBeFalse(); expect($result['queue']['is_action_blocked'])->toBeTrue(); expect($result['draft'])->toBe([ 'has_valid_draft' => true, 'invoice_collection_ids' => [5002], 'is_action_blocked' => false, ]); });