diff --git a/services/nginx/app/classes/customer_mass_import_service.php b/services/nginx/app/classes/customer_mass_import_service.php index c9088b88..6c8bf2c0 100644 --- a/services/nginx/app/classes/customer_mass_import_service.php +++ b/services/nginx/app/classes/customer_mass_import_service.php @@ -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 diff --git a/services/nginx/app/classes/economic.php b/services/nginx/app/classes/economic.php index 5a44b1e9..918230bc 100644 --- a/services/nginx/app/classes/economic.php +++ b/services/nginx/app/classes/economic.php @@ -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) { diff --git a/services/nginx/app/modules/economic/customers/economic_customer_mo.php b/services/nginx/app/modules/economic/customers/economic_customer_mo.php index c77c6cef..b9c6d3dd 100644 --- a/services/nginx/app/modules/economic/customers/economic_customer_mo.php +++ b/services/nginx/app/modules/economic/customers/economic_customer_mo.php @@ -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, diff --git a/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php b/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php index cb8cac1a..b0f4b41e 100644 --- a/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php +++ b/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php @@ -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 } } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/economic/helpers/economic_customer.php b/services/nginx/app/modules/economic/helpers/economic_customer.php index 1911f433..2187780f 100644 --- a/services/nginx/app/modules/economic/helpers/economic_customer.php +++ b/services/nginx/app/modules/economic/helpers/economic_customer.php @@ -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; } -} \ No newline at end of file +} diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 77e222e6..cb04b995 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -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 diff --git a/services/nginx/app/routes/authRoute.php b/services/nginx/app/routes/authRoute.php index d2ede8d2..2525258e 100644 --- a/services/nginx/app/routes/authRoute.php +++ b/services/nginx/app/routes/authRoute.php @@ -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( diff --git a/services/nginx/app/routes/moduleEconomicCustomerRoute.php b/services/nginx/app/routes/moduleEconomicCustomerRoute.php index 735528e3..c7b29fbd 100644 --- a/services/nginx/app/routes/moduleEconomicCustomerRoute.php +++ b/services/nginx/app/routes/moduleEconomicCustomerRoute.php @@ -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 }); } -} \ No newline at end of file +} diff --git a/services/nginx/app/tests/Unit/Auth/EconomicCreateCustomerResponseTest.php b/services/nginx/app/tests/Unit/Auth/EconomicCreateCustomerResponseTest.php index 08974950..8ad749e6 100644 --- a/services/nginx/app/tests/Unit/Auth/EconomicCreateCustomerResponseTest.php +++ b/services/nginx/app/tests/Unit/Auth/EconomicCreateCustomerResponseTest.php @@ -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([]); +}); diff --git a/services/nginx/app/tests/Unit/Customers/CustomerMassImportServiceTest.php b/services/nginx/app/tests/Unit/Customers/CustomerMassImportServiceTest.php index 11e936a4..26d795d7 100644 --- a/services/nginx/app/tests/Unit/Customers/CustomerMassImportServiceTest.php +++ b/services/nginx/app/tests/Unit/Customers/CustomerMassImportServiceTest.php @@ -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; diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicCustomerEanHelperTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicCustomerEanHelperTest.php new file mode 100644 index 00000000..0fd475ff --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicCustomerEanHelperTest.php @@ -0,0 +1,40 @@ +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(); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicEanOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicEanOpenApiSpecTest.php new file mode 100644 index 00000000..bf6e82c4 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicEanOpenApiSpecTest.php @@ -0,0 +1,50 @@ +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}$'"); + } +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftRecipientEanWiringTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftRecipientEanWiringTest.php new file mode 100644 index 00000000..2f34bc69 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftRecipientEanWiringTest.php @@ -0,0 +1,12 @@ +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"); +}); diff --git a/services/nginx/app/tests/Unit/Users/EconomicCustomerModelParsingTest.php b/services/nginx/app/tests/Unit/Users/EconomicCustomerModelParsingTest.php index 3b5a0587..61f793c4 100644 --- a/services/nginx/app/tests/Unit/Users/EconomicCustomerModelParsingTest.php +++ b/services/nginx/app/tests/Unit/Users/EconomicCustomerModelParsingTest.php @@ -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', diff --git a/services/nginx/app/tests/auth/RegisterCvrTest.php b/services/nginx/app/tests/auth/RegisterCvrTest.php index bdc281ec..43ebdddc 100644 --- a/services/nginx/app/tests/auth/RegisterCvrTest.php +++ b/services/nginx/app/tests/auth/RegisterCvrTest.php @@ -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.');