- 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)
13 KiB
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_seatfor a customer traveling with a companion) — stored as one row per selected option inbooking_vehicle_options(booking_id,vehicle_option,passenger_count,unit_price,line_total), not a single column onbookings.bookings.priceis the sum of every line'sline_total. - Each Vehicle Option can appear at most once per booking (no two separate
front_seatlines — bumppassenger_countinstead).whole_vehiclecannot 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_counton thefront_seatline (BOOKING_FRONT_SEAT_MAX_PER_BOOKING, currently1— 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— currently1, 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_optionsis deliberately shaped so that phase can be built as a new query against it (sum(passenger_count) group by vehicle_optionfor 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) + optionalpickup_lat/pickup_lng.dropoff_address(free text) + optionaldropoff_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
RoutePricingholds one price per (Route, Vehicle Option) pair.- When a Booking is created, the resolved price is snapshotted onto
bookings.priceand never re-read fromRoutePricingagain. 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
confirmedonce 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 tocancelledon 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 onBooking, not a separatedrivers/vehiclescatalog. Null until assigned;car_modelstays nullable even after assignment (optional detail).- Filled in via
AssignDriverAction, gated toconfirmedbookings only — assigning a driver to apending_payment/cancelled/expiredbooking is rejected (DriverAssignmentNotAllowedException). Staff can re-run it to reassign a different driver/vehicle as long as the booking is stillconfirmed. - Filament-only for now: the "Assign Driver" action on the admin Booking list/detail page (
manage_bookingspermission), 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/vehiclescatalog, no driver scheduling/availability, no linking a driver to anEvCompany. If driver roster management becomes a real need, this is the natural point to introduce aDriver/Vehiclecatalog 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 sortedkey=valpairs joined by&, append&key={merchant_key}, SHA-256 hash, uppercase. Every request setssign_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
queryorderrather 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: aPaymentGatewayFactoryresolvesPaymentGatewayInterfaceimplementations byPaymentMethodenum, registered once. - Old: raw associative arrays passed through
PaymentInterfacemethods. New: typed DTOs (PaymentRequestData,PaymentResultData,RefundResultData). - Old: KBZ credentials (merchant code, keys, URLs) lived in a DB singleton
payment_settingtable, 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
orderstable (payment_statusint,order_statusstring,payment_remarktext) with no structured history. New: dedicatedpayments+refundstables (refunds.payment_idFK topayments.id), each with a proper status enum. - Old: refund amount was effectively hardcoded to full-amount (the
refund_amountparam was commented out inKBZMiniApp::refund). New:RefundBookingActionexplicitly 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 anotify_urlconfig value). - Old: status transitions applied inline inside
BasePayment::paymentStatusChange(), tightly coupling the gateway class to booking-status logic. New: gateway strategies only return aPaymentResultData/RefundResultData— booking-status changes happen in listeners reacting toPaymentCompleted/PaymentFailed/RefundProcesseddomain 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 fromEvRoute) for when a customer is too far for door-to-door — v1 is pure free-textpickup_address/dropoff_addressonBooking(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_paymentbookings. - 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:readonly. 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.