Files
famous-ly4-ev/domain.md
T
Nyan Lin Paing fa908cdcaf
PHP Tests / php-tests (push) Has been cancelled
add notes/remark and refactor round-trip
2026-08-22 21:43:41 +07:00

154 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 §1719, §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.