Refactor and centralize Shelly rate-limiting logic in the shelly class, add global enforcement and unit tests, and remove redundant implementations in self-serve lane controllers.

This commit is contained in:
Jeppe Bundgaard
2026-03-26 20:15:36 +01:00
parent 0d825fb45a
commit b42a1a69a0
12 changed files with 415 additions and 111 deletions
Binary file not shown.
+88 -1
View File
@@ -13,6 +13,10 @@ use shelly\shelly_c;
class shelly implements shelly_i
{
private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20;
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;
private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate';
/**
* Configuration of the shelly module
* @var shelly_c
@@ -146,6 +150,7 @@ class shelly implements shelly_i
self::requireModuleEnabled();
self::requireValidSecretKey();
self::requireValidServerURL();
$this->waitForShellyRateLimitWindow();
// Send the request
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, self::appendAuthKeyToQuery($this->config->server_url->getVariableValue() . $endpoint));
@@ -185,4 +190,86 @@ class shelly implements shelly_i
{
return $url . '?auth_key=' . $this->config->secret_key->getVariableValue();
}
}
/**
* @throws Exception
*/
private function waitForShellyRateLimitWindow(): void
{
$deadline = $this->nowTimestamp() + self::SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS;
do {
if ($this->tryAcquireShellyRateLimitSlot()) {
return;
}
if ($this->nowTimestamp() >= $deadline) {
throw new Exception('Shelly rate limit gate wait timed out');
}
$remaining_ms = $this->getShellyRateLimitSlotRemainingMs();
if ($remaining_ms <= 0) {
$remaining_ms = 50;
}
$this->sleepMicroseconds(min($remaining_ms, 250) * 1000);
} while (true);
}
private function tryAcquireShellyRateLimitSlot(): bool
{
$redis = $this->redisFacade();
if ($redis === null) {
return true;
}
try {
$result = $redis->get_client()->set(
self::SHELLY_RATE_LIMIT_GATE_KEY,
(string)$this->nowTimestamp(),
'PX',
self::SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS,
'NX'
);
return $result === true || strtoupper((string)$result) === 'OK';
} catch (\Throwable) {
// If Redis gate can't be evaluated, fail open to avoid blocking API traffic completely.
return true;
}
}
private function getShellyRateLimitSlotRemainingMs(): int
{
$redis = $this->redisFacade();
if ($redis === null) {
return 0;
}
try {
$ttl = $redis->get_client()->pttl(self::SHELLY_RATE_LIMIT_GATE_KEY);
if (!is_numeric($ttl)) {
return 0;
}
$ttl = (int)$ttl;
return $ttl > 0 ? $ttl : 0;
} catch (\Throwable) {
return 0;
}
}
protected function redisFacade(): mixed
{
return defined('redis') ? redis : null;
}
protected function nowTimestamp(): float
{
return microtime(true);
}
protected function sleepMicroseconds(int $microseconds): void
{
if ($microseconds > 0) {
usleep($microseconds);
}
}
}
@@ -50,14 +50,14 @@ trait selfserve_lane_command_t
}
/**
* Resolve whether the program selector relay currently reports ON.
* Resolve whether the program selector relay is currently online.
* Fail-closed to false when relay status cannot be read.
*/
protected function isProgramSelectorRelayOnForStop(): bool
protected function isProgramSelectorRelayOnlineForStop(): bool
{
try {
$status = $this->getMachineProgramPickerRelayStatus();
return (bool)($status['on'] ?? false);
return (bool)($status['online'] ?? false);
} catch (\Throwable) {
return false;
}
@@ -128,14 +128,12 @@ trait selfserve_lane_command_t
/**
* Disable relays after STOP in deterministic order:
* 1. Cleaner relay
* 2. Program selector relay
* 3. Machine relay
* 2. Machine relay
*/
protected function turnOffRelaysAfterStop(): void
{
$relays = [
selfserve_lane_relay::MACHINE_CLEANER,
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
selfserve_lane_relay::MACHINE,
];
@@ -322,20 +320,20 @@ trait selfserve_lane_command_t
if (($this->getCustomerNumber() !== $arguments->customer_number) && !$this->isBypassCustomerNumberValidation()) {
throw new \InvalidArgumentException("Customer number mismatch: Lane customer number " . $this->getCustomerNumber() . " does not match argument customer number " . $arguments->customer_number);
}
// Snapshot program selector state before relay shutdown.
$program_selector_on = $this->isProgramSelectorRelayOnForStop();
// Snapshot selector relay online state before relay shutdown.
$program_selector_online = $this->isProgramSelectorRelayOnlineForStop();
// Open the exit port
$this->open(selfserve_lane_port::EXIT);
// Turn off configured relays in deterministic order.
// Turn off relays in deterministic order after STOP
$this->turnOffRelaysAfterStop();
// Log the lane stop event
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
// Finalize any active self-serve wash session before resetting lane state.
$this->completeLatestSessionForStop();
// Invoice the customer
$this->invoice();
// If program selector was ON, add vehicle-type product to the self-serve order.
$this->addVehicleTypeProductToInvoiceIfNeeded($program_selector_on);
// If program selector relay is online at stop time, bill the primary product.
$this->addVehicleTypeProductToInvoiceIfNeeded($program_selector_online);
// Finalize any active self-serve wash session for this lane
$this->completeLatestSessionForStop();
// Reset the lane
self::execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments());
break;
@@ -59,21 +59,17 @@ trait selfserve_lane_port_controller_t
*/
public function shellyOpenPort(selfserve_lane_port $port): bool
{
// Get the relay ID based on the port
// Queue the relay switch command to be executed asynchronously
$relay_id = match ($port) {
selfserve_lane_port::ENTRANCE => $this->department_lane->relay_out_id->value(),
selfserve_lane_port::EXIT => $this->department_lane->relay_in_id->value(),
default => throw new \Exception("Invalid port specified, must be ENTRANCE or EXIT"),
};
// Make sure relay ID is valid
if (empty($relay_id)) {
throw new \Exception("Invalid relay ID for port {$port->name}");
// If the relay ID indicates a demo port, skip the Shelly switch call but keep the logging and state changes
if ($this->isDemoPortRelayId($relay_id)) {
return true;
}
//if ($this->isDemoPortRelayId((string)$relay_id)) {
// return true;
//}
$device = $this->createShellySwitchDevice();
$device->id = (string)$relay_id;
$device->switch(true);
@@ -14,8 +14,6 @@ trait selfserve_lane_relay_controller_t
{
private const SHELLY_STATUS_WAIT_TIMEOUT_SECONDS = 20;
private const SHELLY_RETRY_SLEEP_MICROSECONDS = 250000;
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 1000;
private const SHELLY_RATE_LIMIT_GATE_KEY = 'selfserve_shelly_cloud_rate_limit_gate';
private const SHELLY_STATUS_SNAPSHOT_TTL_SECONDS = 1;
private const SHELLY_STATUS_SNAPSHOT_KEY_PREFIX = 'selfserve_lane_shelly_status_snapshot_';
private const SHELLY_DEFAULT_CHANNEL = 0;
@@ -94,6 +92,16 @@ trait selfserve_lane_relay_controller_t
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, $on);
}
/**
* Set PROGRAM SELECTOR relay status directly, bypassing lane status guards.
* Intended for department-level operational toggles.
* @throws \Exception
*/
public function setProgramSelectorRelayStatusHard(bool $on): bool
{
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on);
}
/**
* Set MACHINE_PROGRAM_PICKER relay status directly.
* @param bool $on true to turn on, false to turn off
@@ -349,7 +357,7 @@ trait selfserve_lane_relay_controller_t
$last_reason = 'Shelly relay status is not ready yet';
do {
$result = $this->sendShellyPostRateLimited('/v2/devices/api/get', $this->buildStatusGetPayload($cloud_relay_ids));
$result = $this->sendShellyPost('/v2/devices/api/get', $this->buildStatusGetPayload($cloud_relay_ids));
$snapshot = $this->appendDemoRelaySnapshots(
$this->mapResponseToRelaySnapshot($result),
$demo_relay_ids
@@ -667,80 +675,6 @@ trait selfserve_lane_relay_controller_t
);
}
/**
* @throws \Exception
*/
private function sendShellyPostRateLimited(string $endpoint, array $payload): array|object|null
{
$this->waitForShellyRateLimitWindow();
return $this->sendShellyPost($endpoint, $payload);
}
/**
* @throws \Exception
*/
private function waitForShellyRateLimitWindow(): void
{
$deadline = $this->nowTimestamp() + self::SHELLY_STATUS_WAIT_TIMEOUT_SECONDS;
do {
if ($this->tryAcquireShellyRateLimitSlot()) {
return;
}
if ($this->nowTimestamp() >= $deadline) {
throw new \Exception('Shelly rate limit gate wait timed out');
}
$remaining_ms = $this->getShellyRateLimitSlotRemainingMs();
if ($remaining_ms <= 0) {
$remaining_ms = 50;
}
$this->sleepMicroseconds(min($remaining_ms, 250) * 1000);
} while (true);
}
private function tryAcquireShellyRateLimitSlot(): bool
{
$redis = $this->redisFacade();
if ($redis === null) {
return true;
}
try {
$result = $redis->get_client()->set(
self::SHELLY_RATE_LIMIT_GATE_KEY,
(string)$this->nowTimestamp(),
'PX',
self::SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS,
'NX'
);
return $result === true || strtoupper((string)$result) === 'OK';
} catch (\Throwable) {
// If Redis gate can't be evaluated, fail open to avoid blocking relay control completely.
return true;
}
}
private function getShellyRateLimitSlotRemainingMs(): int
{
$redis = $this->redisFacade();
if ($redis === null) {
return 0;
}
try {
$ttl = $redis->get_client()->pttl(self::SHELLY_RATE_LIMIT_GATE_KEY);
if (!is_numeric($ttl)) {
return 0;
}
$ttl = (int)$ttl;
return $ttl > 0 ? $ttl : 0;
} catch (\Throwable) {
return 0;
}
}
/**
* @throws \Exception
*/
@@ -890,7 +824,7 @@ trait selfserve_lane_relay_controller_t
$response = $this->isDemoRelayId($relay_id)
? [$this->buildDemoRelaySnapshotEntry($relay_id, $on)]
: $this->sendShellyPostRateLimited('/v2/devices/api/set/switch', $payload);
: $this->sendShellyPost('/v2/devices/api/set/switch', $payload);
$this->seedLaneShellySnapshotFromSwitch($relay_id, $on, $response);
return true;
}
@@ -47,6 +47,14 @@ class shelly_device_switch extends shelly_device_state
'on' => $on,
...($skip_toggle_after ? [] : ['toggle_after' => (int)$this->toggle_after]),
];
return $this->sendShellySwitchRequest($parameters);
}
/**
* @throws Exception
*/
protected function sendShellySwitchRequest(array $parameters): array|object|null
{
return (new shelly())->sendPostRequest('/v2/devices/api/set/switch', $parameters);
}
}
}
@@ -49,6 +49,18 @@ class SelfserveLanePortSwitchFake extends shelly_device_switch
}
}
class SelfservePortSwitchOutputFake extends shelly_device_switch
{
/** @var array<int,array> */
public array $switchCalls = [];
protected function sendShellySwitchRequest(array $parameters): array|object|null
{
$this->switchCalls[] = $parameters;
return ['ok' => true];
}
}
class SelfserveLanePortControllerHarness
{
use selfserve_lane_port_controller_t;
@@ -131,3 +143,15 @@ it('keeps demo relay gate-open queued but skips Shelly switch calls', function (
expect($lane->laneState)->toBe(selfserve_lane_state::EXIT_PORT_OPEN_QUEUED);
expect($lane->switchFake->switchCalls)->toBe([]);
});
it('does not print Shelly switch responses to output', function (): void {
$switch = new SelfservePortSwitchOutputFake();
$switch->id = 'relay-port-123';
ob_start();
$switch->switch(true);
$output = (string)ob_get_clean();
expect($output)->toBe('');
expect($switch->switchCalls)->toHaveCount(1);
});
@@ -306,7 +306,7 @@ it('batches sequential machine relay status requests into a single Shelly get ca
expect($harness->shellyCalls[0]['payload']['select'])->toBe(['status']);
});
it('respects the 1 request per second Shelly gate for back-to-back requests', function (): void {
it('keeps back-to-back get/switch requests ordered through the relay controller', function (): void {
$harness = selfserve_lane_shelly_test_harness();
$harness->queueShellyResponse('/v2/devices/api/get', [
[
@@ -330,7 +330,30 @@ it('respects the 1 request per second Shelly gate for back-to-back requests', fu
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
expect($harness->shellyCalls[0]['endpoint'])->toBe('/v2/devices/api/get');
expect($harness->shellyCalls[1]['endpoint'])->toBe('/v2/devices/api/set/switch');
expect(array_sum($harness->sleepCalls))->toBeGreaterThanOrEqual(1000000);
});
it('executes sequential switch requests used by wash start and stop flows', function (): void {
$harness = selfserve_lane_shelly_test_harness();
for ($i = 0; $i < 5; $i++) {
$harness->queueShellyResponse('/v2/devices/api/set/switch', [
[
'id' => 'relay-machine',
'online' => true,
'status' => ['switch:0' => ['output' => true]],
],
]);
}
// START-like sequence.
$harness->setMachineCleanerRelayStatusHard(true);
$harness->setMachineRelayStatusHard(true);
// STOP-like sequence.
$harness->setMachineCleanerRelayStatusHard(false);
$harness->setMachineProgramPickerRelayStatusHard(false);
$harness->setMachineRelayStatusHard(false);
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(5);
});
it('retries missing Shelly status payloads until relay status becomes ready', function (): void {
@@ -68,14 +68,17 @@ class SelfserveLaneStopFlowHarness
private int $washStartTime = 120;
private ?int $reservationStartTime = null;
private bool $bypassCustomerValidation = false;
private bool $programSelectorOnline;
private bool $programSelectorOn;
public function __construct(
bool $programSelectorOn,
bool $programSelectorOnline,
bool $programSelectorOn = true,
string $machineRelayId = 'relay-machine',
string $programRelayId = 'relay-program',
string $cleanerRelayId = 'relay-cleaner'
) {
$this->programSelectorOnline = $programSelectorOnline;
$this->programSelectorOn = $programSelectorOn;
$this->department_lane = new SelfserveLaneCommandDepartmentLaneFake(
$machineRelayId,
@@ -162,7 +165,10 @@ class SelfserveLaneStopFlowHarness
public function getMachineProgramPickerRelayStatus(): array
{
return ['on' => $this->programSelectorOn];
return [
'online' => $this->programSelectorOnline,
'on' => $this->programSelectorOn,
];
}
public function open(selfserve_lane_port $port): bool
@@ -193,8 +199,8 @@ class SelfserveLaneStopFlowHarness
}
}
it('adds vehicle type product on STOP when program selector is on, then turns off cleaner machine and selector relays', function (): void {
$lane = new SelfserveLaneStopFlowHarness(programSelectorOn: true);
it('adds vehicle type product on STOP when program selector relay is online, then turns off cleaner and machine relays', function (): void {
$lane = new SelfserveLaneStopFlowHarness(programSelectorOnline: true);
$args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234);
$lane->execute(selfserve_lane_command::STOP, $args);
@@ -205,15 +211,14 @@ it('adds vehicle type product on STOP when program selector is on, then turns of
expect($lane->openedPorts)->toBe([selfserve_lane_port::EXIT]);
expect($lane->turnedOffRelays)->toBe([
selfserve_lane_relay::MACHINE_CLEANER,
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
selfserve_lane_relay::MACHINE,
]);
expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE);
});
it('skips vehicle type product add when program selector is off and only disables configured relays', function (): void {
it('skips vehicle type product add when program selector relay is offline and only disables configured relays', function (): void {
$lane = new SelfserveLaneStopFlowHarness(
programSelectorOn: false,
programSelectorOnline: false,
machineRelayId: 'relay-machine',
programRelayId: 'relay-program',
cleanerRelayId: ''
@@ -226,7 +231,20 @@ it('skips vehicle type product add when program selector is off and only disable
expect($lane->ensureInvoiceOrderContextCalls)->toBe(0);
expect($lane->vehicleTypeProductAddCalls)->toBe(0);
expect($lane->turnedOffRelays)->toBe([
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
selfserve_lane_relay::MACHINE,
]);
});
it('bills primary product when selector relay is online even if relay output is off', function (): void {
$lane = new SelfserveLaneStopFlowHarness(
programSelectorOnline: true,
programSelectorOn: false
);
$args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234);
$lane->execute(selfserve_lane_command::STOP, $args);
expect($lane->invoiceCalls)->toBe(1);
expect($lane->ensureInvoiceOrderContextCalls)->toBe(1);
expect($lane->vehicleTypeProductAddCalls)->toBe(1);
});
@@ -0,0 +1,173 @@
<?php
app_require('classes/shelly.php');
use classes\shelly;
class GlobalShellyRateLimitClock
{
public float $now = 0.0;
}
class GlobalShellyRateLimitRedisClientFake
{
public function __construct(private readonly GlobalShellyRateLimitRedisFake $owner) {}
public function set(string $key, string $value, mixed ...$args): bool|string
{
return $this->owner->clientSet($key, $value, $args);
}
public function pttl(string $key): int
{
return $this->owner->clientPttl($key);
}
}
class GlobalShellyRateLimitRedisFake
{
/** @var array<string,string> */
private array $store = [];
/** @var array<string,float> */
private array $expiresAt = [];
private GlobalShellyRateLimitRedisClientFake $client;
public function __construct(private readonly GlobalShellyRateLimitClock $clock)
{
$this->client = new GlobalShellyRateLimitRedisClientFake($this);
}
public function get_client(): GlobalShellyRateLimitRedisClientFake
{
return $this->client;
}
public function clientSet(string $key, string $value, array $args): bool|string
{
$this->purgeExpired($key);
$useNx = false;
$ttlMs = null;
$count = count($args);
for ($i = 0; $i < $count; $i++) {
$token = strtoupper((string)$args[$i]);
if ($token === 'NX') {
$useNx = true;
continue;
}
if ($token === 'PX' && isset($args[$i + 1])) {
$ttlMs = max(1, (int)$args[$i + 1]);
$i++;
}
}
if ($useNx && array_key_exists($key, $this->store)) {
return false;
}
$this->store[$key] = $value;
if ($ttlMs === null) {
unset($this->expiresAt[$key]);
} else {
$this->expiresAt[$key] = $this->clock->now + ($ttlMs / 1000);
}
return 'OK';
}
public function clientPttl(string $key): int
{
$this->purgeExpired($key);
if (!array_key_exists($key, $this->store)) {
return -2;
}
if (!isset($this->expiresAt[$key])) {
return -1;
}
$remainingMs = (int)ceil(($this->expiresAt[$key] - $this->clock->now) * 1000);
return $remainingMs > 0 ? $remainingMs : 0;
}
private function purgeExpired(string $key): void
{
if (!isset($this->expiresAt[$key])) {
return;
}
if ($this->clock->now < $this->expiresAt[$key]) {
return;
}
unset($this->expiresAt[$key], $this->store[$key]);
}
}
class GlobalShellyRateLimitHarness extends shelly
{
/** @var array<int,int> */
public array $sleepCalls = [];
public function __construct(
private readonly GlobalShellyRateLimitClock $clock,
private readonly ?GlobalShellyRateLimitRedisFake $redis = null
) {
}
public function waitForShellyRateLimitWindowForTest(): void
{
$invoke = \Closure::bind(function (): void {
$this->waitForShellyRateLimitWindow();
}, $this, shelly::class);
$invoke();
}
protected function redisFacade(): mixed
{
return $this->redis;
}
protected function nowTimestamp(): float
{
return $this->clock->now;
}
protected function sleepMicroseconds(int $microseconds): void
{
if ($microseconds <= 0) {
return;
}
$this->sleepCalls[] = $microseconds;
$this->clock->now += ($microseconds / 1000000);
}
}
it('enforces the 2 second Shelly gate across separate request contexts', function (): void {
$clock = new GlobalShellyRateLimitClock();
$redis = new GlobalShellyRateLimitRedisFake($clock);
$requestA = new GlobalShellyRateLimitHarness($clock, $redis);
$requestB = new GlobalShellyRateLimitHarness($clock, $redis);
$requestA->waitForShellyRateLimitWindowForTest();
$timeBeforeRequestB = $clock->now;
$requestB->waitForShellyRateLimitWindowForTest();
expect(array_sum($requestA->sleepCalls))->toBe(0);
expect(array_sum($requestB->sleepCalls))->toBeGreaterThanOrEqual(2000000);
expect($clock->now - $timeBeforeRequestB)->toBeGreaterThanOrEqual(2.0);
});
it('does not delay when the Shelly gate is already expired', function (): void {
$clock = new GlobalShellyRateLimitClock();
$redis = new GlobalShellyRateLimitRedisFake($clock);
$firstRequest = new GlobalShellyRateLimitHarness($clock, $redis);
$firstRequest->waitForShellyRateLimitWindowForTest();
$clock->now += 2.1;
$secondRequest = new GlobalShellyRateLimitHarness($clock, $redis);
$secondRequest->waitForShellyRateLimitWindowForTest();
expect(array_sum($secondRequest->sleepCalls))->toBe(0);
});
@@ -0,0 +1,25 @@
<?php
it('enforces a global 2 second Shelly gate in sendPostRequest', function (): void {
$shellyClass = file_get_contents(app_path('classes/shelly.php'));
expect($shellyClass)->not->toBeFalse();
expect($shellyClass)->toContain('private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;');
expect($shellyClass)->toContain("private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate';");
$sendPostRequestOffset = strpos($shellyClass, 'function sendPostRequest(string $endpoint, array $data): array|object|null');
expect($sendPostRequestOffset)->not->toBeFalse();
$sendPostRequestBody = substr($shellyClass, (int)$sendPostRequestOffset, 2200);
expect($sendPostRequestBody)->toContain('$this->waitForShellyRateLimitWindow();');
});
it('uses Redis NX PX semantics for cross-request Shelly rate limiting', function (): void {
$shellyClass = file_get_contents(app_path('classes/shelly.php'));
expect($shellyClass)->not->toBeFalse();
expect($shellyClass)->toContain("self::SHELLY_RATE_LIMIT_GATE_KEY");
expect($shellyClass)->toContain("'PX'");
expect($shellyClass)->toContain("'NX'");
expect($shellyClass)->toContain('pttl(self::SHELLY_RATE_LIMIT_GATE_KEY)');
});
+18
View File
@@ -145,6 +145,13 @@ Accept: application/json
Content-Type: application/json
Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8
### GET request to weather (specific ID)
GET https://api.truckwash.io/departments/weather?id=1
Accept: application/json
Content-Type: application/json
Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8
### PUT request to order bookings (update specific ID)
PUT https://api.truckwash.dk:4433/order-bookings
Accept: application/json
@@ -178,6 +185,17 @@ Content-Type: application/json
"customer_number": 12345679
}
### Get department distribution e-conomic
GET http://localhost/api/superuser/invoicing/period/distribution/v2/booked-department-75
Accept: application/json
Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8
Content-Type: application/json
{
"dateFrom": "2026-01-01",
"dateTo": "2026-01-31"
}
###
### POST request to order bookings
POST https://api.truckwash.dk:4433/order-bookings