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
@@ -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
|
||||
{
|
||||
// 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);
|
||||
$this->assertValidNormalizedPayload($normalized);
|
||||
|
||||
@@ -62,9 +66,15 @@ class customer_mass_import_service
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
if ($createdCustomerNumber !== $customerNumber) {
|
||||
@@ -111,6 +121,7 @@ class customer_mass_import_service
|
||||
'cvr' => $this->normalizeDigitString($payload['cvr'] ?? null),
|
||||
'name' => $this->normalizeText($payload['name'] ?? $payload['company_name'] ?? null),
|
||||
'email' => $this->normalizeEmail($payload['email'] ?? null),
|
||||
'invoice_email' => $this->normalizeInvoiceEmail($payload['invoice_email'] ?? null),
|
||||
'ean' => $this->normalizeDigitString($payload['ean'] ?? null),
|
||||
];
|
||||
}
|
||||
@@ -193,6 +204,42 @@ class customer_mass_import_service
|
||||
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
|
||||
{
|
||||
if ($normalized['name'] !== null) {
|
||||
@@ -209,12 +256,9 @@ class customer_mass_import_service
|
||||
|
||||
protected function resolveCreateEmail(array $normalized, array &$warnings): string
|
||||
{
|
||||
if ($normalized['email'] !== null) {
|
||||
return $normalized['email'];
|
||||
}
|
||||
|
||||
$warnings[] = 'No email was provided, defaulted to jb@truckwash.dk for the new e-conomic customer.';
|
||||
return 'jb@truckwash.dk';
|
||||
// TRU-77 / DRIFT 16: invoices must be routed to the dedicated
|
||||
// invoice_email when provided, otherwise to the customer's email.
|
||||
return $this->resolveInvoiceEmail($normalized, $warnings);
|
||||
}
|
||||
|
||||
protected function searchEconomicCustomersByCvr(string $cvr): array
|
||||
@@ -229,7 +273,7 @@ class customer_mass_import_service
|
||||
return is_array($response) ? $response : [];
|
||||
}
|
||||
|
||||
protected function createEconomicCustomer(array $normalized): object
|
||||
protected function createEconomicCustomer(array $normalized, string $createEmail): object
|
||||
{
|
||||
$payload = [
|
||||
'customerNumber' => (int)$normalized['customer_number'],
|
||||
@@ -241,7 +285,10 @@ class customer_mass_import_service
|
||||
'paymentTermsNumber' => 12,
|
||||
],
|
||||
'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'],
|
||||
'telephoneAndFaxNumber' => (string)$normalized['phone'],
|
||||
'mobilePhone' => (string)$normalized['phone'],
|
||||
@@ -396,6 +443,7 @@ class customer_mass_import_service
|
||||
'cvr' => (string)$normalized['cvr'],
|
||||
'name' => $customerName,
|
||||
'email' => $normalized['email'],
|
||||
'invoice_email' => $normalized['invoice_email'] ?? null,
|
||||
'ean' => $normalized['ean'],
|
||||
'action' => $action,
|
||||
'message' => $message,
|
||||
@@ -416,6 +464,7 @@ class customer_mass_import_service
|
||||
|
||||
$name = $normalized['name'] ?? null;
|
||||
$email = $normalized['email'] ?? null;
|
||||
$invoice_email = $normalized['invoice_email'] ?? null;
|
||||
$phone = $normalized['phone'] ?? null;
|
||||
|
||||
$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())) {
|
||||
try {
|
||||
$customer->setPhoneNumber((int)$phone);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -119,8 +119,19 @@ class usersRoute
|
||||
if ($role !== 0) {
|
||||
$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
|
||||
(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
|
||||
(new logs_o())->add('users', 'global', 1, $user->id, 'ADD_USER', 'Successfully added a user');
|
||||
// Return a success message
|
||||
@@ -193,6 +204,22 @@ class usersRoute
|
||||
}
|
||||
// Edit the user
|
||||
(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
|
||||
(new logs_o())->add('users', 'global', 1, $user->id, 'EDIT_USER', 'Successfully edited a user with ID: ' . $data['id']);
|
||||
// Return a success message
|
||||
|
||||
@@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS `users` (
|
||||
`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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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)[
|
||||
'customerNumber' => (int)$normalized['customer_number'],
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user