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:
co-authored by
Jeppe B
OpenClaw Bugfix Bot
bugfix sub-agent
parent
60222a7d91
commit
3d0a8eeae7
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user