queryOne('SELECT invoice_collection_id FROM orders WHERE id = ' . $orderId . ' LIMIT 1'); return (int)($row['invoice_collection_id'] ?? 0); } function monthly_split_invoice_row(int $invoiceCollectionId): array { return api_test_runtime()->queryOne('SELECT id, created_at, closed_at FROM collected_order_invoices WHERE id = ' . $invoiceCollectionId . ' LIMIT 1') ?? []; } function monthly_split_customer_collection_count(int $customerNumber): int { $row = api_test_runtime()->queryOne('SELECT COUNT(*) AS count FROM collected_order_invoices WHERE customer_number = ' . $customerNumber); return (int)($row['count'] ?? 0); } function monthly_split_cleanup_collections(array $invoiceCollectionIds): void { $ids = array_values(array_unique(array_filter(array_map('intval', $invoiceCollectionIds), static fn(int $id): bool => $id > 0))); if ($ids === []) { return; } api_test_runtime()->db()->query('UPDATE orders SET invoice_collection_id = NULL WHERE invoice_collection_id IN (' . implode(',', $ids) . ')'); api_test_runtime()->db()->query('DELETE FROM collected_order_invoices WHERE id IN (' . implode(',', $ids) . ')'); } it('previews monthly split changes without moving orders or creating collections', function (): void { api_test_covers('POST /collected-invoices/split-by-month', 'preview'); $customer = api_fixtures()->createUser(['display_name' => 'Preview Monthly Split Customer']); $department = api_fixtures()->createDepartment(); $invoiceCollection = api_fixtures()->createInvoiceCollection([ 'customer_number' => $customer['customer_number'], ]); $marchOrder = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => $department['id'], 'invoice_collection_id' => $invoiceCollection['id'], 'created_at' => '2096-03-15 10:00:00', ]); $aprilOrder = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => $department['id'], 'invoice_collection_id' => $invoiceCollection['id'], 'created_at' => '2096-04-02 10:00:00', ]); $session = api_fixtures()->createUserSession(['split_collected_invoice']); $collectionCountBefore = monthly_split_customer_collection_count((int)$customer['customer_number']); $response = api_client()->post('/collected-invoices/split-by-month', [ 'dateFrom' => '2096-03-01', 'dateTo' => '2096-04-30', 'preview' => true, ], $session['headers']); $response ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); $payload = $response->data(); $months = (array)($payload['changed'][0]['months'] ?? []); expect($payload['preview'] ?? null)->toBeTrue() ->and($payload['processed_count'] ?? null)->toBe(1) ->and($payload['changed_count'] ?? null)->toBe(1) ->and($payload['changed'][0]['created_invoice_collection_ids'] ?? null)->toBe([]) ->and($months[0]['month'] ?? null)->toBe('2096-03') ->and($months[0]['will_create_collection'] ?? null)->toBeFalse() ->and($months[0]['target_invoice_collection_id'] ?? null)->toBe((int)$invoiceCollection['id']) ->and($months[0]['closed_at'] ?? null)->toBeNull() ->and($months[1]['month'] ?? null)->toBe('2096-04') ->and($months[1]['will_create_collection'] ?? null)->toBeTrue() ->and($months[1]['target_invoice_collection_id'] ?? null)->toBeNull() ->and($months[1]['closed_at'] ?? null)->toBeNull() ->and(monthly_split_customer_collection_count((int)$customer['customer_number']))->toBe($collectionCountBefore) ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe((int)$invoiceCollection['id']); }); it('splits a selected March and April collected invoice into monthly collections', function (): void { api_test_covers('POST /collected-invoices/split-by-month', 'happy'); $customer = api_fixtures()->createUser(['display_name' => 'Monthly Split Customer']); $department = api_fixtures()->createDepartment(); $invoiceCollection = api_fixtures()->createInvoiceCollection([ 'customer_number' => $customer['customer_number'], 'created_at' => '2096-03-01 00:00:01', ]); $marchOrder = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => $department['id'], 'invoice_collection_id' => $invoiceCollection['id'], 'created_at' => '2096-03-15 10:00:00', ]); $aprilOrder = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => $department['id'], 'invoice_collection_id' => $invoiceCollection['id'], 'created_at' => '2096-04-02 10:00:00', ]); $session = api_fixtures()->createUserSession(['split_collected_invoice']); $createdCollectionIds = []; try { $response = api_client()->post('/collected-invoices/split-by-month', [ 'dateFrom' => '2096-03-01', 'dateTo' => '2096-04-30', ], $session['headers']); $response ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); $payload = $response->data(); $createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []); $aprilCollectionId = (int)($createdCollectionIds[0] ?? 0); expect($payload['processed_count'] ?? null)->toBe(1) ->and($payload['changed_count'] ?? null)->toBe(1) ->and($payload['skipped_count'] ?? null)->toBe(0) ->and($aprilCollectionId)->toBeGreaterThan(0) ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe($aprilCollectionId) ->and(monthly_split_invoice_row((int)$invoiceCollection['id'])['created_at'] ?? null)->toBe('2096-03-01 00:00:01') ->and(monthly_split_invoice_row((int)$invoiceCollection['id'])['closed_at'] ?? null)->toBeNull() ->and(monthly_split_invoice_row($aprilCollectionId)['created_at'] ?? null)->toBe('2096-04-01 00:00:01') ->and(monthly_split_invoice_row($aprilCollectionId)['closed_at'] ?? null)->toBeNull(); } finally { monthly_split_cleanup_collections($createdCollectionIds); } }); it('sets closed_at to month end when split month has ended', function (): void { api_test_covers('POST /collected-invoices/split-by-month', 'closed-at'); $customer = api_fixtures()->createUser(['display_name' => 'Ended Monthly Split Customer']); $department = api_fixtures()->createDepartment(); $invoiceCollection = api_fixtures()->createInvoiceCollection([ 'customer_number' => $customer['customer_number'], 'created_at' => '2001-03-01 00:00:01', ]); $marchOrder = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => $department['id'], 'invoice_collection_id' => $invoiceCollection['id'], 'created_at' => '2001-03-15 10:00:00', ]); $aprilOrder = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => $department['id'], 'invoice_collection_id' => $invoiceCollection['id'], 'created_at' => '2001-04-02 10:00:00', ]); $session = api_fixtures()->createUserSession(['split_collected_invoice']); $createdCollectionIds = []; try { $response = api_client()->post('/collected-invoices/split-by-month', [ 'dateFrom' => '2001-03-01', 'dateTo' => '2001-04-30', ], $session['headers']); $response ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); $payload = $response->data(); $createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []); $aprilCollectionId = (int)($createdCollectionIds[0] ?? 0); expect($payload['changed_count'] ?? null)->toBe(1) ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe($aprilCollectionId) ->and(monthly_split_invoice_row((int)$invoiceCollection['id'])['closed_at'] ?? null)->toBe('2001-03-31 23:59:59') ->and(monthly_split_invoice_row($aprilCollectionId)['closed_at'] ?? null)->toBe('2001-04-30 23:59:59'); } finally { monthly_split_cleanup_collections($createdCollectionIds); } }); it('splits the whole affected collection even when only one month is selected', function (): void { api_test_covers('POST /collected-invoices/split-by-month', 'partial'); $customer = api_fixtures()->createUser(['display_name' => 'Partial Monthly Split Customer']); $department = api_fixtures()->createDepartment(); $invoiceCollection = api_fixtures()->createInvoiceCollection([ 'customer_number' => $customer['customer_number'], ]); $marchOrder = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => $department['id'], 'invoice_collection_id' => $invoiceCollection['id'], 'created_at' => '2096-03-20 09:00:00', ]); $aprilOrder = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => $department['id'], 'invoice_collection_id' => $invoiceCollection['id'], 'created_at' => '2096-04-10 09:00:00', ]); $session = api_fixtures()->createUserSession(['split_collected_invoice']); $createdCollectionIds = []; try { $response = api_client()->post('/collected-invoices/split-by-month', [ 'dateFrom' => '2096-03-01', 'dateTo' => '2096-03-31', ], $session['headers']); $response ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); $payload = $response->data(); $createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []); $aprilCollectionId = (int)($createdCollectionIds[0] ?? 0); expect($payload['changed_count'] ?? null)->toBe(1) ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe($aprilCollectionId); } finally { monthly_split_cleanup_collections($createdCollectionIds); } }); it('does not affect booked collected invoices', function (): void { api_test_covers('POST /collected-invoices/split-by-month', 'booked-skip'); $customer = api_fixtures()->createUser(['display_name' => 'Booked Monthly Split Customer']); $department = api_fixtures()->createDepartment(); $invoiceCollection = api_fixtures()->createInvoiceCollection([ 'customer_number' => $customer['customer_number'], 'booked_invoice_id' => 987654, ]); $marchOrder = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => $department['id'], 'invoice_collection_id' => $invoiceCollection['id'], 'created_at' => '2096-03-05 12:00:00', ]); $aprilOrder = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => $department['id'], 'invoice_collection_id' => $invoiceCollection['id'], 'created_at' => '2096-04-05 12:00:00', ]); $session = api_fixtures()->createUserSession(['split_collected_invoice']); $response = api_client()->post('/collected-invoices/split-by-month', [ 'dateFrom' => '2096-03-01', 'dateTo' => '2096-03-31', ], $session['headers']); $response ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); $payload = $response->data(); expect($payload['changed_count'] ?? null)->toBe(0) ->and($payload['skipped_count'] ?? null)->toBe(1) ->and((string)($payload['skipped'][0]['message'] ?? ''))->toContain('booked') ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe((int)$invoiceCollection['id']); }); it('skips draft linked, Stripe, and single month collections', function (): void { api_test_covers('POST /collected-invoices/split-by-month', 'skip'); $customer = api_fixtures()->createUser(['display_name' => 'Skipped Monthly Split Customer']); $department = api_fixtures()->createDepartment(); $draftCollection = api_fixtures()->createInvoiceCollection([ 'customer_number' => $customer['customer_number'], 'external_id' => 'draft-external-reference', ]); $stripeCollection = api_fixtures()->createInvoiceCollection([ 'customer_number' => $customer['customer_number'], 'processor' => 2, ]); $singleMonthCollection = api_fixtures()->createInvoiceCollection([ 'customer_number' => $customer['customer_number'], ]); foreach ([$draftCollection, $stripeCollection] as $collection) { api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => $department['id'], 'invoice_collection_id' => $collection['id'], 'created_at' => '2096-03-05 12:00:00', ]); api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => $department['id'], 'invoice_collection_id' => $collection['id'], 'created_at' => '2096-04-05 12:00:00', ]); } api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => $department['id'], 'invoice_collection_id' => $singleMonthCollection['id'], 'created_at' => '2096-03-10 12:00:00', ]); $session = api_fixtures()->createUserSession(['split_collected_invoice']); $response = api_client()->post('/collected-invoices/split-by-month', [ 'dateFrom' => '2096-03-01', 'dateTo' => '2096-03-31', ], $session['headers']); $response ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); $payload = $response->data(); expect($payload['processed_count'] ?? null)->toBe(3) ->and($payload['changed_count'] ?? null)->toBe(0) ->and($payload['skipped_count'] ?? null)->toBe(3); }); it('rejects invalid monthly split date ranges', function (): void { api_test_covers('POST /collected-invoices/split-by-month', 'failure'); $session = api_fixtures()->createUserSession(['split_collected_invoice']); api_client()->post('/collected-invoices/split-by-month', [ 'dateFrom' => '2096-04-30', 'dateTo' => '2096-03-01', ], $session['headers']) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false); });