Skip to content

Submitting an EOI (Expression of Interest)

An EOI is a paid Expression of Interest. A sales agent — a Broker or a Sales-Man, never the shopper — submits it on behalf of a client against a Launch (a launch groups the projects and unit-types being released). The client then pays a small holding amount to confirm their interest, via a tokenized payment link the platform emails and SMSes to them. Everything here is verified against the current backend (develop @ 8434e475).

From the broker (or sales-man) app, the agent opens EOI Submissions — filtered by the same states as the payment-status enum below: Pending Confirmation, Confirmed Payment, Requires Action:

Broker app — EOI Submissions list: All / Pending Confirmation / Confirmed Payment / Requires Action tabs, phone search, and the New EOI Submission button.

New EOI Submission opens the form the agent fills on the client’s behalf — prospective buyer, the Launch, the sales agent, ID documents, products and payment. Everything here is submitted through the async job below:

Broker app — the create-EOI form: Prospective Buyer Full Name, Launch selector, Sales Agent name/phone, and (below) the personal-identification and product fields.

The authoritative EOI lives in the CRM; the portal keeps a local EOIRequest row (App\Models\EOIRequest → table eoi_requests, app/Models/EOIRequest.php:28) keyed by the CRM’s source_id. That row belongsTo(Launch) and morphMany(Transaction); its $fillable carries source_id, paid_amount, currency, launch_id, transaction_id (:15-26).

Two enums govern its lifecycle.

Payment statusApp\Enums\EOIPaymentStatusEnums (int-backed, app/Enums/EOIPaymentStatusEnums.php:10-17):

Value Enum Meaning
1 PENDING_PAYMENT EOI created; awaiting the client’s holding payment. Only this status (with PAYMENT_LINK) fires the payment link.
2 PAID Holding amount received — via the Paymob callback, or an offline cheque/InstaPay marked paid.
3 DRAFT Draft; not yet submitted for payment.
4 EXPIRED Payment window elapsed. CRM-driven; the agent is notified (ExpiredEOI).
5 COLLECTED Funds collected / settled. CRM-driven — no portal code assigns this; it arrives via the inbound sync.
6 REFUNDED Holding amount refunded. CRM-driven — likewise set CRM-side, never by the portal.
7 REQUIRES_ACTION Needs agent/CRM action; set via the require-action endpoint.

Payment methodApp\Enums\EOIPaymentMethodEnums (string-backed, app/Enums/EOIPaymentMethodEnums.php:10-12):

Value Enum Path
'1' PAYMENT_LINK Online card — the only method that triggers the tokenized link + Paymob online flow.
'2' CHEQUE Offline proof the agent uploads; marked paid without a gateway.
'3' INSTAPAY Offline bank transfer; same offline treatment as cheque.
sequenceDiagram
actor AG as Agent (Broker / Sales-Man)
participant SPA as Agent SPA
participant API as Backend (Laravel)
participant Q as Queue worker
participant MW as crms-middleware → CRM
participant DB as Portal DB
actor SH as Client (shopper)
participant PM as Paymob (Egypt only)
AG->>SPA: Fill EOI form (ID docs, unit-types, preferences, payment info)
SPA->>API: POST /launches/{publishedLaunch}/eois → storeAll
API-->>SPA: 200 respondSuccess (immediately, before any CRM work)
API->>Q: dispatch SaveEOIToCrmJob (ShouldQueue)
Note over Q: WithoutOverlapping(uniqueId) · tries = 3
Q->>MW: 1 createEOI → captures eoi_source_id
Q->>MW: 2 createSalesAgents (optional)
Q->>MW: 3-4 createAttachment (ID front, then back)
Q->>MW: 5 createEOIProducts → unit-types → preferences
Q->>DB: 6 saveEOIToPortal → EOIRequest row (key = source_id)
Q->>MW: 7 updateEOIToCRMAndPortal (paid_amount, status, method, currency)
Note over Q: only if PENDING_PAYMENT AND PAYMENT_LINK
Q->>SH: email + SMS 24h link /pay-eoi/{launch_id}/{source_id}?token=…
SH->>API: GET …/validate-token (403 if expired)
SH->>API: POST …/payment → EOIPayment (Egypt case only)
API->>PM: auth → register order → payment key (+ PENDING Transaction)
SH->>PM: pay on the hosted page
PM->>API: POST /paymob/callback
Note over API: mark Transaction SUCCESS
API->>MW: UpdateEOI → payment_status = PAID
API->>AG: ConfirmedEOIPayment · email client
EOI submit — async create, then tokenized client payment (verified)

The defining trait: creation is asynchronous

Section titled “The defining trait: creation is asynchronous”

The single most important thing to know about this flow: the submit call does almost nothing synchronously. It validates, stores the uploaded attachments, and dispatches a queued job — then returns 200 immediately. All the CRM work happens later, on a worker.

