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:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+264
@@ -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();
|
||||
}
|
||||
});
|
||||
+48
@@ -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');");
|
||||
});
|
||||
|
||||
+19
-14
@@ -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' }");
|
||||
});
|
||||
|
||||
+82
@@ -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'");
|
||||
});
|
||||
|
||||
+30
@@ -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')));");
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user