Surface silent-skip paths and additional silent failures in email/booking/order flows (#352)

## Why

Customer `k.sand@ksand.dk` reported never receiving wash certificates for completed bookings. Two methods contained silent early-return guards so the actual reason was unobservable from container logs:

- `order_bookings_o::sendWashCertificateToCustomer()` — 5 silent returns
- `email::sendWashCertificateEmailToCustomer()` — 1 silent return

The most likely root cause: `email_notifications_enabled` defaults to `0` in the schema and `users.add()` does not set it on insert, so newly imported customers have notifications off until toggled. `wantsEmailNotifications()` then returns false and the email silently skips.

## What changed

### Original commit (`0ead5de5`)
- `objects/order_bookings_o.php` — all 5 silent early-returns now log via new `logWashCertificateSkip()` helper (Redis stream `module=email / action=WASH_CERT_SKIP` + `error_log('[wash-cert-skip] …')`).
- `classes/email.php` — silent `hasTransaction()` return in `sendWashCertificateEmailToCustomer()` now logs too.
- `objects/bookings_o.php` — emits `WASH_CERT_SKIP` (legacy_no_wash_certificate_email) when `washCertificateEmail` is empty; no behavioural change.
- **New** `routes/washCertificateDebugRoute.php` — `GET /debug/wash-certificates/diagnose?customer_number=&from=&to=` (404 in prod via $DEBUG; superuser-auth otherwise) replays the decision tree and reports `blocking_reason` per booking.

### Follow-up commit (`46a59e4e`) — silent-failure sweep

**PART A — silent returns / silent errors (10 fixes):**
- `email::sendEmailMailerSend()` — blacklisted-recipient skip now logs with context.
- `email::sendNewCustomerRegistrationNotifications()` — empty-email skip + per-recipient try/catch with error_log (was unprotected; a single MailerSend error broke the loop).
- `bookings_new_o::generateWashCertificate()` — wrapped `sendWashCertificateEmail()` in try/catch with error_log and re-throw (same pattern as the k.sand fix).
- `users_o::getCustomerName()` — replaced catch-and-swallow with structured error_log.
- `users_o::getCustomerEcocomicData()` — same.
- `bookingsRoute.php` — added booking-id context to 4 × `$response->error('Booking not found', 404)` calls.

**PART B — cron paths (10 files):** Added error_log breadcrumb + try/catch to `CheckUnfulfilledBookings`, `ClearAllUsersEconomicCustomerDetails`, `ClearAllUsersEconomicCustomerDiscounts`, `RunXLVaskModuleCron`, `SyncBookings`, `SyncEconomicInvoiceStatus`, `SyncLogs`, `BackfillEconomicV2History`, `EnsureXLVaskAutomationSchema`, and 3 functions in `Cron.php`. Each uses a distinct `[cron-…]` prefix for grep-ability.

**PART C — real bugs (2 fixed):**
1. `email::sendEmailMailerSend()` attachment `array_map` — the previous exception message emitted a binary blob because `$attachment[0]` was already overwritten by `file_get_contents()`. Now captures $path first.
2. `bookings_new_o::generateWashCertificate()` — booking persisted as `completed` before email was sent, with no try/catch. Fixed (see PART A).

## How to verify

1. Deploy to staging.
2. Hit `/debug/wash-certificates/diagnose?customer_number=<k.sand's customer_number>` as a superuser — the response lists every booking's `blocking_reason`.
3. Tail container logs for `[wash-cert-skip]`, `[email-skip]`, `[cron-…]`, and Redis stream `module=email` action `WASH_CERT_SKIP` to see real-world skips going forward.

## Follow-ups (out of scope)

- Schema migration to default `email_notifications_enabled` to `1` and backfill non-empty-email customers.
- Move `error_log` to a proper PSR-3 logger.

## Risk

- Logging only + new debug endpoint (404-gated in prod). No behavioural change for any path that previously sent mail successfully. `php -l` could not be run in the original sandbox; please verify on your CI box before deploying.

🤖 Generated with [OpenClaw](https://openclaw.ai)
This commit is contained in:
Jeppe B
2026-08-09 00:21:04 +02:00
committed by GitHub
parent 8735bae8d5
commit 0aaf32efa4
18 changed files with 671 additions and 48 deletions
+23 -2
View File
@@ -492,7 +492,18 @@ class users_o extends db
if (!$cached) {
try {
$tmp_user->getCustomerEcocomicData($customer_number);
} catch (Exception) {
} catch (Exception $e) {
// Previously this caught and returned the local fallback
// without any breadcrumb, so a permanently broken e-conomic
// customer feed would only show up as customers being
// renamed to "Unknown" in the UI. Surface the failure so
// ops can correlate missing names with API incidents.
$context = [
'reason' => 'economic_customer_name_fetch_failed',
'customer_number' => $customer_number,
'error' => $e->getMessage(),
];
error_log('[user-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return $fallbackName;
}
$cached = $tmp_user->getCached('economic_customer');
@@ -542,7 +553,17 @@ class users_o extends db
try {
$this->economic_customer = (new economic_customer_mo())->getCustomerByCustomerNumber($customer_number);
} catch (Exception) {
} catch (Exception $e) {
// Previously this caught and replaced the customer with an empty
// economic_customer_mo() without any breadcrumb, so a broken
// e-conomic upstream would silently degrade every UI surface that
// reads from $this->economic_customer. Surface the failure.
$context = [
'reason' => 'economic_customer_fetch_failed',
'customer_number' => $customer_number,
'error' => $e->getMessage(),
];
error_log('[user-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
$this->economic_customer = new economic_customer_mo();
}
return $this;