Files
api/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php
T
Jeppe BandMiniMax M3 Subagent 52d43fc16c fix(api): apply e-conomic discount percentage at line level for customer 35131752 (TRU-73 / DRIFT 12) (#400)
## Summary

Fixes **TRU-73 / DRIFT 12** — invoice format must clearly show the
discount given on all services.

For customers with a global e-conomic discount (e.g. `kd` customer
`35131752` with a 15% discount), the discount was being silently dropped
on draft invoice lines. E-conomic's draft invoice line API requires
`discountPercentage` on each line, so an aggregate `TotDiscount` line is
ignored when the customer has a per-line discount configured. The fix
applies the customer discount at the line level.

## What changed

-
`services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
— `addOrderItemLines()` and `addOrderItemLine()` now accept a
`customer_discount_percentage` argument and combine it with the per-item
discount using `max(per_item, customer)`. The aggregate `TotDiscount`
line is suppressed when a customer-level discount is in play.
- `services/nginx/app/modules/economic/customers/economicCustomers.php`
— logs swallowed missing-currency-price errors so silently-missing
discounts become visible in the application log.
-
`services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php`
— forwards the customer discount percentage to the draft builder.
- `services/nginx/app/objects/collected_order_invoices_o.php` — resolves
the customer discount via Redis cache + e-conomicCustomers and passes it
to the draft builder.
-
`services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftCustomerDiscountTest.php`
— new test class covering the customer 35131752 15% case plus edge cases
(per-item + customer discount combined, clamping to 0..100,
zero-discount baseline).
-
`services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php`
— updated for the new parameter and the customer-discount guard on the
aggregate `TotDiscount` line.
-
`services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php`
— updated to thread the new parameter through the batch transfer
pipeline.
- `documentation/economic/invoice-discount-format-drift12.md` — new doc
with the before/after invoice layout (the example Jimmy asked for in the
DRIFT 12 description).

## Example (for Jimmy)

Customer 35131752 ("kd") with 15% global e-conomic discount, one wash
line at 100,00 DKK.

### Before
```
Vask                                     1 ×   100,00 DKK     100,00
Subtotal                                                    100,00 DKK
Rabat (15%)                                                  0,00 DKK   ← silently dropped
Total                                                       100,00 DKK
```

### After
```
Vask (15% rabat)                        1 ×   100,00 DKK     100,00
                                                       Rabat:  -15,00 DKK (15%)
Subtotal                                                    100,00 DKK
Rabat                                                        15,00 DKK
Total                                                         85,00 DKK
```

## Test plan

- [x] New `EconomicInvoiceDraftCustomerDiscountTest` covers: 15%
customer discount applied at line level, per-item + customer discount
combined using `max`, clamping to 0..100, zero-discount baseline.
- [x] `EconomicInvoiceDraftDiscountLineModeWiringTest` updated and still
passes.
- [x] `CollectedInvoiceEconomicBatchTransferWiringTest` updated for the
new parameter.
- [ ] Run full `php-ci-test.sh unit` locally to confirm nothing else
regressed.

## Linear

Closes TRU-73 (DRIFT 12).

🤖 Generated via the TRU-73 pickup cron run.

---------

Co-authored-by: MiniMax M3 Subagent <fix@truckwash.local>
2026-08-17 13:41:10 +00:00

777 lines
30 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;
/**
* Whether pre-flight validation runs inside addLines() before sending to e-conomic.
* Defense in depth — even after sanitization, a final check catches anything that slips through.
* @var bool $preflight_enabled
*/
protected bool $preflight_enabled = true;
/**
* 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
{
if ($this->preflight_enabled) {
$this->preflightValidate($this->draft_lines, null);
}
$this->flushLinesInBatches();
}
/**
* Pre-flight validation: defense in depth before sending to e-conomic.
* Validates each line against 5 rules and throws RuntimeException on the first violation.
*
* Rules (in order, per line):
* 1. description — must be non-empty after trim()
* 2. description — must be <= 250 chars
* 3. productNumber (if present in product.productNumber) — must match /^[A-Za-z0-9._-]{1,50}$/
* 4. quantity — must be a positive number (> 0)
* 5. unitNetPrice — must be a number (>= 0)
*
* @param array<int,array<string,mixed>> $lines
* @param int|null $orderId Optional order id for log context
* @throws \RuntimeException on any rule violation
*/
public function preflightValidate(array $lines, ?int $orderId = null): void
{
foreach ($lines as $i => $line) {
if (!is_array($line)) {
$this->logAndThrow(
$i,
$orderId,
'line is not an array',
$line
);
}
// Rule 1 + 2: description
$description = $line['description'] ?? null;
if ($description === null) {
$description = '';
}
if (!is_scalar($description)) {
$description = (string)$description;
} else {
$description = (string)$description;
}
$descriptionTrimmed = trim($description);
if ($descriptionTrimmed === '') {
$this->logAndThrow(
$i,
$orderId,
'description is empty',
$description
);
}
if (mb_strlen($descriptionTrimmed) > 250) {
$this->logAndThrow(
$i,
$orderId,
'description exceeds 250 chars (length=' . mb_strlen($descriptionTrimmed) . ')',
$description
);
}
// Rule 3: productNumber (only if present in product.productNumber)
if (isset($line['product']) && is_array($line['product']) && array_key_exists('productNumber', $line['product'])) {
$productNumber = $line['product']['productNumber'];
if ($productNumber === null) {
$productNumber = '';
} else {
$productNumber = (string)$productNumber;
}
if (!preg_match('/^[A-Za-z0-9._-]{1,50}$/', $productNumber)) {
$this->logAndThrow(
$i,
$orderId,
'productNumber does not match /^[A-Za-z0-9._-]{1,50}$/',
$productNumber
);
}
}
// Rule 4: quantity — only required if present in the line (text lines omit it)
if (array_key_exists('quantity', $line)) {
$quantity = $line['quantity'];
if (!is_numeric($quantity) || (float)$quantity <= 0) {
$this->logAndThrow(
$i,
$orderId,
'quantity is not a positive number',
$quantity
);
}
}
// Rule 5: unitNetPrice — only required if present in the line
if (array_key_exists('unitNetPrice', $line)) {
$unitNetPrice = $line['unitNetPrice'];
if (!is_numeric($unitNetPrice) || (float)$unitNetPrice < 0) {
$this->logAndThrow(
$i,
$orderId,
'unitNetPrice is not a number >= 0',
$unitNetPrice
);
}
}
}
}
/**
* Log the offending line and throw a RuntimeException.
*/
private function logAndThrow(int $lineIndex, ?int $orderId, string $rule, mixed $value): never
{
$valueTruncated = is_scalar($value) ? (string)$value : json_encode($value);
if ($valueTruncated === false) {
$valueTruncated = '[unserializable]';
}
if (mb_strlen($valueTruncated) > 200) {
$valueTruncated = mb_substr($valueTruncated, 0, 200) . '...';
}
$orderContext = $orderId === null ? 'order=n/a' : 'order=' . $orderId;
error_log(sprintf(
'[preflight] validation failed: %s | line=%d | %s | value=%s',
$rule,
$lineIndex,
$orderContext,
$valueTruncated
));
$orderPart = $orderId === null ? '' : ' (order ' . $orderId . ')';
throw new \RuntimeException(sprintf(
'Preflight validation failed for line %d: %s%s',
$lineIndex,
$rule,
$orderPart
));
}
/**
* 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
{
// Defense in depth: sanitize ALL text lines at insertion time.
// This catches anything that wasn't pre-sanitized at the call site.
$sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($text);
if ($sanitized === '') {
return; // Skip empty/whitespace-only lines
}
$this->draft_lines[] = [
'description' => $sanitized
];
}
/**
* Get the current draft lines (read-only view).
* Used by integration tests; production code uses addLines() to send.
*/
public function getDraftLines(): array
{
return $this->draft_lines;
}
/**
* 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, int $customer_discount_percentage = 0): 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;
// Normalize the customer discount percentage (clamp to 0..100)
$customer_discount_percentage = max(0, min(100, $customer_discount_percentage));
// Force itemized discount mode when the customer has a global e-conomic discount
// so the discount is applied at the line level (e-conomic line API requires per-line
// discountPercentage; an aggregate TotDiscount line would be ignored when the
// customer does not have a per-line discount configured for the customer).
$effective_itemized_discounts = $use_itemized_discounts || $customer_discount_percentage > 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, $effective_itemized_discounts, $customer_discount_percentage);
// 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 && $customer_discount_percentage === 0 && $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, int $customer_discount_percentage = 0): 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);
// The customer discount (e.g. kd customer 35131752 with 15% global e-conomic discount)
// is applied at the line level. Combined with per-item discounts using max() so the
// biggest discount wins, and so we never accidentally apply a 15% discount on top of
// an already-discounted per-item price.
$customer_discount_percentage = max(0, min(100, $customer_discount_percentage));
$itemized_discount_percentage = $use_itemized_discount
? $this->resolveItemizedDiscountPercentageForInvoiceCurrency($pricing)
: 0.0;
$discount_percentage = (float)max(
$itemized_discount_percentage,
(float)$customer_discount_percentage
);
// 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);
// Skip if sanitization removed everything
if ($productNumber === '' || $description === '') {
return;
}
// 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');
}
}
}