Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard db1b9a2c96 Fix e-conomic EAN customer transfer 2026-07-06 11:34:23 +02:00
15 changed files with 318 additions and 15 deletions
@@ -137,6 +137,10 @@ class customer_mass_import_service
if ($cvrLength < 8 || $cvrLength > 20) {
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400);
}
if ($normalized['ean'] !== null && strlen((string)$normalized['ean']) > 13) {
throw new \RuntimeException('EAN must be at most 13 digits.', 400);
}
}
protected function normalizePositiveInt(mixed $value): ?int
+29 -1
View File
@@ -172,7 +172,8 @@ class economic implements economic_i
string $email,
int $phone,
?int $mobile_phone = null,
object|array|null $company_information = null
object|array|null $company_information = null,
?string $ean = null
): object
{
$payload = [
@@ -196,10 +197,37 @@ class economic implements economic_i
];
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information));
$normalized_ean = self::normalizeCustomerEan($ean);
if ($normalized_ean !== null) {
$payload['ean'] = $normalized_ean;
}
return $this->customers->customers->create($payload);
}
public static function normalizeCustomerEan(mixed $value): ?string
{
if ($value === null) {
return null;
}
$digits = preg_replace('/\D+/', '', (string)$value);
if (!is_string($digits)) {
return null;
}
$digits = trim($digits);
if ($digits === '') {
return null;
}
if (strlen($digits) > 13) {
throw new \InvalidArgumentException('EAN must be at most 13 digits.');
}
return $digits;
}
private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
{
if ($company_information === null) {
@@ -14,6 +14,8 @@ class economic_customer_mo
public null|string $message;
public null|string $corporateIdentificationNumber;
public null|string $email;
public null|string $ean;
public null|string $publicEntryNumber;
public null|string $mobilePhone;
public null|string $currency;
public null|string $country;
@@ -46,6 +48,8 @@ class economic_customer_mo
$this->zip = ($customer->zip ?? null);
$this->corporateIdentificationNumber = ($customer->corporateIdentificationNumber ?? null);
$this->email = ($customer->email ?? null);
$this->ean = ($customer->ean ?? null);
$this->publicEntryNumber = ($customer->publicEntryNumber ?? $customer->public_entry_number ?? null);
$this->mobilePhone = ($customer->mobilePhone ?? null);
$this->currency = ($customer->currency ?? null);
$this->country = ($customer->country ?? null);
@@ -100,6 +104,8 @@ class economic_customer_mo
'zip' => $this->zip,
'corporateIdentificationNumber' => $this->corporateIdentificationNumber,
'email' => $this->email,
'ean' => $this->ean,
'publicEntryNumber' => $this->publicEntryNumber,
'mobilePhone' => $this->mobilePhone,
'currency' => $this->currency,
'country' => $this->country,
@@ -127,6 +127,23 @@ class economic_invoices_drafts_endpoint
$customer_address = $customer->getAddress() ?? 'Ukendt';
$customer_zip = $customer->getZipCode() ?? 'Ukendt';
$customer_city = $customer->getCity() ?? 'Ukendt';
$recipient = [
'name' => $customer_name,
'address' => $customer_address,
'zip' => $customer_zip,
'city' => $customer_city,
'vatZone' => [
'vatZoneNumber' => (int)$customer->getVatZoneNumber(),
],
];
$customer_ean = $customer->getEan();
if ($customer_ean !== null) {
$recipient['ean'] = $customer_ean;
}
$public_entry_number = $customer->getPublicEntryNumber();
if ($public_entry_number !== null) {
$recipient['publicEntryNumber'] = $public_entry_number;
}
// Send the request
$response = $this->send_request(
@@ -165,15 +182,7 @@ class economic_invoices_drafts_endpoint
'currency' => $customer->getCurrency() ?? 'DKK',
// Set the recipient details
'recipient' => [
'name' => $customer_name,
'address' => $customer_address,
'zip' => $customer_zip,
'city' => $customer_city,
'vatZone' => [
'vatZoneNumber' => (int)$customer->getVatZoneNumber(),
],
],
'recipient' => $recipient,
])
);
// Return the response as an object
@@ -194,4 +203,4 @@ class economic_invoices_drafts_endpoint
}
}
}
}
@@ -149,6 +149,29 @@ class economic_customer
return $this->customer_data_object->email;
}
public function getEan(): ?string
{
self::requireSelected();
return $this->nullableStringField('ean');
}
public function getPublicEntryNumber(): ?string
{
self::requireSelected();
return $this->nullableStringField('publicEntryNumber');
}
protected function nullableStringField(string $field): ?string
{
$value = $this->customer_data_object->{$field} ?? null;
if ($value === null) {
return null;
}
$normalized = trim((string)$value);
return $normalized !== '' ? $normalized : null;
}
/**
* Get the customer address
* @return string The customer address
@@ -227,4 +250,4 @@ class economic_customer
return $this->customer_data_object->vatZone->vatZoneNumber;
}
}
}
+12
View File
@@ -2595,6 +2595,12 @@ paths:
type: string
description: Contact person name
example: "Mikkel"
ean:
type: string
description: Optional EAN used for e-invoicing in e-conomic
maxLength: 13
pattern: '^[0-9]{1,13}$'
example: "5790001234567"
g_recaptcha_response:
type: string
description: reCAPTCHA verification token
@@ -8653,6 +8659,12 @@ paths:
email: {type: string}
phone: {type: integer}
name: {type: string}
ean:
type: string
description: Optional EAN used for e-invoicing in e-conomic
maxLength: 13
pattern: '^[0-9]{1,13}$'
example: "5790001234567"
responses:
'200':
description: Success
+9
View File
@@ -427,6 +427,7 @@ class authRoute
$contactEmail = self::getParameter('contactEmail');
$contactPhone = (int)self::getParameter('contactPhone');
$contactName = self::getParameter('contactName');
$ean = null;
/**
* Validate
*/
@@ -454,6 +455,13 @@ class authRoute
self::requireMinValue($contactPhone, 10000000);
self::requireMaxValue($contactPhone, 9999999999);
}
if (self::isParametersSet(['ean'])) {
try {
$ean = economic::normalizeCustomerEan(self::getParameter('ean'));
} catch (\InvalidArgumentException $exception) {
$response->error($exception->getMessage(), 400);
}
}
/**
* If the contact phone is empty, default to company phone
@@ -545,6 +553,7 @@ class authRoute
(int)$companyPhone,
(int)$contactPhone,
$companyInformation,
$ean,
);
} catch (Exception $exception) {
$recoveredCustomer = $this->recoverRegistrationAfterCreateFailure(
@@ -60,6 +60,14 @@ class moduleEconomicCustomerRoute
self::requireMaxLength('phone', 255);
self::requireMinLength('name', 1);
self::requireMaxLength('name', 255);
$ean = null;
if (self::isParametersSet(['ean'])) {
try {
$ean = economic::normalizeCustomerEan(self::getParameter('ean'));
} catch (\InvalidArgumentException $exception) {
$response->error($exception->getMessage(), 400);
}
}
(new logs_o())->add('modules_economic', 'global', 1, 0, 'MODULES_ECONOMIC', 'User accessed the customer');
$result = (new economic())->createCustomer(
(int)self::getParameter('customer_number'),
@@ -67,6 +75,9 @@ class moduleEconomicCustomerRoute
(int)self::getParameter('cvr'),
(string)self::getParameter('email'),
(int)self::getParameter('phone'),
null,
null,
$ean,
);
$response->success((object)$result);
} else {
@@ -76,4 +87,4 @@ class moduleEconomicCustomerRoute
});
}
}
}
@@ -62,6 +62,7 @@ it('returns the raw upstream create response and preserves the requested payload
expect($probe->inner->lastPayload['phone'])->toBe(42331123);
expect($probe->inner->lastPayload['telephoneAndFaxNumber'])->toBe('42331123');
expect($probe->inner->lastPayload['mobilePhone'])->toBe('42331123');
expect(array_key_exists('ean', $probe->inner->lastPayload))->toBeFalse();
});
it('adds supported CVR company fields to the e-conomic customer payload', function (): void {
@@ -97,3 +98,46 @@ it('adds supported CVR company fields to the e-conomic customer payload', functi
expect($probe->inner->lastPayload['mobilePhone'])->toBe('55667788');
expect(array_key_exists('industrycode', $probe->inner->lastPayload))->toBeFalse();
});
it('adds a normalized EAN to the e-conomic customer payload when provided', function (): void {
$stubResponse = (object)[
'customerNumber' => 42331123,
'name' => 'Truckwash ApS',
];
$probe = new EconomicCreateCustomerProbe($stubResponse);
$probe->createCustomer(
42331123,
'Truckwash ApS',
37781258,
'invoice@truckwash.test',
42331123,
null,
null,
'57 90-001234567',
);
expect($probe->inner->lastPayload['ean'])->toBe('5790001234567');
});
it('rejects EAN values longer than e-conomic accepts', function (): void {
$stubResponse = (object)[
'customerNumber' => 42331123,
'name' => 'Truckwash ApS',
];
$probe = new EconomicCreateCustomerProbe($stubResponse);
$call = static fn() => $probe->createCustomer(
42331123,
'Truckwash ApS',
37781258,
'invoice@truckwash.test',
42331123,
null,
null,
'57900012345678',
);
expect($call)->toThrow(InvalidArgumentException::class, 'EAN must be at most 13 digits.');
expect($probe->inner->lastPayload)->toBe([]);
});
@@ -176,6 +176,22 @@ it('creates a new e-conomic customer and returns a created result for new rows',
expect($result['has_account'])->toBeFalse();
});
it('rejects EAN values longer than e-conomic accepts before creating customers', function (): void {
$service = new CustomerMassImportServiceProbe();
$call = static fn() => $service->import([
'cvr' => '29424764',
'name' => 'TGP TRANSPORT APS',
'email' => 'tgp@example.com',
'ean' => '57900012345678',
'phone' => '22725567',
]);
expect($call)->toThrow(RuntimeException::class, 'EAN must be at most 13 digits.');
expect($service->createCalls)->toBe([]);
expect($service->bootstrapCalls)->toBe([]);
});
it('creates the economic record for an existing local account when no matching upstream customer exists', function (): void {
$service = new CustomerMassImportServiceProbe();
$service->localExists = true;
@@ -0,0 +1,40 @@
<?php
app_require('modules/economic/helpers/economic_customer.php');
use helpers\economic_customer;
function economic_customer_helper_from_payload(object $payload): economic_customer
{
$reflection = new ReflectionClass(economic_customer::class);
/** @var economic_customer $customer */
$customer = $reflection->newInstanceWithoutConstructor();
$property = $reflection->getProperty('customer_data_object');
$property->setAccessible(true);
$property->setValue($customer, $payload);
return $customer;
}
it('exposes optional EAN and public entry number from fetched e-conomic customer data', function (): void {
$customer = economic_customer_helper_from_payload((object)[
'customerNumber' => 42331123,
'ean' => ' 5790001234567 ',
'publicEntryNumber' => ' DK123456789 ',
]);
expect($customer->getEan())->toBe('5790001234567');
expect($customer->getPublicEntryNumber())->toBe('DK123456789');
});
it('returns null for blank optional e-conomic customer recipient identifiers', function (): void {
$customer = economic_customer_helper_from_payload((object)[
'customerNumber' => 42331123,
'ean' => ' ',
'publicEntryNumber' => '',
]);
expect($customer->getEan())->toBeNull();
expect($customer->getPublicEntryNumber())->toBeNull();
});
@@ -0,0 +1,50 @@
<?php
function economic_ean_openapi_content_or_skip(): string
{
$candidates = [];
for ($depth = 1; $depth <= 8; $depth++) {
$candidates[] = dirname(__DIR__, $depth) . DIRECTORY_SEPARATOR . 'openapi.yaml';
}
$cwd = getcwd();
if (is_string($cwd) && $cwd !== '') {
$candidates[] = $cwd . DIRECTORY_SEPARATOR . 'openapi.yaml';
$candidates[] = dirname($cwd) . DIRECTORY_SEPARATOR . 'openapi.yaml';
}
foreach (array_values(array_unique($candidates)) as $candidate) {
if (is_file($candidate)) {
$content = file_get_contents($candidate);
if ($content !== false) {
return $content;
}
}
}
test()->markTestSkipped('openapi.yaml is not available in this runtime environment.');
}
function economic_ean_openapi_block(string $content, string $start, string $end): string
{
$start_pos = strpos($content, $start);
$end_pos = strpos($content, $end);
expect($start_pos)->not->toBeFalse();
expect($end_pos)->not->toBeFalse();
expect($end_pos)->toBeGreaterThan($start_pos);
return substr($content, (int)$start_pos, (int)$end_pos - (int)$start_pos);
}
it('documents optional EAN on customer creation endpoints', function (): void {
$content = economic_ean_openapi_content_or_skip();
$register_block = economic_ean_openapi_block($content, '/auth/register/cvr:', '/auth/password-reset/request:');
$economic_customer_block = economic_ean_openapi_block($content, '/modules/economic/customer:', '/economic/layouts:');
foreach ([$register_block, $economic_customer_block] as $block) {
expect($block)->toContain('ean:');
expect($block)->toContain('maxLength: 13');
expect($block)->toContain("pattern: '^[0-9]{1,13}$'");
}
});
@@ -0,0 +1,12 @@
<?php
it('wires EAN and public entry number into e-conomic invoice draft recipients', function (): void {
$content = file_get_contents(app_path('modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('$customer->getEan()');
expect($content)->toContain("\$recipient['ean']");
expect($content)->toContain('$customer->getPublicEntryNumber()');
expect($content)->toContain("\$recipient['publicEntryNumber']");
expect($content)->toContain("'recipient' => \$recipient");
});
@@ -18,6 +18,8 @@ it('parses cached economic customer payloads that use snake_case customer_number
'customer_number' => '42331123',
'name' => 'Truckwash ApS',
'email' => 'jb@truckwash.dk',
'ean' => '5790001234567',
'public_entry_number' => 'DK123456789',
'currency' => 'DKK',
'country' => 'DK',
'barred' => true,
@@ -31,6 +33,8 @@ it('parses cached economic customer payloads that use snake_case customer_number
'zip' => null,
'corporateIdentificationNumber' => null,
'email' => 'jb@truckwash.dk',
'ean' => '5790001234567',
'publicEntryNumber' => 'DK123456789',
'mobilePhone' => null,
'currency' => 'DKK',
'country' => 'DK',
@@ -105,7 +105,30 @@ namespace classes {
};
}
public function createCustomer($number, $name, $cvr, $email, $phone, $mobilePhone = null, $companyInformation = null): object
public static function normalizeCustomerEan(mixed $value): ?string
{
if ($value === null) {
return null;
}
$digits = preg_replace('/\D+/', '', (string)$value);
if (!is_string($digits)) {
return null;
}
$digits = trim($digits);
if ($digits === '') {
return null;
}
if (strlen($digits) > 13) {
throw new \InvalidArgumentException('EAN must be at most 13 digits.');
}
return $digits;
}
public function createCustomer($number, $name, $cvr, $email, $phone, $mobilePhone = null, $companyInformation = null, $ean = null): object
{
self::$create_calls[] = [
'number' => (int)$number,
@@ -115,6 +138,7 @@ namespace classes {
'phone' => (int)$phone,
'mobile_phone' => $mobilePhone === null ? null : (int)$mobilePhone,
'company_information' => $companyInformation,
'ean' => $ean === null ? null : (string)$ean,
];
if (self::$mock_create_exception !== null) {
@@ -424,6 +448,16 @@ namespace {
'expected_error' => 'Parameter cvr must be at least 8 characters long',
'expected_status' => 400,
],
[
'name' => 'Invalid EAN length (too long)',
'params' => array_merge($baseParams, ['ean' => '57900012345678']),
'expected_error' => 'EAN must be at most 13 digits.',
'expected_status' => 400,
'assert' => static function (): void {
assert_true(count(\classes\economic::$create_calls) === 0, 'Invalid EAN must not create e-conomic customers.');
assert_true(count(\classes\email::$sent) === 0, 'Invalid EAN must not send welcome emails.');
},
],
[
'name' => 'CVR lookup failure returns validation error without creating customer',
'params' => array_merge($baseParams, ['cvr' => '11111112']),
@@ -563,7 +597,7 @@ namespace {
],
[
'name' => 'Successful registration bootstraps local user before welcome emails',
'params' => array_merge($baseParams, ['contactPhone' => 87654320]),
'params' => array_merge($baseParams, ['contactPhone' => 87654320, 'ean' => '57 90-001234567']),
'setup' => static function (): void {
\classes\economic::$mock_create_response = (object)[
'customerNumber' => 12345678,
@@ -579,6 +613,7 @@ namespace {
assert_true(count(\classes\economic::$create_calls) === 1, 'Fresh registration must call create exactly once.');
assert_true(\classes\economic::$create_calls[0]['phone'] === 12345678, 'Fresh registration must use the company phone as the e-conomic customer phone.');
assert_true(\classes\economic::$create_calls[0]['mobile_phone'] === 87654320, 'Fresh registration must pass the contact phone as the e-conomic mobile phone.');
assert_true(\classes\economic::$create_calls[0]['ean'] === '5790001234567', 'Fresh registration must pass normalized EAN to e-conomic.');
$companyInformation = \classes\economic::$create_calls[0]['company_information'];
assert_true(is_object($companyInformation), 'Fresh registration must pass CVR company information to e-conomic.');
assert_true($companyInformation->address === 'Demo Street 1', 'Fresh registration must pass the CVR address to e-conomic.');