Configure a product: questions, covers, rules and pricing
A real outbound travel product, built as configuration: its inputs, its cover tree, eligibility and pricing workflows, refer and decline rules, and how it answers the standard quote form.
The BimaStack team · · 10 min read
A product on BimaStack is one versioned document, the product definition. It says what the product asks, what it covers, which workflow does each job, and what each finding means. The logic itself lives in the workflows and rate tables the definition names. This post builds a real one: outbound travel cover, from an insurer’s published flyer, with no code.
What a definition holds
Section
Says
inputs
The questions, their types and their limits: required, a minimum and maximum, allowed values, an age range
coverages
The covers and benefits, as a tree, with limits, deductibles, excess, waiting periods and conditions
stages
Which workflow runs validation, eligibility, underwriting and pricing
rules
What each yes/no flag a stage raises means: reject the request, refer it, or decline it
quoteClass
How the product answers the standard quote form for its class, so it can be compared
options
Switches, such as pricing a declined request anyway
There are no product classes in the platform: no motor type, no travel type. Two products differ only in their definitions and the workflows and tables those name.
These are checked before any workflow runs, and every problem is reported at once with its path, such as inputs.travellerAges: a misspelt input is reported, never ignored, and “30” as text is not a number. A date can be bounded by today or by another answer; a date of birth can be held to an age range on the cover date.
Each stage is optional, and each names a published workflow by key. A stage workflow’s inputs are filled by name from the answers, the chosen covers (coverages), the date and currency (context.asOfDate, context.currency) and every earlier stage’s outputs (eligibility.something). A mapping lets an existing workflow serve a stage without being edited: here the pricing workflow’s grandTotal is the product’s pricing.total.
The eligibility workflow: one yes/no flag
workflow "travel-eligibility" {
input destinationZone : STRING
input travellerAges : LIST<NUMBER>
transform TE_maxAge {
op: MAX
elements: age : LIST<NUMBER>
}
formula TE_hasSenior {
out flag : BOOLEAN
in maxAge : NUMBER
expression: maxAge >= 81
}
formula TE_notEuropeSchengen {
out flag : BOOLEAN
in destinationZone : STRING
expression: destinationZone != "EUROPE_SCHENGEN"
}
formula TE_seniorOutsideEurope {
out flag : BOOLEAN
in hasSenior : BOOLEAN
in notEurope : BOOLEAN
expression: hasSenior and notEurope
}
output result {
}
connect travellerAges -> TE_maxAge.age
connect TE_maxAge.max -> TE_hasSenior.maxAge
connect destinationZone -> TE_notEuropeSchengen.destinationZone
connect TE_hasSenior.flag -> TE_seniorOutsideEurope.hasSenior
connect TE_notEuropeSchengen.flag -> TE_seniorOutsideEurope.notEurope
connect TE_seniorOutsideEurope.flag -> result.seniorOutsideEurope
}
The pricing workflow reads the flat rate from the rate table by zone, individual or family, and trip length; age-rates each individual traveller and adds them up (a family uses the family rate as it is); doubles it for winter sports; takes the group discount; and rounds once. Nothing in it is specific to travel except the names.
The pricing workflow
formuladef "travel-age-factor" {
in age : NUMBER
expression: if(age < 18, 0.5, if(age <= 65, 1.0, if(age <= 75, 1.5, if(age <= 80, 2.0, 4.0))))
}
formuladef "travel-per-traveller-premium" {
in rate : NUMBER
in factor : NUMBER
expression: rate * factor
}
workflow "travel-pricing" {
input destinationZone : STRING
input familyPolicy : BOOLEAN
input tripDays : NUMBER
input travellerAges : LIST<NUMBER>
input groupSize : NUMBER
input winterSports : BOOLEAN
formula TR_familyKey {
out label : STRING
in familyPolicy : BOOLEAN
expression: if(familyPolicy, "FAMILY", "INDIVIDUAL")
}
block TR_baseRate {
in key1 : STRING
in key2 : STRING
in x : NUMBER
out rate : NUMBER
param tableCode = "travel-outbound-base-premium"
binding: block "protected:table-rate:2d-band"
}
transform TR_ageFactors {
op: MAP
elements: age : LIST<NUMBER>
binding: formula "travel-age-factor"
}
transform TR_perTraveller {
op: MAP
elements: factor : LIST<NUMBER>
context: rate : NUMBER
binding: formula "travel-per-traveller-premium"
}
transform TR_individualSum {
op: SUM
elements: amount : LIST<NUMBER>
}
formula TR_baseAmount {
in familyPolicy : BOOLEAN
in familyRate : NUMBER
in individualSum : NUMBER
expression: if(familyPolicy, familyRate, individualSum)
}
formula TR_winterMultiplier {
in winterSports : BOOLEAN
expression: if(winterSports, 2, 1)
}
formula TR_groupDiscountPercent {
in groupSize : NUMBER
expression: if(groupSize >= 201, 25, if(groupSize >= 101, 20, if(groupSize >= 51, 15, if(groupSize >= 21, 10, if(groupSize >= 10, 5, 0)))))
}
formula TR_total {
in base : NUMBER
in winterMultiplier : NUMBER
in groupDiscountPercent : NUMBER
expression: round(base * winterMultiplier * (1 - groupDiscountPercent / 100), 2, "HALF_EVEN")
}
output result {
}
connect familyPolicy -> TR_familyKey.familyPolicy
connect destinationZone -> TR_baseRate.key1
connect TR_familyKey.label -> TR_baseRate.key2
connect tripDays -> TR_baseRate.x
connect travellerAges -> TR_ageFactors.age
connect TR_ageFactors.mapped -> TR_perTraveller.factor
connect TR_baseRate.rate -> TR_perTraveller.rate
connect TR_perTraveller.mapped -> TR_individualSum.amount
connect familyPolicy -> TR_baseAmount.familyPolicy
connect TR_baseRate.rate -> TR_baseAmount.familyRate
connect TR_individualSum.sum -> TR_baseAmount.individualSum
connect winterSports -> TR_winterMultiplier.winterSports
connect groupSize -> TR_groupDiscountPercent.groupSize
connect TR_baseAmount -> TR_total.base
connect TR_winterMultiplier -> TR_total.winterMultiplier
connect TR_groupDiscountPercent -> TR_total.groupDiscountPercent
connect TR_total -> result.grandTotal
}
4. The rules
rules
"rules": [
{ "code": "SENIOR_OUTSIDE_EUROPE_SCHENGEN", "stage": "eligibility", "effect": "REFER",
"message": "Persons aged 81 and above are covered only under the Europe/Schengen plan",
"firedBy": "seniorOutsideEurope" }
]
Effect
Means
ERROR
The request is invalid: it ends as a validation error, never as an outcome
DECLINE
Not offered. Not priced, unless options.pricingOnDecline is set
REFER
Needs an underwriter. Still priced, so the quote shows what it would cost
The outcome is DECLINE if any decline rule fired, else REFER if any refer rule fired, else ACCEPT, with the reasons in the order the rules are written. The workflow only says yes or no; the definition decides what that means. A threshold belongs in a rate table, not typed into the formula.
What it quotes
Zone
Policy
Days
Ages
Extras
Outcome
Total
Worldwide Basic
Individual
5
30
ACCEPT
$22.34
Worldwide Plus
Individual
10
10, 70
ACCEPT
$69.86 (17.465 + 52.395)
Worldwide Extra
Family
20
40
Winter sports
ACCEPT
$217.76 (108.88 × 2)
Worldwide Basic
Individual
5
30
Group of 15
ACCEPT
$21.22 (22.34 × 0.95)
Worldwide Basic
Individual
5
82
REFER, still priced
$89.36 (22.34 × 4.0)
5. Answering the standard quote form
Clients don’t fill in a form built from one product. Each class of business has one friendly standard form, the same for every insurer, and each product says how it fills its own inputs from that form’s answers. That is what lets one quote compare every insurer.
values translates the form’s choices into the product’s own. An answer with no entry is one it doesn’t sell, so it is left out of that comparison, never priced wrongly.
as derives a value: days spanned between two dates, a count, a sum, an age from a date of birth or the reverse.
accepts limits what it prices: this product sells single trips, so an annual multi-trip request leaves it out.
Nothing personal is needed to price. The check refuses a mapping that reads a question asked only after a plan is chosen.
Checked before it goes live
Saving refuses a definition with a structural problem: a duplicate code, a cover whose parent doesn’t exist, a limit default outside its options, a rule for an undeclared stage. Publishing also checks that the documents agree:
every stage workflow is published on the version’s start date;
every input a stage workflow declares can be supplied, with a compatible type;
every rule’s flag is a yes/no output of its stage’s workflow;
pricing produces pricing.total as a number;
the quote form exists, and every question, add-on and highlight the mapping names is on it.
So a broken link fails when you publish, not on a client’s first quote. The Setup checklist shows, for any product, what it still needs and what each piece is used by.
Test it and publish it
1
Simulate the draft
Run the unpublished definition against real cases. Nothing is recorded. Ask for the trace to see each stage’s steps.