Skip to content

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.

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.

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=true
Buying a unit add-on — save row, charge card, submit on callback
  1. Browse purchasable add-onsGET /api/shopper/units/{publishedUnitShopper}/addons (UnitAddonController@index, UnitAddonController.php:22). Reads the local addons mirror via the addon_unit pivot (Addon.php:49) — no CRM call. Open one with GET /api/shopper/addons/{addon} (@show, :30).

  2. Submit the purchasePOST /api/shopper/units/{publishedUnitShopper}/purchase-addon (@purchaseAddonsByVisa, :63). The body is validated by PaymentRequest (PaymentRequest.php:11): price (int), currency_source_id, customer_source_id, payment_type_id (must be a UnitAddonPaymentType), unit_addon_source_id, sale_source_id.

  3. Write the local sale rowSaveUnitAddonSale (SaveUnitAddonSale.php:11) does an updateOrCreate keyed on (sale_source_id, unit_addon_source_id, unit_id), storing currency_id, user_id, reservation_amount (= price), customer_source_id, and payment_type_id. Re-posting the same keys updates the row rather than duplicating it.

  4. Get the Paymob payment keyAddonPayment (AddonPayment.php:13). Egypt-only: it charges only when the unit’s country slug is CountryEnums::EGYPT (:19). PaymobService::registerOrder (PaymobService.php:47) opens the order, then getPaymentKey (:78) creates a PENDING Transaction polymorphic on the UnitAddonSale (type = PAYMOB, :84) and stamps transaction_id back on the sale row (:94). A null key → the controller answers 400 failed to get payment key… (UnitAddonController.php:78).

  5. Pay at the gateway — the SPA redirects to the Paymob iframe with the returned key and the owner enters card details.

  6. Success callback re-enters server-sidePOST /paymob/callback (api.php:32) → handleSuccessTransactionCallback (PaymentService.php:36). It marks the Transaction SUCCESS (:45), stamps reservation_amount + payment_method = CREDIT_DEBIT_CARD on the row (:57), then dispatches on model class to submitAddonToCrm (:64).

  7. Submit the add-on to the CRMsubmitAddonToCrm (PaymentService.php:245) rebuilds the params from the local row and calls SubmitUnitAddon (SubmitUnitAddon.php:17) → POST {crms-middleware}/units/{sourceId}/addons with a country header. Only a CRM 200 sets status_after_success_transaction = 1 (:70, :263).

  8. Land back in the appredirectSuccessUrl (PaymentService.php:118) sends the SPA to /units/{unitId}/addons?buy-addon=true — or ?buy-addon=false if the CRM submit failed after a successful charge (:126). A failed/cancelled charge lands on ?failed=true / ?canceled=true (redirectFailUrl, :136).

Two int-backed enums live in the add-on layer. Only one of them is enforced on the request.

Payment tierpayment_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 categoryUnitAddonPackageType (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
  • Add-on card payment is Paymob / Egypt-only. AddonPayment only charges when the unit’s country is CountryEnums::EGYPT (AddonPayment.php:19); every other country falls through to a null key and the controller returns 400 failed 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 with status_after_success_transaction = 0 on the Transaction plus the submitAddonToCrm logs; reconcile against the CRM manually. This is the add-on equivalent of the unit flow’s “paid but still DRAFT”.

  • Callback HMAC is disabled. checkTransactionCallbackHMAC is commented out inside handleTransactionCallback (PaymobService.php:146-152) — the callback trusts obj.success outright. This is a flagged audit item (the Paymob HMAC == vs hash_equals weakness). Treat the raw callback as untrusted when reasoning about a disputed charge.

  • is_purchasable is surfaced but not enforced server-side. index / show return is_purchasable (AddonResource.php:34) so the SPA can hide the buy button, but purchaseAddonsByVisa never checks it — there is no server guard against buying an add-on flagged non-purchasable.

  • The GET response-callback sleeps. handlePaymobResponseCallback (PaymentService.php:100) calls sleep(10) before reading the transaction, to let the server-to-server POST /paymob/callback land first. So the browser-facing redirect deliberately lags the write — a slow landing is expected, not a hang.

  • updateOrCreate makes re-posts idempotent. Re-submitting the same (sale_source_id, unit_addon_source_id, unit_id) updates the existing unit_addon_sale row instead of creating a duplicate (SaveUnitAddonSale.php:15) — but each attempt still opens a new Transaction via getPaymentKey, so read the latest one when reconciling.