Buying a unit add-on
Buying an add-on is a post-purchase, card-only flow. An existing owner opens a unit they already
bought (the Pre-Delivery area), browses the purchasable add-ons attached to that unit, and pays
for one by card. The portal writes a thin local unit_addon_sale record and a polymorphic
Transaction, charges the card through Paymob, and — only after the charge succeeds — submits
the add-on to the CRM. Verified against current backend code.
What the shopper sees
Section titled “What the shopper sees”The add-on catalog is a local mirror: index returns $unit->addons joined through the
addon_unit pivot (UnitAddonController.php:22), each item carrying a price, gallery images, and an
is_purchasable flag (AddonResource.php:34). The owner picks one, chooses a payment tier, and taps
buy — the SPA redirects to the Paymob card iframe.
The flow
Section titled “The flow”sequenceDiagram
participant SPA as Shopper SPA
participant API as Backend
participant GW as Paymob (Egypt only)
participant CRM as crms-middleware
SPA->>API: GET /api/shopper/units/{unit}/addons
API-->>SPA: purchasable add-ons (local mirror)
SPA->>API: POST /api/shopper/units/{unit}/purchase-addon
Note over API: SaveUnitAddonSale writes the local unit_addon_sale row
API->>GW: register order then get payment key
Note over API: getPaymentKey creates a PENDING Transaction
API-->>SPA: payment key (or 400 when no key)
SPA->>GW: redirect to iframe and pay by card
GW-->>API: success callback POST /paymob/callback
Note over API: mark Transaction SUCCESS then submitAddonToCrm
API->>CRM: POST /units/{sourceId}/addons
API-->>SPA: redirect /units/{unitId}/addons?buy-addon=trueStep by step
Section titled “Step by step”-
Browse purchasable add-ons —
GET /api/shopper/units/{publishedUnitShopper}/addons(UnitAddonController@index,UnitAddonController.php:22). Reads the localaddonsmirror via theaddon_unitpivot (Addon.php:49) — no CRM call. Open one withGET /api/shopper/addons/{addon}(@show,:30). -
Submit the purchase —
POST /api/shopper/units/{publishedUnitShopper}/purchase-addon(@purchaseAddonsByVisa,:63). The body is validated byPaymentRequest(PaymentRequest.php:11):price(int),currency_source_id,customer_source_id,payment_type_id(must be aUnitAddonPaymentType),unit_addon_source_id,sale_source_id. -
Write the local sale row —
SaveUnitAddonSale(SaveUnitAddonSale.php:11) does anupdateOrCreatekeyed on (sale_source_id,unit_addon_source_id,unit_id), storingcurrency_id,user_id,reservation_amount(=price),customer_source_id, andpayment_type_id. Re-posting the same keys updates the row rather than duplicating it. -
Get the Paymob payment key —
AddonPayment(AddonPayment.php:13). Egypt-only: it charges only when the unit’s country slug isCountryEnums::EGYPT(:19).PaymobService::registerOrder(PaymobService.php:47) opens the order, thengetPaymentKey(:78) creates a PENDINGTransactionpolymorphic on theUnitAddonSale(type = PAYMOB,:84) and stampstransaction_idback on the sale row (:94). A null key → the controller answers 400failed to get payment key…(UnitAddonController.php:78). -
Pay at the gateway — the SPA redirects to the Paymob iframe with the returned key and the owner enters card details.
-
Success callback re-enters server-side —
POST /paymob/callback(api.php:32) →handleSuccessTransactionCallback(PaymentService.php:36). It marks theTransactionSUCCESS (:45), stampsreservation_amount+payment_method = CREDIT_DEBIT_CARDon the row (:57), then dispatches on model class tosubmitAddonToCrm(:64). -
Submit the add-on to the CRM —
submitAddonToCrm(PaymentService.php:245) rebuilds the params from the local row and callsSubmitUnitAddon(SubmitUnitAddon.php:17) →POST {crms-middleware}/units/{sourceId}/addonswith acountryheader. Only a CRM 200 setsstatus_after_success_transaction = 1(:70,:263). -
Land back in the app —
redirectSuccessUrl(PaymentService.php:118) sends the SPA to/units/{unitId}/addons?buy-addon=true— or?buy-addon=falseif the CRM submit failed after a successful charge (:126). A failed/cancelled charge lands on?failed=true/?canceled=true(redirectFailUrl,:136).
Add-on types and payment tiers
Section titled “Add-on types and payment tiers”Two int-backed enums live in the add-on layer. Only one of them is enforced on the request.
Payment tier — payment_type_id is validated against UnitAddonPaymentType
(UnitAddonPaymentType.php:7) on both the purchase and the direct-submit request:
payment_type_id |
Case | Note |
|---|---|---|
1 |
BASIC |
|
2 |
DELUXY |
Spelled DELUXY in source — not DELUXE |
3 |
LUXURY |
Package category — UnitAddonPackageType (UnitAddonPackageType.php:7) is the 11-value add-on
taxonomy, but it is defined only — no purchase or submit code references it (it is a CRM-side
category, not a portal-validated field):
| Value | Case | Value | Case |
|---|---|---|---|
1 |
FINISHING |
7 |
POOL |
2 |
HOME_AUTOMATION |
8 |
ROOF_AREA_ENHANCMENT |
3 |
SOLAR_SYSTEM |
9 |
BUNDLES |
4 |
BASEMENT_FINISHING |
10 |
FURNITURE_PACKAGES |
5 |
PENTHOUSE_FINISHING |
11 |
ADDITIONAL_ITEMS |
6 |
EXTENSION |
Gotchas
Section titled “Gotchas”-
Add-on card payment is Paymob / Egypt-only.
AddonPaymentonly charges when the unit’s country isCountryEnums::EGYPT(AddonPayment.php:19); every other country falls through to anullkey and the controller returns 400failed to get payment key…. Unlike the unit-sale flow (Paymob / Thawani / WSPG by country), there is no Thawani or WSPG branch here — an Oman or Montenegro owner cannot buy an add-on by card through this path. -
The CRM submit happens after the charge, not before. A successful card charge with a failing CRM submit lands the owner on
?buy-addon=false— the money moved but the add-on is not in the CRM. Diagnose withstatus_after_success_transaction = 0on theTransactionplus thesubmitAddonToCrmlogs; reconcile against the CRM manually. This is the add-on equivalent of the unit flow’s “paid but still DRAFT”. -
Callback HMAC is disabled.
checkTransactionCallbackHMACis commented out insidehandleTransactionCallback(PaymobService.php:146-152) — the callback trustsobj.successoutright. This is a flagged audit item (the Paymob HMAC==vshash_equalsweakness). Treat the raw callback as untrusted when reasoning about a disputed charge. -
is_purchasableis surfaced but not enforced server-side.index/showreturnis_purchasable(AddonResource.php:34) so the SPA can hide the buy button, butpurchaseAddonsByVisanever checks it — there is no server guard against buying an add-on flagged non-purchasable. -
The GET response-callback sleeps.
handlePaymobResponseCallback(PaymentService.php:100) callssleep(10)before reading the transaction, to let the server-to-serverPOST /paymob/callbackland first. So the browser-facing redirect deliberately lags the write — a slow landing is expected, not a hang. -
updateOrCreatemakes re-posts idempotent. Re-submitting the same (sale_source_id,unit_addon_source_id,unit_id) updates the existingunit_addon_salerow instead of creating a duplicate (SaveUnitAddonSale.php:15) — but each attempt still opens a newTransactionviagetPaymentKey, so read the latest one when reconciling.
