Travel Insurance (Intermundial)
Sells and issues Intermundial travel insurance in the checkout. Each market has a base policy that is included for free on every booking; the customer can upgrade to a higher tier (paid), which replaces the base. Prices are quoted live, and the real, irreversible policy is emitted out-of-band only after the booking is paid — so every booking emits a policy (the base, or the chosen upgrade).
When to Use
Section titled “When to Use”- A booking carries the included base policy (free) on the checkout extras step, or the customer upgrades to a paid tier.
- A paid booking needs its insurance policy emitted with Intermundial.
- Admins manage the catalogue of sellable policies per market, including which one is the included base.
Do NOT call the emission path (PostInsurance) directly: it is irreversible and
must only run via ContractInsuranceJob after payment.
Configuration
Section titled “Configuration”Required env vars (config/intermundial.php):
INTERMUNDIAL_ENVIRONMENT=sandbox # sandbox | productionINTERMUNDIAL_BASE_URL= # gateway; falls back to env defaultINTERMUNDIAL_USERNAME=INTERMUNDIAL_PASSWORD=INTERMUNDIAL_API_KEY=Optional overrides: timeouts/retries per environment, quote/contract tuning,
logging, and the country-catalogue TTL. See config/intermundial.php for the
full list. The country cache TTL is read from
intermundial.country_catalog.cache_ttl (default 24h / 86400s) and is not
present in .env.example — set INTERMUNDIAL_COUNTRY_CACHE_TTL only when
overriding the default.
Sandbox vs production policies: The real Horizonte production policies
(Selected ESB390 — the included base, Exclusive ESB391, Grand ESB392) live in
production and are managed per market from the admin panel.
SupplierInsuranceSeeder is environment-aware: it seeds the real Horizonte
products (Selected flagged is_included) when pointed at production, and live UAT
test products otherwise, so the policies endpoint returns resolvable data in either
environment.
Architecture
Section titled “Architecture”flowchart TD
subgraph Checkout [Checkout — pre-payment]
FE[InsuranceSelector React]
FE -->|GET policies| POL[GET /checkout/insurance/policies]
FE -->|live quote| QUO[POST /checkout/insurance/quote]
FE -->|persist selection| SEL[PUT /checkout/insurance-selection]
SEL --> SESS[(checkout session<br/>insurance_selection)]
end
subgraph Finalization [On successful payment]
FIN[BookingFinalizationService] --> UPS[BookingUpsell type=Insurance]
FIN --> CON[InsuranceContract status=pending]
FIN -->|after commit| JOB[ContractInsuranceJob]
end
subgraph Emission [Out-of-band, irreversible]
JOB -->|PostInsurance| IM[(Intermundial API)]
JOB -->|success| ISS[InsuranceContract status=issued]
JOB -->|failure| FAIL[InsuranceContract status=failed + Sentry]
end
SESS --> FIN
Building Blocks
Section titled “Building Blocks”Intermundial SDK
Section titled “Intermundial SDK”backend/app/Services/Insurance/Intermundial/ wraps the Nexus API:
| Service | Responsibility |
|---|---|
IntermundialAuthenticationService | Token login + cache (8h, with buffer) |
IntermundialClientService | HTTP client, env routing, 401 re-auth, retries |
IntermundialQuoteService | GetPolicy + PostPricing (live quote) |
IntermundialContractService | PostInsurance (irreversible emission) |
IntermundialCountryCatalogService | Country catalogue (see below) |
Country catalogue
Section titled “Country catalogue”IntermundialCountryCatalogService resolves ISO codes to Intermundial’s
proprietary idDyn, required by the contract for countryDestiny/countryOrigin.
It fetches the undocumented GET policies/v5/country endpoint (returns countries
with idDyn, isoCode2/3, and translations) and caches the result for the
configurable TTL above. The catalogue is autodiscovered via the API — no
external Intermundial file is needed.
Methods: all, findByIsoCode2, toContractCountry.
Source: backend/app/Services/Insurance/Intermundial/IntermundialCountryCatalogService.php
Checkout orchestration
Section titled “Checkout orchestration”CheckoutInsuranceService bridges the SDK and the checkout:
getAvailablePolicies(Market)— readssupplier_insurancesrows scoped to the market plus global ones (market_id IS NULL), ordered with the included base first, then enriches each with liveGetPolicydata (is_included, age brackets, and the customer-facingcoverage_info).getResolvedQuote(array)/priceSelection(array, paxNum)— livePostPricingquote that also resolves the coverage params (destination + duration) server-side.contractInsurance(array)—PostInsurance; re-quotes to recover a validbasePrices.idDynwhen one was not supplied.
Source: backend/app/Services/Checkout/CheckoutInsuranceService.php
Policy catalogue (admin)
Section titled “Policy catalogue (admin)”Sellable policies live in the supplier_insurances table. The market_id column
scopes a policy to a single market (or global when null), and the is_included
flag marks the auto-applied base policy (at most one per market; the rest are paid
upgrades). Managed from the Filament SupplierInsuranceResource (Suppliers group →
“Insurance Policies”).
Source: backend/app/Filament/Resources/SupplierInsurances/SupplierInsuranceResource.php,
backend/database/seeders/SupplierInsuranceSeeder.php
All endpoints are under /api/{market}/{lang}/checkout. The quote endpoint runs
behind the stateful.api (session) middleware and is rate-limited.
GET /checkout/insurance/policies
Section titled “GET /checkout/insurance/policies”Lists sellable policies for the market (market-scoped + global), enriched with
live Intermundial data — including is_included and the customer-facing
coverage_info (headline bullets + scope description). The included base is
listed first. Returns 408 on timeout, 502 on upstream error.
POST /checkout/insurance/quote
Section titled “POST /checkout/insurance/quote”Live price quote. Rate limit: 20/min. Request: see GetInsuranceQuoteRequest.
Returns retail_price (whole-party total, already factors in pax_num),
formatted_price, coverage_extensions, and base_prices_id_dyn.
PUT /checkout/insurance-selection
Section titled “PUT /checkout/insurance-selection”Persists (or clears, when insurance is null) the chosen policy in the checkout
session and recalculates the total. Request: see
UpdateInsuranceSelectionRequest. The price is re-quoted server-side on every
change (the client-sent price is discarded), and is_included is derived from the
chosen policy — the included base is forced to 0 for the customer while keeping
its real quoted price for the emission.
Source: backend/app/Http/Controllers/Api/InsuranceController.php,
backend/app/Http/Controllers/Api/CheckoutController.php (updateInsuranceSelection)
Pricing
Section titled “Pricing”The included base policy is free to the customer: it adds 0 to
extras_price/total_price, even though it carries a real retail_price
(Intermundial still charges Volare for it — a cost Volare absorbs, recorded on the
emitted contract, never passed to the customer). Only a paid upgrade adds its
retail_price to the total — as-is, never multiplied by the traveler count (the
quote already covers the whole party). The is_included flag is derived
server-side from the catalogue, never trusted from the client. The BookingUpsell
(type Insurance) stores the customer price (0 for the base, the retail for an
upgrade) as both unit_price and total_price with quantity = 1.
Source: backend/app/Services/Checkout/CheckoutSessionService.php
(calculateInsuranceExtrasPrice), backend/app/Services/Booking/BookingFinalizationService.php
Post-Payment Emission
Section titled “Post-Payment Emission”The real policy is never emitted during checkout. On successful payment,
BookingFinalizationService::finalizeFromCheckoutSession() first backfills the
included base when the session carries no insurance selection but the market has
one — the frontend auto-apply is async, so a fast customer can reach payment
without it. The base is resolved with a live quote outside the transaction and
degrades gracefully on failure, guaranteeing the base always emits without ever
adding to the customer total. It then:
- Creates the
BookingUpsell(typeInsurance). - Creates a
pendingInsuranceContract, capturing every parameter the job needs (the checkout session is gone by the time the job runs) plus aproduct_namesnapshot, so the admin keeps showing the policy name even if thesupplier_insurancesrow is later deleted or replaced. - After the DB transaction commits, dispatches
ContractInsuranceJob.
ContractInsuranceJob (queued, $tries = 3, progressive backoff):
- Idempotent by the unique
booking_idoninsurance_contracts; returns immediately if the contract is alreadyissued(never re-emits an irreversible policy). - Builds
insured_listfrom the booking’s passengers (first passenger is the main insured). - Resolves destination country from the product (
offer → productByMarket → getPrimaryCountryIsoCode()) and origin from the main passenger’s residence country, falling back to the booking market’s first country code — both mapped to IntermundialidDynvia the country catalogue. - On success → contract
issuedwith the emitted policy details. - On Intermundial failure → contract
failed+ reported to Sentry. The already-paid booking is never rolled back.
Source: backend/app/Jobs/ContractInsuranceJob.php,
backend/app/Models/InsuranceContract.php,
backend/app/Enums/InsuranceContractStatus.php
Frontend
Section titled “Frontend”- API client:
frontend/src/features/checkout/api/insuranceApi.ts(fetchInsurancePolicies,fetchInsuranceQuote,updateInsuranceSelection). InsuranceSelector(React) renders on the checkout extras step (Step 4, the transfers page). The included base shows an “Incluido” badge (auto-applied, not removable); the other tiers show a paid “Mejorar” action that replaces the base and reverts to it on removal. Each card’scoverage_infobullets sit behind a collapsible chevron (sharedProductCardpattern). The active policy is summarised on the summary/payment step (Step 7).- No billing address is collected at checkout. Intermundial’s emission only needs the policy holder’s country, which is derived from the main traveler’s residence country captured on the travelers step (Step 6).
Source: frontend/src/features/checkout/components/InsuranceSelector.tsx,
frontend/src/features/checkout/components/SummaryPaymentPage.tsx
Business Rules
Section titled “Business Rules”- Each market has at most one included base policy, applied for free to every booking; the rest are paid upgrades. Every paid booking emits one policy.
- The included base is free to the customer but has a real cost Volare bears (recorded on the emitted contract).
- No billing address is collected at checkout; emission needs only the policy holder’s country, derived from the main traveler’s residence country (Step 6).
- Quote/contract require 1–20 passengers (
pax_num). - A booking has at most one insurance contract (unique
booking_id) to guarantee emission idempotency. - Emission is irreversible and only runs post-payment via the job.
- A policy with no coverage params cannot be quoted and is surfaced as unavailable.
Related
Section titled “Related”- Checkout API — full checkout flow
- Payment Gateway System — deposit/balance split
- Bookings —
BookingUpsellmodel - Queue System — job processing