Improve invoice period data and POS add-on validation
This commit is contained in:
+218
-5
@@ -7465,7 +7465,8 @@ paths:
|
||||
description: Invoicing periods retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
schema:
|
||||
$ref: '#/components/schemas/InvoicingPeriodResponseEnvelope'
|
||||
|
||||
/superuser/invoicing/period/distribution/fixed-pricing:
|
||||
get:
|
||||
@@ -10916,23 +10917,89 @@ paths:
|
||||
get:
|
||||
tags:
|
||||
- Attachments
|
||||
summary: Download order attachment
|
||||
description: Download a specific order attachment
|
||||
summary: Get order attachment download link
|
||||
description: Return a legacy HTTPS download link for a specific order attachment
|
||||
operationId: downloadOrderAttachment
|
||||
parameters:
|
||||
- name: id
|
||||
- name: order_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: attachment_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Attachment downloaded successfully
|
||||
description: Attachment download link resolved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OrderAttachmentDownloadLinkResponse'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'502':
|
||||
description: Attachment storage is unavailable
|
||||
|
||||
/orders/attachments/content:
|
||||
get:
|
||||
tags:
|
||||
- Attachments
|
||||
summary: Stream authenticated order attachment content
|
||||
description: Streams an attachment after validating order scope and attachment ownership
|
||||
operationId: streamOrderAttachmentContent
|
||||
parameters:
|
||||
- name: order_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: attachment_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: disposition
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: [inline, attachment]
|
||||
default: inline
|
||||
responses:
|
||||
'200':
|
||||
description: Attachment content streamed successfully
|
||||
headers:
|
||||
Content-Disposition:
|
||||
schema:
|
||||
type: string
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
image/*:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
application/pdf:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'502':
|
||||
description: Attachment storage is unavailable
|
||||
|
||||
# Forms Endpoints
|
||||
/form:
|
||||
@@ -17737,6 +17804,142 @@ components:
|
||||
- meta
|
||||
- includes
|
||||
|
||||
OrderAttachmentDownloadLinkResponse:
|
||||
type: object
|
||||
required: [success, data, meta, includes]
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
enum: [true]
|
||||
data:
|
||||
type: object
|
||||
required: [download_link]
|
||||
properties:
|
||||
download_link:
|
||||
type: string
|
||||
format: uri
|
||||
pattern: '^https://'
|
||||
meta:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
includes:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
InvoicingPeriodResponseEnvelope:
|
||||
type: object
|
||||
required: [success, data, meta, includes]
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
enum: [true]
|
||||
data:
|
||||
$ref: '#/components/schemas/InvoicingPeriodData'
|
||||
meta:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
includes:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
InvoicingPeriodData:
|
||||
type: object
|
||||
required: [dateFrom, dateTo, types]
|
||||
properties:
|
||||
dateFrom:
|
||||
type: string
|
||||
format: date
|
||||
dateTo:
|
||||
type: string
|
||||
format: date
|
||||
types:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/InvoicingPeriodCustomer'
|
||||
|
||||
InvoicingPeriodCustomer:
|
||||
type: object
|
||||
required: [customer_number, customer_name, transactions, invoice_collections]
|
||||
additionalProperties: true
|
||||
properties:
|
||||
customer_number:
|
||||
type: integer
|
||||
customer_name:
|
||||
type: string
|
||||
transactions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/InvoicingPeriodTransaction'
|
||||
invoice_collections:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/InvoicingPeriodInvoiceCollection'
|
||||
|
||||
InvoicingPeriodTransaction:
|
||||
type: object
|
||||
required: [id, booked, invoice_state]
|
||||
additionalProperties: true
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
invoice_collection_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
booked:
|
||||
type: boolean
|
||||
invoice_state:
|
||||
type: string
|
||||
enum: [open, closed, economic_draft, economic_booked]
|
||||
completed_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
amount:
|
||||
type: number
|
||||
format: float
|
||||
|
||||
InvoicingPeriodInvoiceCollection:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- invoice_collection_id
|
||||
- customer_number
|
||||
- state
|
||||
- order_ids
|
||||
- order_count
|
||||
- total_net_amount
|
||||
additionalProperties: true
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
invoice_collection_id:
|
||||
type: integer
|
||||
customer_number:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
external_id:
|
||||
type: string
|
||||
nullable: true
|
||||
booked_invoice_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
state:
|
||||
type: string
|
||||
enum: [open, closed, economic_draft, economic_booked]
|
||||
order_ids:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
order_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
total_net_amount:
|
||||
type: number
|
||||
format: float
|
||||
|
||||
CollectedInvoiceEconomicCompareResponse:
|
||||
type: object
|
||||
description: Result of comparing a collected invoice with its E-conomic counterpart
|
||||
@@ -17827,6 +18030,7 @@ components:
|
||||
type: integer
|
||||
economic:
|
||||
type: object
|
||||
required: [draft_id, booked_id, state, available_pdf_type]
|
||||
properties:
|
||||
draft_id:
|
||||
type: integer
|
||||
@@ -17834,6 +18038,15 @@ components:
|
||||
booked_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
state:
|
||||
type: string
|
||||
enum: [draft, booked, none]
|
||||
description: Current authoritative e-conomic target state
|
||||
available_pdf_type:
|
||||
type: string
|
||||
enum: [draft, booked]
|
||||
nullable: true
|
||||
description: PDF target that callers should request
|
||||
customer:
|
||||
$ref: '#/components/schemas/CollectedInvoiceEconomicV2CustomerSummary'
|
||||
internal:
|
||||
|
||||
@@ -37,8 +37,21 @@ class attachment_store implements minio_uploads_i
|
||||
*/
|
||||
public function isValidFilePath(string $filePath): bool
|
||||
{
|
||||
// Check if the file path is valid
|
||||
return preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) === 1;
|
||||
if (
|
||||
$filePath === ''
|
||||
|| str_starts_with($filePath, '/')
|
||||
|| preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) !== 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (explode('/', $filePath) as $segment) {
|
||||
if ($segment === '' || $segment === '.' || $segment === '..') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +95,10 @@ class attachment_store implements minio_uploads_i
|
||||
{
|
||||
$host = 'https://api.truckwash.io';
|
||||
|
||||
$this->requireValidFilePath($fileName);
|
||||
$encodedPath = implode('/', array_map('rawurlencode', explode('/', $fileName)));
|
||||
|
||||
// Generate a direct download URL for the given file name
|
||||
return $host . '/files/' . $fileName;
|
||||
return $host . '/files/' . $encodedPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ class customer_product_rule_service
|
||||
{
|
||||
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
|
||||
|
||||
private const ADDON_CATEGORY_ID = 4;
|
||||
public const STANDALONE_ADDITIONAL_SERVICE_CATEGORY_ID = 8;
|
||||
private const TANK_CLEANING_CATEGORY_ID = 5;
|
||||
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
|
||||
private const STANDALONE_ADDITIONAL_SERVICE_CATEGORY_NAMES = ['tillægsydelser', 'tillaegsydelser'];
|
||||
|
||||
/**
|
||||
* @return array{rule:string,message:string}|null
|
||||
@@ -40,7 +41,11 @@ class customer_product_rule_service
|
||||
$isTankCleaningProduct = $this->isTankCleaningProduct($categoryId, $searchableProduct);
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictAdditionalServices')
|
||||
&& $this->isAdditionalServiceProduct($orderId, $relatedItemId, $categoryId, $searchableProduct)) {
|
||||
&& self::isStandaloneAdditionalServiceRow([
|
||||
'related_item_id' => $relatedItemId,
|
||||
'product_category' => $categoryId,
|
||||
'category_name' => $categoryName,
|
||||
])) {
|
||||
return $this->violation('restrictAdditionalServices');
|
||||
}
|
||||
|
||||
@@ -76,21 +81,19 @@ class customer_product_rule_service
|
||||
];
|
||||
}
|
||||
|
||||
private function isAdditionalServiceProduct(int $orderId, ?int $relatedItemId, int $categoryId, string $searchableProduct): bool
|
||||
public static function isStandaloneAdditionalServiceRow(array $row): bool
|
||||
{
|
||||
if ($relatedItemId !== null && $relatedItemId > 0) {
|
||||
if ((int)($row['related_item_id'] ?? 0) > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$categoryId = (int)($row['product_category'] ?? $row['category'] ?? $row['category_id'] ?? 0);
|
||||
if ($categoryId === self::STANDALONE_ADDITIONAL_SERVICE_CATEGORY_ID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($categoryId === self::ADDON_CATEGORY_ID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->containsAny($searchableProduct, ['add-on', 'add on', 'addon', 'tilvalg'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->countStandaloneOrderItems($orderId) > 0;
|
||||
$categoryName = mb_strtolower(trim((string)($row['category_name'] ?? $row['categoryName'] ?? '')));
|
||||
return in_array($categoryName, self::STANDALONE_ADDITIONAL_SERVICE_CATEGORY_NAMES, true);
|
||||
}
|
||||
|
||||
private function isTankCleaningProduct(int $categoryId, string $searchableProduct): bool
|
||||
@@ -147,22 +150,4 @@ class customer_product_rule_service
|
||||
return strtolower((string)($row['name'] ?? ''));
|
||||
}
|
||||
|
||||
private function countStandaloneOrderItems(int $orderId): int
|
||||
{
|
||||
global $db;
|
||||
|
||||
$result = $db->query(
|
||||
'SELECT COUNT(*) AS item_count
|
||||
FROM order_items
|
||||
WHERE order_id = ' . $orderId . '
|
||||
AND deleted_at IS NULL
|
||||
AND (related_item_id IS NULL OR related_item_id = 0)'
|
||||
);
|
||||
if (!$result) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)($row['item_count'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -902,7 +902,7 @@ class invoice_period_flag_service
|
||||
$isTankCleaningProduct = $this->rowIsTankCleaningProduct($row);
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'restrictAdditionalServices')
|
||||
&& (int)($row['related_item_id'] ?? 0) > 0
|
||||
&& customer_product_rule_service::isStandaloneAdditionalServiceRow($row)
|
||||
&& (int)($row['item_price'] ?? 0) > 0) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_restrict_addon_services',
|
||||
|
||||
@@ -62,7 +62,7 @@ class pdf_store implements minio_pdfs_i
|
||||
|
||||
public function isFileInStore(string $file): bool
|
||||
{
|
||||
return self::getS3Client()->doesObjectExist(self::getBucket(), $file);
|
||||
return self::doesObjectExist($file);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,4 +76,4 @@ class pdf_store implements minio_pdfs_i
|
||||
$file_path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use classes\db;
|
||||
use classes\customer_order_product_policy;
|
||||
use classes\object_property;
|
||||
use Exception;
|
||||
use RuntimeException;
|
||||
use traits\db_object_t;
|
||||
|
||||
class order_items_o extends db
|
||||
@@ -90,10 +91,42 @@ class order_items_o extends db
|
||||
$this->include_in_invoice = new object_property($this->table, $this->id, 'include_in_invoice', 'bool', false);
|
||||
}
|
||||
|
||||
private static function validatedRelatedItemId(int $orderId, mixed $relatedItemId): ?int
|
||||
{
|
||||
if ($relatedItemId === null || $relatedItemId === '' || (int)$relatedItemId === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedRelatedItemId = (int)$relatedItemId;
|
||||
if ($normalizedRelatedItemId < 1) {
|
||||
throw new RuntimeException('Related item ID must be a positive integer');
|
||||
}
|
||||
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
"SELECT order_id
|
||||
FROM order_items
|
||||
WHERE id = {$normalizedRelatedItemId}
|
||||
AND (deleted_at IS NULL OR deleted_at = '')
|
||||
LIMIT 1"
|
||||
);
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
throw new RuntimeException('Related order item not found');
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
if ((int)($row['order_id'] ?? 0) !== $orderId) {
|
||||
throw new RuntimeException('Related order item must belong to the same order');
|
||||
}
|
||||
|
||||
return $normalizedRelatedItemId;
|
||||
}
|
||||
|
||||
public function add(int $order_id, int $product_id, string $reference, string $notes, int $cashier_id, int $price, int $quantity, $related_item_id = null): void
|
||||
{
|
||||
global $db, $response;
|
||||
try {
|
||||
$related_item_id = self::validatedRelatedItemId($order_id, $related_item_id);
|
||||
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||
// Avoid SQL injection
|
||||
$reference = $db->escape_string($reference);
|
||||
@@ -169,6 +202,7 @@ class order_items_o extends db
|
||||
try {
|
||||
// Get the order
|
||||
$order = (new orders_o())->getOrderById($order_id);
|
||||
$related_item_id = self::validatedRelatedItemId($order_id, $related_item_id);
|
||||
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||
// Get the product price
|
||||
$product = (new products_o())->getProductById($product_id);
|
||||
|
||||
@@ -1259,6 +1259,7 @@ class orders_o extends db
|
||||
o.reg_1,
|
||||
o.reg_2,
|
||||
o.reg_3,
|
||||
o.completed_at,
|
||||
o.invoice_collection_id,
|
||||
CASE
|
||||
WHEN o.include_in_invoice IS NOT NULL THEN o.include_in_invoice
|
||||
@@ -1299,6 +1300,7 @@ class orders_o extends db
|
||||
o.reg_1,
|
||||
o.reg_2,
|
||||
o.reg_3,
|
||||
o.completed_at,
|
||||
o.invoice_collection_id,
|
||||
o.include_in_invoice,
|
||||
department_flags.exclude_from_invoicing
|
||||
@@ -1329,6 +1331,7 @@ class orders_o extends db
|
||||
'reg_1' => (string)($row['reg_1'] ?? ''),
|
||||
'reg_2' => (string)($row['reg_2'] ?? ''),
|
||||
'reg_3' => (string)($row['reg_3'] ?? ''),
|
||||
'completed_at' => !empty($row['completed_at']) ? (string)$row['completed_at'] : null,
|
||||
'excluded' => (int)($row['include_in_invoice_effective'] ?? 1) !== 1,
|
||||
'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null,
|
||||
'queue_status' => null,
|
||||
|
||||
@@ -7893,7 +7893,8 @@ paths:
|
||||
description: Invoicing periods retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
schema:
|
||||
$ref: '#/components/schemas/InvoicingPeriodResponseEnvelope'
|
||||
|
||||
/superuser/invoicing/period/distribution/fixed-pricing:
|
||||
get:
|
||||
@@ -11587,23 +11588,89 @@ paths:
|
||||
get:
|
||||
tags:
|
||||
- Attachments
|
||||
summary: Download order attachment
|
||||
description: Download a specific order attachment
|
||||
summary: Get order attachment download link
|
||||
description: Return a legacy HTTPS download link for a specific order attachment
|
||||
operationId: downloadOrderAttachment
|
||||
parameters:
|
||||
- name: id
|
||||
- name: order_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: attachment_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Attachment downloaded successfully
|
||||
description: Attachment download link resolved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OrderAttachmentDownloadLinkResponse'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'502':
|
||||
description: Attachment storage is unavailable
|
||||
|
||||
/orders/attachments/content:
|
||||
get:
|
||||
tags:
|
||||
- Attachments
|
||||
summary: Stream authenticated order attachment content
|
||||
description: Streams an attachment after validating order scope and attachment ownership
|
||||
operationId: streamOrderAttachmentContent
|
||||
parameters:
|
||||
- name: order_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: attachment_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: disposition
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: [inline, attachment]
|
||||
default: inline
|
||||
responses:
|
||||
'200':
|
||||
description: Attachment content streamed successfully
|
||||
headers:
|
||||
Content-Disposition:
|
||||
schema:
|
||||
type: string
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
image/*:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
application/pdf:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'502':
|
||||
description: Attachment storage is unavailable
|
||||
|
||||
# Forms Endpoints
|
||||
/form:
|
||||
@@ -19635,6 +19702,142 @@ components:
|
||||
- meta
|
||||
- includes
|
||||
|
||||
OrderAttachmentDownloadLinkResponse:
|
||||
type: object
|
||||
required: [success, data, meta, includes]
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
enum: [true]
|
||||
data:
|
||||
type: object
|
||||
required: [download_link]
|
||||
properties:
|
||||
download_link:
|
||||
type: string
|
||||
format: uri
|
||||
pattern: '^https://'
|
||||
meta:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
includes:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
InvoicingPeriodResponseEnvelope:
|
||||
type: object
|
||||
required: [success, data, meta, includes]
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
enum: [true]
|
||||
data:
|
||||
$ref: '#/components/schemas/InvoicingPeriodData'
|
||||
meta:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
includes:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
InvoicingPeriodData:
|
||||
type: object
|
||||
required: [dateFrom, dateTo, types]
|
||||
properties:
|
||||
dateFrom:
|
||||
type: string
|
||||
format: date
|
||||
dateTo:
|
||||
type: string
|
||||
format: date
|
||||
types:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/InvoicingPeriodCustomer'
|
||||
|
||||
InvoicingPeriodCustomer:
|
||||
type: object
|
||||
required: [customer_number, customer_name, transactions, invoice_collections]
|
||||
additionalProperties: true
|
||||
properties:
|
||||
customer_number:
|
||||
type: integer
|
||||
customer_name:
|
||||
type: string
|
||||
transactions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/InvoicingPeriodTransaction'
|
||||
invoice_collections:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/InvoicingPeriodInvoiceCollection'
|
||||
|
||||
InvoicingPeriodTransaction:
|
||||
type: object
|
||||
required: [id, booked, invoice_state]
|
||||
additionalProperties: true
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
invoice_collection_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
booked:
|
||||
type: boolean
|
||||
invoice_state:
|
||||
type: string
|
||||
enum: [open, closed, economic_draft, economic_booked]
|
||||
completed_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
amount:
|
||||
type: number
|
||||
format: float
|
||||
|
||||
InvoicingPeriodInvoiceCollection:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- invoice_collection_id
|
||||
- customer_number
|
||||
- state
|
||||
- order_ids
|
||||
- order_count
|
||||
- total_net_amount
|
||||
additionalProperties: true
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
invoice_collection_id:
|
||||
type: integer
|
||||
customer_number:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
external_id:
|
||||
type: string
|
||||
nullable: true
|
||||
booked_invoice_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
state:
|
||||
type: string
|
||||
enum: [open, closed, economic_draft, economic_booked]
|
||||
order_ids:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
order_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
total_net_amount:
|
||||
type: number
|
||||
format: float
|
||||
|
||||
CollectedInvoiceEconomicCompareResponse:
|
||||
type: object
|
||||
description: Result of comparing a collected invoice with its E-conomic counterpart
|
||||
@@ -19725,6 +19928,7 @@ components:
|
||||
type: integer
|
||||
economic:
|
||||
type: object
|
||||
required: [draft_id, booked_id, state, available_pdf_type]
|
||||
properties:
|
||||
draft_id:
|
||||
type: integer
|
||||
@@ -19732,6 +19936,15 @@ components:
|
||||
booked_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
state:
|
||||
type: string
|
||||
enum: [draft, booked, none]
|
||||
description: Current authoritative e-conomic target state
|
||||
available_pdf_type:
|
||||
type: string
|
||||
enum: [draft, booked]
|
||||
nullable: true
|
||||
description: PDF target that callers should request
|
||||
customer:
|
||||
$ref: '#/components/schemas/CollectedInvoiceEconomicV2CustomerSummary'
|
||||
internal:
|
||||
|
||||
@@ -1890,6 +1890,10 @@ class InvoicingPeriodRoute
|
||||
$draftOverlay['by_collection_id'] ?? [],
|
||||
$draftOverlay['by_customer_number'] ?? [],
|
||||
);
|
||||
$invoiceCollectionMetadata = self::debugGetTime(function () use ($types) {
|
||||
return self::getPeriodInvoiceCollectionMetadata($types);
|
||||
}, 'invoice_collection_metadata');
|
||||
$types = self::applyPeriodInvoiceStateOverlayToTypes($types, $invoiceCollectionMetadata);
|
||||
if ($includeInvoicePeriodFlags) {
|
||||
$types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) {
|
||||
return (new invoice_period_flag_service())->applyFlagsToPeriodTypes(
|
||||
@@ -2029,6 +2033,7 @@ class InvoicingPeriodRoute
|
||||
'meta' => $meta ?? [],
|
||||
'queue' => self::getDefaultQueueSummary(),
|
||||
'draft' => self::getDefaultDraftSummary(),
|
||||
'invoice_collections' => [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2046,11 +2051,15 @@ class InvoicingPeriodRoute
|
||||
|
||||
$departmentId = (int)$transaction->department_id->value();
|
||||
$invoiceCollectionId = (int)$transaction->invoice_collection_id->value();
|
||||
$booked = self::isTransactionBookedFromLocalState($transaction);
|
||||
$completedAt = !empty($transaction->completed_at->value())
|
||||
? (string)$transaction->completed_at->value()
|
||||
: null;
|
||||
return [
|
||||
'id' => $transaction->id,
|
||||
'date' => $transaction->created_at->value(),
|
||||
'amount' => $transaction->temporary_net_amount, // Use the temporary net amount set earlier
|
||||
'booked' => self::isTransactionBookedFromLocalState($transaction),
|
||||
'booked' => $booked,
|
||||
'department_id' => $departmentId,
|
||||
'customer_number' => (int)$transaction->customer_id->value(),
|
||||
'reference' => (string)$transaction->reference->value(),
|
||||
@@ -2059,8 +2068,10 @@ class InvoicingPeriodRoute
|
||||
'reg_1' => (string)$transaction->reg_1->value(),
|
||||
'reg_2' => (string)$transaction->reg_2->value(),
|
||||
'reg_3' => (string)$transaction->reg_3->value(),
|
||||
'completed_at' => $completedAt,
|
||||
'excluded' => !$transaction->isIncludedInInvoicing(),
|
||||
'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null,
|
||||
'invoice_state' => self::periodOrderState($booked, $completedAt),
|
||||
'queue_status' => null,
|
||||
'queue_job_id' => null,
|
||||
];
|
||||
@@ -2082,8 +2093,13 @@ class InvoicingPeriodRoute
|
||||
'reg_1' => (string)($transaction['reg_1'] ?? ''),
|
||||
'reg_2' => (string)($transaction['reg_2'] ?? ''),
|
||||
'reg_3' => (string)($transaction['reg_3'] ?? ''),
|
||||
'completed_at' => !empty($transaction['completed_at']) ? (string)$transaction['completed_at'] : null,
|
||||
'excluded' => (bool)($transaction['excluded'] ?? ((int)($transaction['include_in_invoice_effective'] ?? 1) !== 1)),
|
||||
'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null,
|
||||
'invoice_state' => self::periodOrderState(
|
||||
(bool)($transaction['booked'] ?? false),
|
||||
!empty($transaction['completed_at']) ? (string)$transaction['completed_at'] : null
|
||||
),
|
||||
'queue_status' => $transaction['queue_status'] ?? null,
|
||||
'queue_job_id' => $transaction['queue_job_id'] ?? null,
|
||||
];
|
||||
@@ -2121,6 +2137,15 @@ class InvoicingPeriodRoute
|
||||
return self::$periodOrderBookedCache[$orderId] = !empty($row['invoice_id'] ?? null);
|
||||
}
|
||||
|
||||
private static function periodOrderState(bool $booked, ?string $completedAt): string
|
||||
{
|
||||
if ($booked) {
|
||||
return 'economic_booked';
|
||||
}
|
||||
|
||||
return !empty($completedAt) ? 'closed' : 'open';
|
||||
}
|
||||
|
||||
private static function checkRequiresAction(array $parsed_transactions, bool $requires_action): bool
|
||||
{
|
||||
// If requires_action is already set to true, return true
|
||||
@@ -2625,6 +2650,199 @@ class InvoicingPeriodRoute
|
||||
return $customer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the collected-invoice rows referenced by the complete period payload in one local query.
|
||||
* No e-conomic calls are allowed from the period endpoint.
|
||||
*
|
||||
* @return array<int,array<string,mixed>> Rows keyed by collected invoice ID.
|
||||
*/
|
||||
private static function getPeriodInvoiceCollectionMetadata(array $types): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$invoiceCollectionIds = [];
|
||||
foreach ($types as $customers) {
|
||||
if (!is_array($customers)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($customers as $customer) {
|
||||
if (!is_array($customer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (($customer['transactions'] ?? []) as $transaction) {
|
||||
$invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0);
|
||||
if ($invoiceCollectionId > 0) {
|
||||
$invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['queue', 'draft'] as $summaryKey) {
|
||||
foreach (($customer[$summaryKey]['invoice_collection_ids'] ?? []) as $invoiceCollectionId) {
|
||||
$invoiceCollectionId = (int)$invoiceCollectionId;
|
||||
if ($invoiceCollectionId > 0) {
|
||||
$invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($invoiceCollectionIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sql = 'SELECT id, customer_number, name, notes, processor, external_id, booked_invoice_id, '
|
||||
. 'po_number, error_message, closed_at, created_at, updated_at '
|
||||
. 'FROM collected_order_invoices '
|
||||
. 'WHERE id IN (' . implode(',', array_map('intval', array_values($invoiceCollectionIds))) . ') '
|
||||
. 'ORDER BY id';
|
||||
|
||||
try {
|
||||
$result = $db->query($sql);
|
||||
if (!$result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$metadata = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$invoiceCollectionId = (int)($row['id'] ?? 0);
|
||||
if ($invoiceCollectionId < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row['id'] = $invoiceCollectionId;
|
||||
$row['invoice_collection_id'] = $invoiceCollectionId;
|
||||
$row['customer_number'] = (int)($row['customer_number'] ?? 0);
|
||||
$row['processor'] = (int)($row['processor'] ?? 0);
|
||||
$row['booked_invoice_id'] = !empty($row['booked_invoice_id'])
|
||||
? (int)$row['booked_invoice_id']
|
||||
: null;
|
||||
$row['state'] = self::periodInvoiceCollectionState($row);
|
||||
$metadata[$invoiceCollectionId] = $row;
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static function periodInvoiceCollectionState(array $invoiceCollection): string
|
||||
{
|
||||
if (!empty($invoiceCollection['booked_invoice_id'])) {
|
||||
return 'economic_booked';
|
||||
}
|
||||
|
||||
if (!defined('\\objects\\ECONOMIC_PROCESSOR')) {
|
||||
class_exists(collected_order_invoices_o::class);
|
||||
}
|
||||
$economicProcessor = defined('\\objects\\ECONOMIC_PROCESSOR')
|
||||
? (int)constant('\\objects\\ECONOMIC_PROCESSOR')
|
||||
: 1;
|
||||
if (
|
||||
(int)($invoiceCollection['processor'] ?? 0) === $economicProcessor
|
||||
&& trim((string)($invoiceCollection['external_id'] ?? '')) !== ''
|
||||
&& trim((string)($invoiceCollection['error_message'] ?? '')) === ''
|
||||
) {
|
||||
return 'economic_draft';
|
||||
}
|
||||
|
||||
return !empty($invoiceCollection['closed_at']) ? 'closed' : 'open';
|
||||
}
|
||||
|
||||
private static function applyPeriodInvoiceStateOverlayToTypes(array $types, array $invoiceCollectionsById): array
|
||||
{
|
||||
foreach ($types as $type => $customers) {
|
||||
if (!is_array($customers)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$types[$type] = array_map(static function ($customer) use ($invoiceCollectionsById) {
|
||||
if (!is_array($customer)) {
|
||||
return $customer;
|
||||
}
|
||||
|
||||
return self::applyPeriodInvoiceStateOverlayToCustomer($customer, $invoiceCollectionsById);
|
||||
}, $customers);
|
||||
}
|
||||
|
||||
return $types;
|
||||
}
|
||||
|
||||
private static function applyPeriodInvoiceStateOverlayToCustomer(array $customer, array $invoiceCollectionsById): array
|
||||
{
|
||||
$customerNumber = (int)($customer['customer_number'] ?? 0);
|
||||
$collectionStats = [];
|
||||
$invoiceCollectionIds = [];
|
||||
$transactions = [];
|
||||
|
||||
foreach (($customer['transactions'] ?? []) as $transaction) {
|
||||
if (!is_array($transaction)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0);
|
||||
$invoiceCollection = $invoiceCollectionsById[$invoiceCollectionId] ?? null;
|
||||
$canUseCollection = is_array($invoiceCollection)
|
||||
&& (int)($invoiceCollection['customer_number'] ?? 0) === $customerNumber;
|
||||
|
||||
if ($canUseCollection) {
|
||||
$transaction['invoice_state'] = (string)$invoiceCollection['state'];
|
||||
$transaction['booked'] = $transaction['invoice_state'] === 'economic_booked';
|
||||
$invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId;
|
||||
$collectionStats[$invoiceCollectionId] = $collectionStats[$invoiceCollectionId] ?? [
|
||||
'order_ids' => [],
|
||||
'total_net_amount' => 0.0,
|
||||
];
|
||||
$orderId = (int)($transaction['id'] ?? 0);
|
||||
if ($orderId > 0) {
|
||||
$collectionStats[$invoiceCollectionId]['order_ids'][$orderId] = $orderId;
|
||||
}
|
||||
$collectionStats[$invoiceCollectionId]['total_net_amount'] += (float)($transaction['amount'] ?? 0);
|
||||
} else {
|
||||
$transaction['invoice_state'] = (string)($transaction['invoice_state'] ?? self::periodOrderState(
|
||||
(bool)($transaction['booked'] ?? false),
|
||||
!empty($transaction['completed_at']) ? (string)$transaction['completed_at'] : null
|
||||
));
|
||||
}
|
||||
|
||||
$transactions[] = $transaction;
|
||||
}
|
||||
|
||||
foreach (['queue', 'draft'] as $summaryKey) {
|
||||
foreach (($customer[$summaryKey]['invoice_collection_ids'] ?? []) as $invoiceCollectionId) {
|
||||
$invoiceCollectionId = (int)$invoiceCollectionId;
|
||||
if (
|
||||
$invoiceCollectionId > 0
|
||||
&& isset($invoiceCollectionsById[$invoiceCollectionId])
|
||||
&& (int)($invoiceCollectionsById[$invoiceCollectionId]['customer_number'] ?? 0) === $customerNumber
|
||||
) {
|
||||
$invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$invoiceCollections = [];
|
||||
foreach ($invoiceCollectionIds as $invoiceCollectionId) {
|
||||
$invoiceCollection = $invoiceCollectionsById[$invoiceCollectionId];
|
||||
$stats = $collectionStats[$invoiceCollectionId] ?? [
|
||||
'order_ids' => [],
|
||||
'total_net_amount' => 0.0,
|
||||
];
|
||||
$orderIds = array_values($stats['order_ids']);
|
||||
$invoiceCollection['order_ids'] = $orderIds;
|
||||
$invoiceCollection['order_count'] = count($orderIds);
|
||||
$invoiceCollection['total_net_amount'] = (float)$stats['total_net_amount'];
|
||||
$invoiceCollections[] = $invoiceCollection;
|
||||
}
|
||||
|
||||
$customer['transactions'] = $transactions;
|
||||
$customer['invoice_collections'] = $invoiceCollections;
|
||||
return $customer;
|
||||
}
|
||||
|
||||
private static function customerSupportsCustomerLevelQueueBlocking(array $customer): bool
|
||||
{
|
||||
$meta = $customer['meta'] ?? [];
|
||||
|
||||
@@ -2023,16 +2023,31 @@ class orderInvoicesRoute
|
||||
|
||||
$economic = new economic();
|
||||
|
||||
try {
|
||||
$draft_id = $invoice->getInvoiceDraftId();
|
||||
} catch (Exception $e) {
|
||||
$warnings[] = 'Draft id unavailable: ' . $e->getMessage();
|
||||
}
|
||||
$persisted_booked_id = (int)$invoice->booked_invoice_id->value();
|
||||
if ($persisted_booked_id > 0) {
|
||||
// A booked invoice replaces its draft in e-conomic. Avoid reporting that expected
|
||||
// transition as a missing-draft warning.
|
||||
$booked_id = $persisted_booked_id;
|
||||
} else {
|
||||
$draft_error = null;
|
||||
try {
|
||||
$draft_id = $invoice->getInvoiceDraftId();
|
||||
} catch (Exception $e) {
|
||||
$draft_error = $e;
|
||||
}
|
||||
|
||||
try {
|
||||
$booked_id = $invoice->getInvoiceBookedId();
|
||||
} catch (Exception $e) {
|
||||
$warnings[] = 'Booked id unavailable: ' . $e->getMessage();
|
||||
// Older rows may have an external ID but no persisted booked ID. Only probe the
|
||||
// booked endpoint when the draft is gone, then persist through getInvoiceBookedId().
|
||||
if ($draft_id === null) {
|
||||
try {
|
||||
$booked_id = $invoice->getInvoiceBookedId();
|
||||
} catch (Exception $e) {
|
||||
if ($draft_error !== null) {
|
||||
$warnings[] = 'Draft id unavailable: ' . $draft_error->getMessage();
|
||||
}
|
||||
$warnings[] = 'Booked id unavailable: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($draft_id !== null) {
|
||||
@@ -2093,6 +2108,11 @@ class orderInvoicesRoute
|
||||
? economic_v2_line_normalizer::normalizeBookedInvoice($booked_raw)
|
||||
: null;
|
||||
|
||||
$economic_state = $booked_id !== null
|
||||
? 'booked'
|
||||
: ($draft_id !== null ? 'draft' : 'none');
|
||||
$available_pdf_type = $economic_state === 'none' ? null : $economic_state;
|
||||
|
||||
return [
|
||||
'collected_invoice_id' => $collected_invoice_id,
|
||||
'external_id' => (string)$invoice->external_id->value(),
|
||||
@@ -2102,6 +2122,8 @@ class orderInvoicesRoute
|
||||
'economic' => [
|
||||
'draft_id' => $draft_id !== null ? (int)$draft_id : null,
|
||||
'booked_id' => $booked_id !== null ? (int)$booked_id : null,
|
||||
'state' => $economic_state,
|
||||
'available_pdf_type' => $available_pdf_type,
|
||||
],
|
||||
'customer' => $customer,
|
||||
'internal' => [
|
||||
@@ -2134,26 +2156,45 @@ class orderInvoicesRoute
|
||||
$invoice->requireSelected();
|
||||
$this->requireCollectedInvoiceContextAccess($invoice);
|
||||
|
||||
$resolved_type = $type;
|
||||
$economic_invoice_id = null;
|
||||
try {
|
||||
$economic_invoice_id = $type === 'draft'
|
||||
? $invoice->getInvoiceDraftId()
|
||||
: $invoice->getInvoiceBookedId();
|
||||
} catch (Exception $e) {
|
||||
$response->error('No ' . $type . ' e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404);
|
||||
$persisted_booked_id = (int)$invoice->booked_invoice_id->value();
|
||||
|
||||
if ($persisted_booked_id > 0) {
|
||||
$resolved_type = 'booked';
|
||||
$economic_invoice_id = $persisted_booked_id;
|
||||
} else {
|
||||
try {
|
||||
$economic_invoice_id = $type === 'draft'
|
||||
? $invoice->getInvoiceDraftId()
|
||||
: $invoice->getInvoiceBookedId();
|
||||
} catch (Exception $e) {
|
||||
// A legacy booked row can have only the former draft external ID persisted.
|
||||
// Fall back to the booked target when a requested draft no longer exists.
|
||||
if ($type === 'draft') {
|
||||
try {
|
||||
$economic_invoice_id = $invoice->getInvoiceBookedId();
|
||||
$resolved_type = 'booked';
|
||||
} catch (Exception $bookedException) {
|
||||
$response->error('No e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404);
|
||||
}
|
||||
} else {
|
||||
$response->error('No booked e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$economic_invoice_id = (int)$economic_invoice_id;
|
||||
if ($economic_invoice_id <= 0) {
|
||||
$response->error('No ' . $type . ' e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404);
|
||||
$response->error('No ' . $resolved_type . ' e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404);
|
||||
}
|
||||
|
||||
$economic = new economic();
|
||||
$invoice_path_file = $type === 'draft'
|
||||
$invoice_path_file = $resolved_type === 'draft'
|
||||
? $economic->invoices->pdf->getDraft($economic_invoice_id)
|
||||
: $economic->invoices->pdf->getBooked($economic_invoice_id);
|
||||
|
||||
$store_key_id = 'economic_' . $type . '_' . $economic_invoice_id;
|
||||
$store_key_id = 'economic_' . $resolved_type . '_' . $economic_invoice_id;
|
||||
$invoice_store = new invoice_store();
|
||||
try {
|
||||
$invoice_store->uploadFile('invoice_' . $store_key_id . '.pdf', $invoice_path_file);
|
||||
@@ -2175,7 +2216,7 @@ class orderInvoicesRoute
|
||||
|
||||
return [
|
||||
'collected_invoice_id' => $collected_invoice_id,
|
||||
'type' => $type,
|
||||
'type' => $resolved_type,
|
||||
'economic_invoice_id' => $economic_invoice_id,
|
||||
'url' => $invoice_store->getInvoiceDownloadUrl($store_key_id),
|
||||
];
|
||||
|
||||
@@ -9,6 +9,7 @@ use classes\authentication;
|
||||
use classes\economic;
|
||||
use classes\order_reference_suggestions_service;
|
||||
use classes\orders_input_normalizer;
|
||||
use classes\pdf_store;
|
||||
use classes\response;
|
||||
use classes\stripe;
|
||||
use JetBrains\PhpStorm\NoReturn;
|
||||
@@ -334,68 +335,13 @@ class ordersRoute
|
||||
);
|
||||
|
||||
$this->get('/orders/attachments/download', function () {
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
// Permissions (subuser-aware)
|
||||
$permission_own = self::definePermission('download_order_attachments_own', subusers_permission_node_key::ORDERS_LIST);
|
||||
$permission_other = self::definePermission('download_order_attachments');
|
||||
$has_permission_other = self::hasPermission($permission_other);
|
||||
if (!$has_permission_other) {
|
||||
self::requirePermission($permission_own);
|
||||
}
|
||||
// Get the user object
|
||||
$user = (new authentication())->get_user();
|
||||
// Check if the request was successful
|
||||
if ($user) {
|
||||
// Get the order ID and attachment ID from the request
|
||||
self::requireParameters([
|
||||
'order_id',
|
||||
'attachment_id'
|
||||
]);
|
||||
$order_id = self::getParameter('order_id');
|
||||
$attachment_id = self::getParameter('attachment_id');
|
||||
if (!is_numeric($order_id) || (int)$order_id < 1) {
|
||||
$response->error('Invalid order ID', 400);
|
||||
}
|
||||
if (!is_numeric($attachment_id) || (int)$attachment_id < 1) {
|
||||
$response->error('Invalid attachment ID', 400);
|
||||
}
|
||||
// Get the current order
|
||||
$order = (new orders_o())->getOrderById((int)$order_id);
|
||||
// Check if the order exists
|
||||
if (!$order->exists()) {
|
||||
$response->error('Order not found', 400);
|
||||
}
|
||||
// If operating under own-scope (classic user or subuser), ensure the order belongs to the effective customer context
|
||||
if (!$has_permission_other) {
|
||||
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
|
||||
if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) {
|
||||
$response->forbidden([$permission_other->permission]);
|
||||
}
|
||||
}
|
||||
// Get the attachment
|
||||
$attachment = $order->getAttachment((int)$attachment_id);
|
||||
if (!$attachment->exists()) {
|
||||
$response->error('Attachment not found', 400);
|
||||
}
|
||||
// Create a download link
|
||||
$attachment_store = new attachment_store();
|
||||
$attachments = new attachments();
|
||||
$attachment_formatted = $attachments->format($attachment);
|
||||
$download_link = $attachment_store->generateDirectDownloadUrl(
|
||||
$attachment_formatted->content->document
|
||||
);
|
||||
// Log the incident
|
||||
(new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DOWNLOAD_ORDER_ATTACHMENT', 'Successfully downloaded an attachment for an order (Order ID: ' . $order_id . ', Attachment ID: ' . $attachment_id . ')');
|
||||
// Return the download link
|
||||
$response->success(['download_link' => $download_link]);
|
||||
|
||||
} else {
|
||||
// Log the incident
|
||||
(new logs_o())->add('orders', 'global', 1, 0, 'DOWNLOAD_ORDER_ATTACHMENT', 'No user found, or invalid session');
|
||||
// Return an error
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$context = $this->requireOrderAttachmentDownloadContext();
|
||||
$downloadLink = $context['store'] instanceof pdf_store
|
||||
? $context['store']->getPresignedUrl($context['object_key'])
|
||||
: $context['store']->generateDirectDownloadUrl($context['object_key']);
|
||||
$this->logOrderAttachmentDownload($context, 'DOWNLOAD_ORDER_ATTACHMENT');
|
||||
$response->success(['download_link' => $downloadLink]);
|
||||
},
|
||||
[
|
||||
'download_order_attachments' => 'Download attachments for an order',
|
||||
@@ -403,6 +349,49 @@ class ordersRoute
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/orders/attachments/content', function () {
|
||||
global $response;
|
||||
|
||||
$context = $this->requireOrderAttachmentDownloadContext();
|
||||
$disposition = strtolower(trim((string)(self::getParameter('disposition') ?? 'inline')));
|
||||
if (!in_array($disposition, ['inline', 'attachment'], true)) {
|
||||
$response->error('disposition must be either inline or attachment', 400);
|
||||
}
|
||||
|
||||
$temporaryPath = null;
|
||||
try {
|
||||
$temporaryPath = $context['store']->downloadToTemporaryFile($context['object_key']);
|
||||
$mimeType = $this->detectAttachmentMimeType($temporaryPath);
|
||||
$fileName = $this->sanitizeAttachmentDownloadFileName(
|
||||
$context['file_name'],
|
||||
$context['object_key']
|
||||
);
|
||||
$fileSize = filesize($temporaryPath);
|
||||
if ($fileSize === false) {
|
||||
throw new \RuntimeException('Unable to determine attachment size');
|
||||
}
|
||||
|
||||
$this->logOrderAttachmentDownload($context, 'STREAM_ORDER_ATTACHMENT');
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Disposition: ' . $disposition . '; filename="' . addcslashes($fileName, "\\\"") . '"; filename*=UTF-8\'\'' . rawurlencode($fileName));
|
||||
header('Content-Length: ' . $fileSize);
|
||||
header('Cache-Control: private, no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
http_response_code(200);
|
||||
readfile($temporaryPath);
|
||||
} finally {
|
||||
if (is_string($temporaryPath) && file_exists($temporaryPath)) {
|
||||
unlink($temporaryPath);
|
||||
}
|
||||
}
|
||||
exit;
|
||||
},
|
||||
[
|
||||
'download_order_attachments' => 'Stream attachments for an order',
|
||||
'download_order_attachments_own' => 'Stream attachments for an order (Only for own orders). Subusers require node: ORDERS_LIST and X-Customer-Number header.'
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/orders/attachments', function () {
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
@@ -1669,6 +1658,146 @@ class ordersRoute
|
||||
return $flags;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{user:object,order:orders_o,order_id:int,attachment_id:int,object_key:string,file_name:string,store:attachment_store|pdf_store}
|
||||
*/
|
||||
private function requireOrderAttachmentDownloadContext(): array
|
||||
{
|
||||
global $response;
|
||||
|
||||
$permissionOwn = self::definePermission('download_order_attachments_own', subusers_permission_node_key::ORDERS_LIST);
|
||||
$permissionOther = self::definePermission('download_order_attachments');
|
||||
$hasPermissionOther = self::hasPermission($permissionOther);
|
||||
if (!$hasPermissionOther) {
|
||||
self::requirePermission($permissionOwn);
|
||||
}
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('orders', 'global', 1, 0, 'DOWNLOAD_ORDER_ATTACHMENT', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
self::requireParameters(['order_id', 'attachment_id']);
|
||||
$orderId = self::getParameter('order_id');
|
||||
$attachmentId = self::getParameter('attachment_id');
|
||||
if (!is_numeric($orderId) || (int)$orderId < 1) {
|
||||
$response->error('Invalid order ID', 400);
|
||||
}
|
||||
if (!is_numeric($attachmentId) || (int)$attachmentId < 1) {
|
||||
$response->error('Invalid attachment ID', 400);
|
||||
}
|
||||
$orderId = (int)$orderId;
|
||||
$attachmentId = (int)$attachmentId;
|
||||
|
||||
$order = (new orders_o())->getOrderById($orderId);
|
||||
if (!$order->exists()) {
|
||||
$response->error('Order not found', 404);
|
||||
}
|
||||
if (!$hasPermissionOther) {
|
||||
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
|
||||
if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) {
|
||||
$response->forbidden([$permissionOther->permission]);
|
||||
}
|
||||
}
|
||||
|
||||
$attachment = $order->getAttachment($attachmentId);
|
||||
$attachmentType = $attachment->exists()
|
||||
? trim((string)$attachment->object_type->value(), '`')
|
||||
: '';
|
||||
if (
|
||||
!$attachment->exists()
|
||||
|| $attachmentType !== 'orders'
|
||||
|| (int)$attachment->object_id->value() !== $orderId
|
||||
|| !empty($attachment->deleted_at->value())
|
||||
) {
|
||||
$response->error('Attachment not found for order', 404);
|
||||
}
|
||||
|
||||
$formattedAttachment = (new attachments())->format($attachment);
|
||||
$objectKey = trim((string)(
|
||||
$formattedAttachment->content->document
|
||||
?? $formattedAttachment->content->image
|
||||
?? ''
|
||||
));
|
||||
if ($objectKey === '') {
|
||||
$response->error('Attachment has no stored file', 404);
|
||||
}
|
||||
|
||||
$attachmentStore = new attachment_store();
|
||||
if (!$attachmentStore->isValidFilePath($objectKey)) {
|
||||
$response->error('Attachment contains an invalid stored file path', 400);
|
||||
}
|
||||
$isWashCertificate = $formattedAttachment->isWashCertificate();
|
||||
$store = $isWashCertificate ? new pdf_store() : $attachmentStore;
|
||||
try {
|
||||
if (!$store->doesObjectExist($objectKey)) {
|
||||
$response->error('Attachment file not found', 404);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$response->error('Attachment storage is unavailable', 502);
|
||||
}
|
||||
|
||||
$originalFileName = is_string($formattedAttachment->content->other)
|
||||
? $formattedAttachment->content->other
|
||||
: '';
|
||||
if ($isWashCertificate) {
|
||||
$originalFileName = 'wash_certificate.pdf';
|
||||
}
|
||||
|
||||
return [
|
||||
'user' => $user,
|
||||
'order' => $order,
|
||||
'order_id' => $orderId,
|
||||
'attachment_id' => $attachmentId,
|
||||
'object_key' => $objectKey,
|
||||
'file_name' => $originalFileName,
|
||||
'store' => $store,
|
||||
];
|
||||
}
|
||||
|
||||
private function logOrderAttachmentDownload(array $context, string $action): void
|
||||
{
|
||||
(new logs_o())->add(
|
||||
'orders',
|
||||
$context['order']->department_id->value(),
|
||||
1,
|
||||
$context['user']->id,
|
||||
$action,
|
||||
'Successfully accessed an attachment for an order (Order ID: '
|
||||
. $context['order_id']
|
||||
. ', Attachment ID: '
|
||||
. $context['attachment_id']
|
||||
. ')'
|
||||
);
|
||||
}
|
||||
|
||||
private function detectAttachmentMimeType(string $path): string
|
||||
{
|
||||
$mimeType = false;
|
||||
if (class_exists(\finfo::class)) {
|
||||
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->file($path);
|
||||
}
|
||||
if ((!is_string($mimeType) || $mimeType === '') && function_exists('mime_content_type')) {
|
||||
$mimeType = mime_content_type($path);
|
||||
}
|
||||
|
||||
return is_string($mimeType) && preg_match('#^[a-z0-9.+-]+/[a-z0-9.+-]+$#i', $mimeType) === 1
|
||||
? $mimeType
|
||||
: 'application/octet-stream';
|
||||
}
|
||||
|
||||
private function sanitizeAttachmentDownloadFileName(string $fileName, string $objectKey): string
|
||||
{
|
||||
$fileName = trim(str_replace(["\r", "\n", "\0"], '', basename($fileName)));
|
||||
if ($fileName === '' || $fileName === '.' || $fileName === '..') {
|
||||
$fileName = basename($objectKey);
|
||||
}
|
||||
|
||||
$fileName = preg_replace('/[\\x00-\\x1F\\x7F\\/\\\\]/u', '_', $fileName) ?? 'attachment';
|
||||
return trim($fileName) !== '' ? $fileName : 'attachment';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
* @param response $response
|
||||
|
||||
@@ -317,7 +317,7 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun
|
||||
expect($response->data()['requires_note'] ?? null)->toBeTrue();
|
||||
});
|
||||
|
||||
it('blocks addon products added as standalone additional order items for customers restricted from additional services', function (): void {
|
||||
it('blocks standalone category 8 products for customers restricted from additional services', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
|
||||
@@ -326,8 +326,8 @@ it('blocks addon products added as standalone additional order items for custome
|
||||
'price' => 200,
|
||||
]);
|
||||
$addonProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Drying add-on',
|
||||
'category' => 4,
|
||||
'name' => 'Extra detergent',
|
||||
'category' => 8,
|
||||
'price' => 50,
|
||||
]);
|
||||
|
||||
@@ -345,7 +345,7 @@ it('blocks addon products added as standalone additional order items for custome
|
||||
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||
});
|
||||
|
||||
it('allows standalone additional order items when the customer is not restricted from additional services', function (): void {
|
||||
it('allows standalone category 8 products when the customer is not restricted from additional services', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture();
|
||||
@@ -354,8 +354,8 @@ it('allows standalone additional order items when the customer is not restricted
|
||||
'price' => 200,
|
||||
]);
|
||||
$addonProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Unrestricted add-on',
|
||||
'category' => 4,
|
||||
'name' => 'Unrestricted additional service',
|
||||
'category' => 8,
|
||||
'price' => 50,
|
||||
]);
|
||||
|
||||
@@ -370,7 +370,7 @@ it('allows standalone additional order items when the customer is not restricted
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
it('blocks related addon order items for customers restricted from additional services', function (): void {
|
||||
it('allows related addon order items for customers restricted from additional services', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
|
||||
@@ -381,6 +381,7 @@ it('blocks related addon order items for customers restricted from additional se
|
||||
]);
|
||||
$addonProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Related extra brush',
|
||||
'category' => 8,
|
||||
'price' => 35,
|
||||
]);
|
||||
$primaryItem = api_fixtures()->createOrderItem([
|
||||
@@ -392,6 +393,28 @@ it('blocks related addon order items for customers restricted from additional se
|
||||
|
||||
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [
|
||||
'related_item_id' => $primaryItem['id'],
|
||||
])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
it('still blocks related add-ons covered by a specific customer product rule', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture(['restrictInteriorCleaning']);
|
||||
$cashier = api_fixtures()->createUser(['display_name' => 'Specific Related Rule Cashier']);
|
||||
$primaryProduct = api_fixtures()->createProduct(['name' => 'Primary truck wash', 'price' => 200]);
|
||||
$interiorProduct = api_fixtures()->createProduct(['name' => 'Indvendig vask Forvogn', 'category' => 4, 'price' => 35]);
|
||||
$primaryItem = api_fixtures()->createOrderItem([
|
||||
'order_id' => $fixture['order']['id'],
|
||||
'product_id' => $primaryProduct['id'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
'price' => 200,
|
||||
]);
|
||||
|
||||
post_order_item($fixture['order'], $interiorProduct, $fixture['session']['headers'], [
|
||||
'related_item_id' => $primaryItem['id'],
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
@@ -399,6 +422,109 @@ it('blocks related addon order items for customers restricted from additional se
|
||||
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||
});
|
||||
|
||||
it('does not infer additional-service restrictions from category 4, product names, or existing order items', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
|
||||
$primaryProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Primary wash',
|
||||
'category' => 4,
|
||||
'price' => 200,
|
||||
]);
|
||||
$namedAddonProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Trailer add-on',
|
||||
'category' => 4,
|
||||
'price' => 35,
|
||||
]);
|
||||
|
||||
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
post_order_item($fixture['order'], $namedAddonProduct, $fixture['session']['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
it('uses the exact Tillægsydelser category name as the legacy additional-service fallback', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
|
||||
$category = api_fixtures()->createCategory(['name' => 'Tillægsydelser']);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Legacy additional service',
|
||||
'category' => $category['id'],
|
||||
'price' => 35,
|
||||
]);
|
||||
|
||||
post_order_item($fixture['order'], $product, $fixture['session']['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||
});
|
||||
|
||||
it('validates that related order items exist, are active, and belong to the target order', function (): void {
|
||||
api_test_covers('POST /order/items', 'related-item-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture();
|
||||
$cashier = api_fixtures()->createUser(['display_name' => 'Related Item Cashier']);
|
||||
$product = api_fixtures()->createProduct(['name' => 'Related item validation product', 'price' => 35]);
|
||||
$otherOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $fixture['customer']['customer_number'],
|
||||
'department_id' => $fixture['department']['id'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
]);
|
||||
$validParent = api_fixtures()->createOrderItem([
|
||||
'order_id' => $fixture['order']['id'],
|
||||
'product_id' => $product['id'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
'price' => 35,
|
||||
]);
|
||||
$otherOrderParent = api_fixtures()->createOrderItem([
|
||||
'order_id' => $otherOrder['id'],
|
||||
'product_id' => $product['id'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
'price' => 35,
|
||||
]);
|
||||
$deletedParent = api_fixtures()->createOrderItem([
|
||||
'order_id' => $fixture['order']['id'],
|
||||
'product_id' => $product['id'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
'price' => 35,
|
||||
'deleted_at' => '2026-07-15 12:00:00',
|
||||
]);
|
||||
|
||||
foreach ([$deletedParent['id'], 999999999] as $missingParentId) {
|
||||
post_order_item($fixture['order'], $product, $fixture['session']['headers'], [
|
||||
'related_item_id' => $missingParentId,
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Related order item not found');
|
||||
}
|
||||
|
||||
post_order_item($fixture['order'], $product, $fixture['session']['headers'], [
|
||||
'related_item_id' => $otherOrderParent['id'],
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Related order item must belong to the same order');
|
||||
|
||||
$response = post_order_item($fixture['order'], $product, $fixture['session']['headers'], [
|
||||
'related_item_id' => $validParent['id'],
|
||||
]);
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
expect((int)($response->data()['related_item_id'] ?? 0))->toBe((int)$validParent['id']);
|
||||
});
|
||||
|
||||
it('blocks named restricted service products for the selected customer', function (string $attribute, array $productAttributes): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
it('resolves persisted booked invoices before probing for a draft', function (): void {
|
||||
$content = file_get_contents(app_path('routes/orderInvoicesRoute.php'));
|
||||
expect($content)->not->toBeFalse();
|
||||
$content = (string)$content;
|
||||
|
||||
$persistedBookedPosition = strpos($content, '$persisted_booked_id = (int)$invoice->booked_invoice_id->value();');
|
||||
$draftLookupPosition = strpos($content, '$draft_id = $invoice->getInvoiceDraftId();', (int)$persistedBookedPosition);
|
||||
|
||||
expect($persistedBookedPosition)->not->toBeFalse();
|
||||
expect($draftLookupPosition)->not->toBeFalse();
|
||||
expect($persistedBookedPosition)->toBeLessThan($draftLookupPosition);
|
||||
expect($content)->toContain("'state' => \$economic_state");
|
||||
expect($content)->toContain("'available_pdf_type' => \$available_pdf_type");
|
||||
});
|
||||
|
||||
it('falls back from a missing draft to the booked pdf target', function (): void {
|
||||
$content = file_get_contents(app_path('routes/orderInvoicesRoute.php'));
|
||||
expect($content)->not->toBeFalse();
|
||||
$content = (string)$content;
|
||||
|
||||
expect($content)->toContain("\$resolved_type = 'booked';");
|
||||
expect($content)->toContain('$economic->invoices->pdf->getBooked($economic_invoice_id)');
|
||||
expect($content)->toContain("'type' => \$resolved_type");
|
||||
});
|
||||
@@ -331,6 +331,63 @@ it('allows tank cleaning products for only tank cleaning customers', function ()
|
||||
expect($restrictedTankCleaningFlags[0]['target_id'])->toBe(801);
|
||||
});
|
||||
|
||||
it('flags only standalone Tillægsydelser items for the additional-services customer rule', function (): void {
|
||||
$baseRow = [
|
||||
'customer_number' => 424242,
|
||||
'customer_name' => 'Additional Services Customer',
|
||||
'order_id' => 61427,
|
||||
'invoice_collection_id' => 3091,
|
||||
'department_id' => 5,
|
||||
'item_price' => 100,
|
||||
'order_reference' => 'REF',
|
||||
'order_po' => 'PO',
|
||||
'reg_1' => 'AB12345',
|
||||
];
|
||||
$standaloneCategoryEight = $baseRow + [
|
||||
'order_item_id' => 811,
|
||||
'product_id' => 91,
|
||||
'product_name' => 'Extra detergent',
|
||||
'product_category' => 8,
|
||||
'category_name' => 'Tillægsydelser',
|
||||
'related_item_id' => null,
|
||||
];
|
||||
$relatedCategoryEight = $baseRow + [
|
||||
'order_item_id' => 812,
|
||||
'product_id' => 71,
|
||||
'product_name' => 'Interior rinse',
|
||||
'product_category' => 8,
|
||||
'category_name' => 'Tillægsydelser',
|
||||
'related_item_id' => 810,
|
||||
];
|
||||
$namedCategoryFourAddon = $baseRow + [
|
||||
'order_item_id' => 813,
|
||||
'product_id' => 63,
|
||||
'product_name' => 'Trailer add-on',
|
||||
'product_category' => 4,
|
||||
'category_name' => 'Addons',
|
||||
'related_item_id' => null,
|
||||
];
|
||||
$legacyCategoryName = $baseRow + [
|
||||
'order_item_id' => 814,
|
||||
'product_id' => 94,
|
||||
'product_name' => 'Legacy additional service',
|
||||
'product_category' => 18,
|
||||
'category_name' => 'TILLÆGSYDELSER',
|
||||
'related_item_id' => null,
|
||||
];
|
||||
|
||||
$flags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [
|
||||
[$standaloneCategoryEight, $relatedCategoryEight, $namedCategoryFourAddon, $legacyCategoryName],
|
||||
[424242 => ['restrictAdditionalServices' => true]],
|
||||
]);
|
||||
|
||||
expect(array_column($flags, 'definition_key'))->toBe([
|
||||
'customer_rule_restrict_addon_services',
|
||||
'customer_rule_restrict_addon_services',
|
||||
]);
|
||||
expect(array_column($flags, 'target_id'))->toBe([811, 814]);
|
||||
});
|
||||
|
||||
it('does not flag interior wash variants as historical primary product mismatches', function (): void {
|
||||
global $db;
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
it('documents period invoice states and authenticated attachment content', function (): void {
|
||||
$openApiFiles = [
|
||||
app_path('openapi.yaml'),
|
||||
dirname(app_path(), 3) . '/openapi.yaml',
|
||||
];
|
||||
|
||||
foreach ($openApiFiles as $openApiFile) {
|
||||
expect($openApiFile)->toBeFile();
|
||||
$content = file_get_contents($openApiFile);
|
||||
expect($content)->not->toBeFalse();
|
||||
$content = (string)$content;
|
||||
|
||||
expect($content)->toContain('/orders/attachments/content:');
|
||||
expect($content)->toContain('OrderAttachmentDownloadLinkResponse:');
|
||||
expect($content)->toContain('InvoicingPeriodResponseEnvelope:');
|
||||
expect($content)->toContain('InvoicingPeriodInvoiceCollection:');
|
||||
expect($content)->toContain('enum: [open, closed, economic_draft, economic_booked]');
|
||||
expect($content)->toContain('available_pdf_type:');
|
||||
expect($content)->toContain('enum: [draft, booked, none]');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
app_require('routes/InvoicingPeriodRoute.php');
|
||||
|
||||
use routes\InvoicingPeriodRoute;
|
||||
|
||||
function invoicing_period_invoice_state_invoke(string $method, array $args = []): mixed
|
||||
{
|
||||
$reflection = new ReflectionClass(InvoicingPeriodRoute::class);
|
||||
$target = $reflection->getMethod($method);
|
||||
return $target->invokeArgs(null, $args);
|
||||
}
|
||||
|
||||
final class InvoicingPeriodInvoiceStateFakeResult
|
||||
{
|
||||
public int $num_rows;
|
||||
|
||||
public function __construct(private array $rows)
|
||||
{
|
||||
$this->num_rows = count($rows);
|
||||
}
|
||||
|
||||
public function fetch_assoc(): ?array
|
||||
{
|
||||
return array_shift($this->rows);
|
||||
}
|
||||
}
|
||||
|
||||
final class InvoicingPeriodInvoiceStateFakeDb
|
||||
{
|
||||
public int $queryCount = 0;
|
||||
public string $sql = '';
|
||||
|
||||
public function __construct(private array $rows)
|
||||
{
|
||||
}
|
||||
|
||||
public function query(string $sql): InvoicingPeriodInvoiceStateFakeResult
|
||||
{
|
||||
$this->queryCount++;
|
||||
$this->sql = $sql;
|
||||
return new InvoicingPeriodInvoiceStateFakeResult($this->rows);
|
||||
}
|
||||
}
|
||||
|
||||
it('applies canonical invoice collection state precedence', function (): void {
|
||||
expect(invoicing_period_invoice_state_invoke('periodInvoiceCollectionState', [[
|
||||
'booked_invoice_id' => 9001,
|
||||
'processor' => 1,
|
||||
'external_id' => 'draft-1',
|
||||
'closed_at' => '2026-06-30 12:00:00',
|
||||
]]))->toBe('economic_booked');
|
||||
|
||||
expect(invoicing_period_invoice_state_invoke('periodInvoiceCollectionState', [[
|
||||
'booked_invoice_id' => null,
|
||||
'processor' => 1,
|
||||
'external_id' => 'draft-2',
|
||||
'error_message' => null,
|
||||
'closed_at' => '2026-06-30 12:00:00',
|
||||
]]))->toBe('economic_draft');
|
||||
|
||||
expect(invoicing_period_invoice_state_invoke('periodInvoiceCollectionState', [[
|
||||
'processor' => 2,
|
||||
'external_id' => 'stripe-id',
|
||||
'closed_at' => '2026-06-30 12:00:00',
|
||||
]]))->toBe('closed');
|
||||
|
||||
expect(invoicing_period_invoice_state_invoke('periodInvoiceCollectionState', [[]]))->toBe('open');
|
||||
});
|
||||
|
||||
it('adds transaction states and collection summaries without losing logical orders', function (): void {
|
||||
$customer = [
|
||||
'customer_number' => 42424242,
|
||||
'transactions' => [
|
||||
['id' => 11, 'amount' => 100.5, 'invoice_collection_id' => 501, 'booked' => false],
|
||||
['id' => 12, 'amount' => 50.0, 'invoice_collection_id' => 501, 'booked' => false],
|
||||
['id' => 13, 'amount' => 25.0, 'invoice_collection_id' => 502, 'booked' => false],
|
||||
['id' => 14, 'amount' => 10.0, 'invoice_collection_id' => null, 'booked' => false, 'completed_at' => '2026-06-30 10:00:00'],
|
||||
],
|
||||
'queue' => ['invoice_collection_ids' => []],
|
||||
'draft' => ['invoice_collection_ids' => [503]],
|
||||
];
|
||||
$metadata = [
|
||||
501 => ['id' => 501, 'invoice_collection_id' => 501, 'customer_number' => 42424242, 'state' => 'economic_booked'],
|
||||
502 => ['id' => 502, 'invoice_collection_id' => 502, 'customer_number' => 42424242, 'state' => 'closed'],
|
||||
503 => ['id' => 503, 'invoice_collection_id' => 503, 'customer_number' => 42424242, 'state' => 'economic_draft'],
|
||||
];
|
||||
|
||||
$result = invoicing_period_invoice_state_invoke('applyPeriodInvoiceStateOverlayToCustomer', [$customer, $metadata]);
|
||||
|
||||
expect(array_column($result['transactions'], 'invoice_state'))->toBe([
|
||||
'economic_booked',
|
||||
'economic_booked',
|
||||
'closed',
|
||||
'closed',
|
||||
]);
|
||||
expect(array_column($result['transactions'], 'booked'))->toBe([true, true, false, false]);
|
||||
expect($result['invoice_collections'])->toHaveCount(3);
|
||||
expect($result['invoice_collections'][0])->toMatchArray([
|
||||
'id' => 501,
|
||||
'order_ids' => [11, 12],
|
||||
'order_count' => 2,
|
||||
'total_net_amount' => 150.5,
|
||||
]);
|
||||
expect($result['invoice_collections'][2])->toMatchArray([
|
||||
'id' => 503,
|
||||
'order_ids' => [],
|
||||
'order_count' => 0,
|
||||
'total_net_amount' => 0.0,
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads all referenced invoice collection metadata in one local query', function (): void {
|
||||
$hadDb = array_key_exists('db', $GLOBALS);
|
||||
$originalDb = $GLOBALS['db'] ?? null;
|
||||
$fakeDb = new InvoicingPeriodInvoiceStateFakeDb([
|
||||
[
|
||||
'id' => '501',
|
||||
'customer_number' => '42424242',
|
||||
'processor' => '1',
|
||||
'external_id' => 'draft-501',
|
||||
'booked_invoice_id' => null,
|
||||
'error_message' => null,
|
||||
'closed_at' => '2026-06-30 12:00:00',
|
||||
],
|
||||
[
|
||||
'id' => '502',
|
||||
'customer_number' => '42424242',
|
||||
'processor' => '1',
|
||||
'external_id' => 'draft-502',
|
||||
'booked_invoice_id' => '7002',
|
||||
'error_message' => null,
|
||||
'closed_at' => '2026-06-30 12:00:00',
|
||||
],
|
||||
]);
|
||||
$GLOBALS['db'] = $fakeDb;
|
||||
|
||||
try {
|
||||
$metadata = invoicing_period_invoice_state_invoke('getPeriodInvoiceCollectionMetadata', [[
|
||||
'all' => [[
|
||||
'customer_number' => 42424242,
|
||||
'transactions' => [
|
||||
['invoice_collection_id' => 501],
|
||||
['invoice_collection_id' => 501],
|
||||
],
|
||||
'queue' => ['invoice_collection_ids' => [502]],
|
||||
'draft' => ['invoice_collection_ids' => []],
|
||||
]],
|
||||
'fixed_pricing' => [[
|
||||
'customer_number' => 42424242,
|
||||
'transactions' => [['invoice_collection_id' => 502]],
|
||||
]],
|
||||
]]);
|
||||
} finally {
|
||||
if ($hadDb) {
|
||||
$GLOBALS['db'] = $originalDb;
|
||||
} else {
|
||||
unset($GLOBALS['db']);
|
||||
}
|
||||
}
|
||||
|
||||
expect($fakeDb->queryCount)->toBe(1);
|
||||
expect($fakeDb->sql)->toContain('WHERE id IN (501,502)');
|
||||
expect($metadata[501]['state'])->toBe('economic_draft');
|
||||
expect($metadata[502]['state'])->toBe('economic_booked');
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/attachment_store.php');
|
||||
|
||||
use classes\attachment_store;
|
||||
use classes\pdf_store;
|
||||
|
||||
it('registers authenticated attachment streaming with ownership and safe headers', function (): void {
|
||||
$content = file_get_contents(app_path('routes/ordersRoute.php'));
|
||||
expect($content)->not->toBeFalse();
|
||||
$content = (string)$content;
|
||||
|
||||
expect($content)->toContain("\$this->get('/orders/attachments/content'");
|
||||
expect($content)->toContain('(int)$attachment->object_id->value() !== $orderId');
|
||||
expect($content)->toContain("\$attachmentType !== 'orders'");
|
||||
expect($content)->toContain('$formattedAttachment->content->document');
|
||||
expect($content)->toContain('$formattedAttachment->content->image');
|
||||
expect($content)->toContain('$formattedAttachment->isWashCertificate()');
|
||||
expect($content)->toContain('$isWashCertificate ? new pdf_store() : $attachmentStore');
|
||||
expect($content)->toContain("header('Content-Disposition: '");
|
||||
expect($content)->toContain("header('Cache-Control: private, no-store')");
|
||||
expect($content)->toContain("header('X-Content-Type-Options: nosniff')");
|
||||
});
|
||||
|
||||
it('keeps the legacy link HTTPS and safely encodes attachment object paths', function (): void {
|
||||
$store = new attachment_store();
|
||||
|
||||
expect($store->generateDirectDownloadUrl('folder/test_file.pdf'))
|
||||
->toBe('https://api.truckwash.io/files/folder/test_file.pdf');
|
||||
expect(fn() => $store->generateDirectDownloadUrl('../secret.pdf'))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
it('downloads uploaded attachments and generated wash certificates through isolated temporary files', function (): void {
|
||||
$previousRunApiTests = getenv('RUN_API_TESTS');
|
||||
$previousMinio = $GLOBALS['MINIO'] ?? null;
|
||||
putenv('RUN_API_TESTS=1');
|
||||
$GLOBALS['MINIO'] = ['endpoint' => '', 'access_key' => '', 'secret_key' => ''];
|
||||
|
||||
$attachmentStore = new attachment_store();
|
||||
$pdfStore = new pdf_store();
|
||||
$attachmentKey = 'unit/' . uniqid('attachment_', true) . '.txt';
|
||||
$certificateKey = 'pdf_' . uniqid('wash_certificate_', true) . '.pdf';
|
||||
$attachmentTemporaryPath = null;
|
||||
$certificateTemporaryPath = null;
|
||||
|
||||
try {
|
||||
expect($attachmentStore->createObject($attachmentKey, 'attachment-content'))->toBeTrue();
|
||||
expect($pdfStore->createObject($certificateKey, '%PDF-1.4 wash certificate'))->toBeTrue();
|
||||
expect($attachmentStore->doesObjectExist($certificateKey))->toBeFalse();
|
||||
expect($pdfStore->doesObjectExist($certificateKey))->toBeTrue();
|
||||
|
||||
$attachmentTemporaryPath = $attachmentStore->downloadToTemporaryFile($attachmentKey);
|
||||
$certificateTemporaryPath = $pdfStore->downloadToTemporaryFile($certificateKey);
|
||||
|
||||
expect($attachmentTemporaryPath)->toBeFile();
|
||||
expect(file_get_contents($attachmentTemporaryPath))->toBe('attachment-content');
|
||||
expect($certificateTemporaryPath)->toBeFile();
|
||||
expect(file_get_contents($certificateTemporaryPath))->toBe('%PDF-1.4 wash certificate');
|
||||
expect(basename($attachmentTemporaryPath))->toStartWith('stored_object_');
|
||||
expect(basename($certificateTemporaryPath))->toStartWith('stored_object_');
|
||||
} finally {
|
||||
foreach ([$attachmentTemporaryPath, $certificateTemporaryPath] as $temporaryPath) {
|
||||
if (is_string($temporaryPath) && file_exists($temporaryPath)) {
|
||||
unlink($temporaryPath);
|
||||
}
|
||||
}
|
||||
if ($previousRunApiTests === false) {
|
||||
putenv('RUN_API_TESTS');
|
||||
} else {
|
||||
putenv('RUN_API_TESTS=' . $previousRunApiTests);
|
||||
}
|
||||
if ($previousMinio === null) {
|
||||
unset($GLOBALS['MINIO']);
|
||||
} else {
|
||||
$GLOBALS['MINIO'] = $previousMinio;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -213,6 +213,39 @@ trait minio_t
|
||||
return self::getS3Client()->doesObjectExist(self::getBucket(), $key);
|
||||
}
|
||||
|
||||
public function downloadToTemporaryFile(string $key): string
|
||||
{
|
||||
if (trim($key) === '') {
|
||||
throw new \InvalidArgumentException('Object key cannot be empty');
|
||||
}
|
||||
|
||||
$path = tempnam(sys_get_temp_dir(), 'stored_object_');
|
||||
if ($path === false) {
|
||||
throw new \RuntimeException('Unable to create temporary object file');
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->shouldUseLocalTestStorage()) {
|
||||
if (!copy($this->getLocalTestObjectPath($key), $path)) {
|
||||
throw new \RuntimeException('Unable to copy object from local storage');
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
self::getS3Client()->getObject([
|
||||
'Bucket' => self::getBucket(),
|
||||
'Key' => $key,
|
||||
'SaveAs' => $path,
|
||||
]);
|
||||
return $path;
|
||||
} catch (\Throwable $e) {
|
||||
if (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store temp file from base64 string
|
||||
* @param string $base64 The base64 encoded string
|
||||
|
||||
Reference in New Issue
Block a user