Add unit tests for self-serve invoice billing logic, refactor minute-based billing calculations, and improve error handling for billable minutes adjustments.

This commit is contained in:
Jeppe Bundgaard
2026-03-26 21:09:21 +01:00
parent b42a1a69a0
commit 0dc1581eb4
5 changed files with 192 additions and 18 deletions
@@ -68,17 +68,15 @@ trait selfserve_lane_command_t
*/
protected function addVehicleTypeProductToInvoiceIfNeeded(bool $should_add): void
{
if (!$should_add || !method_exists($this, 'addVehicleTypeProductToLastInvoiceOrder')) {
if (!$should_add) {
return;
}
try {
if (method_exists($this, 'ensureInvoiceOrderContextForVehicleProduct')) {
$this->ensureInvoiceOrderContextForVehicleProduct();
}
$this->addVehicleTypeProductToLastInvoiceOrder();
$active_wash = new selfserve_wash_flow();
$active_wash->addVehicleTypeProductToInvoiceForLane($this->id);
} catch (\Throwable) {
// Never block STOP on optional order-line enrichment.
// Best effort only; invoice correction can be handled manually if needed.
}
}
@@ -2,11 +2,13 @@
namespace modules\selfserve\traits;
require_once WD . '/modules/selfserve/helpers/selfserve_lane_status.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_mode.php';
require_once WD . '/modules/selfserve/classes/selfserve_lane.php';
use classes\selfserve;
use Exception;
use modules\selfserve\classes\selfserve_lane;
use modules\selfserve\helpers\selfserve_lane_mode;
use modules\selfserve\helpers\selfserve_lane_status;
use objects\customer_vehicles_o;
use objects\order_items_o;
@@ -27,6 +29,11 @@ trait selfserve_lane_invoice_t
* @var int|null $machine_wash_minutes_included
*/
public ?int $machine_wash_minutes_included = null;
/**
* The billable minutes order item, stored to be corrected / deleted when a machine wash is performed
* @var order_items_o|null
*/
public ?order_items_o $billable_minutes_order_item = null;
/**
* Get the minute billing product ID
* @return int|null The product ID for minute-based billing, or null if not set
@@ -52,13 +59,10 @@ trait selfserve_lane_invoice_t
->config
->machine_wash_minutes_included
->getVariableValue();
$included_minutes = is_numeric($included_minutes) ? (int)$included_minutes : null;
if (is_numeric($included_minutes)) {
$this->machine_wash_minutes_included = (int)$included_minutes;
}
if ($this->machine_wash_minutes_included === null || $this->machine_wash_minutes_included < 0) {
$this->machine_wash_minutes_included = 0;
if ($included_minutes === null || $included_minutes < 0) {
return 0;
}
return $this->machine_wash_minutes_included;
@@ -108,7 +112,7 @@ trait selfserve_lane_invoice_t
if (empty($this->getLicensePlate())) throw new \Exception("License plate is not set for lane ID {$this->id}.");
if (empty($product_id = $this->getMinuteBillingProductId())) throw new \Exception("Minute billing product ID is not set.");
$elapsed_minutes = $this->calculateElapsedMinutesForBilling($this->getElapsedWashTime());
$included_minutes = $this->getMachineWashMinutesIncluded();
$included_minutes = $this->resolveIncludedMinutesForBilling();
$billable_minutes = $this->calculateBillableMinutes($elapsed_minutes, $included_minutes);
if ($billable_minutes <= 0) {
@@ -116,13 +120,14 @@ trait selfserve_lane_invoice_t
}
$order = $this->createInvoiceOrderContext();
$this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
$this->billable_minutes_order_item = $this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
return true;
}
/**
* Add the current vehicle type product as a single order line on the latest invoice order.
* Returns true when the line was added, false when vehicle/product context was unavailable.
* @throws Exception
*/
public function addVehicleTypeProductToLastInvoiceOrder(): bool
{
@@ -135,13 +140,25 @@ trait selfserve_lane_invoice_t
return false;
}
(new order_items_o())->addItemToOrder(
$primary_vehicle_order_item = (new order_items_o())->addItemToOrder(
(int)$this->last_invoice_order_id,
$vehicle_type_product_id,
self::INVOICE_SYSTEM_USER_ID,
1
);
// If the billable minutes order item isn't defined, throw an exception as this method should only be called in the context of an existing invoice with a billable minutes line
if ($this->billable_minutes_order_item === null) {
throw new \Exception("Billable minutes order item is not defined; cannot adjust quantity for included minutes.");
}
// If the time exceeds the included minutes, reduce the quantity by the included minutes, if not delete the order item
if ($this->billable_minutes_order_item->quantity->value() > $this->machine_wash_minutes_included) {
$this->billable_minutes_order_item->quantity->set($this->billable_minutes_order_item->quantity->value() - $this->machine_wash_minutes_included);
} else {
$this->billable_minutes_order_item->delete();
}
return true;
}
@@ -170,6 +187,21 @@ trait selfserve_lane_invoice_t
return (int)ceil($elapsed_wash_time_seconds / 60);
}
protected function resolveIncludedMinutesForBilling(): int
{
if (!$this->shouldApplyIncludedMinutesReduction()) {
return 0;
}
return $this->getMachineWashMinutesIncluded();
}
protected function shouldApplyIncludedMinutesReduction(): bool
{
// Only apply included minutes reduction for machine wash mode
return $this->getLaneMode() === selfserve_lane_mode::AUTOMATIC;
}
protected function calculateBillableMinutes(int $elapsed_minutes, int $included_minutes): int
{
if ($elapsed_minutes <= 0) {
@@ -196,9 +228,9 @@ trait selfserve_lane_invoice_t
return $order;
}
protected function addMinuteBillingLine(int $order_id, int $product_id, int $quantity): void
protected function addMinuteBillingLine(int $order_id, int $product_id, int $quantity): order_items_o
{
(new order_items_o())->addItemToOrder(
return (new order_items_o())->addItemToOrder(
$order_id,
$product_id,
self::INVOICE_SYSTEM_USER_ID,
@@ -57,4 +57,16 @@ class shelly_device_switch extends shelly_device_state
{
return (new shelly())->sendPostRequest('/v2/devices/api/set/switch', $parameters);
}
/**
* @throws Exception
*/
public function getStatus(): array|object|null
{
$parameters = [
'id' => (string)$this->id,
'channel' => (int)$this->channel,
];
return $this->sendShellySwitchRequest($parameters);
}
}
+2 -1
View File
@@ -161,7 +161,7 @@ class order_items_o extends db
}
}
public function addItemToOrder(int $order_id, int $product_id, int $cashier_id, int $quantity, $related_item_id = null, $notes = null, $forcePrice = null): void
public function addItemToOrder(int $order_id, int $product_id, int $cashier_id, int $quantity, $related_item_id = null, $notes = null, $forcePrice = null): order_items_o
{
global $db, $response;
try {
@@ -199,6 +199,7 @@ class order_items_o extends db
}
// Invalidate the order cache
$order->objectChanged();
return (new order_items_o())->select($this->id);
} catch (Exception $e) {
$response->error($e->getMessage());
@@ -0,0 +1,131 @@
<?php
app_require('modules/selfserve/traits/selfserve_lane_invoice_t.php');
app_require('modules/selfserve/helpers/selfserve_lane_mode.php');
app_require('modules/selfserve/helpers/selfserve_lane_status.php');
app_require('objects/orders_o.php');
use modules\selfserve\helpers\selfserve_lane_mode;
use modules\selfserve\helpers\selfserve_lane_status;
use modules\selfserve\traits\selfserve_lane_invoice_t;
use objects\orders_o;
class SelfserveLaneInvoiceModeOrderStub extends orders_o
{
public function __construct() {}
}
class SelfserveLaneInvoiceModeBillingHarness
{
use selfserve_lane_invoice_t;
public int $id = 101;
public ?int $lastAddedOrderId = null;
public ?int $lastAddedProductId = null;
public ?int $lastAddedQuantity = null;
private selfserve_lane_status $laneStatus = selfserve_lane_status::OCCUPIED;
private selfserve_lane_mode $laneMode = selfserve_lane_mode::MANUAL;
private int $customerNumber = 1234;
private string $licensePlate = 'AB12345';
private int $elapsedWashTimeSeconds = 0;
private int $minuteBillingProductId = 999;
private int $includedMinutes = 0;
public function setLaneModeForTest(selfserve_lane_mode $mode): void
{
$this->laneMode = $mode;
}
public function setElapsedWashTimeSecondsForTest(int $seconds): void
{
$this->elapsedWashTimeSeconds = $seconds;
}
public function setIncludedMinutesForTest(int $minutes): void
{
$this->includedMinutes = $minutes;
}
public function getLaneStatus(): selfserve_lane_status
{
return $this->laneStatus;
}
public function getLaneMode(): selfserve_lane_mode
{
return $this->laneMode;
}
public function getCustomerNumber(): int
{
return $this->customerNumber;
}
public function getLicensePlate(): string
{
return $this->licensePlate;
}
public function getMinuteBillingProductId(): ?int
{
return $this->minuteBillingProductId;
}
public function getElapsedWashTime(): ?int
{
return $this->elapsedWashTimeSeconds;
}
public function getMachineWashMinutesIncluded(): int
{
return $this->includedMinutes;
}
protected function createInvoiceOrderContext(): orders_o
{
$order = new SelfserveLaneInvoiceModeOrderStub();
$order->id = 424242;
$this->last_invoice_order_id = (int)$order->id;
return $order;
}
protected function addMinuteBillingLine(int $order_id, int $product_id, int $quantity): void
{
$this->lastAddedOrderId = $order_id;
$this->lastAddedProductId = $product_id;
$this->lastAddedQuantity = $quantity;
}
}
it('bills manual self-serve stop using full elapsed minutes without included-minute reduction', function (): void {
$harness = new SelfserveLaneInvoiceModeBillingHarness();
$harness->setLaneModeForTest(selfserve_lane_mode::MANUAL);
$harness->setElapsedWashTimeSecondsForTest(59);
$harness->setIncludedMinutesForTest(5);
$result = $harness->invoice();
expect($result)->toBeTrue();
expect($harness->lastAddedOrderId)->toBe(424242);
expect($harness->lastAddedProductId)->toBe(999);
expect($harness->lastAddedQuantity)->toBe(1);
expect($harness->getLastInvoiceOrderId())->toBe(424242);
});
it('keeps included-minute reduction for automatic mode', function (): void {
$harness = new SelfserveLaneInvoiceModeBillingHarness();
$harness->setLaneModeForTest(selfserve_lane_mode::AUTOMATIC);
$harness->setElapsedWashTimeSecondsForTest(59);
$harness->setIncludedMinutesForTest(5);
$result = $harness->invoice();
expect($result)->toBeTrue();
expect($harness->lastAddedOrderId)->toBeNull();
expect($harness->lastAddedProductId)->toBeNull();
expect($harness->lastAddedQuantity)->toBeNull();
expect($harness->getLastInvoiceOrderId())->toBeNull();
});