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

18 KiB
Raw Blame History

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 setroute: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.