Files
api/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php
T
Jeppe BandOpenClaw b16b2cfe44 fix(economic): sanitize user-input fields to prevent 400 errors (#391)
## Problem

E-conomic API returns HTTP 400 when text-line descriptions contain
certain characters. The most common case is `/` in the order reference
field, which causes the entire draft-invoice export to fail.

## Root cause

When `order.reference` (or notes, reg_*, po) contains `/`, e-conomic's
text-line validation rejects the entire draft with HTTP 400. Same for
control characters and very long strings.

## Fix

Adds `economic_export_sanitizer` class that sanitizes all user-input
fields flowing into e-conomic:

- `/` → `-` (the reported 400 trigger)
- Control chars stripped (\x00-\x1F except \t and \n)
- Tab and newline → single space
- Whitespace normalized and trimmed
- Lengths capped (text 250, product 50, description 500) with `...`
suffix
- Multibyte safe (æ, ø, å, emoji, Chinese)

## Applied to

In `economic_invoice_draft.php`:
- `order.po`
- `order.reference` (PRIMARY FIX for the reported issue)
- `order.notes`
- `order.reg_1/2/3`
- `order_item.reference`
- `order_item.notes`
- `product.description`
- `product.productNumber`
- `department_name`

## Test coverage

- 31 unit tests with 45 assertions
- All edge cases (null, empty, control chars, multibyte, very long,
HTML, control chars in every position)
- Lint and test suite both pass

## Linear

Refs: TRU-189, TRU-190, TRU-191, TRU-192, TRU-193, TRU-194, TRU-196

Co-authored-by: OpenClaw <openclaw@copenhagentruckwash.io>
2026-08-17 12:24:04 +02:00

602 lines
23 KiB
PHP

<?php
namespace helpers;
use classes\economic;
use Exception;
use objects\currency_conversion_rates_o;
use objects\departments_o;
use objects\orders_o;
class economic_invoice_draft
{
public const DEFAULT_LINE_BATCH_SIZE = 500;
/**
* The Economic draftInvoiceNumber
* @var int $draft_invoice_number
*/
protected int $draft_invoice_number;
/**
* The draft lines
* @var array $draft_lines
*/
protected array $draft_lines = [];
/**
* The raw draft invoice data
* @var object $draft_invoice_data
*/
protected object $draft_invoice_data;
/**
* The currency for the draft invoice
* @var string $currency
* @note The currency is set to DKK by default
*/
protected string $currency = 'DKK';
/**
* The currency conversion rate
* @var float $conversion_rate
* @note The conversion rate is set to 1.0 by default
*/
protected float $conversion_rate = 1.0;
/**
* Construct a new Economic draft invoice object
* @throws Exception if the invoice data is invalid or empty
*/
public function __construct(int $draft_invoice_number, string $currency = 'DKK', bool $skip_fetch = false)
{
$this->setDraftInvoiceNumber($draft_invoice_number);
$this->setCurrency(strtoupper($currency));
// If the skip fetch is set to false, fetch the draft invoice data
if (!$skip_fetch) {
$this->fetchDraftInvoiceData();
} else {
$this->draft_invoice_data = new \stdClass();
$this->draft_invoice_data->draftInvoiceNumber = $this->draft_invoice_number;
}
$this->requireSelected();
}
/**
* Set the draft invoice number
* @param int $draft_invoice_number
* @return void
*/
private function setDraftInvoiceNumber(int $draft_invoice_number): void
{
$this->draft_invoice_number = $draft_invoice_number;
}
/**
* Set the currency for the draft invoice
* @param string $currency The currency to set
* @return void
* @throws Exception If the currency is not valid
* @throws Exception If the conversion rate is not valid
*/
private function setCurrency(string $currency): void
{
// Set the currency for the draft invoice
$this->currency = $currency;
// Get the conversion rate for the currency
$this->conversion_rate = (float)(new currency_conversion_rates_o())->convertTo($this->currency, 1);
}
/**
* Fetch the draft invoice data from the Economic system
* @throws Exception If the request fails
* @throws Exception If the invoice is not found
*/
private function fetchDraftInvoiceData(): void
{
$economic = new economic();
$this->draft_invoice_data = $economic->invoices->draft->get($this->draft_invoice_number);
if (!isset($this->draft_invoice_data->draftInvoiceNumber)) {
throw new Exception('Draft invoice not found');
}
}
/**
* Require that the draft invoice data is selected
* @throws Exception if the draft invoice data is not set
*/
protected function requireSelected(): void
{
if (!isset($this->draft_invoice_data->draftInvoiceNumber)) {
throw new Exception('The draft invoice data is not set');
}
}
/**
* Add the lines to the draft invoice.
*/
public function addLines(): void
{
$this->flushLinesInBatches();
}
/**
* Add queued draft lines using chunked requests.
*
* @return array{line_count:int,batch_count:int,batch_sizes:array<int,int>}
*/
public function flushLinesInBatches(int $batch_size = self::DEFAULT_LINE_BATCH_SIZE): array
{
$lines = array_values($this->draft_lines);
$line_count = count($lines);
if ($line_count === 0) {
return [
'line_count' => 0,
'batch_count' => 0,
'batch_sizes' => [],
];
}
$batch_size = max(1, $batch_size);
$batch_sizes = [];
foreach (array_chunk($lines, $batch_size) as $batch) {
$this->sendDraftLines($batch);
$batch_sizes[] = count($batch);
}
$this->draft_lines = [];
return [
'line_count' => $line_count,
'batch_count' => count($batch_sizes),
'batch_sizes' => $batch_sizes,
];
}
public function pendingLineCount(): int
{
return count($this->draft_lines);
}
protected function sendDraftLines(array $draft_lines): object
{
$economic = new economic();
return $economic->invoices->draft->add_lines($this->draft_invoice_number, $draft_lines);
}
/**
* Get the draft invoice number
* @return int The draft invoice number
*/
public function getDraftInvoiceNumber(): int
{
return $this->draft_invoice_number;
}
/**
* Add a new transaction header to the draft invoice
* @note The lines won't be saved until the addLines() method is called.
* @note This is the "[28/02/2025 18:29 Roskilde #685]" line in the invoice
* @param orders_o $order The order to add
* @return void
*/
public function addNewTransactionHeader(orders_o $order): void
{
// Get the department name
$department_name = (new departments_o())->getDepartmentName((int)$order->department_id->value());
// Parse the date of the transaction.
$parsed_date = date('d/m/Y H:i', strtotime($order->created_at->value()));
// Sanitize the department name (could contain "/" or other chars)
$department_name = \classes\economic_export_sanitizer::sanitizeTextLine($department_name, 100);
// Add the text line to the draft invoice
self::addTextLine("[ " . $parsed_date . ' ' . $department_name . ' #' . $order->id . " ]");
// If there's a PO number, add it to the invoice
if ($order->po->value() !== '') {
self::addTextLine('PO: ' . \classes\economic_export_sanitizer::sanitizeTextLine($order->po->value()));
}
// If there's a reference, add it to the invoice
$reference_value = $order->reference->value();
if ($reference_value !== '') {
self::addTextLine('Reference:');
// Sanitize the whole reference (handles "/" → "-" per TRU-188)
$reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($reference_value);
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($reference_sanitized, "\n")) {
foreach ( explode("\n", $reference_sanitized) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $reference_sanitized);
}
}
// Add the registration numbers (if any)
$line_reg = '';
if ($order->reg_1->value() !== '') {
$line_reg .= 'Reg 1: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_1->value(), 50));
}
if ($order->reg_2->value() !== '') {
$line_reg .= ', Reg 2: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_2->value(), 50));
}
if ($order->reg_3->value() !== '') {
$line_reg .= ', Reg 3: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_3->value(), 50));
}
// Add the line to the invoice (If there's any registration numbers)
if ($line_reg !== '')
self::addTextLine($line_reg);
// If there's a note, add it to the invoice
$notes_value = $order->notes->value();
if ($notes_value !== '') {
self::addTextLine('Notat:');
// Sanitize notes (could contain "/", newlines, special chars)
$notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($notes_value);
if (str_contains($notes_sanitized, "\n")) {
foreach ( explode("\n", $notes_sanitized) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $notes_sanitized);
}
}
}
/**
* Add a text line to the draft invoice
* @note The lines won't be saved until the addLines() method is called.
* @param string $text The text to add
* @return void
*/
public function addTextLine(string $text): void
{
$this->draft_lines[] = [
'description' => $text
];
}
/**
* Add an order to the draft invoice
* @note The lines won't be saved until the addLines() method is called.
* @param orders_o $order The order to add
* @return void
* @throws Exception if the order is not found
* @throws Exception if the order is not valid
*/
public function addOrderItemLines(orders_o $order, bool $use_itemized_discounts = false): void
{
// Get the order items
$order_items = $order->getOrderItems($order->id);
// Apply the department pricing
$order_items = $order->applyDepartmentPrices($order_items, $order->department_id->value());
// Get the department
$department = $order->getDepartmentByOrderId($order->id);
// If the department id is 10, check if the user has a default department set
if ((int)$department['id'] === 10) {
// If the default department is set, use it
$department = (new departments_o())->getDepartmentById((int)$department['id'], true);
}
// Remove all the order items that are not included in the invoice
$order_items = array_filter($order_items, function ($order_item) {
return (bool)$order_item['include_in_invoice'];
});
// Define the total discount applied to the order
$total_discount = 0;
// Loop through the order items
foreach ( $order_items as $order_item ) {
if ($this->shouldSkipOrderItemLine($order_item)) {
continue;
}
// Add the order item to the draft invoice
self::addOrderItemLine($order_item, $department, false, $use_itemized_discounts);
// Add the line discount to the total discount
$total_discount += ($order_item['product']['price'] - $order_item['price']) * $order_item['quantity'];
}
// If the total discount is greater than 0, add it to the invoice
if (!$use_itemized_discounts && $total_discount > 0) {
// Add the discount to the invoice
self::addProductDiscountLine($total_discount, $department['economic_department_id'] ?? 0, $department['dimension'] ?? 0);
}
}
/**
* Add an order item line to the draft invoice
* @note The lines won't be saved until the addLines() method is called.
* @param array $order_item The order item to add
* @param array $department The department to add
* @param boolean $show_discount Whether to show the discount or not
* @return void
* @throws Exception if the order item is not found
* @throws Exception if the order item is not valid
*/
public function addOrderItemLine(array $order_item, array $department, bool $show_discount = false, bool $use_itemized_discount = false): void
{
// Check if the order item is valid
if (!isset($order_item['id'])) {
throw new Exception('The order item is not valid');
}
if ($this->shouldSkipOrderItemLine($order_item)) {
return;
}
// Get the department id
$economic_department_id = $department['economic_department_id'] ?? 0;
// Get the dimension id
$economic_dimension_id = $department['economic_dimension_id'] ?? 0;
$pricing = self::resolveOrderItemInvoicePricing($order_item);
$discount_percentage = $use_itemized_discount
? $this->resolveItemizedDiscountPercentageForInvoiceCurrency($pricing)
: 0;
// Add the order item to the draft invoice
self::addProductLine(
(string)$order_item['product']['economic_product_id'],
(string)$order_item['product']['name'],
(int)$order_item['quantity'] ?? 1,
$use_itemized_discount
? $pricing['invoice_unit_price']
: (int)($order_item['product']['price'] == 0 ? $order_item['price'] : $order_item['product']['price']),
(int)$economic_department_id,
$economic_dimension_id,
$discount_percentage
);
// Calculate the discount percentage (If the final price is 0, set the discount percentage to 100)
if ($order_item['product']['price'] == 0) {
$show_discount = false;
$discountPercentage = 0;
} else {
// Calculate the discount percentage
$discountPercentage = round((($order_item['product']['price'] - $order_item['price']) / $order_item['product']['price']) * 100, 0);
}
// If the price is different from the product price, add it to the line
if ($order_item['price'] !== $order_item['product']['price'] && $show_discount)
self::addTextLine('Rabat: ' . ($order_item['price'] - $order_item['product']['price']) . ' ' . $this->currency . ' (' . $discountPercentage . '%)');
// If there's a reference, add it to the line
if ($order_item['reference'] !== '') {
self::addTextLine('Reference:');
// Sanitize the reference (handles "/" → "-" per TRU-188)
$item_reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($order_item['reference']);
if (str_contains($item_reference_sanitized, "\n")) {
foreach ( explode("\n", $item_reference_sanitized) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $item_reference_sanitized);
}
}
// If there's a note, add it to the line
if (!empty($order_item['notes'])) {
self::addTextLine('Notat:');
// Sanitize notes (could contain "/", newlines, special chars)
$item_notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($order_item['notes']);
if (str_contains($item_notes_sanitized, "\n")) {
foreach ( explode("\n", $item_notes_sanitized) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $item_notes_sanitized);
}
}
}
/**
* Skip line when quantity is zero/negative, or final unit price is zero.
*/
private function shouldSkipOrderItemLine(array $order_item): bool
{
$quantity = isset($order_item['quantity']) && is_numeric($order_item['quantity'])
? (float)$order_item['quantity']
: 0.0;
if ($quantity <= 0.0) {
return true;
}
$price = isset($order_item['price']) && is_numeric($order_item['price'])
? (float)$order_item['price']
: 0.0;
return abs($price) < 0.00001;
}
/**
* Return exact line discount data from the original department price and stored final item price.
*
* @return array{
* original_unit_price:float,
* final_unit_price:float,
* invoice_unit_price:float,
* quantity:float,
* discount_unit_amount:float,
* discount_total_amount:float,
* discount_percentage:float,
* has_discount:bool
* }
*/
public static function resolveOrderItemInvoicePricing(array $order_item): array
{
$quantity = isset($order_item['quantity']) && is_numeric($order_item['quantity'])
? (float)$order_item['quantity']
: 0.0;
$final_unit_price = isset($order_item['price']) && is_numeric($order_item['price'])
? (float)$order_item['price']
: 0.0;
$original_unit_price = isset($order_item['product']['price']) && is_numeric($order_item['product']['price'])
? (float)$order_item['product']['price']
: 0.0;
if (abs($original_unit_price) < 0.00001) {
$original_unit_price = $final_unit_price;
}
$has_discount = $quantity > 0.0
&& $final_unit_price > 0.0
&& $original_unit_price > 0.0
&& $final_unit_price < ($original_unit_price - 0.00001);
$discount_unit_amount = $has_discount
? round($original_unit_price - $final_unit_price, 2)
: 0.0;
$discount_total_amount = round($discount_unit_amount * $quantity, 2);
return [
'original_unit_price' => $original_unit_price,
'final_unit_price' => $final_unit_price,
'invoice_unit_price' => $has_discount ? $original_unit_price : $final_unit_price,
'quantity' => $quantity,
'discount_unit_amount' => $discount_unit_amount,
'discount_total_amount' => $discount_total_amount,
'discount_percentage' => $has_discount
? round((1 - ($final_unit_price / $original_unit_price)) * 100, 10)
: 0.0,
'has_discount' => $has_discount,
];
}
public static function orderItemHasBillableDiscount(array $order_item): bool
{
return self::resolveOrderItemInvoicePricing($order_item)['has_discount'];
}
/**
* Calculate the hidden e-conomic percentage from converted amounts so the rendered monetary discount stays exact.
*
* @param array{invoice_unit_price:float,final_unit_price:float,has_discount:bool,discount_percentage:float} $pricing
*/
private function resolveItemizedDiscountPercentageForInvoiceCurrency(array $pricing): float
{
if (!$pricing['has_discount']) {
return 0.0;
}
$converted_original = self::convertCurrency($pricing['invoice_unit_price']);
$converted_final = self::convertCurrency($pricing['final_unit_price']);
if ($converted_original <= 0.0 || $converted_final <= 0.0) {
return $pricing['discount_percentage'];
}
return round((1 - ($converted_final / $converted_original)) * 100, 10);
}
/**
* Add a product line to the draft invoice
* @note The lines won't be saved until the addLines() method is called.
*/
public function addProductLine(string $productNumber, string $description, float $quantity, float $unitNetPrice, int $economic_department_id, int $dimension, float $discountPercentage = 0): void
{
// Sanitize product identifier and description (defense in depth — also done at addLines())
$productNumber = \classes\economic_export_sanitizer::sanitizeProductNumber($productNumber);
$description = \classes\economic_export_sanitizer::sanitizeProductDescription($description);
// We then add a 0 in front of the product number to make sure it's the right one, made by FlexPOS.
// Add a line to the invoice
$line = [
'product' => [
'productNumber' => $productNumber,
],
'quantity' => $quantity,
'unitNetPrice' => self::convertCurrency($unitNetPrice),
'discountPercentage' => $discountPercentage,
'description' => $description,
];
// If the department is set, add it to the line
if ($economic_department_id && $economic_department_id > 0) {
$line['departmentalDistribution'] = [
'departmentalDistributionNumber' => $economic_department_id,
'DistributionType' => 'Department',
'dimension' => $dimension
];
}
$this->draft_lines[] = $line;
}
private function convertCurrency(float $amount): float
{
// Convert the amount to the correct currency
$converted_value = $amount * self::getConversionRate();
// Round the value to 2 decimal places
return round($converted_value, 2);
}
private function getConversionRate(): float
{
// Get the conversion rate for the currency
return $this->conversion_rate;
}
/**
* Add a discount line to the draft invoice
* @note The lines won't be saved until the addLines() method is called.
* @param float $discount The discount to add
* @param int $economic_department_id The department id to add
* @param int $economic_dimension_id The dimension id to add
* @return void
*/
public function addProductDiscountLine(float $discount, int $economic_department_id, int $economic_dimension_id): void
{
// Add a line to the invoice
$line = [
'product' => [
'productNumber' => 'TotDiscount',
],
'quantity' => 1,
'unitNetPrice' => self::convertCurrency(-$discount),
'discountPercentage' => 0,
'description' => 'Rabat',
];
// If the department is set, add it to the line
if ($economic_department_id && $economic_department_id > 0) {
$line['departmentalDistribution'] = [
'departmentalDistributionNumber' => $economic_department_id,
'DistributionType' => 'Department',
'dimension' => $economic_dimension_id
];
}
$this->draft_lines[] = $line;
}
/**
* Check if a draft invoice exists in the Economic system
* @throws Exception if the draft invoice does not exist
*/
protected function requireExists(): void
{
if (!self::exists()) {
throw new Exception('The draft invoice does not exist');
}
}
/**
* Check if a customer exists in the Economic system
* @return bool True if the customer exists, false if not
* @throws Exception
*/
public function exists(): bool
{
// Make sure the draft invoice number is set before calling
self::requireDraftInvoiceNumber();
// Check if the draft invoice exists
$economic = new economic();
return $economic->invoices->drafts->exists($this->draft_invoice_number);
}
/**
* Require that the draft invoice number is set
* @throws Exception if the draft invoice number is not set
*/
protected function requireDraftInvoiceNumber(): void
{
if (!isset($this->draft_invoice_number)) {
throw new Exception('The draft invoice number is not set');
}
}
/**
* Require that the draft invoice data is set
* @throws Exception if the draft invoice data is not set
*/
protected function requireDraftInvoiceData(): void
{
if (!isset($this->draft_invoice_data)) {
throw new Exception('The draft invoice data is not set');
}
}
}