Skip to content

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).

Every route below is registered by RouteServiceProvider::setRouteGroups() (backend/app/Providers/RouteServiceProvider.php:142-145) under:

  • Prefix: api/shopper — so the full URL is https://{backend-host}/api/shopper/{path}.
  • Controller namespace: App\Http\Controllers\Api\Shopper.
  • Route file: backend/routes/api/shopper.php (77 active routes; one commented-out sms-test line 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-144 stacks auth:sanctum + ability:access-shopper-api (AuthTokenAbilities::ACCESS_SHOPPER_API). Callers pass Authorization: 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

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

All paths are relative to api/shopper. “Auth” is public unless a row says Bearer.

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
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
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)
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
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)

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 without lead_source_id, string, nullable.
  • lead_source_id — required without customer_source_id, string, nullable.
  • broker_id — nullable; must be a users row with role_id = BROKER.
  • reservation_amount — required, numeric.
  • paymentplan_source_id — required, string.
  • referral_number, referral_unit_name — nullable, string.
  • currency — nullable, must be in CurrencyEnums.
  • currency_rate — nullable, numeric, decimal:0,3, required if currency is filled.
  • salesman_id — nullable; must exist in sales_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, in CurrencyEnums.
  • currency_rate — required, decimal:0,3.
  • reservation_amount — nullable, int.
  • reservation_payment_method — required, in PaymentMethods.
  • 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: reservePENDING_INFO, update-infoDOWNLOAD_FORM, reservation-formPENDING_FINISH, finish-saleDONE (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, in CurrencyEnums.
  • currency_rate — nullable, numeric, decimal:0,3, required if currency is 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 in currencies.source_id.
  • customer_source_id — required, string.
  • payment_type_id — required, integer, in UnitAddonPaymentType.
  • 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.


  • Sanctum, not Passport. Despite the backend/CLAUDE.md auth quick-map, shopper tokens are Sanctum personal-access tokens with abilities (Shopper uses Laravel\Sanctum\HasApiTokens; routes gate on auth:sanctum + ability:access-shopper-api). Confusingly, config/auth.php’s generic api guard 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_DELIVERY and the required is_predelivery boolean on SubmitLeadRequest. In LeadController::submitLead (LeadController.php:48-51), when portal_page == REFERRAL_PRE_DELIVERY the saved user-request type is switched from LEAD to REFERRAL. There is also a PreDeliveryUnitSaleResource (app/Http/Resources/PreDeliveryUnitSaleResource.php) that is dead code — it is not referenced anywhere in app/.
  • login/send-otp and register/send-otp always 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 leads is public but shopper-aware. It sits in the no-token band (shopper.php:86) yet calls Auth::user() and attaches $shopper?->id when 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 the salePayment binding (DRAFT crm_status and status only). 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 an EOIRequest), 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 phone field, not IP. send-otp-phone / verify-otp-phone allow 3/min per phone value (AppServiceProvider.php:44-67), so a caller rotating phone values is not rate-limited by these buckets (the global throttle:api 60/min per IP still applies).
  • Several account endpoints are mocks. user/offers and user/referrals return placeholder data with //TODO CRM integration (UserController.php:91-99), and POST sales-request is a stub. Treat their shapes as provisional.
  • finish-sale uses PATCH and has no FormRequest. It trusts the {sale} binding alone and force-sets status = DONE + closed_at_dm (UnitSaleController.php:197-215); there is no body validation on this state transition.