# Phase 3 — Build Notes

Read alongside `PHASE1-NOTES.md` and `PHASE2-NOTES.md`. Covers Hubtel payments, WhatsApp/SMS,
rate plans, and invoicing — plus the bugs testing caught and the assumptions worth your review.

## Credentials: never hardcoded, never in .env

Per explicit instruction: every credential this phase introduces (Hubtel Checkout client
ID/secret, Hubtel SMS client ID/secret, WhatsApp access token) is written to the current
tenant's own `tenant_settings` table via a new `Settings` service — never to `.env`, never
in code. Anything whose key contains `secret` or `token` is transparently encrypted at rest
using CI4's Encryption service (keyed by `encryption.key`, which stays in `.env` since it's
legitimately shared app infrastructure, not a per-tenant credential). The settings page
never redisplays a secret in plaintext — it shows a masked "Configured" placeholder, and
leaving that field blank on save keeps the existing value. Verified: raw DB value is
ciphertext; the page correctly round-trips plain fields and masks secret ones.

Configure these under **Setup → Integration Settings** (manager only) before payments,
WhatsApp, or SMS will actually work for a given hotel. Nothing is pre-configured for the
demo tenant — every outbound call in this phase was tested either against the real Hubtel
endpoint (which cleanly rejects fake credentials — proves the request shape and error
handling are correct) or by simulating the inbound webhook directly (see below).

## What was built

Hubtel Checkout payments → deposit handling → WhatsApp/SMS messaging → rate plans →
invoicing/statements, in that order, per the brief. The webhook was tested thoroughly
(success, duplicate delivery, failed payment, unrecognized reference, unknown tenant,
malformed JSON, empty body) **before** deposit automation was built on top of it, per the
brief's explicit sequencing instruction.

- **Hubtel Checkout**: `payment_attempts` table (created before the API call, so a
  webhook referencing an unknown reference is obviously suspect); `HubtelService` wraps
  `payproxyapi.hubtel.com/items/initiate`; folio page has "Generate Payment Link"
- **Webhook**: `PaymentWebhookController`, tenant resolved from a URL segment
  (`/webhooks/hubtel/checkout/{slug}`) rather than subdomain/session — webhooks are
  machine-to-machine calls with neither. `tenant`/`csrf` global filters except `webhooks/*`
- **Deposits**: booking creation gets an optional deposit amount; if set, the booking holds
  as `pending_deposit` (reserved since Phase 1) and a Hubtel Checkout is generated and sent
  to the guest. The webhook flips it to `confirmed` and notifies front desk automatically
- **WhatsApp**: `WhatsAppService` wraps Meta's Cloud API directly (confirmed via live
  research, not assumed — see "Assumptions" below for the one thing that couldn't be
  verified without a real Meta Business account)
- **SMS**: Hubtel SMS Quick Send, same `HubtelService`
- **Messaging dispatch**: `MessagingService` reads each tenant's per-message-type channel
  preference (WhatsApp/SMS/both) and sends accordingly; every attempt is logged to
  `message_log` regardless of outcome, and a down/misconfigured channel never blocks the
  triggering action (a booking still gets created even if SMS isn't set up)
- **Pre-arrival reminders**: `php spark bookings:send-pre-arrival-reminders`, a daily batch
  job (not real-time, per the brief) across every active tenant
- **Post-stay feedback**: fires automatically at checkout; no feedback table yet (brief:
  "just send the message here — collecting/analyzing is a later phase")
- **Rate plans**: date-range only, flat override or percentage adjustment, many-to-many
  to room types; resolved automatically into the booking rate, with a "Rate plan applied"
  indicator on the confirm screen
- **Invoicing**: `dompdf` (added via Composer) renders a branded per-folio invoice PDF and
  a monthly corporate-statement PDF; minimal `corporate_accounts` table + a "Corporate
  Billing" tag on the folio (independent of split-billing payer allocations)

## Bugs found and fixed during this session

All caught by exercising real writes/webhooks, not just page loads:

- **Malformed webhook JSON crashed with an uncaught 500.** `getJSON()` throws on invalid
  JSON; a webhook endpoint that machines call unattended must never 500 on a bad body.
  Fixed to catch and respond 400.
- **`pending_deposit` bookings didn't block room availability.** A second guest could have
  been offered a room already held by someone's unpaid deposit — `BookingModel::hasOverlap`,
  `RoomModel::findAvailable`, and `BookingModel::inRange` only checked `confirmed`/
  `checked_in`. Fixed to include `pending_deposit` in all three.
- **`reminder_sent_at` wasn't in `BookingModel::$allowedFields`.** The update silently
  no-opped into a `DataException` on every single run of the reminder command — the
  "already sent" marker could never actually be written, meaning reminders would have
  resent every day forever. Fixed and verified: second run correctly sends zero.
- **`BookingController::create()` crashed on every booking once rate plans existed** —
  fetched the room with plain `RoomModel::find()`, which doesn't include `base_rate` (only
  available via the `room_types` join). Not just bookings a rate plan matched — *every*
  booking, since rate resolution always ran. Fixed by using `withType()`.
