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).
What the agent sees
Section titled “What the agent sees”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:

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:

What an EOI is, in data
Section titled “What an EOI is, in data”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 status — App\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 method — App\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. |
The whole trip, in one diagram
Section titled “The whole trip, in one diagram”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 clientThe 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.
The queued job and its 8 CRM steps
Section titled “The queued job and its 8 CRM steps”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:
createEOI()—SubmitNewEOIPOST to the CRM; captures the returnedeoi_source_id(:124-148). This is the point of no return for rollback (see below).createSalesAgents()— optional channel sales agent (:150-159).createAttachment()(front) — the ID-doc FRONT / passport image (:80-91,:161-211).createAttachment()(back) — the ID-doc BACK, only if present (:161-211).createEOIProducts()— products → unit-types → preferences, nested (:93-94,:213-319).saveEOIToPortal()— writes the localEOIRequestrow keyed bysource_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 thisEOIRequestand (forPAYMENT_LINK) inserts anExpirationToken.updateEOIToCRMAndPortal()— pushespaid_amount/payment_status/payment_method/currency_id(+ optional cheque/InstaPay fields) to the CRM, then callsUpdateEOIPortalAndSendNotifications— this is what generates and sends the payment link (:99-100,:333-389).- CHEQUE / INSTAPAY attachments — optional offline-proof uploads (
:102-112).
When the job finally fails
Section titled “When the job finally fails”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 aFailedEOIEmailcarrying 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.
The tokenized 24-hour payment link
Section titled “The tokenized 24-hour payment link”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 isconfig('app.shopper_frontend_url')← envSHOPPER_FRONTEND_URL(config/app.php:58). - Token:
App\Actions\ExpirationToken\CreateTokenreuses any existing non-expired token for the EOI, else generates aStr::uuid()withexpiration_date = now()->addHours(24)and stores anExpirationToken(polymorphic toEOIRequest) — 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 whenpayment_status == PENDING_PAYMENTandpayment_method == PAYMENT_LINK:- Email —
EOIPaymentEmail($eoi, $paymentLink)to$eoi->email(:62-64). - SMS —
sendPaymentLinkViaSms(), gated byconfig('sms.allow_send_sms'), “complete your payment within 24 hours” (:65,:142-163). - Agent notification —
SuccessEOISubmission(:68-76).
- Email —
Online payment is Egypt / Paymob only
Section titled “Online payment is Egypt / Paymob only”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::paymentEOI → App\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).
Personas and routes
Section titled “Personas and routes”| Persona | Creates an EOI? | Route / group | Country scoping |
|---|---|---|---|
| Broker | Yes (storeAll) |
routes/api/broker.php:198 — auth: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:151 — user-provider:sales_man + auth:sanctum |
Multi-country — Country 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.php — client (Passport client_credentials) |
n/a |
Inbound: CRM → portal sync
Section titled “Inbound: CRM → portal sync”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 eoi→CrmsMiddleware\EOIController::store→SaveEOIFromCRMToPortal::handle, which doesEOIRequest::firstOrCreate(['source_id' => …], $prepared)— resolvinguser_type/user_idfromsource_of_sale+agent_source_id,launch_idfromlaunch_source_id, and currency (defaultEGP) (app/Actions/EOI/SaveEOIFromCRMToPortal.php:16-48). It notifies the agent (EOIIssueResolved); ifPENDING_PAYMENT+PAYMENT_LINK, it emailsEOIResolvedIssueEmailto the client (EOIController.php:26-32).POST eois/{eoiSourceId}/notifications→ upserts viaSaveEOIFromCRMToPortal, thenEOINotification::handlenotifies the agent withExpiredEOIwhenpayment_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.
Support
Section titled “Support”EOI never reached the CRM
Section titled “EOI never reached the CRM”- Is a queue worker running? The whole create path is queued (
SaveEOIToCrmJob implements ShouldQueue). Nophp artisan queue:work⇒ the client got a200and nothing reached the CRM. This is the most common cause. - Grep
laravel.logfor the step strings. Each CRM step asserts HTTP 200 and throws a step-specificAppCustomException: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. - Look for the partial-failure fingerprint. A CRM EOI cancelled with “Job Server Error” and
no local
eoi_requestsrow means the job failed mid-flow after the CRM create (:441-466). - 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). - Confirm the failure alert fired. Final failure emails
EOI_FAILED_EMAILSand notifies the agent (FailedEOISubmit) (:391-431). If nobody got the alert, checkEOI_FAILED_EMAILSis set. CRM base URL isconfig('services.crms-middleware.api_url')(used bySubmitNewEOI.php:26).
The client can’t pay / the link fails
Section titled “The client can’t pay / the link fails”| Symptom | Cause | Where to look |
|---|---|---|
"failed to get payment key… contact your admin" |
Non-Egypt EOI — EOIPayment 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 callback isn’t marking the EOI PAID
Section titled “The callback isn’t marking the EOI PAID”- The server callback needs the PENDING
Transactionrow (created ingetPaymentKey) matched bypayment_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,updateEOIToCrmreturns 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.
