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
+75 -1
View File
@@ -44,6 +44,7 @@ class users_o extends db
public object_property $sms_notifications_enabled;
public object_property $email_notifications_enabled;
public object_property $wash_certificate_email; // Optional
public object_property $invoice_email; // Optional - TRU-77 / DRIFT 16
protected array $wash_subscription_transactions;
public object_property $two_factor_secret;
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->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->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_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;
// Ensure the invoice_email column exists (TRU-77 / DRIFT 16)
\classes\customer_invoice_email_schema_bootstrap::ensureSchema();
// Avoid SQL injection
$customer_number = $db->escape_string($customer_number);
$role = $db->escape_string($role);
// Hash the password
$password = password_hash($password, PASSWORD_DEFAULT);
$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
$sql = "INSERT INTO $this->table (customer_number, password, group_id) VALUES ('$customer_number', '$password', $role)";
$db->query($sql);
@@ -256,6 +270,11 @@ class users_o extends db
// Set the values of the object properties
$this->getObjectProperties();
if ($invoice_email_value !== null) {
$this->invoice_email->set($invoice_email_value);
}
// Set the default attributes
//$this->addAttribute('invoiceAllOrdersIndividually');
$this->addAttribute('restrictTankCleaning');
@@ -441,6 +460,8 @@ class users_o extends db
'number' => $phone,
],
'email' => $this->email->value(),
'invoice_email' => $this->getInvoiceEmailOverride(),
'invoice_email_fallback' => $this->email->value(),
'notifications' => [
'sms_notifications_enabled' => (bool)$this->sms_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);
}
/**
* 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
{
if ($customer_number === 0) {