Rate tables: your whole rate card as one approved document
Keys, bands and rates, checked for gaps and overlaps, loaded from Excel, approved by a second person and scheduled to a date. And how a workflow reads them.
The BimaStack team · · 6 min read
A rate table holds one whole rate card, every row of it, as a single document. It is checked for gaps and overlaps before it is saved, approved by a second person, and takes effect on the date you choose, all at once. Rates live here and only here, never as numbers typed inside a formula, so a rate change is publishing a new table.
The shape of a table
A table has some key columns (exact matches: a zone, a cover type, “individual” or “family”), an optional band (a numeric range: days, age, sum insured) and a rate on each row.
A band matches from ≤ value < to. “1 to 8 days” is written from 1 to 9. The top of one band is the bottom of the next, so there is never a value that falls between them.
Keys are text and matched by position: the first key of every row is the zone. The labels are for people.
Any number of keys, or none (a table that is only a band, such as a minimum premium by sum insured).
A rate may be negative, for a discount table.
Up to 5,000 rows in one table.
A number to match exactly (a limit of 500,000) is a band too: from 500000 to the next limit.
Checked before it is saved
A table with any problem is refused, with every problem listed and pointing at its row. A half-wrong rate card can never reach a quote.
Problem
Finding
The same keys twice (no band)
DUPLICATE_ROW_KEY
Two bands for the same keys that share values
BAND_OVERLAP
Values between two bands that no row covers (unless the table allows gaps)
BAND_GAP
A row with the wrong number of keys, or a blank one
ROW_KEY_COUNT_MISMATCH, ROW_KEY_MISSING
from not below to, or a band missing
ROW_BAND_INVALID, ROW_BAND_MISSING
No rate on a row
ROW_RATE_REQUIRED
A misspelt column ("raet")
TABLE_UNPARSEABLE: never silently dropped
Nobody can know the largest value a quote will bring, so give the top band a generous “to”. A value that still matches no row stops the quote with a clear error. It is never priced at zero.
Bring it in from Excel
1
Download the template
Made from your table’s own shape: a column per key, the band’s from and to, then rate. Key columns are text, so a code like 01 keeps its zero.
Type or paste values. Numbers may carry thousands commas (5,000,000). Blank rows are skipped.
3
Upload it
Every problem comes back with the row number Excel shows. Nothing is saved until you review the table and save it as a draft.
Approve, then schedule
A draft prices nothing. Save as often as you like.
A second person approves. Whoever drafted it can’t (unless they are their organisation’s only member).
Publish with a start date. Publish next month’s card today: this month’s keeps pricing until the new one starts, then the whole new card applies at once.
History stays. A quote for a past date reads the table that was live then.
Retire ends a version with no replacement.
One code, many scopes
A table is found by its code (travel-outbound-base-premium) in a scope: your organisation, and optionally a binder or a product. The most specific wins, so a scheme rate negotiated for one binder overrides your standard card for that binder only, and the workflow that reads it doesn’t change.
Reading a table in a workflow
A workflow reads a rate with a ready-made table-rate block. You give it the table’s code and wire in the keys and the band value; it returns the rate.
Block
Wired inputs
Matches
protected:table-rate:band
x
The band only
protected:table-rate:1d / 2d / 3d / 4d
key1 … key4
Exact keys
protected:table-rate:1d-band / 2d-band / 3d-band
key1 … key3, x
Exact keys and the band
Pricing a trip from the table above
workflow "travel-base" {
input destinationZone : STRING
input familyPolicy : BOOLEAN
input tripDays : NUMBER
formula policyKey {
out label : STRING
in familyPolicy : BOOLEAN
expression: if(familyPolicy, "FAMILY", "INDIVIDUAL")
}
block 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"
}
output result {
}
connect familyPolicy -> policyKey.familyPolicy
connect destinationZone -> baseRate.key1
connect policyKey.label -> baseRate.key2
connect tripDays -> baseRate.x
connect baseRate.rate -> result.basePremium
}
These blocks are ordinary workflows written in the same language as yours, shipped by the platform. When the table has no matching row, the block stops the run with “FIRST_MATCH found no matching element”.
For developers
Call
Does
GET /api/rate-tables?tableCode=&organizationId=&productId=&status=
Every version, a page at a time
GET /api/rate-tables/{id}
One version: {summary, table}
POST /api/rate-tables/validate
Check without saving: {valid, findings}
POST /api/rate-tables
Save a draft: {scope, tableCode, table, effectiveFrom, effectiveTo, expectedCurrentVersion, reason}; 422 with findings
POST /api/rate-tables/{id}/approve · publish · retire
Lifecycle, each with {reason}
GET /api/rate-tables/{id}/template.xlsx · GET /api/rate-tables/template.xlsx?name=&keys=&band=
The Excel template, filled or blank
POST /api/rate-tables/workbook
Read a filled-in template back: {valid, table, findings}; saves nothing
expectedCurrentVersion is null for a table’s first version and the version you edited from after that; someone else’s save in between is a 409. A finding is {rule, path, message}, with sheetRow when it came from a workbook.
Load your rate card
Bring the spreadsheet; we’ll help you set up the first table.