Skip to content

Generating a sales-offer PDF

Generating a sales offer is an async flow. The portal asks the backend to start generation, gets back a tracking id, polls for status, and only downloads once a signed URL appears. The heavy work — rendering HTML to images and stitching a PDF — happens in a separate Node/Puppeteer microservice (backend-pdf-service), driven by a queued Laravel job. Everything here is traced to current code across two repos: backend/ (Laravel) and backend-pdf-service/ (Node/Express).

It starts with one control — the Download Sales Offer link on a unit page (shopper, broker, and sales-man portals all have it):

Shopper app — unit detail actions with the “Download Sales Offer” link that starts the async PDF generation (alongside “Reserve unit” and “I’m interested”).

The one thing to know before you touch this

Section titled “The one thing to know before you touch this”
sequenceDiagram
actor U as Portal user
participant SPA as Portal SPA (React)
participant API as Backend (Laravel)
participant DB as SalesOfferPdfGeneration
participant Q as Queue "pdf-service"
participant JOB as GenerateSalesOfferPdfJob
participant PDF as PDF service (Puppeteer)
participant FS as Cache disk (SALES_OFFER_DISK)
U->>SPA: Click "download sales offer"
SPA->>API: POST .../units/{unit}/sales-offer/{plan}/generate
Note over API: validate offer context + payment plan<br/>throttle:sales-offer-generate
API->>DB: create row, status=Pending
API->>Q: dispatch GenerateSalesOfferPdfJob(id)
API-->>SPA: 200 { id }
Q->>JOB: run (only if row still Pending)
Note over JOB: status=Processing<br/>resolve plan + EGP rate if capping
JOB->>FS: cache hit? sales-offers/cache/{sha}.pdf
alt cache miss
  JOB->>PDF: POST /api/sales-offer (Bearer token)
  PDF-->>JOB: 200 application/pdf
  JOB->>FS: store {sha}.pdf
end
JOB->>DB: status=Completed, storage_path
loop poll every 5s (max ~20 attempts)
  SPA->>API: GET .../sales-offer/{id}/status
  API-->>SPA: { status, file_url when Completed }
end
SPA->>API: GET signed file_url (download route)
Note over API: signed middleware + path guards<br/>(NOT persona auth)
API-->>SPA: stream PDF (attachment)
SPA->>U: browser saves the PDF
Sales-offer PDF — async generate → poll → download (verified)

Every generation is one SalesOfferPdfGeneration row whose status is a SalesOfferPdfGenerationStatus enum (backend/app/Enums/SalesOfferPdfGenerationStatus.php:5-11):