The public POST launches/{publishedLaunch}/eois route points at storeAll (Broker routes/api/broker.php:198, Sales-Man routes/api/sales-man.php:151). storeAll persists the uploaded ID and InstaPay attachments to disk (the sales-man variant also persists cheque proofs; the broker one doesn’t), tags the source_of_sale (BROKER for brokers, DIRECT for sales-men), builds a unique key 'EOI-'.Str::uuid(), then dispatches the job.

App\Jobs\SaveEOIToCrmJob (app/Jobs/SaveEOIToCrmJob.php) implements ShouldQueue, sets public int $tries = 3 (:22), and its middleware() returns new WithoutOverlapping($this->uniqueId) (:72-75) so two submissions sharing the same unique EOI id can’t run concurrently. handle() delegates to SaveEOIToCrmService::handle() (app/Services/EOI/SaveEOIToCrmService.php:62-122), which runs 8 ordered steps through crms-middleware, each asserting HTTP 200 and throwing an AppCustomException with a step-specific message otherwise:

  1. createEOI()SubmitNewEOI POST to the CRM; captures the returned eoi_source_id (:124-148). This is the point of no return for rollback (see below).
  2. createSalesAgents() — optional channel sales agent (:150-159).
  3. createAttachment() (front) — the ID-doc FRONT / passport image (:80-91, :161-211).
  4. createAttachment() (back) — the ID-doc BACK, only if present (:161-211).
  5. createEOIProducts() — products → unit-types → preferences, nested (:93-94, :213-319).
  6. saveEOIToPortal() — writes the local EOIRequest row keyed by source_id (:96-97, :321-331). This is the first local DB write — steps 1–5 leave no local row at all; step 7 then updates this EOIRequest and (for PAYMENT_LINK) inserts an ExpirationToken.
  7. updateEOIToCRMAndPortal() — pushes paid_amount / payment_status / payment_method / currency_id (+ optional cheque/InstaPay fields) to the CRM, then calls UpdateEOIPortalAndSendNotificationsthis is what generates and sends the payment link (:99-100, :333-389).
  8. CHEQUE / INSTAPAY attachments — optional offline-proof uploads (:102-112).

After tries is exhausted, Laravel calls SaveEOIToCrmJob::failed()SaveEOIToCrmService::failed() (app/Services/EOI/SaveEOIToCrmService.php:391-431). It:

  • Reads recipients from env('EOI_FAILED_EMAILS') (comma-split, trimmed) and emails each one a FailedEOIEmail carrying the attachment paths (:400-401, :428-430).
  • Notifies the submitting agent with FailedEOISubmit — “EOI Submission Delay – Technical Issue” (EN + AR, promising the link within 15 minutes), app/Notifications/Shared/FailedEOISubmit.php:44-68.

If EOI_FAILED_EMAILS is unset, explode(',', null) collapses the recipient list to a single empty address (['']), so the operator alert has no real recipient — confirm it’s configured in every environment.

When step 7 lands the EOI at PENDING_PAYMENT with method PAYMENT_LINK, the platform builds and delivers a token-gated public link to the client.

  • Builder: App\Actions\EOI\GetPaymentLink::handle() (app/Actions/EOI/GetPaymentLink.php:10-18) produces {SHOPPER_FRONTEND_URL}/pay-eoi/{launch_id}/{source_id}?token={token}. The base is config('app.shopper_frontend_url') ← env SHOPPER_FRONTEND_URL (config/app.php:58).
  • Token: App\Actions\ExpirationToken\CreateToken reuses any existing non-expired token for the EOI, else generates a Str::uuid() with expiration_date = now()->addHours(24) and stores an ExpirationToken (polymorphic to EOIRequest) — so the link is valid 24 hours (app/Actions/ExpirationToken/CreateToken.php:13-30).
  • Delivery: inside UpdateEOIPortalAndSendNotifications::handleEOIPendingPaymentFlow() (app/Actions/EOI/UpdateEOIPortalAndSendNotifications.php:54-78), and only when payment_status == PENDING_PAYMENT and payment_method == PAYMENT_LINK:
    • Email — EOIPaymentEmail($eoi, $paymentLink) to $eoi->email (:62-64).
    • SMS — sendPaymentLinkViaSms(), gated by config('sms.allow_send_sms'), “complete your payment within 24 hours” (:65, :142-163).
    • Agent notification — SuccessEOISubmission (:68-76).

The online card payment for an EOI is wired for Egypt only. This is a hard constraint in the code, not a config toggle.