- **Invoice PDFs had literally overlapping/interleaved text** (e.g. a phone number and a
  date rendered character-by-character merged into one string). Root cause: dompdf doesn't
  reliably clear CSS `float` before the next block element — a known dompdf limitation, not
  a typo. Fixed by rewriting both PDF templates to table-based layout throughout (the
  standard safe pattern for dompdf). Verified via `pdftotext` extraction before/after: the
  garbled interleaving is gone and every value now extracts as a clean, intact string.

## Assumptions flagged for your review

- **Hubtel Checkout** confirmed via live documentation research at build time: endpoint
  `payproxyapi.hubtel.com/items/initiate`, Basic Auth, response `data.checkoutUrl`; webhook
  payload shape (`ResponseCode`, `Status`, `Data.{ClientReference,Status,Amount,
  PaymentDetails.PaymentType,...}`) confirmed from Hubtel's own published callback example.
  Never tested against a *real* Hubtel merchant account (none provided) — only against the
  live endpoint with placeholder credentials (confirms the request is well-formed and
  errors are handled cleanly) and via simulated webhook payloads matching Hubtel's
  documented shape. Worth a real end-to-end test with your actual Hubtel account before
  going live.
- **WhatsApp template names are assumed**, not verified against a real Meta Business
  account: `ahenfie_booking_confirmation`, `ahenfie_pre_arrival_reminder`,
  `ahenfie_post_stay_feedback`, `ahenfie_deposit_request` (see `MessagingService::TEMPLATES`).
  Each tenant must create and get Meta to approve templates with these exact names (or you
  tell me the real names and I'll update the constant) before WhatsApp sending will
  succeed — template messages are required for proactive outbound messages outside a
  customer service window, and Meta requires them to be pre-approved.
  API version pinned to `v21.0`; bump in `WhatsAppService::API_VERSION` as Meta deprecates versions.
- **Overlapping rate plans**: if two active plans cover the same room type and date, the
  most recently created one wins (`RatePlanModel::findActiveFor`). The brief didn't specify
  a tie-break rule.
- **"Pay directly" still always opens a fresh standalone folio per transaction** (carried
  over from Phase 2) — a corporate account tag on a folio only helps if staff remember to
  tag it, since there's no default "this guest's company" on the guest record itself.
- **No logo upload UI exists yet** — invoices/statements fall back to a plain text hotel
  name header for every tenant. `tenant.logo_path` is read and would render correctly if a
  file existed under `writable/uploads/`, but nothing populates that field or path yet.
- **Checkout/deposit links are correct only if `app.baseURL` is a real, internet-reachable
  address** — on `localhost`, Hubtel's servers have nothing to call back to. This is fine
  for local development (webhooks were tested by POSTing simulated payloads directly) but
  the first real deployment needs a public URL before Hubtel payments actually work
  end-to-end.

## Not built (explicitly out of scope per the brief)

Loyalty/membership, housekeeping/maintenance, inventory, KPI dashboards, AI features.
