Add unit and integration tests for economic_transfer_queue and related endpoints, replacing synchronous fallback methods with queue-based processing.

This commit is contained in:
Jeppe Bundgaard
2026-04-08 12:29:57 +02:00
parent ba23ad6e8f
commit c5cd42be7f
17 changed files with 869 additions and 96 deletions
+16
View File
@@ -6026,6 +6026,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/queue:
@@ -6067,6 +6068,7 @@ paths:
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/queue/status:
@@ -6093,6 +6095,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/queue/retry:
@@ -6123,6 +6126,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/compare:
@@ -7830,6 +7834,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/economic/invoice/draft/export/status:
@@ -7856,6 +7861,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/economic/invoice/draft/export/retry:
@@ -7886,6 +7892,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/economic/invoice/export:
@@ -7917,6 +7924,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/economic/invoice/export/status:
@@ -7943,6 +7951,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/economic/invoice/export/retry:
@@ -7973,6 +7982,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
# Module - Stripe Endpoints
@@ -11074,6 +11084,12 @@ components:
application/json:
schema:
$ref: '#/components/schemas/Error'
ServiceUnavailable:
description: Service unavailable - Required async queue dependencies are unavailable
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
InternalServerError:
description: Internal server error
content:
@@ -28,6 +28,12 @@ class economic_transfer_executor
throw new Exception('Order not found');
}
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
$invoice_id = (int)$economic_module_orders->economic_invoice_id->value();
if ($invoice_id > 0) {
throw new Exception('An invoice has already been created, invoice ID: ' . $invoice_id);
}
$order_items = (new orders_o())->getOrderItems($order_id);
$order_items = (new orders_o())->applyDepartmentPrices($order_items, $order->department_id->value());
if (count($order_items) === 0) {
@@ -92,7 +98,6 @@ class economic_transfer_executor
throw new Exception((string)$message);
}
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
if ($economic_module_orders->economic_invoice_draft_id->value() > 0) {
$economic_invoice_draft->deleteInvoiceDraft($economic_module_orders->economic_invoice_draft_id->value());
}
@@ -36,9 +36,22 @@ class economic_transfer_queue
{
global $db;
$transfer_type = $this->validateTransferType($transfer_type);
$max_attempts = max(1, min(10, $max_attempts));
$created_by = max(0, $created_by);
$max_attempts = max(1, min(10, $max_attempts));
$transfer_type = $this->validateTransferType($transfer_type);
$payload = $this->normalizePayloadForTransferType($transfer_type, $payload, $created_by);
$active_job = $this->findActiveJobByTarget($transfer_type, $payload);
if ($active_job !== null) {
$target_label = $this->buildTargetLabel($transfer_type, $payload);
$this->logQueueEvent(
1,
$created_by,
'ECONOMIC_TRANSFER_JOB_DEDUPED',
'Transfer job deduped for active target (' . $target_label . '), returning existing job #' . (int)($active_job['id'] ?? 0)
);
return $active_job;
}
$payload_json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($payload_json === false) {
@@ -62,9 +75,7 @@ class economic_transfer_queue
$job_id = (int)$db->insert_id();
$stmt->close();
(new logs_o())->add(
'economic_transfer_queue',
'global',
$this->logQueueEvent(
1,
$created_by,
'ECONOMIC_TRANSFER_JOB_ENQUEUED',
@@ -372,9 +383,7 @@ class economic_transfer_queue
WHERE id = $job_id";
$db->query($sql);
(new logs_o())->add(
'economic_transfer_queue',
'global',
$this->logQueueEvent(
1,
0,
'ECONOMIC_TRANSFER_JOB_COMPLETED',
@@ -402,9 +411,7 @@ class economic_transfer_queue
WHERE id = $job_id";
$db->query($sql);
(new logs_o())->add(
'economic_transfer_queue',
'global',
$this->logQueueEvent(
3,
0,
'ECONOMIC_TRANSFER_JOB_FAILED',
@@ -462,6 +469,172 @@ class economic_transfer_queue
$db->query($sql);
}
/**
* @throws Exception
*/
private function normalizePayloadForTransferType(string $transfer_type, array $payload, int $created_by): array
{
$normalized_payload = $payload;
if (isset($normalized_payload['requested_by'])) {
$normalized_payload['requested_by'] = max(0, (int)$normalized_payload['requested_by']);
}
return match ($transfer_type) {
self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->normalizeOrderPayload($normalized_payload, $created_by),
self::TYPE_COLLECTED_INVOICE_EXPORT => $this->normalizeCollectedInvoicePayload($normalized_payload, $created_by),
default => $this->rejectPayload($created_by, 'Unsupported transfer type payload: ' . $transfer_type),
};
}
/**
* @throws Exception
*/
private function normalizeOrderPayload(array $payload, int $created_by): array
{
$order_id = $payload['order_id'] ?? null;
if ($order_id === null || !is_numeric($order_id) || (int)$order_id < 1) {
return $this->rejectPayload($created_by, 'order_id is required and must be a positive number');
}
$payload['order_id'] = (int)$order_id;
return $payload;
}
/**
* @throws Exception
*/
private function normalizeCollectedInvoicePayload(array $payload, int $created_by): array
{
$collected_invoice_id = $payload['collected_invoice_id'] ?? null;
if ($collected_invoice_id === null || !is_numeric($collected_invoice_id) || (int)$collected_invoice_id < 1) {
return $this->rejectPayload($created_by, 'collected_invoice_id is required and must be a positive number');
}
$payload['collected_invoice_id'] = (int)$collected_invoice_id;
$payload['send_as_is'] = $this->normalizeBooleanPayloadValue($payload['send_as_is'] ?? false, 'send_as_is', $created_by);
return $payload;
}
/**
* @throws Exception
*/
private function normalizeBooleanPayloadValue(mixed $value, string $field_name, int $created_by): bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))) {
$numeric = (int)$value;
if ($numeric === 0 || $numeric === 1) {
return $numeric === 1;
}
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
}
if (is_string($value)) {
$normalized = strtolower(trim($value));
if (in_array($normalized, ['true', 'false', '1', '0'], true)) {
return in_array($normalized, ['true', '1'], true);
}
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
}
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
}
private function findActiveJobByTarget(string $transfer_type, array $payload): ?array
{
return match ($transfer_type) {
self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget(
$transfer_type,
'$.order_id',
(int)($payload['order_id'] ?? 0)
),
self::TYPE_COLLECTED_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget(
$transfer_type,
'$.collected_invoice_id',
(int)($payload['collected_invoice_id'] ?? 0)
),
default => null,
};
}
private function findActiveJobByJsonNumericTarget(string $transfer_type, string $json_path, int $target_value): ?array
{
global $db;
if ($target_value < 1) {
return null;
}
$stmt = $db->prepare(
"SELECT id
FROM economic_transfer_queue_jobs
WHERE transfer_type = ?
AND status IN (?, ?)
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ?
ORDER BY id DESC
LIMIT 1"
);
if (!$stmt) {
return null;
}
$queued = self::STATUS_QUEUED;
$processing = self::STATUS_PROCESSING;
$stmt->bind_param('sssi', $transfer_type, $queued, $processing, $target_value);
if (!$stmt->execute()) {
$stmt->close();
return null;
}
$result = $stmt->get_result();
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
$stmt->close();
if (!$row || !isset($row['id'])) {
return null;
}
return $this->getJobById((int)$row['id']);
}
private function buildTargetLabel(string $transfer_type, array $payload): string
{
return match ($transfer_type) {
self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => 'order_id=' . (int)($payload['order_id'] ?? 0),
self::TYPE_COLLECTED_INVOICE_EXPORT => 'collected_invoice_id=' . (int)($payload['collected_invoice_id'] ?? 0),
default => 'unknown',
};
}
/**
* @throws Exception
*/
private function rejectPayload(int $created_by, string $message): never
{
$this->logQueueEvent(
3,
max(0, $created_by),
'ECONOMIC_TRANSFER_JOB_VALIDATION_REJECTED',
'Transfer job enqueue rejected: ' . $message
);
throw new Exception($message);
}
private function logQueueEvent(int $status_code, int $user_id, string $event, string $message): void
{
try {
(new logs_o())->add(
'economic_transfer_queue',
'global',
$status_code,
$user_id,
$event,
$message
);
} catch (\Throwable) {
// Logging is best effort for queue operations.
}
}
/**
* @throws Exception
*/
+16
View File
@@ -6026,6 +6026,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/queue:
@@ -6067,6 +6068,7 @@ paths:
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/queue/status:
@@ -6093,6 +6095,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/queue/retry:
@@ -6123,6 +6126,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/compare:
@@ -7830,6 +7834,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/economic/invoice/draft/export/status:
@@ -7856,6 +7861,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/economic/invoice/draft/export/retry:
@@ -7886,6 +7892,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/economic/invoice/export:
@@ -7917,6 +7924,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/economic/invoice/export/status:
@@ -7943,6 +7951,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/economic/invoice/export/retry:
@@ -7973,6 +7982,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
# Module - Stripe Endpoints
@@ -11074,6 +11084,12 @@ components:
application/json:
schema:
$ref: '#/components/schemas/Error'
ServiceUnavailable:
description: Service unavailable - Required async queue dependencies are unavailable
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
InternalServerError:
description: Internal server error
content:
@@ -561,16 +561,7 @@ class orderInvoicesRoute
$response->error('Invoice has already been booked', 400);
}
if (!$this->isEconomicTransferQueueAvailable()) {
try {
$result = $this->exportCollectedInvoiceSynchronously($collected_order_invoices, $send_as_is);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
(new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'ADD_COLLECTED_INVOICE_ECONOMIC', 'Processed collected invoice transfer synchronously (queue unavailable)');
$response->success($result);
}
$this->ensureEconomicTransferQueueIsAvailable();
$queue = new economic_transfer_queue();
$job = $queue->enqueue(
@@ -950,10 +941,28 @@ class orderInvoicesRoute
if ($collected_order_invoices->booked_invoice_id->value() !== null) {
$response->error('Invoice has already been booked', 400);
}
// Add the collected order invoice to Stripe
$collected_order_invoices->addToEconomic(true);
// Return the collected order invoice
$response->success($collected_order_invoices->asArray());
$this->ensureEconomicTransferQueueIsAvailable();
$queue = new economic_transfer_queue();
$job = $queue->enqueue(
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
[
'collected_invoice_id' => (int)self::getParameter('id'),
'requested_by' => (int)$user->id,
],
(int)$user->id
);
$job_id = (int)($job['id'] ?? 0);
if ($job_id < 1) {
$response->error('Failed to enqueue stripe collected invoice export job: missing queue job id in enqueue response', 500);
}
(new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'ADD_COLLECTED_INVOICE_STRIPE_QUEUED', 'Queued Stripe collected invoice export to E-Conomic');
$response->success([
'message' => 'Stripe collected invoice export queued',
'job_id' => $job_id,
'job' => $job,
], 202);
} else {
(new logs_o())->add('orderInvoices', 'global', 0, 0, 'ADD_COLLECTED_INVOICE_STRIPE', 'User tried to add a collected order invoice to Stripe without a valid session');
$response->error('Invalid session', 400);
@@ -1687,52 +1696,6 @@ class orderInvoicesRoute
return json_decode(json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true);
}
/**
* Synchronous fallback when queue components are unavailable in this deployment.
* Mirrors the collected-invoice flow used by the queue executor.
* @throws Exception
*/
private function exportCollectedInvoiceSynchronously(collected_order_invoices_o $collected_order_invoices, bool $send_as_is): array
{
if ($collected_order_invoices->external_id->value() === null) {
if (!$send_as_is) {
$customer_fixed_pricing_o = new customer_fixed_pricing_o();
if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) {
$customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value());
$customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value();
$collected_order_invoices->overridePricesFixed($customer_fixed_pricing_price);
} else {
$collected_order_invoices->addVehicleSubscriptionsTransaction();
}
} else {
$collected_order_invoices->removeSpecialArrangements();
$collected_order_invoices->setAllItemsToBeIncludedInInvoice();
}
$collected_order_invoices->addToEconomic();
} else {
if ($collected_order_invoices->booked_invoice_id->value() !== null) {
throw new Exception('Invoice has already been booked');
}
if ($collected_order_invoices->isDraftExisting()) {
throw new Exception('Invoice draft already exists in E-Conomic');
}
$customer_fixed_pricing_o = new customer_fixed_pricing_o();
if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) {
$customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value());
$customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value();
$collected_order_invoices->overridePricesFixed($customer_fixed_pricing_price);
} else {
$collected_order_invoices->addVehicleSubscriptionsTransaction();
}
$collected_order_invoices->addToEconomic(true);
}
return $collected_order_invoices->asArray();
}
private function isEconomicTransferQueueAvailable(): bool
{
return class_exists('\\classes\\economic_transfer_executor')
+39 -10
View File
@@ -54,27 +54,56 @@ class userInvoicesRoute
(new logs_o())->add('user_invoices', 'global', 0, 0, 'USER_INVOICES', 'User not logged in');
$response->error('Invalid session', 400);
}
self::requireParameters(['id', 'po_number']);
self::requireParameters(['id']);
self::requireType((int)self::getParameter('id'), self::type_int());
$id = (int)self::getParameter('id');
// Make sure the id is valid
self::requireMinValue($id, 1);
self::requireSameLength($id, self::getParameter('id'));
// Make sure the po_number is valid
self::requireType((string)self::getParameter('po_number'), self::type_string());
self::requireMinLength('po_number', 0);
self::requireMaxLength('po_number', 255);
$is_superuser = self::hasPermission('superuser');
if (!self::isParametersSet(['po_number']) && !self::isParametersSet(['closed_at'])) {
$response->error('Missing required parameters: po_number, closed_at', 400);
}
if (self::isParametersSet(['closed_at']) && !$is_superuser) {
$response->error('Forbidden: only superusers can update closed_at', 403);
}
// Make sure optional fields are valid
if (self::isParametersSet(['po_number'])) {
self::requireType((string)self::getParameter('po_number'), self::type_string());
self::requireMinLength('po_number', 0);
self::requireMaxLength('po_number', 255);
}
$closed_at = null;
if (self::isParametersSet(['closed_at'])) {
$closed_at = self::getParameter('closed_at');
if ($closed_at !== null && $closed_at !== '') {
self::requireType((string)$closed_at, self::type_string());
self::requireDateFormat((string)$closed_at, self::FORMAT_DATE());
}
}
// Get the invoice
$collected_order_invoices = new collected_order_invoices_o();
$invoice = $collected_order_invoices->select((int)$id);
$invoice->requireSelected();
// Make sure the invoice belongs to the user
if ((int)$invoice->customer_number->value() !== (int)$user->customer_number->value()) {
(new logs_o())->add('user_invoices', 'global', 0, 0, 'USER_INVOICES', 'User not allowed to access this invoice');
$response->error('Invalid session', 400);
if ((int)$invoice->customer_number->value() !== (int)$user->customer_number->value() && !$is_superuser) {
(new logs_o())->add(
'user_invoices',
'global',
0,
0,
'USER_INVOICES',
'User not allowed to access this invoice (invoice_customer=' . (int)$invoice->customer_number->value() . ', user_customer=' . (int)$user->customer_number->value() . ')'
);
$response->error('Forbidden: invoice does not belong to authenticated user', 403);
}
// Update the invoice
$invoice->po_number->set((string)self::getParameter('po_number'));
if (self::isParametersSet(['po_number'])) {
$invoice->po_number->set((string)self::getParameter('po_number'));
}
if (self::isParametersSet(['closed_at'])) {
$invoice->closed_at->set($closed_at === null || $closed_at === '' ? null : date('Y-m-d 23:59:59', strtotime((string)$closed_at . ' 00:00:01')));
}
// Return success
$response->success($invoice->asArray());
},
@@ -83,4 +112,4 @@ class userInvoicesRoute
]
);
}
}
}
@@ -0,0 +1,264 @@
<?php
use classes\db;
use classes\economic_transfer_executor;
use classes\economic_transfer_queue;
app_require('classes/economic_transfer_executor.php');
if (!class_exists('EconomicTransferQueueIntegrationStubExecutor')) {
class EconomicTransferQueueIntegrationStubExecutor extends economic_transfer_executor
{
/** @var array<int, bool> */
private array $fail_once_order_draft = [];
public function failNextOrderDraft(int $order_id): void
{
$this->fail_once_order_draft[$order_id] = true;
}
public function exportOrderDraftInvoice(int $order_id, int $user_id = 0): array
{
if (($this->fail_once_order_draft[$order_id] ?? false) === true) {
unset($this->fail_once_order_draft[$order_id]);
throw new Exception('Simulated draft export failure for order ' . $order_id);
}
return [
'order_id' => $order_id,
'user_id' => $user_id,
'mode' => 'draft',
];
}
public function exportOrderInvoice(int $order_id, int $user_id = 0): array
{
return [
'order_id' => $order_id,
'user_id' => $user_id,
'mode' => 'invoice',
];
}
public function exportCollectedInvoice(int $collected_invoice_id, bool $send_as_is = false, int $user_id = 0): array
{
return [
'collected_invoice_id' => $collected_invoice_id,
'send_as_is' => $send_as_is,
'user_id' => $user_id,
'mode' => 'collected',
];
}
}
}
function economic_transfer_queue_integration_db(): db
{
if (!integration_enabled()) {
test()->markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run DB integration tests.');
}
$host = getenv('CONFIG_DB_HOST') ?: null;
$user = getenv('CONFIG_DB_USER') ?: null;
$password = getenv('CONFIG_DB_PASSWORD') ?: '';
$database = getenv('CONFIG_DB_DATABASE') ?: null;
if (!$host || !$user || !$database) {
test()->markTestSkipped('Missing DB env vars: CONFIG_DB_HOST/CONFIG_DB_USER/CONFIG_DB_DATABASE.');
}
app_require('classes/db.php');
app_require('classes/economic_transfer_executor.php');
app_require('classes/economic_transfer_queue_schema_bootstrap.php');
app_require('classes/economic_transfer_queue.php');
$GLOBALS['response'] = new class {
public function internal_server_error(string $message): void
{
throw new RuntimeException($message);
}
};
$db = new db([
'host' => $host,
'user' => $user,
'password' => $password,
'database' => $database,
]);
try {
$db->connect();
} catch (Throwable $throwable) {
test()->markTestSkipped('Integration DB unavailable: ' . $throwable->getMessage());
}
$GLOBALS['db'] = $db;
return $db;
}
function economic_transfer_queue_cleanup_for_created_by(db $db, int $created_by): void
{
$db->query("DELETE FROM economic_transfer_queue_jobs WHERE created_by = $created_by");
}
it('processes queued transfer jobs to completion', function (): void {
$db = economic_transfer_queue_integration_db();
$created_by = 920000 + random_int(1000, 9999);
$order_id = 930000 + random_int(1000, 9999);
$queue = new economic_transfer_queue(new EconomicTransferQueueIntegrationStubExecutor());
try {
economic_transfer_queue_cleanup_for_created_by($db, $created_by);
$job = $queue->enqueue(
economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT,
[
'order_id' => $order_id,
'requested_by' => $created_by,
],
$created_by
);
expect((int)$job['id'])->toBeGreaterThan(0);
expect((string)$job['status'])->toBe(economic_transfer_queue::STATUS_QUEUED);
$summary = $queue->processPending(1);
expect((int)$summary['processed'])->toBe(1);
expect((int)$summary['completed'])->toBe(1);
expect((int)$summary['failed'])->toBe(0);
$processed_job = $queue->getJobById((int)$job['id']);
expect($processed_job)->not->toBeNull();
expect((string)$processed_job['status'])->toBe(economic_transfer_queue::STATUS_COMPLETED);
expect((int)($processed_job['result']['order_id'] ?? 0))->toBe($order_id);
} finally {
economic_transfer_queue_cleanup_for_created_by($db, $created_by);
$db->close();
}
});
it('deduplicates active jobs per transfer target', function (): void {
$db = economic_transfer_queue_integration_db();
$created_by = 921000 + random_int(1000, 9999);
$collected_invoice_id = 931000 + random_int(1000, 9999);
$queue = new economic_transfer_queue(new EconomicTransferQueueIntegrationStubExecutor());
try {
economic_transfer_queue_cleanup_for_created_by($db, $created_by);
$first = $queue->enqueue(
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
[
'collected_invoice_id' => $collected_invoice_id,
'send_as_is' => false,
'requested_by' => $created_by,
],
$created_by
);
$second = $queue->enqueue(
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
[
'collected_invoice_id' => $collected_invoice_id,
'send_as_is' => true,
'requested_by' => $created_by,
],
$created_by
);
expect((int)$first['id'])->toBeGreaterThan(0);
expect((int)$second['id'])->toBe((int)$first['id']);
$row = $db->fetch_assoc($db->query(
"SELECT COUNT(*) AS cnt
FROM economic_transfer_queue_jobs
WHERE created_by = $created_by
AND transfer_type = '" . economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT . "'"
));
expect((int)($row['cnt'] ?? 0))->toBe(1);
} finally {
economic_transfer_queue_cleanup_for_created_by($db, $created_by);
$db->close();
}
});
it('rejects invalid payloads without inserting queue rows', function (): void {
$db = economic_transfer_queue_integration_db();
$created_by = 922000 + random_int(1000, 9999);
$queue = new economic_transfer_queue(new EconomicTransferQueueIntegrationStubExecutor());
try {
economic_transfer_queue_cleanup_for_created_by($db, $created_by);
$before = $db->fetch_assoc($db->query(
"SELECT COUNT(*) AS cnt
FROM economic_transfer_queue_jobs
WHERE created_by = $created_by"
));
$before_count = (int)($before['cnt'] ?? 0);
expect(fn () => $queue->enqueue(
economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT,
[
'order_id' => 0,
'requested_by' => $created_by,
],
$created_by
))->toThrow(Exception::class, 'order_id is required and must be a positive number');
$after = $db->fetch_assoc($db->query(
"SELECT COUNT(*) AS cnt
FROM economic_transfer_queue_jobs
WHERE created_by = $created_by"
));
expect((int)($after['cnt'] ?? 0))->toBe($before_count);
} finally {
economic_transfer_queue_cleanup_for_created_by($db, $created_by);
$db->close();
}
});
it('supports fail retry and reprocess lifecycle transitions', function (): void {
$db = economic_transfer_queue_integration_db();
$created_by = 923000 + random_int(1000, 9999);
$order_id = 933000 + random_int(1000, 9999);
$executor = new EconomicTransferQueueIntegrationStubExecutor();
$executor->failNextOrderDraft($order_id);
$queue = new economic_transfer_queue($executor);
try {
economic_transfer_queue_cleanup_for_created_by($db, $created_by);
$job = $queue->enqueue(
economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT,
[
'order_id' => $order_id,
'requested_by' => $created_by,
],
$created_by
);
$job_id = (int)$job['id'];
expect($job_id)->toBeGreaterThan(0);
$first = $queue->processPending(1);
expect((int)$first['processed'])->toBe(1);
expect((int)$first['failed'])->toBe(1);
$failed_job = $queue->getJobById($job_id);
expect($failed_job)->not->toBeNull();
expect((string)$failed_job['status'])->toBe(economic_transfer_queue::STATUS_FAILED);
expect((int)$failed_job['attempts'])->toBe(1);
$retried = $queue->retryJob($job_id);
expect((string)$retried['status'])->toBe(economic_transfer_queue::STATUS_QUEUED);
$second = $queue->processPending(1);
expect((int)$second['processed'])->toBe(1);
expect((int)$second['completed'])->toBe(1);
$completed_job = $queue->getJobById($job_id);
expect($completed_job)->not->toBeNull();
expect((string)$completed_job['status'])->toBe(economic_transfer_queue::STATUS_COMPLETED);
expect((int)($completed_job['result']['order_id'] ?? 0))->toBe($order_id);
} finally {
economic_transfer_queue_cleanup_for_created_by($db, $created_by);
$db->close();
}
});
@@ -0,0 +1,48 @@
<?php
it('returns the queue job id in POST /collected-invoices/economic enqueue response', function (): void {
$content = file_get_contents(app_path('routes/orderInvoicesRoute.php'));
expect($content)->not->toBeFalse();
$content = (string)$content;
$start = strpos($content, "\$this->post('/collected-invoices/economic'");
$end = strpos($content, "\$this->get('/collected-invoices/economic/queue'");
expect($start)->not->toBeFalse();
expect($end)->not->toBeFalse();
expect($end)->toBeGreaterThan($start);
$endpointBlock = substr($content, (int)$start, (int)$end - (int)$start);
expect($endpointBlock)->toContain("\$this->ensureEconomicTransferQueueIsAvailable();");
expect($endpointBlock)->toContain("\$job_id = (int)(\$job['id'] ?? 0);");
expect($endpointBlock)->toContain("'job_id' => \$job_id");
expect($endpointBlock)->toContain("'job' => \$job");
expect($endpointBlock)->toContain("], 202);");
expect($endpointBlock)->not->toContain('exportCollectedInvoiceSynchronously');
});
it('returns queued contract for POST /collected-invoices/stripe/book', function (): void {
$content = file_get_contents(app_path('routes/orderInvoicesRoute.php'));
expect($content)->not->toBeFalse();
$content = (string)$content;
$start = strpos($content, "\$this->post('/collected-invoices/stripe/book'");
$end = strpos($content, "\$this->post('/collected-invoices/vehicle-subscriptions'");
expect($start)->not->toBeFalse();
expect($end)->not->toBeFalse();
expect($end)->toBeGreaterThan($start);
$endpointBlock = substr($content, (int)$start, (int)$end - (int)$start);
expect($endpointBlock)->toContain("\$this->ensureEconomicTransferQueueIsAvailable();");
expect($endpointBlock)->toContain('economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT');
expect($endpointBlock)->toContain("\$job_id = (int)(\$job['id'] ?? 0);");
expect($endpointBlock)->toContain("'job_id' => \$job_id");
expect($endpointBlock)->toContain("'job' => \$job");
expect($endpointBlock)->toContain("'message' => 'Stripe collected invoice export queued'");
expect($endpointBlock)->toContain("], 202);");
});
@@ -0,0 +1,57 @@
<?php
it('keeps order draft export route queue-only', function (): void {
$content = file_get_contents(app_path('routes/economicInvoiceRoute.php'));
expect($content)->not->toBeFalse();
$content = (string)$content;
$start = strpos($content, "\$this->post('/economic/invoice/draft/export'");
$end = strpos($content, "\$this->delete('/economic/invoice/draft/delete'");
expect($start)->not->toBeFalse();
expect($end)->not->toBeFalse();
expect($end)->toBeGreaterThan($start);
$endpointBlock = substr($content, (int)$start, (int)$end - (int)$start);
expect($endpointBlock)->toContain("\$this->ensureEconomicTransferQueueIsAvailable();");
expect($endpointBlock)->toContain('economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT');
expect($endpointBlock)->toContain("'job_id' => \$job_id");
expect($endpointBlock)->toContain("], 202);");
});
it('keeps collected invoice draft-producing routes queue-only in orderInvoicesRoute', function (): void {
$content = file_get_contents(app_path('routes/orderInvoicesRoute.php'));
expect($content)->not->toBeFalse();
$content = (string)$content;
$collectedStart = strpos($content, "\$this->post('/collected-invoices/economic'");
$collectedEnd = strpos($content, "\$this->get('/collected-invoices/economic/queue'");
expect($collectedStart)->not->toBeFalse();
expect($collectedEnd)->not->toBeFalse();
expect($collectedEnd)->toBeGreaterThan($collectedStart);
$collectedBlock = substr($content, (int)$collectedStart, (int)$collectedEnd - (int)$collectedStart);
expect($collectedBlock)->toContain("\$this->ensureEconomicTransferQueueIsAvailable();");
expect($collectedBlock)->toContain('economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT');
expect($collectedBlock)->toContain("'job_id' => \$job_id");
expect($collectedBlock)->toContain("], 202);");
expect($collectedBlock)->not->toContain('addToEconomic(');
expect($collectedBlock)->not->toContain('exportCollectedInvoiceSynchronously');
$stripeStart = strpos($content, "\$this->post('/collected-invoices/stripe/book'");
$stripeEnd = strpos($content, "\$this->post('/collected-invoices/vehicle-subscriptions'");
expect($stripeStart)->not->toBeFalse();
expect($stripeEnd)->not->toBeFalse();
expect($stripeEnd)->toBeGreaterThan($stripeStart);
$stripeBlock = substr($content, (int)$stripeStart, (int)$stripeEnd - (int)$stripeStart);
expect($stripeBlock)->toContain("\$this->ensureEconomicTransferQueueIsAvailable();");
expect($stripeBlock)->toContain('economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT');
expect($stripeBlock)->toContain("'message' => 'Stripe collected invoice export queued'");
expect($stripeBlock)->toContain("'job_id' => \$job_id");
expect($stripeBlock)->toContain("], 202);");
expect($stripeBlock)->not->toContain('addToEconomic(');
expect($content)->not->toContain('exportCollectedInvoiceSynchronously');
});
@@ -48,6 +48,8 @@ it('wires zero-cost and zero-quantity skip guard into transfer line builder', fu
expect($content)->not->toBeFalse();
expect($content)->toContain('if (self::shouldSkipOrderItemForInvoice($order_item, $quantity))');
expect($content)->toContain('$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);');
expect($content)->toContain("throw new Exception('An invoice has already been created, invoice ID: ' . \$invoice_id);");
expect($content)->toContain("throw new Exception('No billable order items found');");
expect($content)->toContain("throw new Exception('Order item is missing economic product id');");
});
@@ -24,29 +24,29 @@ it('guards all economic invoice queue endpoints before constructing queue servic
}
});
it('guards all collected-invoice queue endpoints before constructing queue service', function (): void {
it('guards collected-invoice and stripe draft-producing endpoints before constructing queue service', function (): void {
$content = file_get_contents(app_path('routes/orderInvoicesRoute.php'));
expect($content)->not->toBeFalse();
$guardCount = preg_match_all('/\$this->ensureEconomicTransferQueueIsAvailable\(\);/', (string)$content);
$queueInitCount = preg_match_all('/new economic_transfer_queue\(\);/', (string)$content);
expect($guardCount)->toBe(3);
expect($queueInitCount)->toBe(4);
expect($guardCount)->toBe(5);
expect($queueInitCount)->toBe(5);
$endpointPatterns = [
"/\\\$this->post\\('\\/collected-invoices\\/economic'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
"/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
"/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue\\/status'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
"/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/retry'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
"/\\\$this->post\\('\\/collected-invoices\\/stripe\\/book'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
];
foreach ($endpointPatterns as $pattern) {
expect(preg_match($pattern, (string)$content))->toBe(1);
}
expect($content)->toContain('if (!$this->isEconomicTransferQueueAvailable()) {');
expect($content)->toContain('$result = $this->exportCollectedInvoiceSynchronously($collected_order_invoices, $send_as_is);');
expect($content)->toContain('private function exportCollectedInvoiceSynchronously(collected_order_invoices_o $collected_order_invoices, bool $send_as_is): array');
expect($content)->not->toContain('exportCollectedInvoiceSynchronously');
expect($content)->toContain('private function isEconomicTransferQueueAvailable(): bool');
});
@@ -66,13 +66,18 @@ it('uses a consistent unavailable-service contract for missing queue dependencie
expect($orderInvoicesRouteContent)->toContain("class_exists('\\\\classes\\\\economic_transfer_executor')");
expect($orderInvoicesRouteContent)->toContain("class_exists('\\\\classes\\\\economic_transfer_queue_schema_bootstrap')");
expect($orderInvoicesRouteContent)->toContain('class_exists(economic_transfer_queue::class)');
expect($orderInvoicesRouteContent)->toContain('if (!$this->isEconomicTransferQueueAvailable()) {');
expect(preg_match_all('/if \\(!\\$this->isEconomicTransferQueueAvailable\\(\\)\\) \\{/', (string)$orderInvoicesRouteContent))->toBe(1);
foreach ([$invoiceRouteContent, $orderInvoicesRouteContent] as $content) {
expect($content)->toContain('private function ensureEconomicTransferQueueIsAvailable(): void');
expect($content)->toContain("Economic transfer queue is unavailable in this deployment', 503");
expect($content)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_executor.php';");
expect($content)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';");
expect($content)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_queue.php';");
}
expect($invoiceRouteContent)->toContain('private function ensureEconomicTransferQueueIsAvailable(): void');
expect($invoiceRouteContent)->toContain("Economic transfer queue is unavailable in this deployment', 503");
expect($invoiceRouteContent)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_executor.php';");
expect($invoiceRouteContent)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';");
expect($invoiceRouteContent)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_queue.php';");
expect($orderInvoicesRouteContent)->toContain('private function ensureEconomicTransferQueueIsAvailable(): void');
expect($orderInvoicesRouteContent)->toContain("Economic transfer queue is unavailable in this deployment', 503");
expect($orderInvoicesRouteContent)->not->toContain('exportCollectedInvoiceSynchronously');
expect($orderInvoicesRouteContent)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_executor.php';");
expect($orderInvoicesRouteContent)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';");
expect($orderInvoicesRouteContent)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_queue.php';");
});
@@ -6,7 +6,14 @@ it('hardens transfer queue with type validation retry caps and stale lock recove
expect($content)->not->toBeFalse();
expect($content)->toContain('private const STALE_PROCESSING_LOCK_SECONDS = 900');
expect($content)->toContain('$this->validateTransferType($transfer_type)');
expect($content)->toContain('$this->normalizePayloadForTransferType($transfer_type, $payload, $created_by)');
expect($content)->toContain('$this->findActiveJobByTarget($transfer_type, $payload)');
expect($content)->toContain('$max_attempts = max(1, min(10, $max_attempts));');
expect($content)->toContain('private function normalizePayloadForTransferType(string $transfer_type, array $payload, int $created_by): array');
expect($content)->toContain('private function normalizeBooleanPayloadValue(mixed $value, string $field_name, int $created_by): bool');
expect($content)->toContain('private function findActiveJobByJsonNumericTarget(string $transfer_type, string $json_path, int $target_value): ?array');
expect($content)->toContain('ECONOMIC_TRANSFER_JOB_DEDUPED');
expect($content)->toContain('ECONOMIC_TRANSFER_JOB_VALIDATION_REJECTED');
expect($content)->toContain('Queue job reached max retry attempts');
expect($content)->toContain('AND attempts < max_attempts');
expect($content)->toContain('private function releaseStaleProcessingLocks(): void');
@@ -49,3 +49,10 @@ it('documents economic transfer queue schemas in openapi', function (): void {
expect($content)->toContain('EconomicTransferQueueRetryResponse:');
expect($content)->toContain('EconomicTransferQueueListResponse:');
});
it('documents unavailable queue dependency responses for async economic transfer endpoints', function (): void {
$content = economic_transfer_queue_openapi_content_or_skip();
expect($content)->toContain('ServiceUnavailable:');
expect($content)->toContain("'503': { \$ref: '#/components/responses/ServiceUnavailable' }");
});
@@ -0,0 +1,82 @@
<?php
app_require('classes/economic_transfer_queue.php');
use classes\economic_transfer_queue;
function economic_transfer_queue_invoke_private(economic_transfer_queue $queue, string $method, array $args = []): mixed
{
$reflection = new ReflectionClass($queue);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs($queue, $args);
}
function economic_transfer_queue_new_without_constructor(): economic_transfer_queue
{
$reflection = new ReflectionClass(economic_transfer_queue::class);
/** @var economic_transfer_queue $instance */
$instance = $reflection->newInstanceWithoutConstructor();
return $instance;
}
it('normalizes order payloads to strict positive integer ids', function (): void {
$queue = economic_transfer_queue_new_without_constructor();
$payload = economic_transfer_queue_invoke_private($queue, 'normalizePayloadForTransferType', [
economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT,
[
'order_id' => '42',
'requested_by' => '-7',
],
123,
]);
expect($payload['order_id'])->toBe(42);
expect($payload['requested_by'])->toBe(0);
});
it('normalizes collected-invoice payload booleans to strict bool values', function (): void {
$queue = economic_transfer_queue_new_without_constructor();
$truePayload = economic_transfer_queue_invoke_private($queue, 'normalizePayloadForTransferType', [
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
[
'collected_invoice_id' => '77',
'send_as_is' => 'true',
],
456,
]);
expect($truePayload['collected_invoice_id'])->toBe(77);
expect($truePayload['send_as_is'])->toBeTrue();
$falsePayload = economic_transfer_queue_invoke_private($queue, 'normalizePayloadForTransferType', [
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
[
'collected_invoice_id' => 78,
'send_as_is' => '0',
],
456,
]);
expect($falsePayload['collected_invoice_id'])->toBe(78);
expect($falsePayload['send_as_is'])->toBeFalse();
});
it('rejects invalid transfer payloads before enqueue write attempts', function (): void {
$queue = economic_transfer_queue_new_without_constructor();
expect(fn () => economic_transfer_queue_invoke_private($queue, 'normalizePayloadForTransferType', [
economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT,
[],
1,
]))->toThrow(Exception::class, 'order_id is required and must be a positive number');
expect(fn () => economic_transfer_queue_invoke_private($queue, 'normalizePayloadForTransferType', [
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
[
'collected_invoice_id' => 12,
'send_as_is' => 'maybe',
],
1,
]))->toThrow(Exception::class, 'send_as_is must be a boolean');
});
@@ -30,15 +30,19 @@ it('registers collected-invoice queue endpoints in orderInvoicesRoute', function
expect($content)->toContain("class_exists('\\\\classes\\\\economic_transfer_executor')");
expect($content)->toContain("class_exists('\\\\classes\\\\economic_transfer_queue_schema_bootstrap')");
expect($content)->toContain('$this->ensureEconomicTransferQueueIsAvailable();');
expect($content)->not->toContain('exportCollectedInvoiceSynchronously');
expect($content)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_executor.php';");
expect($content)->toContain('/collected-invoices/economic');
expect($content)->toContain('/collected-invoices/economic/queue');
expect($content)->toContain('/collected-invoices/economic/queue/status');
expect($content)->toContain('/collected-invoices/economic/queue/retry');
expect($content)->toContain('/collected-invoices/stripe/book');
expect($content)->toContain('economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT');
expect($content)->toContain("'job_id' => \$job_id");
expect($content)->toContain("missing queue job id in enqueue response");
expect($content)->toContain("requirePermission('add_collected_invoice_economic')");
expect($content)->toContain("requirePermission('add_collected_invoice_stripe')");
expect($content)->toContain('send_as_is must be a boolean');
expect($content)->toContain("in_array(\$normalized_send_as_is, ['true', '1'], true)");
expect($content)->toContain("'message' => 'Stripe collected invoice export queued'");
});
@@ -0,0 +1,30 @@
<?php
it('requires id and at least one mutable field for PUT /collected-invoices in user route', function (): void {
$routeFile = app_path('routes/userInvoicesRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain("\$this->put('/collected-invoices'");
expect($content)->toContain("self::requireParameters(['id']);");
expect($content)->toContain("\$is_superuser = self::hasPermission('superuser');");
expect($content)->toContain("if (!self::isParametersSet(['po_number']) && !self::isParametersSet(['closed_at'])) {");
expect($content)->toContain("\$response->error('Missing required parameters: po_number, closed_at', 400);");
expect($content)->toContain("if (self::isParametersSet(['closed_at']) && !\$is_superuser) {");
expect($content)->toContain("\$response->error('Forbidden: only superusers can update closed_at', 403);");
expect($content)->toContain("if ((int)\$invoice->customer_number->value() !== (int)\$user->customer_number->value() && !\$is_superuser) {");
});
it('supports independent po_number and closed_at updates for PUT /collected-invoices in user route', function (): void {
$routeFile = app_path('routes/userInvoicesRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain("if (self::isParametersSet(['po_number'])) {");
expect($content)->toContain("\$invoice->po_number->set((string)self::getParameter('po_number'));");
expect($content)->toContain("if (self::isParametersSet(['closed_at'])) {");
expect($content)->toContain("if (\$closed_at !== null && \$closed_at !== '') {");
expect($content)->toContain("self::requireDateFormat((string)\$closed_at, self::FORMAT_DATE());");
expect($content)->toContain("\$invoice->closed_at->set(\$closed_at === null || \$closed_at === '' ? null : date('Y-m-d H:i:s', strtotime((string)\$closed_at . ' 00:00:01')));");
});
+65
View File
@@ -20,6 +20,71 @@ Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb206
"phone": 42331128
}
### POST request to /collected-invoices/move-multiple
POST https://api.truckwash.io:4433/collected-invoices/move-multiple
Accept: application/json
Content-Type: application/json
Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8
{
"order_ids": [54518, 48782, 48744],
"target_customer_number": 42460282,
"closed_at": "2026-03-31"
}
### POST request to /collected-invoices/move-multiple/registration-numbers
POST https://api.truckwash.io:4433/collected-invoices/move-multiple/registration-numbers
Accept: application/json
Content-Type: application/json
Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8
{
"registration_numbers": ["DC29870",
"DJ58975",
"DL29041",
"DN23122",
"DN23014",
"AK71417",
"DX22135",
"DX22132",
"DX22134",
"DR97418",
"DR32398",
"DR97427",
"DS11478",
"DC29873",
"AA72279",
"AC38858",
"AY59064",
"AL94096",
"AZ20321",
"BB25208",
"CZ72055",
"DV11106",
"DJ31995",
"DN23025",
"DN88009",
"DP53305",
"DR97424",
"DS11485",
"DW85802",
"AG4277",
"AG4278",
"AG4279",
"AH2383",
"AH5739",
"AH4647",
"AH5741",
"CM1169",
"EY4630",
"EK2303",
"FB5526",
"FB5527"],
"target_customer_number": 43323232,
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"closed_at": "2026-04-30"
}
### GET request to /subusers/setup
GET https://api.truckwash.dk:4433/subusers/setup?token=1c1be8280bac3937487e5c77b76bb839
Accept: application/json