Known defects & findings register
Security
Section titled “Security”| Finding | Where | Impact |
|---|---|---|
Paymob webhook HMAC verification is entirely commented out — the callback accepts any body and trusts obj.success with no signature check |
app/Services/PaymobService.php:146-152 (guard call), :153 (trust) |
Critical — forged payment-success webhooks are accepted |
Even the dead HMAC code uses == not hash_equals (non-constant-time) |
app/Services/PaymobService.php:210 |
Timing side-channel (moot while disabled, wrong if re-enabled) |
| No replay / idempotency guard on payment callbacks — a replayed success re-marks the transaction, re-submits to the CRM, and re-fires notifications | app/Services/PaymentService.php:36-73 |
High — duplicate CRM writes / notifications |
| Payment callback routes are public / unauthenticated | routes/api.php:31-38 |
High — combined with the above |
check-whitelisted-emails is an unauthenticated GET that leaks whitelist membership and the brokerage source_id |
app/Http/Controllers/Api/Broker/UserController.php:116-121, routes/api/broker.php:72 |
Medium — enumeration / info leak |
OTP throttles are keyed by the phone field value, not IP — rotating the phone value bypasses the bucket |
shopper OTP routes (routes/api/shopper.php) |
Medium — OTP brute-force / spam |
EOI details GET forwards the raw {eoiSourceId} to the CRM with no local existence check (not a route-model binding) |
routes/api/shopper.php (EOI details) |
Low/Medium — CRM probing |
Thawani secret can be sourced from a DB column payment_api_secret_key; Paymob integration id written to the debug log |
app/Services/ThawaniService.php:25, PaymobService.php:100 |
Low — secret handling |
Broken routes & dead code
Section titled “Broken routes & dead code”| Finding | Where | Impact |
|---|---|---|
getSalesOfferPdf route points at a controller method that does not exist — dead route, 500 on hit |
routes/api/broker.php:132 and routes/api/sales-man.php:79 (Base\SalesOfferPdfController has no such method) |
Broken endpoint (superseded by the async generate/status/download trio) |
Sales-man logout resolves to a non-existent class (Api\SalesMan\Auth\MicrosoftController) — 500 “Target class does not exist”; the real class is App\Http\Controllers\SalesMan\Auth\MicrosoftController@logout |
routes/api/sales-man.php:22 |
Broken logout (SSO login works via routes/web.php) |
Broker GET documents returns 4 hardcoded rows with document: "#" |
app/Http/Controllers/Api/Broker/UserController.php:24-52 |
Mock in production surface |
Shopper GET user/referrals (“my referrals”) returns hardcoded mock data behind a // TODO: call CRM |
app/Http/Controllers/.../ReferralController.php:12-51 |
Mock in production surface |
UnitClient::createResale() reads config('services.crms.unit.create_resale'), a key not declared in the config block → resolves to null |
crms-middleware/app/Clients/Crms/UnitClient.php:61 vs config/services.php:177-186 |
Resale-create silently broken |
PreDeliveryUnitSaleResource and the UnitAddonPackageType enum are defined but referenced nowhere in app code |
app/Http/Resources/PreDeliveryUnitSaleResource.php; app/Enums/UnitAddonPackageType.php |
Dead code |
| Taskeen’s Broker code branch is dead (Taskeen is sales-man-only) | app/Actions/Taskeen/GetTaskeenList.php:25-27 |
Dead code |
Config, auth & doc drift
Section titled “Config, auth & doc drift”| Finding | Where | Impact |
|---|---|---|
Shopper auth is Sanctum, not Passport (as backend/CLAUDE.md claims); config/auth.php sets the api guard driver to passport but routes gate on auth:sanctum |
app/Models/Shopper.php:11,16; config/auth.php |
Documentation + config mismatch |
backend/CLAUDE.md §9 references app/Services/Payment/*, a directory that does not exist (payment code is flat under app/Services/) |
backend/CLAUDE.md:§9 |
Stale doc — misdirects searches |
Frontend declares a @orascom/api tsconfig path alias pointing at libs/api/src/index.ts that does not exist |
frontend/tsconfig.base.json |
Dangling alias |
PDF service fails closed on auth (unset API_TOKEN → 401 for all requests) even though its README/CLAUDE claim “no token ⇒ open” |
backend-pdf-service/src/routes/sales-offer.ts:9-12 |
Doc contradicts code |
middleware-package config file is git-ignored / absent, so its provider’s config() reads resolve to null — it would fail to boot as checked out; its provider also auto-registers routes + webhook ingress against its own CLAUDE rules |
middleware-package/ (.gitignore last line; CrmIntegrationServiceProvider.php:48) |
Library unusable as-is (it is also unused: 0 imports platform-wide) |
crm-mock PROJECT-OVERVIEW.md documents a POST /api/oman/brokers/documents route that is not in routes/api.php |
crm-mock/ |
Stale doc |
PDF_SERVICE_URL default is http://localhost:3001 in config but docs cite the Docker host http://pdf-service:3001 |
backend/config/services.php:69 |
Minor drift |
Integration reality-checks
Section titled “Integration reality-checks”| Finding | Where | Impact |
|---|---|---|
| There is no separate Payload CMS — all content is served by the Laravel Voyager admin over the same DB (the handover note “Payload CMS” is a mislabel) | grep across repos (0 payload.config, 0 dep) |
Corrects a false architectural assumption |
Firebase is auth-only, not FCM push — Kreait Firebase Auth verifies shopper tokens; there is no CloudMessage/device-token/push-send code anywhere |
app/Services/FirebaseAuthService.php:16; shopper LoginController.php:25 |
“Push notifications” don’t exist server-side |
OpenTelemetry is not wired (no open-telemetry/* / @opentelemetry/* deps) — Sentry tracing covers the need |
composer.json / package.json | Corrects an assumed integration |
Pusher is config-scaffold only, not wired — no pusher-php-server dep, no ShouldBroadcast, default driver null/log, no frontend pusher-js |
config/broadcasting.php |
Broadcasting is inert |
Egypt is hardcoded in several write paths: broker register and EOI submit (Country::where('slug','egypt')), add-on payment (Egypt-only), and Taskeen (Egypt-only CRM URL) |
UserController.php:74; AddonPayment.php:19; Taskeen middleware |
Non-Egypt markets silently unsupported on these paths |
Currency-Layer is wired with a deliberate CURRENY_LAYER typo “workaround”; the frontend NX_CURRENY_LAYER_* keys are vestigial (unused) |
config/currency.php; frontend env |
Confusing but functional |
Logic & data bugs
Section titled “Logic & data bugs”| Finding | Where | Impact |
|---|---|---|
Precedence bug: if (! $response->getStatusCode() == 200) — ! binds tighter than ==, so the guard is never true and the intended exception can’t fire |
app/Services/PaymobService.php:69, ThawaniService.php:52 |
Error path unreachable |
Resell-assistance reads $requestData['service_slug'], a key the FormRequest never collects → PHP 8.2 warning + service_type = null on every unit_resale row |
app/Http/Controllers/.../CustomerRequestController.php:102 |
Data always null |
A referral is classified referral locally off portal_page, but as is_referral in the CRM off the referral_* fields — the two conditions can disagree |
app/Http/Controllers/.../LeadController.php:48-51 vs ShopperSubmitNewLead.php:41 |
Inconsistent classification |
The unit hold is placed at create-sale (DRAFT), not at the reserve click, and share-reservation-link creates no hold — there is no local reservation TTL (expiry is CRM-owned) |
app/Actions/Sale/CreateSaleCrm.php:35; ReserveSaleCrm.php:31 |
Holds behave unintuitively |
create_unit_type and mark_pipeline_as_lost have no egypt entry in the per-country validator map — a valid egypt header still 422s on those actions |
app/Http/Requests/SalesMan/Country/CountryRequestValidator.php:44-61 |
Egypt agents blocked on 2 actions |
finish-sale is a PATCH that force-sets status = DONE with no FormRequest / body validation on the state transition |
routes/api/shopper.php (finish-sale) |
Unvalidated state change |
Infobip non-2xx errors are swallowed; SMS silently no-ops when ALLOW_SEND_SMS=false even though the OTP row is stored/verifiable |
app/Services/SmsService.php; app/Notifications/SmsNotification.php |
Silent delivery failures |
OTP code space is only ~10k with two fixed leading zeros (e.g. 009999): str_pad((string) random_int(0, 9999), 6, '0', ...) |
app/Actions/Otp/SendOtp.php:16-18 |
Weak OTP entropy |
The commission-analytics enum case CACHED is a misspelling of “cashed” (= paid) |
app/Enums/BrokerCommissionAnalyticsStatus.php |
Cosmetic but confusing |
handlePaymobResponseCallback (serves all Paymob callbacks — sale / add-on / EOI) contains a hardcoded sleep(10) |
app/Services/PaymentService.php:102 |
Blocks the request 10s |
