Files
Nyan Lin Paing 5b68f4fa38 Add Booking module: model, create/read/cancel API, Filament resource (T4.1-T4.7)
- Booking model with booking_vehicle_options line items (supports mixing
  vehicle options like front_seat + back_seat in one booking), price
  snapshot, status machine, and driver/car assignment fields
- BookingService: front-seat max, disabled-option toggles, duplicate-option
  and whole-vehicle-exclusivity guards
- CreateBookingAction, CancelBookingAction, AssignDriverAction
- BookingRefGenerator: sequential EVB-AAAAA1-style refs via row lock
- POST/GET/cancel booking API endpoints (Sanctum, ownership + admin policy)
- BookingPlugin + Filament BookingResource: list, detail view, Cancel and
  Assign Driver actions (shared between table and detail page)
- domain.md updated for multi-vehicle-option bookings (§2) and driver/
  vehicle assignment (§5a)
2026-08-08 21:43:15 +07:00

136 lines
13 KiB
Markdown
Raw Permalink 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 + round-trip flag + one or more Time Slots + pricing per Vehicle Option. |
| **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. |
| **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.
---
## 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.
---
## 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.
---
## 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.