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):

The one thing to know before you touch this
Section titled “The one thing to know before you touch this”The whole trip, in one diagram
Section titled “The whole trip, in one diagram”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 PDFThe status machine
Section titled “The status machine”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). |
Step by step (with the exact code)
Section titled “Step by step (with the exact code)”-
The portal starts generation.
POST .../units/{unit}/sales-offer/{paymentPlanId}/generatehitsSalesOfferPdfController@generate(SalesOfferPdfController.php:26-56). All three personas wire this route to the same controller and all carry->middleware('throttle:sales-offer-generate')(shopperroutes/api/shopper.php:63-65, brokerbroker.php:135-137, sales-mansales-man.php:82-84). The limiter allowsconfig('services.pdf-service.generate_rate_limit_per_minute')per minute (default 5, floored at 1), keyed byuser->id ?: ip(AppServiceProvider.php:77-85). -
The backend validates the offer context.
loadUnitRelations(:115-125) hydrates the unit (project → destination → country, images, masterplans, unit type, phase, base-PDF documents), thenValidatesSalesOfferContext->handle(...)(:38) asserts the unit has a sales-offer base-PDF config and abase_pdfdocument, and resolves the payment plan via CRM middleware (app/Actions/Unit/SalesOffer/ValidatesSalesOfferContext.php:31-64). Missing config or plan throwsSalesOfferException/SalesOfferPaymentPlanException→ HTTP 404. -
A row is created as
Pending.StartSalesOfferPdfGeneration->handle(...)(:42) computesis_capping_enabled= (currency === EGPanddestination->requires_payment_currency_choice === true), does a dry-runBuildSalesOfferUnitPayloadto fail fast, then creates theSalesOfferPdfGenerationrow withstatus = Pendingandexpires_at = now + SALES_OFFER_EXPIRY_DAYS(default 14) (StartSalesOfferPdfGeneration.php:27-53). -
The job is queued and the id is returned.
GenerateSalesOfferPdfJob::dispatch($generation->id)(:44) enqueues onto queuepdf-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. -
The job processes. It flips the row to
Processing(:54-56), re-resolves the payment plan (:73-78), and — only whenis_capping_enabled— fetches the USD/EGP rate (resolveExchangeRate,:80-83,207-232) fromGetEgpExchangeRateCrm->handle()(GET {CRMS_MIDDLEWARE base_url}/api/portals/egp-exchange-rate, headercountry: egypt), readingdata.USDEGPand rounding to 3 dp. A missing rate throwsInvalidArgumentException→ the job fails. -
The payload is built and the PDF is fetched (cache-or-call).
BuildSalesOfferUnitPayload->handle(...)(:85-90) assembles{unit, selectedPaymentTerms, salesOfferTemplate}, thenGetCachedSalesOfferPdf->handle(...)(:92-96) returns the cached PDF or calls the microservice (see below). -
The row is completed or failed. On a 2xx, the job sets
status = Completedandstorage_path= the cache path for that payload (:120-127). On a non-2xx it readserror/message, records afailedmetric, and callsfail()→status = Failed+error_message+Log::warning(:98-118,234-246). Payment-plan andInvalidArgumentExceptionfailures land the same way (:140-154). -
The portal polls, then downloads. The SPA polls
GET .../sales-offer/{id}/statusand, oncestatus = Completed, receives a 15-minute signedfile_url; it thenfetches 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 every5000 ms, up to20attempts.)
Inside the microservice
Section titled “Inside the microservice”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).
-
Auth.
isAuthenticated(req)— fail-closed againstAPI_TOKEN(see the caution above), then a plainBearer <token> === API_TOKENstring compare (sales-offer.ts:8-16). -
Validate.
SalesOfferBodySchema.safeParse(req.body)(Zod,sales-offer.ts:26-30,400on failure). The schema requiresunit,selectedPaymentTerms,salesOfferTemplate; the templatefileaccepts a base64 string and transforms it to aUint8Array(sales-offer.schema.ts:99-102,110-114);show_capping/usd_to_egp_rateare optional. -
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. -
Screenshot.
captureAllBlocksWithNewBrowserlaunchespuppeteer.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 fornetworkidle0+document.fonts.ready+ 300 ms, then screenshots[data-pdf-render-ready](fallbackbody) as base64 PNG. Browser and pages are closed infinallyblocks. -
Assemble.
buildSalesOfferPdf(...)loads the base template PDF from the templatefilebytes and conditionally appends floor-plan, payment-plan, master-plan and gallery pages (pdf/sales-offer.ts:354-417). It returnsapplication/pdf+Content-Disposition: attachment; any throw becomes500 { 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
s3disk it usesStorage::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-insigned(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_TOKEN ≠ API_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).
Config / env quick reference
Section titled “Config / env quick reference”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. |
