Salesforce Integration Guide
How the NestJS html-parser service receives cart submissions from the
embedded quote UI, authenticates with Salesforce OAuth, and creates quotes via Apex REST
/services/apexrest/Quote/.
1. Overview
On cart submit, the API validates the payload, maps frontend dates to the Apex contract,
exchanges OAuth credentials for an access_token and
instance_url, posts the quote to Salesforce, and returns
{ success, message } to the browser.
- Validate payload (
CreateCartDto/CartItemDto) - Map
cart[].DATE→cart[].DATA - Obtain OAuth token via
SALES_FORCE_AUTH_URL/SALES_FORCE_GRANT_TYPE - Send
POST {instance_url}/services/apexrest/Quote/ - Proxy Apex
{ success, message }to the client
cart[].ID to be the TDS ID of a Salesforce
Product2 record. The embedded frontend prefers
data-tds-id on BOM rows; otherwise it falls back to
PARTNUMBER + PARENT, which is not a valid Product2 Id and will fail product
lookup until the export exposes real TDS IDs.
2. Architecture
The integration is implemented by
POST /api/cart (CartController), OAuth in
SalesforceTokenService, and Apex submission in CartService.
flowchart LR
Browser["Browser / scripts.js"]
CartEndpoint["POST /api/cart"]
CartController["CartController"]
Mapper["DATE to DATA mapper"]
CartService["CartService"]
Token["SalesforceTokenService"]
Apex["Apex REST /Quote/"]
SF[("Salesforce CRM")]
Browser --> CartEndpoint
CartEndpoint --> CartController
CartController --> Mapper
Mapper --> CartService
CartService --> Token
Token --> CartService
CartService --> Apex
Apex --> SF
SF --> Apex
Apex --> CartService
CartService --> CartController
CartController --> Browser
3. End-to-end journey
Happy path from the embedded cart UI through validation, mapping, OAuth, Apex Quote creation, and the JSON response shown to the user. On failure after mapping, an asynchronous SMTP alert may also be sent.
sequenceDiagram
participant UI as scripts.js
participant API as CartController
participant Map as QuoteMapper
participant Svc as CartService
participant OAuth as TokenService
participant Apex as ApexREST
participant Mail as ErrorMail
UI->>API: POST /api/cart
API->>API: Validate DTO
API->>Map: Map DATE to DATA
Map->>Svc: QuotePayload
Svc->>OAuth: refresh token
OAuth-->>Svc: access_token + instance_url
Svc->>Apex: POST /services/apexrest/Quote/
alt success
Apex-->>Svc: 200 success true
Svc-->>API: Quote result
API-->>UI: success message
else business or transport error
Apex-->>Svc: 400 / 5xx / network
Svc-->>API: Mapped error
API-->>UI: success false + message
API->>Mail: Async failure alert
end
4. Authentication
Each cart submission performs a fresh OAuth exchange (no in-memory token cache). The
token URL and grant type come from environment variables; SALES_FORCE_ENV
is only a label for logs and e-mails and does not select the OAuth host.
| Variable | Required | Description |
|---|---|---|
SALES_FORCE_AUTH_URL |
Yes | Full OAuth token URL (typically /services/oauth2/token) |
SALES_FORCE_GRANT_TYPE |
Yes | client_credentials or refresh_token |
SALES_FORCE_CLIENT_ID |
Yes | Connected App client ID |
SALES_FORCE_CLIENT_SECRET |
Yes | Connected App client secret |
SALES_FORCE_REFRESH_TOKEN |
If refresh_token | Required only when grant type is refresh_token |
SALES_FORCE_ENV |
Yes | Label for logs/e-mails (sandbox / production) |
SALES_FORCE_INSTANCE_URL |
No | Dev override only; forbidden when NODE_ENV=production |
SALES_FORCE_QUOTE_PATH |
No | Default /services/apexrest/Quote/ |
OAuth request
POST {SALES_FORCE_AUTH_URL} with
application/x-www-form-urlencoded body:
grant_type, client_id, client_secret, and
refresh_token when applicable. Response fields used:
access_token, instance_url.
SALES_FORCE_URL_TOKEN, SALES_FORCE_USERNAME,
SALES_FORCE_PASSWORD (legacy password grant).
5. API contracts
Public endpoint
- Method:
POST - Path:
/api/cart - Handler:
CartController.create() -
Validation: global/controller
ValidationPipewith whitelist + forbid non-whitelisted
Request payload
{
"cnpj": "12345678000199",
"company": "Example Corp",
"PV": "PV123",
"PVZ": "PVZ456",
"email": "user@example.com",
"user": "jdoe",
"language": "pt",
"defaultDate": "2026-06-05",
"cart": [
{
"ID": "01tXXXXXXXXXXXXXXX",
"QTY": "2",
"PARTNUMBER": "MAT-001",
"DATE": "2026-06-05"
}
]
}
| Field | Required | Notes |
|---|---|---|
cnpj |
Yes | Account lookup in Apex |
company |
Yes | Opportunity / quote naming source |
cart |
Yes (non-empty) | Line items |
cart[].ID |
Yes | Product2 TDS ID |
cart[].QTY |
Yes | Quantity |
cart[].DATE |
No | Mapped to DATA server-side |
cart[].DATA |
No | Sent as-is when provided |
| Other cart fields | No | PARENT, ITEM, PARTNUMBER, DESCR, INFO, COUNT |
Success response (HTTP 200)
{
"success": true,
"message": "Quote created successfully"
}
Business error (HTTP 400 from Salesforce)
{
"success": false,
"message": "Product not found"
}
Apex REST
POST {instance_url}{SALES_FORCE_QUOTE_PATH} with
Authorization: Bearer {access_token}. Apex resolves Account by
cnpj and Product2 by cart[].ID (TDS ID), and creates
Opportunity + Quote + QuoteLineItem server-side.
6. Payload mapping
The mapper builds the Apex body from the validated DTO. Item dates prefer
DATA, then DATE, then root defaultDate. Empty or
null dates omit DATA.
flowchart TB
subgraph Frontend["Frontend scripts.js"]
TDS["data-tds-id preferred"]
Fallback["else PARTNUMBER + PARENT"]
DateField["DATE on cart item"]
DefaultDate["defaultDate query/root"]
end
subgraph API["html-parser"]
ResolveID["cart[].ID"]
MapDate["DATE to DATA"]
Payload["QuotePayload"]
end
subgraph Apex["Salesforce Apex"]
Account["Account by cnpj"]
Product["Product2 by TDS ID"]
Quote["Opportunity + Quote + QLI"]
end
TDS --> ResolveID
Fallback --> ResolveID
DateField --> MapDate
DefaultDate --> MapDate
ResolveID --> Payload
MapDate --> Payload
Payload --> Account
Payload --> Product
Account --> Quote
Product --> Quote
| Source | Apex field |
|---|---|
cart[].DATE |
cart[].DATA |
cart[].DATA |
Preferred when already present |
root defaultDate |
Fallback when item has neither DATE nor DATA |
ID, QTY |
Always sent |
PARENT, ITEM, PARTNUMBER, … |
Included only when defined |
Frontend ID resolution
- Prefer
row.dataset.tdsId(data-tds-id) - Else
PARTNUMBER + PARENT(composite key — go-live risk)
Submit URL: POST {HOST}/api/cart with cart plus query-derived
PVZ, PV, cnpj, email,
company, user, language, and
defaultDate. UI treats data.success === true as success and
shows data.message on error.
7. Error handling & retries
flowchart TD
Start["Cart submit"] --> Validate["Validate DTO"]
Validate -->|invalid| Client400["HTTP 400 validation"]
Validate -->|ok| OAuth["OAuth token exchange"]
OAuth -->|transient 502/503/504 or network| OAuthRetry["Retry up to 3"]
OAuthRetry --> OAuth
OAuth -->|client error| Auth500["500 Unable to authenticate"]
OAuth -->|ok| Quote["POST Apex Quote"]
Quote -->|401 or 403| Reauth["Fresh OAuth + retry up to 3"]
Reauth --> Quote
Quote -->|400| Biz400["400 success false + message"]
Quote -->|network| Svc503["503 Unable to reach Salesforce"]
Quote -->|timeout| Svc504["504 timed out"]
Quote -->|5xx| Svc500["500 Salesforce service error"]
Quote -->|200| Ok["200 success true"]
Auth500 --> Mail["Async e-mail alert"]
Biz400 --> Mail
Svc503 --> Mail
Svc504 --> Mail
Svc500 --> Mail
| Condition | HTTP status | Body |
|---|---|---|
| Token / OAuth failure | 500 | { success: false, message: "Unable to authenticate with Salesforce" } |
| 401/403 after retries | 503 | { success: false, message: "Salesforce authentication failed" } |
| Network / unreachable | 503 | { success: false, message: "Unable to reach Salesforce" } |
| Timeout | 504 | { success: false, message: "Salesforce request timed out" } |
| Salesforce 5xx | 500 | { success: false, message: "Salesforce service error" } |
| Apex business error | 400 | { success: false, message: "<Apex message>" } |
-
OAuth: up to 3 automatic retries on network errors or HTTP 502/503/504.
Client errors such as
invalid_grantare not retried. -
Apex REST: on HTTP 401 or 403, a new OAuth exchange runs and the quote
request is retried up to 3 times (
SALESFORCE_MAX_RETRIES). - DTO validation failures do not trigger e-mail alerts.
8. Observability
Every cart submission emits structured Winston logs correlated by
requestId and flow: "cart". Logs go to the console and to
logs/combined-*.log (hourly rotation, 14-day retention).
| Step | When | Notable fields |
|---|---|---|
cart.start |
Request received | itemCount, PV/PVZ fingerprints |
cart.map |
DTO mapped | Full Apex payload |
sf.oauth.request |
Token call | authUrl, grantType, redacted curl |
sf.oauth.response |
Token obtained | instanceUrl |
sf.quote.request |
Apex POST | url, payload, redacted curl |
sf.quote.response |
Apex success | status, success, message |
sf.quote.error |
Failure | status, errorCode, responseBody, curl |
cart.complete / cart.error |
Terminal | Result or errorType |
cURL reproduction
Outbound Salesforce calls include a curl field with secrets redacted
((***REDACTED***). To reproduce a sandbox failure: find the
requestId, copy the cURL from sf.quote.request or
sf.oauth.request, replace the redacted bearer token with a fresh
access_token, and run locally. Never commit secrets.
E-mail alerts
After payload mapping, failures (Salesforce 400, auth, network, timeout, 5xx) trigger an
asynchronous internal e-mail via QuotationErrorNotificationService /
MailService. Content includes requestId, timestamp,
SALES_FORCE_ENV, error summary, and the full Apex JSON payload. Disable with
SMTP_ENABLED=false.
| Variable | Default | Description |
|---|---|---|
SMTP_ENABLED |
true |
Set false to disable alerts |
SMTP_HOST / SMTP_PORT |
relay / 25 | Internal SMTP |
SMTP_TO |
ops mailboxes | Comma-separated recipients |
SMTP_FROM_* |
Portal B2C | From display name and address |
client_secret,
refresh_token, or raw access_token. PV/PVZ are fingerprinted at
cart.start. cnpj, company, and email
appear in Apex payload logs and alert e-mails by design for troubleshooting — restrict
access in production.
9. Go-live gates / UAT notes
PARTNUMBER + PARENT are not Salesforce Product2 Ids
and are not looked up as DWCodigoExterno__c. Export must provide
data-tds-id (or equivalent) before production cutover.
Known Salesforce UAT issues
-
HTTP 500 —
getPricebookByAccountId: Account has no Pricebook; Apex throwsQueryExceptioninstead of structured 400. Fix data and harden Apex. -
HTTP 500 — account not found: unknown CNPJ can surface as
NullPointerException. Apex should return structured 400.
Manual QA checklist (sandbox)
-
Happy path (200) — valid
cnpj,company, and known Product2 TDS IDs →{ success: true } -
Product not found (400) — unknown
cart[].ID→{ success: false, message: "..." } -
Account not found (400) — unknown
cnpj→ structured business error (after Apex hardening)
10. Related source files
| File | Role |
|---|---|
src/cart/cart.controller.ts |
Public POST /api/cart, validation, error mapping, e-mail trigger |
src/cart/cart.service.ts |
Apex REST client + 401/403 retry |
src/cart/salesforce/salesforce-token.service.ts |
OAuth via auth URL + grant type |
src/cart/salesforce/salesforce-quote.mapper.ts |
DTO → Apex payload (DATE → DATA) |
src/cart/salesforce/salesforce-error.util.ts |
Normalize Apex/SF error bodies for logs |
src/cart/dto/create-cart.dto.ts / cart-item.dto.ts |
Request validation |
src/cart/quotation-error-notification.service.ts |
Failure e-mail content |
src/mail/mail.service.ts |
SMTP transport |
assets/scripts.js |
Embedded cart UI, TDS ID resolve, submit |
SALESFORCE_INTEGRATION.md |
Canonical markdown guide in the repository |