# EV Booking System — Business Domain Reference Reference doc for business rules and domain vocabulary. Pull this up alongside `tickets.md` while working any ticket — it explains *why* a rule exists, not how to code it (see the architecture plan for implementation shape). --- ## 1. Glossary | Term | Meaning | |---|---| | **EV Company** | A vehicle operator/fleet owner. Plain reference data — not a tenant (see §4). | | **Destination** | A city/town served by routes. Used as both origin and endpoint. | | **Departure Time Slot** | A shared catalog of times (e.g. "06:00 AM"); attached to routes via a pivot, not owned by one route. | | **EV Route** | Company + From Destination + To Destination + one or more Time Slots + pricing per Vehicle Option. One row is one direction only — round trip is not a flag on the route, see §2b. | | **Vehicle Option** | What the customer books: `front_seat`, `back_seat`, or `whole_vehicle`. Not a numbered seat — see §2. | | **Pickup/Dropoff Address** | Free-text address (+ optional lat/lng) the customer supplies when booking — where the EV meets/drops them. Captured per Booking, not a catalog entity — see §2a. | | **Booking** | A customer's reservation on one Route + Date + Time Slot, with customer-supplied pickup/dropoff addresses. Covers one or more Vehicle Option selections (e.g. `front_seat` + `back_seat` together), each with its own passenger count — see `booking_vehicle_options` in §2. Once `confirmed`, staff assign a driver/vehicle to it — see §5a. A round trip is **two** linked Bookings (outbound + return), not one — see §2b. | | **Payment** | One attempt to pay for a Booking through a gateway (may retry after failure). | | **Refund** | A reversal against a specific successful Payment (not against the Booking directly). | --- ## 2. Vehicle Options & Inventory (the core domain quirk) Unlike a bus-booking system, **there is no seat map and no capacity tracking in v1**: - A Booking can select **more than one Vehicle Option** in the same booking (e.g. `front_seat` + `back_seat` for a customer traveling with a companion) — stored as one row per selected option in `booking_vehicle_options` (`booking_id`, `vehicle_option`, `passenger_count`, `unit_price`, `line_total`), not a single column on `bookings`. `bookings.price` is the sum of every line's `line_total`. - Each Vehicle Option can appear **at most once per booking** (no two separate `front_seat` lines — bump `passenger_count` instead). `whole_vehicle` cannot be combined with any other option in the same booking, since it already covers the entire vehicle. - Any number of *different bookings* can book the same Route + Date + Time Slot. The system does not check whether a "Whole Vehicle" or "Back Seat" is already taken by someone else. - The **only inventory rule enforced in code** is: **max Front Seats per booking**, checked against `passenger_count` on the `front_seat` line (`BOOKING_FRONT_SEAT_MAX_PER_BOOKING`, currently `1` — a per-booking constraint, not a per-trip inventory check). - Back Seat and Whole Vehicle availability are controlled by **blunt config toggles**, not database rows: - `BOOKING_BACK_SEAT_ENABLED` — whether Back Seat can be selected at all right now. - `BOOKING_WHOLE_VEHICLE_ENABLED` — whether Whole Vehicle can be selected at all right now. - `BOOKING_FRONT_SEAT_MAX_PER_BOOKING` — currently `1`, expressed as config in case it ever needs to change. - This is a **deliberate v1 simplification**, not an oversight. Real per-route/date/time-slot capacity holding (e.g. "only 1 Whole Vehicle booking allowed per trip") is an explicitly deferred future phase — see §7. `booking_vehicle_options` is deliberately shaped so that phase can be built as a new query against it (`sum(passenger_count) group by vehicle_option` for a route/date/time-slot) rather than a schema rework. - Consequence: double-booking of "Whole Vehicle" is possible by design until that future phase ships. Admins reconcile manually via the Filament Booking list (filterable by route + date + time). **Do not build seat inventory, seat locking, or availability-checking logic against real capacity in current-phase tickets** unless a ticket explicitly says so — it's out of scope until the "Future Scalability" phase. --- ## 2a. Pickup & Dropoff (door-to-door, v1) The real-world business model is **door-to-door**: the EV drives to wherever the customer requests, not a fixed depot. So, unlike a bus system, `EvRoute` does **not** carry a pickup/dropoff location — those live on `Booking` itself: - `pickup_address` (free text) + optional `pickup_lat`/`pickup_lng`. - `dropoff_address` (free text) + optional `dropoff_lat`/`dropoff_lng`. - Captured at booking time (customer types/pins it), not selected from a catalog. **Deliberate v1 simplification**: there is no fixed "meet-up checkpoint" catalog and no automatic "customer is too far" detection (no geofencing/service-radius check). In reality, dispatch sometimes asks a too-far customer to meet at a fixed checkpoint instead of door-to-door — that checkpoint catalog (`pickup_locations`/`dropoff_locations` tied to `EvRoute`) is **deferred**, see §7. Until then, "meet at a checkpoint" is handled operationally (dispatch calls the customer), not in the schema. **Do not build a `pickup_locations`/`dropoff_locations` catalog or attach pickup/dropoff FKs to `EvRoute`** in current-phase tickets — `Booking` carries the address directly instead. --- ## 2b. Round Trips Client-confirmed business rule: a round trip's return leg is driven by **whichever vehicle/driver is next available**, never guaranteed to be the same car that did the outbound leg. This is why round trip is **not** a flag on a single `EvRoute`/`Booking` row — it's modeled as **two independent, linked one-way `Booking` rows** (outbound + return), each with its own route, time slot, price, status, and driver/vehicle assignment slot (a single `Booking` only has one set of `driver_name`/`car_plate_number`/etc. columns, which can't represent two different vehicles). - **Linking**: `bookings.linked_booking_id` — a nullable, self-referencing FK, set bidirectionally once both legs exist. `bookings.is_return_leg` distinguishes which half is which. `Booking::isRoundTrip` is a computed accessor (`linked_booking_id !== null`), not a stored column. - **Return route**: the client explicitly supplies `return_ev_route_id` (mirroring `ev_route_id` for the outbound leg) — it must already exist as a real catalog `EvRoute` (admin-created, e.g. B→A). The server validates it's genuinely the reverse of the outbound route (`EvRoute::isReverseOf` — from/to swapped), rejecting with 422 otherwise. There is no auto-derivation of a reverse route, since multiple companies could plausibly run the same pair. - `EvRoute.is_round_trip` was removed — it was never load-bearing, and with the return route now explicit + validated it has no remaining purpose. - **Discovering the return route**: `POST /api/v1/routes/search` (not GET — see below) accepts `round_trip=true` alongside `from`/`to` (both required when round trip) and returns **two** result sets in one response: `routes` (from→to) and `return_routes` (to→from, swapped), each a normal paginated collection with its own nested `data`/`links`/`meta` — not a single shared pagination block, since the two sides almost always have different totals. Paging them is likewise independent: `page` pages `routes`, `return_page` pages `return_routes`, each defaulting to 1 and generating links under its own param name. This is how a client finds the `return_ev_route_id` to submit with the booking. It's POST rather than GET because the response shape genuinely branches (two independent collections) rather than being a single filtered list — a query-string GET stays a better fit for the plain `show`/`pricing`/`time-slots` single-route endpoints, which are unchanged. The search endpoint also accepts `time_slot` (a catalog time value like `"06:00"`, not a `DepartureTimeSlot` id) to filter to routes offering that departure time, applied identically to both `routes` and `return_routes`. - **Filter facets**: the response also carries `filters` (and `return_filters` when round trip) — the distinct companies and active time slots actually available for that specific from→to pair, computed independently of any `company`/`time_slot` already applied (so narrowing by one doesn't collapse the options shown for the other). Empty when `from`/`to` aren't both given. Company facet entries are trimmed to `id`/`name`/`mm_name` — not the full company resource (no slug/description/contact/logo needed just to populate a filter dropdown). - **"Popular routes"** (`EvRoute.is_popular`) was removed (2026-08-22) — the blunt boolean flag didn't match the client's actual popularity logic. Revisit once that logic is specified; don't re-add a plain boolean without it. - **Cancellation/refund**: each leg cancels and refunds **independently** — cancelling the return leg does not touch the outbound leg and vice versa. - **Payment**: **combined** on the outbound ("primary") leg — one `Payment` row covers both legs' total (`InitiatePaymentAction` sums `outbound.price + return.price`). The return leg is marked `confirmed` when the primary's payment succeeds (`MarkBookingPaid` confirms both). Since the return leg has no `Payment` of its own, `RefundBookingAction` resolves the payment-holder via `linkedBooking` when refunding a return leg — partial refunds (already supported, §6) keep the running total correctly bounded to the combined `Payment.amount` regardless of which leg is cancelled first. --- ## 3. Pricing - `RoutePricing` holds one price per (Route, Vehicle Option) pair. - When a Booking is created, the resolved price is **snapshotted** onto `bookings.price` and never re-read from `RoutePricing` again. If admin edits pricing later, existing bookings keep their original price. This is intentional — historical bookings must never silently reprice. --- ## 4. Tenancy - **Single tenant.** EV Companies are just rows in a table that the one admin team manages — there is no per-company admin login isolation, no data scoping by company for admin users, and no Spatie Permission "teams" feature in v1. - If multi-tenant admin isolation is ever needed, it layers onto the existing role/permission tables later without migrating the core booking/route schema (see architecture plan §30). --- ## 5. Booking Lifecycle (status machine) ``` pending_payment ──(payment succeeds)──▶ confirmed pending_payment ──(customer/admin cancels, no payment yet)──▶ cancelled confirmed ──(refund processed)──▶ cancelled pending_payment ──(TTL expiry, future phase)──▶ expired ``` - A Booking is created `pending_payment` — it exists before any money moves. - It only becomes `confirmed` once the linked Payment succeeds (via the KBZ webhook, not client-reported success — see §6). - Cancelling a `confirmed` (paid) Booking must go through the Refund flow first — never flip status to `cancelled` on a paid booking without a corresponding refund attempt. --- ## 5a. Driver & Vehicle Assignment Once a Booking is `confirmed` (paid), dispatch assigns who's actually doing the trip — a real driver and a real EV, not a catalog lookup: - `bookings.driver_name`, `driver_phone`, `car_plate_number`, `car_model` — plain nullable columns directly on `Booking`, not a separate `drivers`/`vehicles` catalog. Null until assigned; `car_model` stays nullable even after assignment (optional detail). - Filled in via `AssignDriverAction`, gated to `confirmed` bookings only — assigning a driver to a `pending_payment`/`cancelled`/`expired` booking is rejected (`DriverAssignmentNotAllowedException`). Staff can re-run it to reassign a different driver/vehicle as long as the booking is still `confirmed`. - Filament-only for now: the "Assign Driver" action on the admin Booking list/detail page (`manage_bookings` permission), no customer-facing write path. The values are exposed read-only on the booking API response (`GET /api/v1/bookings*`) so a confirmed customer can see who's picking them up. - **Deliberate v1 simplification**: no `drivers`/`vehicles` catalog, no driver scheduling/availability, no linking a driver to an `EvCompany`. If driver roster management becomes a real need, this is the natural point to introduce a `Driver`/`Vehicle` catalog and swap these free-text columns for FKs — not scoped now. - Round trip needed no schema change here: since a round trip is two independent `Booking` rows (§2b), each leg already has its own independent set of these columns — the outbound and return leg can be assigned different drivers/vehicles with zero extra modeling. --- ## 6. Payment Domain (ported from `bnf_event`, refined) The existing KBZ Mini App payment code at `/home/marcspecta/company_projects/bnf_event` (`app/Strategies/Payments/KBZMiniApp.php`, `KBZPay.php`, `BasePayment.php`, `app/Services/PaymentService.php`) is the reference implementation being ported and refined — **read it before starting any Payment-module ticket.** **What's being kept (business logic, still true in the new system):** - KBZ's signing scheme: flatten the request array (excluding `sign`/`sign_type`) into sorted `key=val` pairs joined by `&`, append `&key={merchant_key}`, SHA-256 hash, uppercase. Every request sets `sign_type = "SHA256"`. - Three KBZ API operations matter: `kbz.payment.precreate` (start payment), `kbz.payment.queryorder` (poll/verify status), `kbz.payment.refund` (reverse a payment). All are outbound HTTPS calls; refund additionally requires **mTLS** (client cert + key + CA bundle). - Client-driven confirmation pattern as a fallback/defense-in-depth: after the client reports payment complete, the server **re-verifies by calling `queryorder`** rather than trusting the client payload blindly. - Refund reason is a required, human-readable string sent to KBZ and stored for audit. **What's changing (the agreed refinements — see architecture plan §17–19, §34):** - Old: 3x duplicated `switch($payment_type)` at each call site to pick a gateway class. New: a `PaymentGatewayFactory` resolves `PaymentGatewayInterface` implementations by `PaymentMethod` enum, registered once. - Old: raw associative arrays passed through `PaymentInterface` methods. New: typed DTOs (`PaymentRequestData`, `PaymentResultData`, `RefundResultData`). - Old: KBZ credentials (merchant code, keys, URLs) lived in a DB singleton `payment_setting` table, editable via admin UI. New: `.env` + `config/services.php` (`KBZ_*` vars) — no DB table, no Filament resource for secrets. - Old: payment/refund state overloaded onto the `orders` table (`payment_status` int, `order_status` string, `payment_remark` text) with no structured history. New: dedicated `payments` + `refunds` tables (`refunds.payment_id` FK to `payments.id`), each with a proper status enum. - Old: refund amount was effectively hardcoded to full-amount (the `refund_amount` param was commented out in `KBZMiniApp::refund`). New: `RefundBookingAction` explicitly supports **partial refunds**, validated against the remaining refundable balance on the Payment. - Old: no real inbound webhook — only client-driven confirmation + server-side re-verification. New: add a genuine **signed inbound webhook** (`POST /api/v1/webhooks/kbz`) that verifies KBZ's signature before processing — confirm KBZ's actual inbound-webhook signature spec as part of the Phase 5 webhook ticket (flagged risk, KBZ's spec was never implemented in the old code, only referenced by a `notify_url` config value). - Old: status transitions applied inline inside `BasePayment::paymentStatusChange()`, tightly coupling the gateway class to booking-status logic. New: gateway strategies only return a `PaymentResultData`/`RefundResultData` — booking-status changes happen in **listeners** reacting to `PaymentCompleted` / `PaymentFailed` / `RefundProcessed` domain events, keeping the gateway strategy ignorant of the Booking module. **Webhook idempotency**: KBZ may retry the notify webhook. The handler must check the Payment's current status before transitioning it — never assume a webhook call is the first/only delivery. **Round trips**: payment is combined on the outbound leg (§2b) — `MarkBookingPaid` confirms both the primary booking and its linked return leg when payment succeeds, and `RefundBookingAction` resolves the payment-holder via `linkedBooking` when refunding a return leg (it has no `Payment` of its own). --- ## 7. Deferred / Future (do not build yet) - Fixed pickup/dropoff checkpoint catalog (`pickup_locations`/`dropoff_locations`, FK'd from `EvRoute`) for when a customer is too far for door-to-door — v1 is pure free-text `pickup_address`/`dropoff_address` on `Booking` (see §2a). Revisit if checkpoint meet-ups become common enough to need a curated, reusable list instead of ad-hoc dispatch calls. - Real per-route/date/time-slot capacity holding + availability checks (see §2). - DB row locking (`SELECT ... FOR UPDATE`) for booking concurrency — only needed once real inventory exists. - Multi-tenant admin isolation (Spatie Permission "teams"). - Multiple payment gateways beyond KBZ (the factory is designed to support this later with zero call-site changes). - Booking auto-expiry TTL job for stale `pending_payment` bookings. - Customer notifications (booking confirmation, payment status push). --- ## 8. External Actors & Access Boundaries - **Mini App / Mobile App**: customer-facing clients, authenticate via Sanctum token, full customer ability set (create/view/cancel own bookings, initiate payment). - **FastAPI AI Agent** (external service, owns Gemini/tool-calling — Laravel has zero AI SDK code): authenticates via a Sanctum token scoped to a **restricted ability set** — `route:read`, `booking:create`, `booking:read` only. It must **never** be able to refund, cancel someone else's booking, or write catalog/pricing data. It never touches the database directly, only `/api/v1`. - **Filament Admin**: session-based, role-gated (`super_admin`, `admin`, `support`), the only place catalog/pricing mutation and refund initiation happen for staff.