Shopper API reference
The Shopper API is the public-facing storefront + buyer + owner surface of the Orascom Portals Laravel backend. It powers the shopper web app (staging: www.orascom.robustastudio.com) — browsing destinations and units, phone-OTP login/registration, reserving and paying for a unit, Expression-of-Interest (EOI) launch payments, and the post-purchase owner area (add-ons, customer requests, saved units).
Base path, host and response envelope
Section titled “Base path, host and response envelope”Every route below is registered by RouteServiceProvider::setRouteGroups() (backend/app/Providers/RouteServiceProvider.php:142-145) under:
- Prefix:
api/shopper— so the full URL ishttps://{backend-host}/api/shopper/{path}. - Controller namespace:
App\Http\Controllers\Api\Shopper. - Route file:
backend/routes/api/shopper.php(77 active routes; one commented-outsms-testline at:145).
Every route also runs the global api middleware group (backend/app/Http/Kernel.php): throttle:api (60 req/min per user-id-or-IP), SubstituteBindings, FormatApiResponse, and SetLocale (reads the locale header, default en). FormatApiResponse wraps each JSON body as { "data": ..., "message": ... }. A handful of routes opt out with withoutMiddleware([FormatApiResponse::class]) and return raw bytes (the sitemap and the sales-offer PDF download).
Authentication and the public vs. authed split
Section titled “Authentication and the public vs. authed split”Every shopper route sits inside one shared group, Route::middleware(['user-provider:shoppers']) (shopper.php:9). The user-provider:shoppers middleware rebinds the web guard and provider to the Shopper Eloquent model (config/auth.php provider shoppers), so all shopper auth resolves against the shoppers table.
The routes then fall into two bands:
- Public (no token): rows in
shopper.php:10-91— browsing, OTP login/registration, sales-offer PDFs, leads, EOI payments. These need no bearer token. - Authenticated: the nested group at
shopper.php:93-144stacksauth:sanctum+ability:access-shopper-api(AuthTokenAbilities::ACCESS_SHOPPER_API). Callers passAuthorization: Bearer {token}.
There is no separate “pre-delivery” URL prefix. All shopper endpoints share api/shopper. The “pre-delivery owner area” is a logical grouping of authenticated post-purchase endpoints, and “pre-delivery” as a concept only exists in the referral lead flow — see Gotchas.
Route-model bindings that act as authorization gates
Section titled “Route-model bindings that act as authorization gates”Several path params resolve through Route::bind() closures (RouteServiceProvider.php:44-118) that call firstOrFail() — an out-of-scope id yields 404 before the controller runs:
| Binding param | Resolves to | Gate |
|---|---|---|
{publishedUnitShopper} |
Unit::published() |
publicly-published only |
{publishedAvailableUnitShopper} |
Unit::published()->available() |
published and available |
{sale} |
UnitSale with unit,user,broker |
must exist |
{salePayment} |
UnitSale where crm_status=DRAFT and status=DRAFT |
only DRAFT sales are payable (:76-81) |
{publishedLaunch} |
Launch::published() |
published only |
{eoiBySourceId} |
EOIRequest by source_id |
must exist |
{pageName} |
Page by name |
must exist |
{countrySlug} / {destinationSlug} |
Country / Destination published() |
published only |
{salesOfferPdfGeneration} |
SalesOfferPdfGeneration by id |
must exist |
Named throttles used by shopper routes
Section titled “Named throttles used by shopper routes”Defined in backend/app/Providers/AppServiceProvider.php:44-85:
| Throttle | Limit | Keyed by |
|---|---|---|
send-otp-phone |
3 / min | phone field |
verify-otp-phone |
3 / min | phone field |
sales-offer-generate |
services.pdf-service.generate_rate_limit_per_minute (default 5) / min |
user-id or IP |
Endpoints
Section titled “Endpoints”All paths are relative to api/shopper. “Auth” is public unless a row says Bearer.
Auth and OTP
Section titled “Auth and OTP”| Method | Path | Purpose | Auth / notes |
|---|---|---|---|
| POST | login/send-otp |
Send login OTP by SMS to a phone | Public. throttle:send-otp-phone. SendOtpRequest. Always 200 even if no shopper exists (LoginController.php:32-45) |
| POST | login/resend-otp |
Resend login OTP (same handler) | Public. throttle:send-otp-phone. sendOtp |
| POST | login |
Verify OTP, issue Sanctum token | Public. LoginRequest (phone, otp). Returns token (LoginController.php:48) |
| GET | user-data-availability |
Check a phone/email is not taken | Public. ValidateNewUserDataAvailability |
| POST | register/send-otp |
Send registration OTP (only if phone is new) | Public. throttle:send-otp-phone. SendOtpRequest |
| POST | register/verify-otp |
Verify OTP, create shopper, issue token | Public. throttle:verify-otp-phone. RegistrationRequest |
| POST | register/resend-otp |
Resend registration OTP (same handler) | Public. throttle:send-otp-phone |
| POST | logout |
Revoke the current access token | Bearer. LogoutController |
Browse and catalog
Section titled “Browse and catalog”| Method | Path | Purpose | Auth / notes |
|---|---|---|---|
| GET | units/sync |
Sync units from CRM by filters | Public. SyncUnitsRequest. Calls CRM {base}/units/sync |
| GET | filters |
Available unit filters (cached) | Public. FilterRequest |
| GET | countries |
List published countries | Public |
| GET | countries/investments |
List investment countries | Public |
| GET | countries/{countrySlug} |
Show one country | Public. Binding countrySlug (published) |
| GET | destinations/{destinationSlug} |
Show one destination | Public. Binding (published) |
| GET | destinations/{destinationSlug}/unit-types |
Destination unit types | Public |
| GET | destinations/{destinationSlug}/projects |
Destination projects | Public |
| GET | destinations/{destinationSlug}/locations |
Destination locations | Public |
| GET | destinations/{destinationSlug}/testimonials |
Destination testimonials | Public |
| GET | destinations/{destinationSlug}/facilities |
Destination facilities | Public |
| GET | destinations/{destinationSlug}/faqs |
Destination FAQs | Public. PaginationRequest |
| GET | destinations/{destinationSlug}/construction-updates |
Construction updates | Public |
| GET | destinations/{destinationSlug}/educational-hubs |
Educational hubs | Public |
| GET | units |
List / search units | Public. IndexRequest |
| GET | units/compare |
Compare units by ids | Public. CompareRequest |
| GET | units/compare/export |
Export comparison as xlsx | Public. CompareExportRequest. Per-unit CRM payment-terms call |
| GET | units/{publishedAvailableUnitShopper} |
Show one unit | Public. Binding (published + available) |
| GET | units/{publishedUnitShopper}/reservation-details |
Unit reservation details | Public. Binding (published) |
| GET | units/{publishedUnitShopper}/payment-terms |
Unit payment terms from CRM | Public. CRM {api}/units/{source_id}/payment-terms |
Reserve, sales-offer and pay
Section titled “Reserve, sales-offer and pay”| Method | Path | Purpose | Auth / notes |
|---|---|---|---|
| GET | sales-nationalities |
Sale nationalities (CRM) | Public. CountryFilterRequest |
| GET | sales-countries |
Sale countries (CRM) | Public. CountryFilterRequest |
| GET | sales-cities |
Sale cities (CRM) | Public |
| GET | sales-occupations |
Sale occupations (CRM) | Public. CountryFilterRequest |
| GET | sales-connections |
Sale connections (CRM) | Public. CountryFilterRequest |
| GET | units/{publishedAvailableUnitShopper}/sales-offer |
Download a unit sales-offer PDF (CRM) | Public. Binding (published + available) |
| POST | units/{publishedAvailableUnitShopper}/sales-offer/{paymentPlanId}/generate |
Start async sales-offer PDF generation | Public. throttle:sales-offer-generate. GenerateRequest. Queues a job to {pdf} |
| GET | units/{publishedAvailableUnitShopper}/sales-offer/{salesOfferPdfGeneration}/status |
Poll PDF generation status | Public. Binding salesOfferPdfGeneration |
| GET | units/{publishedAvailableUnitShopper}/sales-offer/{salesOfferPdfGeneration}/download |
Stream the generated PDF | Public. signed URL. withoutMiddleware([FormatApiResponse]) |
| POST | units/{publishedAvailableUnitShopper}/sale |
Create a unit sale in CRM + portal | Bearer. CreateRequest |
| POST | units/{publishedUnitShopper}/{sale}/reserve |
Reserve the sale in CRM | Bearer. ReserveRequest. Sets sale status PENDING_INFO |
| POST | units/{publishedUnitShopper}/{sale}/attach |
Attach a signed document to the sale | Bearer. AttachRequest (multipart to CRM) |
| POST | units/{publishedUnitShopper}/{salePayment}/payment |
Get a payment key for the sale | Bearer. Binding salePayment (DRAFT-only). PaymentRequest |
| POST | units/{publishedUnitShopper}/{sale}/update-info |
Update buyer info in CRM | Bearer. UpdateInfoRequest. Sets status DOWNLOAD_FORM |
| POST | units/{publishedUnitShopper}/{sale}/reservation-form |
Get the reservation-form PDF from CRM | Bearer. GetReservationFormRequest. Sets status PENDING_FINISH |
| PATCH | units/{publishedUnitShopper}/{sale}/finish-sale |
Mark the sale DONE + notify | Bearer. No FormRequest. Sets status DONE |
| GET | sales/{saleSourceId}/installments |
Sale installments from CRM | Bearer. InstallmentRequest. {saleSourceId} is a raw path value |
EOI (Expression of Interest) launch payment
Section titled “EOI (Expression of Interest) launch payment”| Method | Path | Purpose | Auth / notes |
|---|---|---|---|
| POST | launches/{publishedLaunch}/eois/{eoiBySourceId}/payment |
Get a payment key for an EOI | Public. Bindings publishedLaunch + eoiBySourceId. PaymentRequest (amount) |
| GET | launches/{publishedLaunch}/eois/{eoiSourceId} |
Fetch EOI details from CRM | Public. Binding publishedLaunch. {eoiSourceId} is a raw path value (not bound) |
| GET | launches/{publishedLaunch}/eois/{eoiBySourceId}/validate-token |
Validate an EOI expiration token | Public. ValidateTokenRequest (token) |
Wishlist and account
Section titled “Wishlist and account”| Method | Path | Purpose | Auth / notes |
|---|---|---|---|
| GET | me |
Return the current shopper | Bearer. UserResource |
| GET | details |
Fetch + sync shopper CRM details | Bearer. CRM {base}/shopper/details |
| GET | user/cards |
List the shopper’s access cards | Bearer |
| GET | user/offers |
List user offers | Bearer. PaginationRequest. Mock / TODO CRM (UserController.php:91-99) |
| GET | user/news |
List user news | Bearer. IndexRequest |
| GET | user/referrals |
List user referrals | Bearer. Mock / TODO |
| GET | user/units |
List the shopper’s purchased units | Bearer. Only status=DONE, non-cancelled, non-DRAFT sales |
| GET | user/units/{publishedUnitShopper} |
Show one owned unit | Bearer. Binding (published) + ownership middleware (404 if not the owner) |
| GET | user/saved-units |
List saved (wishlist) units | Bearer. SavedUnitRequest |
| POST | user/saved-units/{publishedAvailableUnitShopper} |
Save a unit | Bearer. Binding (published + available) |
| DELETE | user/saved-units/{publishedUnitShopper} |
Remove a saved unit | Bearer. Binding (published) |
Pre-Delivery owner area (add-ons and requests)
Section titled “Pre-Delivery owner area (add-ons and requests)”Authenticated post-purchase services for an owner whose unit is not yet delivered.
| Method | Path | Purpose | Auth / notes |
|---|---|---|---|
| GET | units/{publishedUnitShopper}/addons |
List add-ons for a unit | Bearer. Binding (published) |
| GET | addons/{addon} |
Show one add-on | Bearer. Implicit addon model binding |
| POST | units/{publishedUnitShopper}/addon |
Submit an add-on to CRM | Bearer. SubmitUnitAddonRequest |
| GET | units/{publishedUnitShopper}/submitted-addons |
List submitted add-ons from CRM | Bearer |
| POST | units/{publishedUnitShopper}/purchase-addon |
Get a payment key to buy an add-on | Bearer. PaymentRequest (UnitAddon). Paymob |
| GET | user/customer-requests |
List customer requests from CRM | Bearer. IndexRequest |
| POST | units/{publishedUnitShopper}/customer-requests |
Submit a unit-service request | Bearer. SubmitUnitServiceRequest |
| POST | units/{publishedUnitShopper}/resell-assistance |
Submit resell assistance | Bearer. SubmitResellAssistanceRequest |
Content and CMS
Section titled “Content and CMS”| Method | Path | Purpose | Auth / notes |
|---|---|---|---|
| GET | sitemap.xml |
Serve the stored sitemap | Public. withoutMiddleware([FormatApiResponse]) (raw XML) |
| GET | pages/{pageName} |
Show a CMS page by name | Public. Binding pageName |
| GET | custom-pages |
List published custom pages | Public |
| GET | promotions |
List active promotions | Public |
| GET | news |
List news with filters | Public. IndexRequest |
| GET | news/{newsSlug} |
Show one news article | Public. Binding newsSlug |
| GET | contact-info |
List contact info | Public |
| GET | issue-types |
List customer-request issue types | Public |
| POST | sales-request |
Submit a sales request | Public. SalesRequest. Stub — TODO CRM integration |
| POST | leads |
Submit a shopper lead / referral to CRM | Public route, reads token if present. SubmitLeadRequest. Handles pre-delivery referral (see Gotchas) |
Key request / response details
Section titled “Key request / response details”POST login/send-otp and POST login
Section titled “POST login/send-otp and POST login”sendOtp (LoginController.php:29-46) validates SendOtpRequest (app/Http/Requests/Auth/SendOtpRequest.php):
phone— required,phone:auto.
It looks up Shopper::where('phone', ...). Only if a shopper exists does it dispatch the OTP (OtpActionEnums::SHOPPER_LOGIN, channel PHONE). Either way it returns 200 with { "message": "..." } (messages.sent_otp). This is deliberate anti-enumeration but means a 200 does not confirm an OTP was actually sent.
login (LoginController.php:48-75) validates LoginRequest:
phone— required,phone:auto.otp— required, string.
It re-fetches the user, verifies + deletes the OTP row, stamps phone_verified_at if empty, then mints the token. Response body (via AuthenticationResponse, app/Http/Resources/AuthenticationResponse.php:14-21):
{ "data": { "user": { "...": "UserResource" }, "token": "1|plainTextSanctumToken...", "token_type": "Bearer" }, "message": null}An unknown phone (no matching shopper) throws AppCustomException('Failed to authenticate')
(LoginController.php:53-55); a wrong/expired OTP throws from inside VerifyOtp->handle().
POST register/send-otp and POST register/verify-otp
Section titled “POST register/send-otp and POST register/verify-otp”RegistrationController::sendOtp (RegistrationController.php:31-44) uses the same SendOtpRequest but inverts the check: it sends the OTP only if no shopper with that phone exists (OtpActionEnums::SHOPPER_REGISTER), and always returns 200.
verifyOtp (:46-70) validates RegistrationRequest (app/Http/Requests/Auth/RegistrationRequest.php):
name— required, string.phone— required,phone:auto.email— required,email:rfc.otp— required, string.
If the phone already belongs to a shopper it throws UnprocessableEntityException (messages.shopper_exists). Otherwise it verifies the OTP, creates the shopper (Arr::except($requestData, 'token')), deletes the OTP, and returns the same AuthenticationResponse token payload as login.
POST units/{publishedAvailableUnitShopper}/sale — create a sale
Section titled “POST units/{publishedAvailableUnitShopper}/sale — create a sale”UnitSaleController::createUnitSale (UnitSaleController.php:38-66) validates CreateRequest (app/Http/Requests/UnitSale/CreateRequest.php):
customer_source_id— required withoutlead_source_id, string, nullable.lead_source_id— required withoutcustomer_source_id, string, nullable.broker_id— nullable; must be ausersrow withrole_id = BROKER.reservation_amount— required, numeric.paymentplan_source_id— required, string.referral_number,referral_unit_name— nullable, string.currency— nullable, must be inCurrencyEnums.currency_rate— nullable, numeric,decimal:0,3, required ifcurrencyis filled.salesman_id— nullable; must exist insales_team.
It calls the CRM (CreateSaleCrm), maps the response, and on CRM 200 persists a local UnitSale via CreateSalePortal, returning a UnitSaleResource. On CRM failure the mapped (non-200) response is returned as-is.
POST units/{publishedUnitShopper}/{sale}/reserve — reserve the sale
Section titled “POST units/{publishedUnitShopper}/{sale}/reserve — reserve the sale”reserveUnitSale (:68-97) validates ReserveRequest (app/Http/Requests/UnitSale/ReserveRequest.php):
currency_code— required, inCurrencyEnums.currency_rate— required,decimal:0,3.reservation_amount— nullable, int.reservation_payment_method— required, inPaymentMethods.transaction_code— nullable, string.
It injects sale_source_id from the bound {sale}, calls ReserveSaleCrm, and on CRM 200 updates the sale to status = PENDING_INFO and stores the payment_method, returning the UnitSaleResource.
The full reserve→pay journey then advances the sale through these states: reserve → PENDING_INFO, update-info → DOWNLOAD_FORM, reservation-form → PENDING_FINISH, finish-sale → DONE (which fires the SaleStatusDone notification, :197-215).
POST units/{publishedUnitShopper}/{salePayment}/payment — pay for the sale
Section titled “POST units/{publishedUnitShopper}/{salePayment}/payment — pay for the sale”paymentUnitSale (:112-143) is gated by the {salePayment} binding, which resolves only sales with crm_status=DRAFT and status=DRAFT (RouteServiceProvider.php:76-81) — a paid/reserved sale returns 404 here. It validates PaymentRequest (app/Http/Requests/UnitSale/PaymentRequest.php):
amount— required, int.currency— nullable, inCurrencyEnums.currency_rate— nullable, numeric,decimal:0,3, required ifcurrencyis filled.
If the destination has requires_payment_currency_choice, currency/currency_rate are written to the sale first; if that flag is set but the sale still has no currency it returns 400 (“Currency is required in this destination”). It then calls PaymentSale::handle($sale, $amount) (app/Actions/Sale/PaymentSale.php:16-49), which selects the gateway by the unit’s country: Paymob (Egypt) returns a payment key, Thawani (Oman) returns a session, WSPG (Montenegro) returns a signature key. That value is returned in data; a null result returns 400 (“failed to get payment key from payment service…”).
POST launches/{publishedLaunch}/eois/{eoiBySourceId}/payment — EOI payment
Section titled “POST launches/{publishedLaunch}/eois/{eoiBySourceId}/payment — EOI payment”EOIController::paymentEOI (EOIController.php:53-72) is public (no token). It validates EOI\PaymentRequest (app/Http/Requests/EOI/PaymentRequest.php):
amount— required, int.
It calls EOIPayment::handle($eoi, $amount) and returns the payment key in data, or 400 on a null gateway result. Both {publishedLaunch} (published launch) and {eoiBySourceId} (EOI by source_id) are firstOrFail bindings, so an unpublished launch or unknown EOI yields 404.
POST units/{publishedUnitShopper}/purchase-addon — owner add-on payment
Section titled “POST units/{publishedUnitShopper}/purchase-addon — owner add-on payment”UnitAddonController::purchaseAddonsByVisa (UnitAddonController.php:63-85) validates UnitAddon\PaymentRequest (app/Http/Requests/UnitAddon/PaymentRequest.php):
price— required, integer.currency_source_id— required, string, must exist incurrencies.source_id.customer_source_id— required, string.payment_type_id— required, integer, inUnitAddonPaymentType.unit_addon_source_id— required, string.sale_source_id— required, string.
It saves the add-on sale (SaveUnitAddonSale), then calls AddonPayment::handle($unitAddon, $price) (Paymob) and returns the payment key in data, or 400 on failure.
Payment gateway callbacks (shared, not under api/shopper)
Section titled “Payment gateway callbacks (shared, not under api/shopper)”The gateways that the shopper pays through call back to routes registered in backend/routes/api.php (prefix api, not api/shopper), all with withoutMiddleware([FormatApiResponse::class]):
| Method | Path | Handler |
|---|---|---|
| POST | api/paymob/callback |
Payment\CallbackController@paymob (server-to-server) |
| GET | api/paymob/callback |
Payment\CallbackController@paymobResponseCallback (browser redirect) |
| GET | api/thawani/callback/{status} |
Payment\CallbackController@thawani |
| GET | api/wspg/callback/{status} |
Payment\CallbackController@wspg |
{status} is one of success, cancel, fail (CallbackController.php:44-65). These have no shopper auth — they are authenticated by the gateway’s own signature inside App\Services\*Service.
Gotchas
Section titled “Gotchas”- Sanctum, not Passport. Despite the
backend/CLAUDE.mdauth quick-map, shopper tokens are Sanctum personal-access tokens with abilities (ShopperusesLaravel\Sanctum\HasApiTokens; routes gate onauth:sanctum+ability:access-shopper-api). Confusingly,config/auth.php’s genericapiguard is the Passport driver, but shopper routes never use it. - There is no pre-delivery URL prefix. The task brief’s assumption of a separate pre-delivery prefix does not hold — all 77 shopper endpoints live under
api/shopper. “Pre-delivery” exists only as data:PortalPages::REFERRAL_PRE_DELIVERYand the requiredis_predeliveryboolean onSubmitLeadRequest. InLeadController::submitLead(LeadController.php:48-51), whenportal_page == REFERRAL_PRE_DELIVERYthe saved user-request type is switched fromLEADtoREFERRAL. There is also aPreDeliveryUnitSaleResource(app/Http/Resources/PreDeliveryUnitSaleResource.php) that is dead code — it is not referenced anywhere inapp/. login/send-otpandregister/send-otpalways return 200. A success response does not mean an OTP was sent — login only sends when the phone belongs to an existing shopper, register only when it does not. This is anti-enumeration; clients must not treat 200 as “OTP delivered.”POST leadsis public but shopper-aware. It sits in the no-token band (shopper.php:86) yet callsAuth::user()and attaches$shopper?->idwhen a token is present (LeadController.php:29,46). Anonymous submissions are allowed; authenticated ones are linked to the shopper.{salePayment}silently 404s non-DRAFT sales. The payment route uses thesalePaymentbinding (DRAFTcrm_statusandstatusonly). Trying to pay for an already-reserved/paid sale returns 404, not a clearer 409/422.{eoiSourceId}on the EOI-details route is not a model binding. Unlike{eoiBySourceId}(which resolves anEOIRequest),GET launches/{publishedLaunch}/eois/{eoiSourceId}passes the raw string straight to the CRM (EOIController.php:24-51) — no local existence check.- OTP throttles are keyed by the
phonefield, not IP.send-otp-phone/verify-otp-phoneallow 3/min per phone value (AppServiceProvider.php:44-67), so a caller rotating phone values is not rate-limited by these buckets (the globalthrottle:api60/min per IP still applies). - Several account endpoints are mocks.
user/offersanduser/referralsreturn placeholder data with//TODOCRM integration (UserController.php:91-99), andPOST sales-requestis a stub. Treat their shapes as provisional. finish-saleuses PATCH and has no FormRequest. It trusts the{sale}binding alone and force-setsstatus = DONE+closed_at_dm(UnitSaleController.php:197-215); there is no body validation on this state transition.
