Implement move collected invoice to customer functionality with API endpoint and associated tests

This commit is contained in:
Jeppe Bundgaard
2026-06-09 13:12:55 +02:00
parent fb1f0883e1
commit aca8be51dc
7 changed files with 237 additions and 1 deletions
File diff suppressed because one or more lines are too long
@@ -25,6 +25,9 @@ class cors_policy
'https://localhost:4433',
'https://twdev.jeppeb.dk',
'http://localhost:5173',
'http://localhost:5174',
'http://127.0.0.1:5173',
'http://127.0.0.1:5174',
];
public static function normalizeOrigin(?string $value): string
@@ -606,6 +606,76 @@ class collected_order_invoices_o extends db
self::deleteCached('asArray', $this->id);
}
/**
* Move this invoice collection and all attached orders to another customer.
*
* @return array<string,mixed>
* @throws Exception
*/
public function moveToCustomer(int $target_customer_number): array
{
global $db;
self::requireSelected();
self::requireValidCustomer((string)$target_customer_number);
if ($target_customer_number <= 0) {
throw new Exception('Target customer number must be greater than zero');
}
if (!empty($this->external_id->value()) || $this->booked_invoice_id->value() !== null) {
throw new Exception('Invoice collections with an external or booked invoice cannot be moved');
}
$source_customer_number = (int)$this->customer_number->value();
if ($source_customer_number === $target_customer_number) {
return [
'invoice_collection_id' => (int)$this->id,
'source_customer_number' => $source_customer_number,
'target_customer_number' => $target_customer_number,
'moved_order_ids' => [],
'moved_order_count' => 0,
'changed' => false,
];
}
$invoice_collection_id = (int)$this->id;
$result = $db->query("SELECT id FROM orders WHERE invoice_collection_id = {$invoice_collection_id}");
$order_ids = array_map(
static fn(array $row): int => (int)$row['id'],
$db->fetch_all($result)
);
$db->conn()->begin_transaction();
try {
$this->customer_number->set($target_customer_number);
foreach ( $order_ids as $order_id ) {
$order = (new orders_o())->select($order_id);
if (!$order->exists()) {
continue;
}
$order->customer_id->set($target_customer_number);
$order->objectChanged();
}
$this->objectChanged();
$db->conn()->commit();
} catch (\Throwable $e) {
$db->conn()->rollback();
throw $e;
}
return [
'invoice_collection_id' => $invoice_collection_id,
'source_customer_number' => $source_customer_number,
'target_customer_number' => $target_customer_number,
'moved_order_ids' => $order_ids,
'moved_order_count' => count($order_ids),
'changed' => true,
];
}
/**
* Get the external id of the invoice collection
* @throws Exception If the request was not successful
@@ -496,6 +496,49 @@ class orderInvoicesRoute
]
);
/** Collected order invoices > Move to customer > POST */
$this->post('/collected-invoices/move-to-customer', function () {
global $response;
self::requirePermission('move_collected_invoice_customer');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('orderInvoices', 'global', 0, 0, 'MOVE_COLLECTED_INVOICE_CUSTOMER', 'User tried to move a collected order invoice without a valid session');
$response->error('Invalid session', 400);
}
self::requireParameters(['id', 'customer_number']);
self::requireType((int)self::getParameter('id'), self::type_int());
self::requireType((int)self::getParameter('customer_number'), self::type_int());
self::requireMinValue((int)self::getParameter('id'), 1);
self::requireMinValue((int)self::getParameter('customer_number'), 1);
self::requireMaxValue((int)self::getParameter('customer_number'), 999999999);
$collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id'));
$collected_order_invoices->requireSelected();
try {
$move_result = $collected_order_invoices->moveToCustomer((int)self::getParameter('customer_number'));
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
(new logs_o())->add(
'orderInvoices',
'global',
1,
$user->id,
'MOVE_COLLECTED_INVOICE_CUSTOMER',
'User moved collected order invoice #' . (int)$collected_order_invoices->id . ' to customer #' . (int)self::getParameter('customer_number')
);
$response->add_meta('move', $move_result);
$response->success($collected_order_invoices->asArray());
},
[
'move_collected_invoice_customer' => 'Move a collected order invoice and its orders to another customer. This is a superuser-only route.'
]
);
/** Collected order invoices > Split > POST */
$this->post('/collected-invoices/split', function () {
global $response;
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
usesApiSuite();
function moved_invoice_collection_customer_number(int $invoiceCollectionId): int
{
$row = api_test_runtime()->queryOne('SELECT customer_number FROM collected_order_invoices WHERE id = ' . $invoiceCollectionId . ' LIMIT 1');
return (int)($row['customer_number'] ?? 0);
}
function moved_order_customer_number(int $orderId): int
{
$row = api_test_runtime()->queryOne('SELECT customer_id FROM orders WHERE id = ' . $orderId . ' LIMIT 1');
return (int)($row['customer_id'] ?? 0);
}
it('moves a collected invoice collection and all attached orders to another customer', function (): void {
api_test_covers('POST /collected-invoices/move-to-customer', 'happy');
$sourceCustomer = api_fixtures()->createUser(['display_name' => 'Move Source Customer']);
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Move Target Customer']);
$department = api_fixtures()->createDepartment();
$invoiceCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $sourceCustomer['customer_number'],
]);
$firstOrder = api_fixtures()->createOrder([
'customer_id' => $sourceCustomer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $invoiceCollection['id'],
]);
$secondOrder = api_fixtures()->createOrder([
'customer_id' => $sourceCustomer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $invoiceCollection['id'],
]);
$session = api_fixtures()->createUserSession(['move_collected_invoice_customer']);
$response = api_client()->post('/collected-invoices/move-to-customer', [
'id' => $invoiceCollection['id'],
'customer_number' => $targetCustomer['customer_number'],
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$payload = $response->data();
$moveMeta = $response->meta()['move'] ?? [];
expect($payload['id'] ?? null)->toBe((int)$invoiceCollection['id'])
->and($payload['customer_number'] ?? null)->toBe((int)$targetCustomer['customer_number'])
->and($moveMeta['source_customer_number'] ?? null)->toBe((int)$sourceCustomer['customer_number'])
->and($moveMeta['target_customer_number'] ?? null)->toBe((int)$targetCustomer['customer_number'])
->and($moveMeta['moved_order_count'] ?? null)->toBe(2)
->and(moved_invoice_collection_customer_number((int)$invoiceCollection['id']))->toBe((int)$targetCustomer['customer_number'])
->and(moved_order_customer_number((int)$firstOrder['id']))->toBe((int)$targetCustomer['customer_number'])
->and(moved_order_customer_number((int)$secondOrder['id']))->toBe((int)$targetCustomer['customer_number']);
});
it('rejects moving a collection that already has an external invoice reference', function (): void {
api_test_covers('POST /collected-invoices/move-to-customer', 'external-guard');
$sourceCustomer = api_fixtures()->createUser(['display_name' => 'Move External Source Customer']);
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Move External Target Customer']);
$department = api_fixtures()->createDepartment();
$invoiceCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $sourceCustomer['customer_number'],
'external_id' => 'external-invoice-123',
]);
$order = api_fixtures()->createOrder([
'customer_id' => $sourceCustomer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $invoiceCollection['id'],
]);
$session = api_fixtures()->createUserSession(['move_collected_invoice_customer']);
$response = api_client()->post('/collected-invoices/move-to-customer', [
'id' => $invoiceCollection['id'],
'customer_number' => $targetCustomer['customer_number'],
], $session['headers']);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
expect($response->data()['message'] ?? '')->toContain('external or booked invoice')
->and(moved_invoice_collection_customer_number((int)$invoiceCollection['id']))->toBe((int)$sourceCustomer['customer_number'])
->and(moved_order_customer_number((int)$order['id']))->toBe((int)$sourceCustomer['customer_number']);
});
@@ -34,6 +34,14 @@ it('builds credential-safe normal CORS response headers for allowed origins', fu
expect($headers['Vary'])->toBe('Origin');
});
it('allows the fallback Vite localhost dev origin used after port 5173 is busy', function (): void {
$preflight = cors_policy::preflightResponse('http://localhost:5174', 'https://truckwash.io');
expect($preflight['allowed'])->toBeTrue();
expect($preflight['status'])->toBe(200);
expect($preflight['headers']['Access-Control-Allow-Origin'])->toBe('http://localhost:5174');
});
it('builds preflight CORS response headers for api-v2 release URLs', function (): void {
$preflight = cors_policy::preflightResponse(
'https://api-v2.truckwash.io/master/api',
@@ -0,0 +1,19 @@
<?php
it('wires collected invoice customer moves through the dedicated route and permission', function (): void {
$routeContent = file_get_contents(app_path('routes/orderInvoicesRoute.php'));
$objectContent = file_get_contents(app_path('objects/collected_order_invoices_o.php'));
expect($routeContent)->not->toBeFalse()
->and($routeContent)->toContain("\$this->post('/collected-invoices/move-to-customer'")
->and($routeContent)->toContain("self::requirePermission('move_collected_invoice_customer')")
->and($routeContent)->toContain('$collected_order_invoices->moveToCustomer')
->and($routeContent)->toContain("\$response->add_meta('move', \$move_result)")
->and($objectContent)->not->toBeFalse()
->and($objectContent)->toContain('public function moveToCustomer(int $target_customer_number): array')
->and($objectContent)->toContain('Invoice collections with an external or booked invoice cannot be moved')
->and($objectContent)->toContain('SELECT id FROM orders WHERE invoice_collection_id = {$invoice_collection_id}')
->and($objectContent)->toContain('$order->customer_id->set($target_customer_number)')
->and($objectContent)->toContain('$this->customer_number->set($target_customer_number)')
->and($objectContent)->toContain('$db->conn()->begin_transaction()');
});