Shopper\EOIController::paymentEOIApp\Actions\EOI\EOIPayment::handle switches on the EOI’s country slug and has only a CountryEnums::EGYPT case (app/Actions/EOI/EOIPayment.php:12-32). Every other country falls through default and returns null, and the controller responds "failed to get payment key from payment service. please contact your admin" (EOIPayment.php:12-32, Shopper/EOIController.php:65-69). The Egypt path uses PaymobService (auth token → register order → payment key), and getPaymentKey creates a PENDING Transaction (payment_reference_id = Paymob order id) and sets eoi.transaction_id (app/Services/PaymobService.php:78-142).

Persona Creates an EOI? Route / group Country scoping
Broker Yes (storeAll) routes/api/broker.php:198auth:sanctum + user-provider:brokers Egypt-pinned — the controller hardcodes Country::where('slug','egypt') throughout (e.g. Broker/EOIController.php:130, :429).
Sales-Man Yes (storeAll) routes/api/sales-man.php:151user-provider:sales_man + auth:sanctum Multi-countryCountry is resolved from the country request header by ValidateSalesManCountry (app/Http/Middleware/ValidateSalesManCountry.php:20-37).
Shopper (client) No — pays only routes/api/shopper.php:88-91 (payment / show / validate-token), outside auth:sanctum (:93) Egypt only (online payment). Shopper show calls the CRM as a hardcoded service account (Shopper/EOIController.php:32).
CRM middleware Inbound sync routes/api/crms-middleware.phpclient (Passport client_credentials) n/a

The CRM pushes EOI state back into the portal over crms-middleware webhooks (Passport client_credentials, group Route::middleware(['client']), routes/api/crms-middleware.php:7).

  • POST eoiCrmsMiddleware\EOIController::storeSaveEOIFromCRMToPortal::handle, which does EOIRequest::firstOrCreate(['source_id' => …], $prepared) — resolving user_type/user_id from source_of_sale + agent_source_id, launch_id from launch_source_id, and currency (default EGP) (app/Actions/EOI/SaveEOIFromCRMToPortal.php:16-48). It notifies the agent (EOIIssueResolved); if PENDING_PAYMENT + PAYMENT_LINK, it emails EOIResolvedIssueEmail to the client (EOIController.php:26-32).
  • POST eois/{eoiSourceId}/notifications → upserts via SaveEOIFromCRMToPortal, then EOINotification::handle notifies the agent with ExpiredEOI when payment_status == EXPIRED (app/Actions/User/EOINotification.php:11-19).

Because the inbound sync is a firstOrCreate on source_id, it is safe to replay and is how an EOI first created CRM-side (or recovered after a failed job) appears locally.

  1. Is a queue worker running? The whole create path is queued (SaveEOIToCrmJob implements ShouldQueue). No php artisan queue:work ⇒ the client got a 200 and nothing reached the CRM. This is the most common cause.
  2. Grep laravel.log for the step strings. Each CRM step asserts HTTP 200 and throws a step-specific AppCustomException: failed to create EOI - step 1, failed to create attachment…, failed to create product, failed to create unit type, failed to create preference, failed to update EOI (SaveEOIToCrmService.php:141,209,236,274,312,375). The one that fired tells you which hop broke.
  3. Look for the partial-failure fingerprint. A CRM EOI cancelled with “Job Server Error” and no local eoi_requests row means the job failed mid-flow after the CRM create (:441-466).
  4. Check WithoutOverlapping. A stuck job sharing the same unique EOI id can hold or skip a new one (SaveEOIToCrmJob.php:72-75); tries = 3 (:22).
  5. Confirm the failure alert fired. Final failure emails EOI_FAILED_EMAILS and notifies the agent (FailedEOISubmit) (:391-431). If nobody got the alert, check EOI_FAILED_EMAILS is set. CRM base URL is config('services.crms-middleware.api_url') (used by SubmitNewEOI.php:26).
Symptom Cause Where to look
"failed to get payment key… contact your admin" Non-Egypt EOIEOIPayment only has an Egypt case and returns null for every other country app/Actions/EOI/EOIPayment.php:12-32
403 "Invalid or expired token." The 24h ExpirationToken expired or never matched ValidateToken.php:10-17; a fresh link needs a re-send
Paymob mis-config AppCustomException('Payment Authentication Error' | 'Failed to register order on Paymob' | 'Failed to get payment key') Check payment.paymob.{destination_slug}_api_key / _{currency}_integration_id (PaymobService.php:35-37,135-137,226-234)
  • The server callback needs the PENDING Transaction row (created in getPaymentKey) matched by payment_reference_id = Paymob order id; if it’s missing, AppCustomException('No transaction with this reference id') (PaymentService.php:40-44).
  • On success, PAID is written to the CRM via UpdateEOI; if that CRM call is non-200, updateEOIToCrm returns false and the client email + agent notification are skipped (PaymentService.php:302-326) — the money moved but the confirmation didn’t.
  • The rest (the sleep(10) browser redirect, the shared success/failure handlers) is on the Online payment pipeline page.