Skip to main content

Data transformation

Data transformation is the stage that turns Salesforce records into document data. Each tenant ships YAML files in data-transformation/ that declare SOQL queries against the org and mappings from query results onto the schema entities of each document type.

The proposal and the finance family (invoice, cancellation invoice, commission invoice, commission statement, final travel documents) share one master transformation file; booking_confirmation and financial_summary resolve to their own files. All files follow the same structure:

doc_type: master
schema_version: '1.0'

cross_references:
placeholders: # tokens used by blueprint default_name / emails
tour_name: _packageDeparture.KaptioTravel__Package__r.KaptioTravel__ExternalName__c
departure_date: _packageDeparture.KaptioTravel__Date__c

data_sources:
- id: salesforce
provider: salesforce
connection:
use_centralized_oauth: true
queries: # named SOQL queries with on_empty behaviour
- id: itinerary
soql: |
SELECT Id, Name, KaptioTravel__Group_Size__c, ...
FROM KaptioTravel__Itinerary__c
WHERE Id = :itineraryId
on_empty: error

mappings:
schema_entities: # query results → schema fields
...

Each query declares its failure behaviour (on_empty: error | warning | skip), so a missing optional record skips its section cleanly while a missing itinerary fails loudly with a user-facing message.

The patterns below are the established conventions, proven across live tenants and encoded in the reference baseline.

Dual departure traversal

The package departure can be reached two ways, depending on what your org's data model provides:

  • Primary: Itinerary Item → PackageDeparture. Traverse Itinerary Item → KaptioTravel__PackageDeparture__r → KaptioTravel__Package__r. This works on any org without custom fields. It returns empty for Anyday/FIT itineraries (items carry no departure link) — that emptiness is expected and skipped.
  • Fallback: direct lookup. Orgs that add a custom Package_Departure__c lookup on the itinerary object can read the departure off the itinerary row directly — one query fewer, and it also works when an itinerary has no items yet.
- id: package_departure
name: Package Departure (via Itinerary Item)
description: >-
Fetch PackageDeparture details through the Itinerary Item
relationship (primary traversal; works without a direct lookup).
Returns empty for Anyday/FIT itineraries — expected, skipped.
on_empty: skip
soql: |
SELECT Id,
KaptioTravel__PackageDeparture__r.KaptioTravel__Date__c,
KaptioTravel__PackageDeparture__r.KaptioTravel__Package__r.KaptioTravel__ExternalName__c,
KaptioTravel__PackageDeparture__r.KaptioTravel__Package__r.KaptioTravel__DepartureType__c
FROM KaptioTravel__Itinerary_Item__c
WHERE KaptioTravel__Itinerary__c = :itineraryId
AND KaptioTravel__PackageDeparture__c != null
ORDER BY KaptioTravel__DateFrom__c ASC NULLS LAST, CreatedDate ASC
LIMIT 1

The reference file documents the direct-lookup variant inline so you can swap it in if your org has the field.

Dual content source, selected by departureType

tour.departureType (from the package) selects where itinerary content comes from:

  • Fixed — departure-level content: day-by-day content records attached to the departure template feed itinerary.days[], and the document renders in days mode.
  • Anyday / Seasonal (FIT) — item-level content: content records attached to the booked itinerary items feed services[] and serviceDates[], and the document renders in services mode.

The transformation derives meta.itineraryMode from the same value, so templates have an explicit switch instead of inferring layout from which arrays happen to be populated. If departureType cannot be resolved, the mapping emits a placeholder value that deliberately fails schema validation — a loud failure instead of a silently wrong layout.

Explicit role maps — never name matching

Where document data needs a classification — most importantly the priceLines[].role — the mapping uses explicit lookup tables keyed on record types and units of measure. Classification by name patterns (Name LIKE '%tax%') is prohibited: names are content, and content changes.

# EXPLICIT ROLE MAP: record-type → role lookup, never name matching.
# Itinerary Item record type → priceLines[].role
# Package_Item → fare; Promotion → discount; Insurance → insurance;
# anything else → addon (catch-all).
_pricingPriceLines:
source: salesforce
query_id: price_lines_items
merge_with: pricing
mapping:
priceLines:
transform: array
fields:
role: >-
ifEq(${KaptioTravel__RecordTypeName__c}, "Package_Item", "fare",
ifEq(${KaptioTravel__RecordTypeName__c}, "Promotion", "discount",
ifEq(${KaptioTravel__RecordTypeName__c}, "Insurance", "insurance",
"addon")))
description:
field: ${KaptioTravel__Description__c}
default: ${Name}
fallback_mode: use_default
quantity: ${KaptioTravel__Quantity__c}
unitPrice: ${KaptioTravel__Unit_Price__c}
total: ${KaptioTravel__Total_Price__c}
displayPrice: true

When you fork, you extend the lookup chain with your record types (port taxes → tax_fee, service charges → gratuity, onboard credit → credit) — you never add name-pattern rules.

The same principle applies to record selection: per-person versus per-room package pricing is selected in SOQL by record type and unit of measure (KaptioTravel__RecordTypeName__c = 'Package_Item' AND ...UOM__c = 'person(s)'), then summed raw — keeping the numeric-pricing rule intact end to end.

Traveler resolution chain

The displayed traveler count is always derived, never hand-entered:

  1. Passenger records first. When passenger records exist on the itinerary, they are emitted as named travelers[] entries, and the count is the length of that array.
  2. Group size fallback. When no passenger records exist, the itinerary's KaptioTravel__Group_Size__c is resolved into pricing.travelers.
_pricingFromItinerary:
source: salesforce
query_id: itinerary
merge_with: pricing
mapping:
travelers: ifEq(count(passengers), 0, ${KaptioTravel__Group_Size__c}, count(passengers))

Templates read only the derived count.

The priceLines firewall

Line-level pricing data is sensitive: a no-price proposal must leak zero amounts, and guest documents must never carry commission detail. The transformation enforces this at the data level:

  • pricing.priceLines is emitted only for create-document actions that request line data. Guest actions that do not request it get a dataset with no line items to leak.
  • Commission columns are selected and emitted only by the commission-family transformations. The guest pricing queries deliberately do not select commission fields, and no guest schema declares anywhere to put them. See the trade firewall.

Display flags (_display) are not set by the transformation — they are injected at generation time from the blueprint action's hide_sections/show_sections lists. The firewall and the display flags are complementary layers: flags control what a template shows, the firewall controls what data exists at all.

Numeric discipline

All monetary mappings emit raw numbers — sums are computed with raw aggregate helpers (sum("query_id", "Field__c")), and no currency formatting happens in the transformation. Legacy helpers that return pre-formatted currency strings are deliberately avoided. The single sanctioned exception is tour.dates, which the schema documents as a pre-formatted display date range. Everything else formats at render time; see Templates.

Documented gaps

Where your org's data model cannot populate a schema entity (for example, a specialist biography field or arrival-logistics content that requires custom objects), the convention is an explicit documented gap: the mapping emits null so the section collapses, with a comment explaining what a fork must add to light the section up. Gaps are never silent — every required schema entity is either mapped or annotated.