Status Backed string Set by
Pending pending generate action creates the row here (StartSalesOfferPdfGeneration.php:27-53).
Processing processing the job, right after it starts (GenerateSalesOfferPdfJob.php:54-56).
Completed completed the job, on a 2xx from the PDF service, with storage_path set (:120-127).
Failed failed the job’s fail() path, with error_message persisted (:234-246).
  1. The portal starts generation. POST .../units/{unit}/sales-offer/{paymentPlanId}/generate hits SalesOfferPdfController@generate (SalesOfferPdfController.php:26-56). All three personas wire this route to the same controller and all carry ->middleware('throttle:sales-offer-generate') (shopper routes/api/shopper.php:63-65, broker broker.php:135-137, sales-man sales-man.php:82-84). The limiter allows config('services.pdf-service.generate_rate_limit_per_minute') per minute (default 5, floored at 1), keyed by user->id ?: ip (AppServiceProvider.php:77-85).

  2. The backend validates the offer context. loadUnitRelations (:115-125) hydrates the unit (project → destination → country, images, masterplans, unit type, phase, base-PDF documents), then ValidatesSalesOfferContext->handle(...) (:38) asserts the unit has a sales-offer base-PDF config and a base_pdf document, and resolves the payment plan via CRM middleware (app/Actions/Unit/SalesOffer/ValidatesSalesOfferContext.php:31-64). Missing config or plan throws SalesOfferException / SalesOfferPaymentPlanExceptionHTTP 404.

  3. A row is created as Pending. StartSalesOfferPdfGeneration->handle(...) (:42) computes is_capping_enabled = (currency === EGP and destination->requires_payment_currency_choice === true), does a dry-run BuildSalesOfferUnitPayload to fail fast, then creates the SalesOfferPdfGeneration row with status = Pending and expires_at = now + SALES_OFFER_EXPIRY_DAYS (default 14) (StartSalesOfferPdfGeneration.php:27-53).

  4. The job is queued and the id is returned. GenerateSalesOfferPdfJob::dispatch($generation->id) (:44) enqueues onto queue pdf-service (GenerateSalesOfferPdfJob.php:34); the controller records a metric and returns { id } (:51,55). The HTTP request ends here — everything below runs on the worker.

  5. The job processes. It flips the row to Processing (:54-56), re-resolves the payment plan (:73-78), and — only when is_capping_enabled — fetches the USD/EGP rate (resolveExchangeRate, :80-83,207-232) from GetEgpExchangeRateCrm->handle() (GET {CRMS_MIDDLEWARE base_url}/api/portals/egp-exchange-rate, header country: egypt), reading data.USDEGP and rounding to 3 dp. A missing rate throws InvalidArgumentException → the job fails.

  6. The payload is built and the PDF is fetched (cache-or-call). BuildSalesOfferUnitPayload->handle(...) (:85-90) assembles {unit, selectedPaymentTerms, salesOfferTemplate}, then GetCachedSalesOfferPdf->handle(...) (:92-96) returns the cached PDF or calls the microservice (see below).

  7. The row is completed or failed. On a 2xx, the job sets status = Completed and storage_path = the cache path for that payload (:120-127). On a non-2xx it reads error/message, records a failed metric, and calls fail()status = Failed + error_message + Log::warning (:98-118,234-246). Payment-plan and InvalidArgumentException failures land the same way (:140-154).

  8. The portal polls, then downloads. The SPA polls GET .../sales-offer/{id}/status and, once status = Completed, receives a 15-minute signed file_url; it then fetches that URL and saves the blob as the file. (Frontend orchestration: downloadSalesOffer, frontend/libs/common-components/src/lib/download-sales-offer/download-sales-offer.tsx:579-645 — poll every 5000 ms, up to 20 attempts.)

backend-pdf-service is Node 20 + Express + Puppeteer + pdf-lib. It boots in src/index.ts (express.json({ limit: BODY_PAYLOAD_LIMIT ?? '20mb' }), router mounted at /api/sales-offer, GET /health, listens on PORT ?? 3001).

  1. Auth. isAuthenticated(req) — fail-closed against API_TOKEN (see the caution above), then a plain Bearer <token> === API_TOKEN string compare (sales-offer.ts:8-16).

  2. Validate. SalesOfferBodySchema.safeParse(req.body) (Zod, sales-offer.ts:26-30, 400 on failure). The schema requires unit, selectedPaymentTerms, salesOfferTemplate; the template file accepts a base64 string and transforms it to a Uint8Array (sales-offer.schema.ts:99-102,110-114); show_capping / usd_to_egp_rate are optional.

  3. Render. salesOfferPdfService.generate(...) builds 3 HTML blocks — floor-plan heading, payment calculator, payment installments (sales-offer-pdf.service.ts:35-39) — and screenshots them with Puppeteer.

  4. Screenshot. captureAllBlocksWithNewBrowser launches puppeteer.launch({ headless: true, args: [--no-sandbox, --disable-setuid-sandbox, --disable-dev-shm-usage, --disable-gpu] }) and renders the 3 blocks in parallel (capture-render.ts:62-112); each page waits for networkidle0 + document.fonts.ready + 300 ms, then screenshots [data-pdf-render-ready] (fallback body) as base64 PNG. Browser and pages are closed in finally blocks.

  5. Assemble. buildSalesOfferPdf(...) loads the base template PDF from the template file bytes and conditionally appends floor-plan, payment-plan, master-plan and gallery pages (pdf/sales-offer.ts:354-417). It returns application/pdf + Content-Disposition: attachment; any throw becomes 500 { error, message }.

The EGP capping path is why the job resolves an exchange rate: when salesOfferTemplate.show_capping === true the service treats the offer as El Gouna EGP and uses usd_to_egp_rate in the payment blocks (sales-offer-pdf.service.ts:28). The rate is resolved backend-side (step 5 above), never inside the service.

Storage, the signed URL, and the download guards

Section titled “Storage, the signed URL, and the download guards”

Cache. GetCachedSalesOfferPdf keys the PDF by a SHA of the payload: sales-offers/cache/{sha}.pdf (storagePathForPayload, GetCachedSalesOfferPdf.php:33-38) on the disk config('filesystems.sales_offer_disk') (env SALES_OFFER_DISK). On a cache hit it returns the stored bytes as a synthetic 200; on a miss it calls the service and, only on success, puts the body at the cache path (:45-93). A completed generation records that same path as storage_path.

