feat(api): add optional invoice_email field for customers (TRU-77) (#381)

## Summary

Adds an optional `invoice_email` (Danish: *faktura email*) field to
customers, so e-conomic can deliver invoices to a dedicated accounting
mailbox instead of the customer's primary email.

Linear: **TRU-77** (DRIFT 16)

## Changes

- **Migration** (additive, via existing schema_bootstrap pattern)
- New `customer_invoice_email_schema_bootstrap` adds the `invoice_email
VARCHAR(255) NULL` column to `users` after `wash_certificate_email`.
Idempotent — skips when the column already exists.

- **Domain object — `objects/users_o.php`**
  - New `invoice_email` object property.
- `getInvoiceEmail()` returns the dedicated address or falls back to the
primary `email`.
- `getInvoiceEmailOverride()` returns only the explicit override (no
fallback).
- `setInvoiceEmail($email)` validates and writes the value; `null`/empty
clears it.
- `add($customer_number, $password, $role, ?$invoice_email = null)` now
accepts the optional field and persists it.
- The user payload output now exposes `invoice_email` and
`invoice_email_fallback`.

- **API — `routes/usersRoute.php`**
- `POST /users` accepts an optional `invoice_email`, validated before
insert.
- `PUT /users` accepts `invoice_email` (including null/empty to clear)
on existing users.

- **Customer mass import — `classes/customer_mass_import_service.php`**
  - Payload now accepts `invoice_email`.
- `normalizeInvoiceEmail()` rejects malformed addresses before any
e-conomic call.
- `resolveInvoiceEmail()` / `resolveCreateEmail()` route the e-conomic
customer email to the dedicated address when set, otherwise the primary
`email` (with the existing `jb@truckwash.dk` fallback when neither is
provided).
  - `syncLocalCustomer()` persists `invoice_email` on the local user.
  - `import()` result now includes the resolved `invoice_email`.

- **Tests — `tests/Unit/Customers/CustomerInvoiceEmailTest.php` (new)**
  - Schema bootstrap adds the column when missing.
  - Schema bootstrap is a no-op when the column already exists.
  - Schema bootstrap skips when the `users` table is not present.
  - e-conomic customer email is set to `invoice_email` when provided.
- e-conomic customer email falls back to `email` when `invoice_email` is
omitted.
  - Invalid `invoice_email` is rejected before any e-conomic call.

## Backwards compatibility

- The column is nullable; existing rows are unaffected.
- The `add()` signature is additive (new optional parameter with default
`null`).
- The route payloads ignore `invoice_email` unless supplied, so no
client change is required.

## Linear

- TRU-77 (DRIFT 16: "Add 'faktura email' field to customer creation
form")

---------

Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: OpenClaw Bugfix Bot <openclaw-bot@truckwash.dk>
Co-authored-by: bugfix sub-agent <bugfix@openclaw.local>
This commit is contained in:
Jeppe B
2026-08-16 18:58:05 +02:00
committed by GitHub
co-authored by Jeppe B OpenClaw Bugfix Bot bugfix sub-agent
parent 60222a7d91
commit 3d0a8eeae7
7 changed files with 518 additions and 14 deletions
@@ -0,0 +1,93 @@
<?php
namespace classes;
/**
* Ensures additive schema for the customer `invoice_email` field
* (TRU-77 / DRIFT 16). The field is optional and stores an
* e-mail address that should receive the customer's invoices
* separately from the customer's primary `email`.
*/
class customer_invoice_email_schema_bootstrap
{
private static bool $initialized = false;
private const TABLE = 'users';
private const COLUMN = 'invoice_email';
public static function ensureSchema(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
self::ensureUsersTable($db);
self::ensureInvoiceEmailColumn($db);
self::$initialized = true;
}
private static function ensureUsersTable(object $db): void
{
$db->query(
"CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
customer_number INT NOT NULL,
display_name VARCHAR(255) NULL,
email VARCHAR(255) NULL,
phone_country_code INT NULL,
phone BIGINT NULL,
password VARCHAR(255) NULL,
group_id INT NOT NULL DEFAULT 0,
xlvask_customer_id VARCHAR(255) NULL,
sms_notifications_enabled TINYINT(1) NOT NULL DEFAULT 0,
email_notifications_enabled TINYINT(1) NOT NULL DEFAULT 0,
wash_certificate_email VARCHAR(255) NULL,
invoice_email VARCHAR(255) NULL,
two_factor_enabled TINYINT(1) NOT NULL DEFAULT 0,
two_factor_secret VARCHAR(255) NULL,
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
KEY idx_users_customer_number (customer_number),
KEY idx_users_group_id (group_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
private static function ensureInvoiceEmailColumn(object $db): void
{
if (!self::tableExists($db, self::TABLE)) {
return;
}
if (self::columnExists($db, self::TABLE, self::COLUMN)) {
return;
}
$safeTable = str_replace('`', '', self::TABLE);
$db->query(
"ALTER TABLE `{$safeTable}`
ADD COLUMN " . self::COLUMN . " VARCHAR(255) NULL
AFTER wash_certificate_email"
);
}
private static function tableExists(object $db, string $table): bool
{
$safeTable = str_replace('`', '', $table);
$result = $db->query("SHOW TABLES LIKE '{$safeTable}'");
return $result && (int)$result->num_rows > 0;
}
private static function columnExists(object $db, string $table, string $column): bool
{
$safeTable = str_replace('`', '', $table);
$safeColumn = str_replace("'", '', $column);
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
return $result && (int)$result->num_rows > 0;
}
}
@@ -14,6 +14,10 @@ class customer_mass_import_service
*/ */
public function import(array $payload): array public function import(array $payload): array
{ {
// TRU-77 / DRIFT 16: ensure the invoice_email column exists before we
// attempt to populate it on a local customer.
customer_invoice_email_schema_bootstrap::ensureSchema();
$normalized = $this->normalizePayload($payload); $normalized = $this->normalizePayload($payload);
$this->assertValidNormalizedPayload($normalized); $this->assertValidNormalizedPayload($normalized);
@@ -62,9 +66,15 @@ class customer_mass_import_service
} }
$normalized['name'] = $this->resolveCreateName($normalized); $normalized['name'] = $this->resolveCreateName($normalized);
$normalized['email'] = $this->resolveCreateEmail($normalized, $warnings); // TRU-77 / DRIFT 16: resolve the e-conomic delivery address into a
// local variable instead of overwriting $normalized['email']. The
// primary customer email must remain intact for the result payload
// and for downstream local-customer sync; the create call needs the
// dedicated invoice address (or the primary as a fallback) on its
// own.
$createEmail = $this->resolveCreateEmail($normalized, $warnings);
$createResponse = $this->createEconomicCustomer($normalized); $createResponse = $this->createEconomicCustomer($normalized, $createEmail);
$createdCustomerNumber = $this->extractEconomicCustomerNumber($createResponse); $createdCustomerNumber = $this->extractEconomicCustomerNumber($createResponse);
if ($createdCustomerNumber !== $customerNumber) { if ($createdCustomerNumber !== $customerNumber) {
@@ -111,6 +121,7 @@ class customer_mass_import_service
'cvr' => $this->normalizeDigitString($payload['cvr'] ?? null), 'cvr' => $this->normalizeDigitString($payload['cvr'] ?? null),
'name' => $this->normalizeText($payload['name'] ?? $payload['company_name'] ?? null), 'name' => $this->normalizeText($payload['name'] ?? $payload['company_name'] ?? null),
'email' => $this->normalizeEmail($payload['email'] ?? null), 'email' => $this->normalizeEmail($payload['email'] ?? null),
'invoice_email' => $this->normalizeInvoiceEmail($payload['invoice_email'] ?? null),
'ean' => $this->normalizeDigitString($payload['ean'] ?? null), 'ean' => $this->normalizeDigitString($payload['ean'] ?? null),
]; ];
} }
@@ -193,6 +204,42 @@ class customer_mass_import_service
return $email; return $email;
} }
/**
* Normalize the optional dedicated invoice email (TRU-77 / DRIFT 16).
* Empty/whitespace values collapse to null. An explicit non-empty value
* must be a syntactically valid email address; an invalid value is
* rejected to keep invoices from being routed to a malformed address.
*/
protected function normalizeInvoiceEmail(mixed $value): ?string
{
$email = $this->normalizeText($value);
if ($email === null) {
return null;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \RuntimeException('Invalid invoice email address.', 400);
}
return $email;
}
/**
* Resolve the e-mail address that e-conomic should use to deliver
* invoices for the customer (TRU-77 / DRIFT 16). Prefers the dedicated
* `invoice_email` when provided, falling back to the customer's primary
* `email`.
*/
protected function resolveInvoiceEmail(array $normalized, array &$warnings): string
{
if (!empty($normalized['invoice_email'])) {
return (string)$normalized['invoice_email'];
}
if (!empty($normalized['email'])) {
return (string)$normalized['email'];
}
$warnings[] = 'No invoice email was provided, defaulted to jb@truckwash.dk for the new e-conomic customer.';
return 'jb@truckwash.dk';
}
protected function resolveCreateName(array $normalized): string protected function resolveCreateName(array $normalized): string
{ {
if ($normalized['name'] !== null) { if ($normalized['name'] !== null) {
@@ -209,12 +256,9 @@ class customer_mass_import_service
protected function resolveCreateEmail(array $normalized, array &$warnings): string protected function resolveCreateEmail(array $normalized, array &$warnings): string
{ {
if ($normalized['email'] !== null) { // TRU-77 / DRIFT 16: invoices must be routed to the dedicated
return $normalized['email']; // invoice_email when provided, otherwise to the customer's email.
} return $this->resolveInvoiceEmail($normalized, $warnings);
$warnings[] = 'No email was provided, defaulted to jb@truckwash.dk for the new e-conomic customer.';
return 'jb@truckwash.dk';
} }
protected function searchEconomicCustomersByCvr(string $cvr): array protected function searchEconomicCustomersByCvr(string $cvr): array
@@ -229,7 +273,7 @@ class customer_mass_import_service
return is_array($response) ? $response : []; return is_array($response) ? $response : [];
} }
protected function createEconomicCustomer(array $normalized): object protected function createEconomicCustomer(array $normalized, string $createEmail): object
{ {
$payload = [ $payload = [
'customerNumber' => (int)$normalized['customer_number'], 'customerNumber' => (int)$normalized['customer_number'],
@@ -241,7 +285,10 @@ class customer_mass_import_service
'paymentTermsNumber' => 12, 'paymentTermsNumber' => 12,
], ],
'name' => (string)$normalized['name'], 'name' => (string)$normalized['name'],
'email' => (string)$normalized['email'], // TRU-77 / DRIFT 16: the dedicated invoice_email (or the
// primary email as a fallback) is passed in explicitly so the
// caller's $normalized['email'] is never mutated here.
'email' => $createEmail,
'phone' => (int)$normalized['phone'], 'phone' => (int)$normalized['phone'],
'telephoneAndFaxNumber' => (string)$normalized['phone'], 'telephoneAndFaxNumber' => (string)$normalized['phone'],
'mobilePhone' => (string)$normalized['phone'], 'mobilePhone' => (string)$normalized['phone'],
@@ -396,6 +443,7 @@ class customer_mass_import_service
'cvr' => (string)$normalized['cvr'], 'cvr' => (string)$normalized['cvr'],
'name' => $customerName, 'name' => $customerName,
'email' => $normalized['email'], 'email' => $normalized['email'],
'invoice_email' => $normalized['invoice_email'] ?? null,
'ean' => $normalized['ean'], 'ean' => $normalized['ean'],
'action' => $action, 'action' => $action,
'message' => $message, 'message' => $message,
@@ -416,6 +464,7 @@ class customer_mass_import_service
$name = $normalized['name'] ?? null; $name = $normalized['name'] ?? null;
$email = $normalized['email'] ?? null; $email = $normalized['email'] ?? null;
$invoice_email = $normalized['invoice_email'] ?? null;
$phone = $normalized['phone'] ?? null; $phone = $normalized['phone'] ?? null;
$displayName = trim((string)($customer->display_name->value() ?? '')); $displayName = trim((string)($customer->display_name->value() ?? ''));
@@ -431,6 +480,16 @@ class customer_mass_import_service
} }
} }
// TRU-77 / DRIFT 16: persist the dedicated invoice email override
// when provided so invoice routing survives subsequent local edits.
if ($invoice_email !== null && $customer->getInvoiceEmailOverride() === null) {
try {
$customer->setInvoiceEmail($invoice_email);
} catch (\Throwable $throwable) {
$warnings[] = 'Unable to update local invoice email: ' . $throwable->getMessage();
}
}
if ($phone !== null && empty($customer->phone->value())) { if ($phone !== null && empty($customer->phone->value())) {
try { try {
$customer->setPhoneNumber((int)$phone); $customer->setPhoneNumber((int)$phone);
+75 -1
View File
@@ -44,6 +44,7 @@ class users_o extends db
public object_property $sms_notifications_enabled; public object_property $sms_notifications_enabled;
public object_property $email_notifications_enabled; public object_property $email_notifications_enabled;
public object_property $wash_certificate_email; // Optional public object_property $wash_certificate_email; // Optional
public object_property $invoice_email; // Optional - TRU-77 / DRIFT 16
protected array $wash_subscription_transactions; protected array $wash_subscription_transactions;
public object_property $two_factor_secret; public object_property $two_factor_secret;
public object_property $two_factor_enabled; public object_property $two_factor_enabled;
@@ -123,6 +124,7 @@ class users_o extends db
$this->sms_notifications_enabled = new object_property($this->table, $this->id, 'sms_notifications_enabled', 'bool', false); $this->sms_notifications_enabled = new object_property($this->table, $this->id, 'sms_notifications_enabled', 'bool', false);
$this->email_notifications_enabled = new object_property($this->table, $this->id, 'email_notifications_enabled', 'bool', false); $this->email_notifications_enabled = new object_property($this->table, $this->id, 'email_notifications_enabled', 'bool', false);
$this->wash_certificate_email = new object_property($this->table, $this->id, 'wash_certificate_email', 'string', false); $this->wash_certificate_email = new object_property($this->table, $this->id, 'wash_certificate_email', 'string', false);
$this->invoice_email = new object_property($this->table, $this->id, 'invoice_email', 'string', false);
$this->two_factor_secret = new object_property($this->table, $this->id, 'two_factor_secret', 'string', false); $this->two_factor_secret = new object_property($this->table, $this->id, 'two_factor_secret', 'string', false);
$this->two_factor_enabled = new object_property($this->table, $this->id, 'two_factor_enabled', 'bool', false); $this->two_factor_enabled = new object_property($this->table, $this->id, 'two_factor_enabled', 'bool', false);
} }
@@ -234,15 +236,27 @@ class users_o extends db
} }
public function add(string $customer_number, mixed $password, int $role = 0): void public function add(string $customer_number, mixed $password, int $role = 0, ?string $invoice_email = null): void
{ {
global $db; global $db;
// Ensure the invoice_email column exists (TRU-77 / DRIFT 16)
\classes\customer_invoice_email_schema_bootstrap::ensureSchema();
// Avoid SQL injection // Avoid SQL injection
$customer_number = $db->escape_string($customer_number); $customer_number = $db->escape_string($customer_number);
$role = $db->escape_string($role); $role = $db->escape_string($role);
// Hash the password // Hash the password
$password = password_hash($password, PASSWORD_DEFAULT); $password = password_hash($password, PASSWORD_DEFAULT);
$password = $db->escape_string($password); $password = $db->escape_string($password);
$invoice_email_value = null;
if ($invoice_email !== null) {
$trimmed = trim($invoice_email);
if ($trimmed !== '') {
if (!filter_var($trimmed, FILTER_VALIDATE_EMAIL)) {
throw new Exception('Invalid invoice email address');
}
$invoice_email_value = $db->escape_string($trimmed);
}
}
// Create a new record in the database // Create a new record in the database
$sql = "INSERT INTO $this->table (customer_number, password, group_id) VALUES ('$customer_number', '$password', $role)"; $sql = "INSERT INTO $this->table (customer_number, password, group_id) VALUES ('$customer_number', '$password', $role)";
$db->query($sql); $db->query($sql);
@@ -256,6 +270,11 @@ class users_o extends db
// Set the values of the object properties // Set the values of the object properties
$this->getObjectProperties(); $this->getObjectProperties();
if ($invoice_email_value !== null) {
$this->invoice_email->set($invoice_email_value);
}
// Set the default attributes // Set the default attributes
//$this->addAttribute('invoiceAllOrdersIndividually'); //$this->addAttribute('invoiceAllOrdersIndividually');
$this->addAttribute('restrictTankCleaning'); $this->addAttribute('restrictTankCleaning');
@@ -441,6 +460,8 @@ class users_o extends db
'number' => $phone, 'number' => $phone,
], ],
'email' => $this->email->value(), 'email' => $this->email->value(),
'invoice_email' => $this->getInvoiceEmailOverride(),
'invoice_email_fallback' => $this->email->value(),
'notifications' => [ 'notifications' => [
'sms_notifications_enabled' => (bool)$this->sms_notifications_enabled->value(), 'sms_notifications_enabled' => (bool)$this->sms_notifications_enabled->value(),
'email_notifications_enabled' => (bool)$this->email_notifications_enabled->value(), 'email_notifications_enabled' => (bool)$this->email_notifications_enabled->value(),
@@ -1577,6 +1598,59 @@ class users_o extends db
$this->email->set($email); $this->email->set($email);
} }
/**
* Get the optional invoice email for the user.
* Returns the dedicated invoice email when set, otherwise falls back to
* the user's primary email. This is the address e-conomic uses to send
* invoices for the customer (TRU-77 / DRIFT 16).
*/
public function getInvoiceEmail(): string|null
{
self::requireSelected();
$invoice = $this->invoice_email->value();
if ($invoice !== null && trim((string)$invoice) !== '') {
return (string)$invoice;
}
$primary = $this->email->value();
if ($primary !== null && trim((string)$primary) !== '') {
return (string)$primary;
}
return null;
}
/**
* Get the explicit invoice email override, if any. Unlike
* {@see getInvoiceEmail()} this does not fall back to the primary email.
*/
public function getInvoiceEmailOverride(): string|null
{
self::requireSelected();
$invoice = $this->invoice_email->value();
if ($invoice === null) {
return null;
}
$trimmed = trim((string)$invoice);
return $trimmed === '' ? null : $trimmed;
}
/**
* Set the optional invoice email for the user. Pass null/empty to clear.
* @throws Exception If the email address is invalid
*/
public function setInvoiceEmail(string|null $email): void
{
self::requireSelected();
if ($email === null || trim($email) === '') {
$this->invoice_email->set(null);
return;
}
$trimmed = trim($email);
if (!filter_var($trimmed, FILTER_VALIDATE_EMAIL)) {
throw new Exception('Invalid invoice email address');
}
$this->invoice_email->set($trimmed);
}
public function isCustomerBarred(int $customer_number): bool public function isCustomerBarred(int $customer_number): bool
{ {
if ($customer_number === 0) { if ($customer_number === 0) {
+28 -1
View File
@@ -119,8 +119,19 @@ class usersRoute
if ($role !== 0) { if ($role !== 0) {
$this->requirePermission('edit_user_role'); $this->requirePermission('edit_user_role');
} }
// TRU-77 / DRIFT 16: optional dedicated invoice email
$invoice_email = null;
if (isset($data['invoice_email']) && $data['invoice_email'] !== null && $data['invoice_email'] !== '') {
$candidate = trim((string)$data['invoice_email']);
if ($candidate !== '') {
if (!filter_var($candidate, FILTER_VALIDATE_EMAIL)) {
$response->error('Invalid invoice email address', 400);
}
$invoice_email = $candidate;
}
}
// Add the user // Add the user
(new users_o())->add($data['customer_number'], $data['password'], $role); (new users_o())->add($data['customer_number'], $data['password'], $role, $invoice_email);
// Log the incident // Log the incident
(new logs_o())->add('users', 'global', 1, $user->id, 'ADD_USER', 'Successfully added a user'); (new logs_o())->add('users', 'global', 1, $user->id, 'ADD_USER', 'Successfully added a user');
// Return a success message // Return a success message
@@ -193,6 +204,22 @@ class usersRoute
} }
// Edit the user // Edit the user
(new users_o())->edit((int)$data['id'], (string)$data['customer_number'], $data['role'], $data['password'], $data['display_name']); (new users_o())->edit((int)$data['id'], (string)$data['customer_number'], $data['role'], $data['password'], $data['display_name']);
// TRU-77 / DRIFT 16: allow updating the dedicated invoice email
if (array_key_exists('invoice_email', $data)) {
$raw = $data['invoice_email'];
if ($raw === null || $raw === '' || $raw === 'null') {
$targetUser->setInvoiceEmail(null);
} else {
$candidate = trim((string)$raw);
if ($candidate === '') {
$targetUser->setInvoiceEmail(null);
} elseif (!filter_var($candidate, FILTER_VALIDATE_EMAIL)) {
$response->error('Invalid invoice email address', 400);
} else {
$targetUser->setInvoiceEmail($candidate);
}
}
}
// Log the incident // Log the incident
(new logs_o())->add('users', 'global', 1, $user->id, 'EDIT_USER', 'Successfully edited a user with ID: ' . $data['id']); (new logs_o())->add('users', 'global', 1, $user->id, 'EDIT_USER', 'Successfully edited a user with ID: ' . $data['id']);
// Return a success message // Return a success message
@@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS `users` (
`sms_notifications_enabled` TINYINT(1) NOT NULL DEFAULT 0, `sms_notifications_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`email_notifications_enabled` TINYINT(1) NOT NULL DEFAULT 0, `email_notifications_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`wash_certificate_email` VARCHAR(255) NULL, `wash_certificate_email` VARCHAR(255) NULL,
`invoice_email` VARCHAR(255) NULL,
`two_factor_enabled` TINYINT(1) NOT NULL DEFAULT 0, `two_factor_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`two_factor_secret` VARCHAR(255) NULL, `two_factor_secret` VARCHAR(255) NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
@@ -0,0 +1,243 @@
<?php
use classes\customer_invoice_email_schema_bootstrap;
use classes\customer_mass_import_service;
if (!class_exists('CustomerInvoiceEmailSchemaResultStub')) {
final class CustomerInvoiceEmailSchemaResultStub
{
public int $num_rows = 0;
/** @var list<array<string, mixed>> */
private array $rows;
/** @param list<array<string, mixed>> $rows */
public function __construct(array $rows = [])
{
$this->rows = array_values($rows);
$this->num_rows = count($this->rows);
}
/** @return array<string, mixed>|null */
public function fetch_assoc(): ?array
{
return array_shift($this->rows) ?? null;
}
}
}
if (!class_exists('CustomerInvoiceEmailSchemaDbStub')) {
final class CustomerInvoiceEmailSchemaDbStub
{
public bool $hasUsersTable = true;
public bool $hasInvoiceEmailColumn = false;
/** @var list<string> */
public array $queries = [];
public function query(string $sql): CustomerInvoiceEmailSchemaResultStub
{
$this->queries[] = $sql;
if (str_contains($sql, "SHOW TABLES LIKE 'users'")) {
return $this->hasUsersTable
? new CustomerInvoiceEmailSchemaResultStub([['Tables_in_db' => 'users']])
: new CustomerInvoiceEmailSchemaResultStub();
}
if (str_contains($sql, "SHOW COLUMNS FROM `users` LIKE 'invoice_email'")) {
return $this->hasInvoiceEmailColumn
? new CustomerInvoiceEmailSchemaResultStub([['Field' => 'invoice_email']])
: new CustomerInvoiceEmailSchemaResultStub();
}
return new CustomerInvoiceEmailSchemaResultStub();
}
}
}
it('adds the invoice_email column to the users table when the column is missing', function (): void {
$db = new CustomerInvoiceEmailSchemaDbStub();
$db->hasUsersTable = true;
$db->hasInvoiceEmailColumn = false;
$GLOBALS['db'] = $db;
try {
customer_invoice_email_schema_bootstrap::ensureSchema();
} finally {
unset($GLOBALS['db']);
}
expect($db->queries)->toContain('ALTER TABLE `users`
ADD COLUMN invoice_email VARCHAR(255) NULL
AFTER wash_certificate_email');
});
it('does not re-add the invoice_email column when it already exists', function (): void {
$db = new CustomerInvoiceEmailSchemaDbStub();
$db->hasUsersTable = true;
$db->hasInvoiceEmailColumn = true;
$GLOBALS['db'] = $db;
try {
customer_invoice_email_schema_bootstrap::ensureSchema();
} finally {
unset($GLOBALS['db']);
}
$alterStatements = array_values(array_filter(
$db->queries,
static fn(string $query): bool => str_contains($query, 'ALTER TABLE')
));
expect($alterStatements)->toBe([]);
});
it('skips column add when the users table does not exist yet', function (): void {
$db = new CustomerInvoiceEmailSchemaDbStub();
$db->hasUsersTable = false;
$db->hasInvoiceEmailColumn = false;
$GLOBALS['db'] = $db;
try {
customer_invoice_email_schema_bootstrap::ensureSchema();
} finally {
unset($GLOBALS['db']);
}
$alterStatements = array_values(array_filter(
$db->queries,
static fn(string $query): bool => str_contains($query, 'ALTER TABLE')
));
expect($alterStatements)->toBe([]);
});
// --- customer mass import service invoice_email routing (TRU-77) ---
if (!class_exists('CustomerInvoiceEmailMassImportProbe')) {
final class CustomerInvoiceEmailMassImportProbe extends customer_mass_import_service
{
public array $economicSearchResults = [];
public array $createCalls = [];
public array $bootstrapCalls = [];
public ?object $bootstrapUser = null;
public ?object $createResponse = null;
public string $companyName = 'Probe Company';
protected function searchEconomicCustomersByCvr(string $cvr): array
{
return $this->economicSearchResults;
}
protected function createEconomicCustomer(array $normalized, string $createEmail): object
{
// The production service no longer mutates $normalized['email'];
// the create call uses the dedicated invoice_email (or the
// primary as a fallback) that import() resolves for it. Mirror
// that here so the recorded payload reflects what is sent to
// e-conomic.
$payload = $normalized;
$payload['email'] = $createEmail;
$this->createCalls[] = $payload;
return $this->createResponse ?? (object)[
'customerNumber' => (int)$normalized['customer_number'],
];
}
protected function bootstrapLocalCustomerOrFail(int $customerNumber): object
{
$this->bootstrapCalls[] = $customerNumber;
if ($this->bootstrapUser === null) {
throw new RuntimeException('Customer was created in e-conomic but could not be imported locally.', 500);
}
return $this->bootstrapUser;
}
protected function fetchCompanyNameByCvr(string $cvr): string
{
return $this->companyName;
}
protected function syncLocalCustomer(object $customer, array $normalized, array &$warnings): void
{
// No-op for the routing assertions; tests focus on payload + create call.
}
// Override the DB lookup so the unit test does not need a real
// (or stubbed) mysqli connection. The TRU-77 routing tests treat
// the import as a "new customer" flow, so we hard-code the
// "does not exist locally" answer.
protected function localCustomerNumberExists(int $customerNumber): bool
{
return false;
}
protected function loadLocalCustomerByNumber(int $customerNumber): ?object
{
return null;
}
}
}
it('routes the e-conomic customer email to the dedicated invoice_email when provided', function (): void {
$service = new CustomerInvoiceEmailMassImportProbe();
$service->bootstrapUser = new class {
public int $id = 5001;
public function exists(): bool { return true; }
public function hasPassword(): bool { return false; }
};
$service->createResponse = (object)['customerNumber' => 22725567];
$result = $service->import([
'cvr' => '29424764',
'name' => 'TGP TRANSPORT APS',
'email' => 'primary@example.com',
'invoice_email' => 'faktura@example.com',
'phone' => '22725567',
]);
expect($service->createCalls)->toHaveCount(1);
expect($service->createCalls[0]['email'])->toBe('faktura@example.com');
expect($result['email'])->toBe('primary@example.com');
expect($result['invoice_email'])->toBe('faktura@example.com');
});
it('falls back to the primary email when no dedicated invoice_email is provided', function (): void {
$service = new CustomerInvoiceEmailMassImportProbe();
$service->bootstrapUser = new class {
public int $id = 5002;
public function exists(): bool { return true; }
public function hasPassword(): bool { return false; }
};
$service->createResponse = (object)['customerNumber' => 22725567];
$result = $service->import([
'cvr' => '29424764',
'name' => 'TGP TRANSPORT APS',
'email' => 'primary@example.com',
'phone' => '22725567',
]);
expect($service->createCalls)->toHaveCount(1);
expect($service->createCalls[0]['email'])->toBe('primary@example.com');
expect($result['invoice_email'])->toBeNull();
});
it('rejects an invalid dedicated invoice_email before contacting e-conomic', function (): void {
$service = new CustomerInvoiceEmailMassImportProbe();
$service->bootstrapUser = new class {
public int $id = 5003;
public function exists(): bool { return true; }
public function hasPassword(): bool { return false; }
};
$call = static fn() => $service->import([
'cvr' => '29424764',
'name' => 'TGP TRANSPORT APS',
'email' => 'primary@example.com',
'invoice_email' => 'not-an-email',
'phone' => '22725567',
]);
expect($call)->toThrow(RuntimeException::class, 'Invalid invoice email address.');
expect($service->createCalls)->toBe([]);
});
@@ -49,9 +49,16 @@ if (!class_exists('CustomerMassImportServiceProbe')) {
return $this->economicSearchResults; return $this->economicSearchResults;
} }
protected function createEconomicCustomer(array $normalized): object protected function createEconomicCustomer(array $normalized, string $createEmail): object
{ {
$this->createCalls[] = $normalized; // TRU-77 / DRIFT 16: the production service no longer mutates
// $normalized['email'] before calling createEconomicCustomer —
// the dedicated invoice_email (or the primary as a fallback) is
// resolved by import() and passed in as $createEmail. Mirror that
// here so the recorded payload reflects what is sent to e-conomic.
$payload = $normalized;
$payload['email'] = $createEmail;
$this->createCalls[] = $payload;
return $this->createResponse ?? (object)[ return $this->createResponse ?? (object)[
'customerNumber' => (int)$normalized['customer_number'], 'customerNumber' => (int)$normalized['customer_number'],
]; ];