The signed download URL. GetGeneratedPdfStatus adds a 15-minute file_url when a generation is Completed with a storage_path (GetGeneratedPdfStatus.php:19-50):

  • On an s3 disk it uses Storage::disk('s3')->temporaryUrl(...).
  • Otherwise it hand-rolls an HMAC-signed URL (buildSignedDownloadUrl, :55-69): hash_hmac('sha256', "{url}?expires={ts}", config('app.key')) over the persona-specific download path — deliberately matching Laravel’s built-in signed (ValidateSignature) middleware that guards the route.

Download guards. GetGeneratedPdfPath->handle() re-validates before streaming (GetGeneratedPdfPath.php:16-60): the generation must belong to the unit (404); status must be Completed with a storage_path (404); the path must start with sales-offers/ and contain no .. (403 traversal guard); the s3 disk is rejected for streamed download (404); and the file must exist (404). Only then does the controller stream it via response()->streamDownload reading Storage::disk($disk)->readStream($path) (SalesOfferPdfController.php:86-101).

Support: reading a stuck or failed generation

Section titled “Support: reading a stuck or failed generation”

Start from the SalesOfferPdfGeneration row’s status, then the status-endpoint payload and logs.

Symptom Most likely cause Where to look
Stuck in pending The queue worker for the pdf-service queue isn’t running, so the job never ran; or the row wasn’t Pending when it ran (idempotency guard exited early). Confirm queue:work is processing queue pdf-service; GenerateSalesOfferPdfJob.php:165-167.
failed with a message The job caught a non-2xx / exception. The reason is in the status payload message and the logs. message = error_message (GetGeneratedPdfStatus.php:45-47); logs PDF service error / processing failed (GenerateSalesOfferPdfJob.php:110-116,148-153).
failed, message shows a 401 Service API_TOKEN unset (fails closed) or PDF_SERVICE_TOKENAPI_TOKEN. sales-offer.ts:8-16,20-23; align the two env vars.
failed on a capping (EGP) unit USD/EGP rate missing or the CRM rate call failed. resolveExchangeRate (GenerateSalesOfferPdfJob.php:207-232); GetEgpExchangeRateCrm.php:14-26.
download returns 403 Path failed the sales-offers/ prefix / .. traversal guard. GetGeneratedPdfPath.php:37-46.
download returns 404 Wrong unit, not Completed, s3 disk rejected for streaming, expired/invalid signature, or the cached file was swept (rows carry expires_at). GetGeneratedPdfPath.php:25-60; signature TTL is 15 min.

Trace the whole path in logs. Backend: Sales offer PDF generate: request started / job dispatched (SalesOfferPdfController.php:34,46-49); PDF service: sending request / response received with duration_ms (CallPdfServiceSalesOffer.php:30-55); cache hit / miss / stored (GetCachedSalesOfferPdf.php:59-89). Service: per-stage timings in a daily log file (patchConsoleToDailyFile, backend-pdf-service/src/index.ts:1-4). Neither side logs the PDF body or customer PII — the outbound call logs metadata only (the PDF service: sending request / response received lines with status + duration_ms, CallPdfServiceSalesOffer.php:30-56; :57 is the metric).

Names and defaults only — never commit real values.

Var Repo / location Default Note
PDF_SERVICE_URL backend config/services.php:69 http://localhost:3001 Doc drift: backend CLAUDE.md says http://pdf-service:3001.
PDF_SERVICE_TOKEN backend config/services.php:70 Sent as Bearer; must equal the service API_TOKEN.
PDF_SERVICE_TIMEOUT backend config/services.php:71 120 (s) HTTP client timeout for the outbound call.
PDF_SERVICE_GENERATE_RATE_LIMIT_PER_MINUTE backend config/services.php:72 5 Per-user/IP throttle for generate.
SALES_OFFER_DISK backend config/filesystems.php:27 Cache/store + download disk.
SALES_OFFER_EXPIRY_DAYS backend config/filesystems.php:28 14 Generation expires_at window.
API_TOKEN pdf-service routes/sales-offer.ts:9 Unset ⇒ 401 for all requests (fail closed).
PORT pdf-service index.ts:14 3001 Service listen port.
BODY_PAYLOAD_LIMIT pdf-service index.ts:15 20mb Ceiling for the base64 template body.