學習目標 · Learning Objectives
- Compute a premium by hand from a rate card, an age band, an occupation class, and loading factors, and know exactly which of the five inputs the client can influence.
- Explain the loading stack (occupation, health, lifestyle, attainment) and why a loading is a permanent contract term rather than a temporary price adjustment.
- Read a multi-carrier comparison matrix and normalise it — which benefit definitions must be aligned before two annual premiums are comparable at all — and walk the quote → proposal → application lifecycle, including validity periods, re-quote triggers, and the exact point at which a quote stops being quotable.
The Quoting Engine (報價引擎)
Learning Objectives
- Compute a premium by hand from a rate card, an age band, an occupation class, and loading factors, and know exactly which of the five inputs the client can influence.
- Explain the loading stack (occupation, health, lifestyle, attainment) and why a loading is a permanent contract term rather than a temporary price adjustment.
- Read a multi-carrier comparison matrix and normalise it — which benefit definitions must be aligned before two annual premiums are comparable at all — and walk the quote → proposal → application lifecycle, including validity periods, re-quote triggers, and the exact point at which a quote stops being quotable.
What a Quote Actually Is
In practice: you press "產生報價" and eight seconds later you have HK$27,222/year for a HK$500,000 sum assured on a 20-year paying plan. What you are actually holding is not a price — it is a promise, bound to five version pointers, with an expiry date.
A quote (報價) in an insurance POS is a computed premium together with everything needed to make that computation reproducible later. It is the atom out of which the proposal is built, and it is immutable once issued.
Three properties define it, and every one of them is a lesson-learned scar somewhere in the industry:
- Reproducible. Anyone, at any time, must be able to re-run the same inputs and get the same number. That is why the quote stores the rate card version, not just the price.
- Time-boxed. A quote expires — commonly 30 days for ordinary life products, shorter where a promotional rate is involved. After expiry the number must be re-derived, not reused.
- Immutable. If the loading changes, or the client changes their smoking status, or the rate card updates, that is a new quote, not an edit. Lesson 01's case trace showed exactly this at step 18.
The anatomy of a quote record
{
"quoteId": "QT-20881-A",
"status": "ISSUED",
"issuedAt": "2025-03-14T11:38:22+08:00",
"validUntil": "2025-04-13T11:38:22+08:00",
"client": {
"clientId": "CL-20881",
"ageAtIssue": 38,
"sex": "F",
"smoker": false,
"occupationClass": 2,
"residency": "HK_RESIDENT",
"heightCm": 163,
"weightKg": 57,
"bmi": 21.4
},
"product": {
"productCode": "HK-whole-life-spp-2024",
"productVersion": 4,
"rateCardVersion": "PRC-LIFE-2025-03-14",
"benefitTableVersion": "participating-bonus-2025q1",
"assumptionsVersion": "ASSUMP-2025.02",
"disclosureVersionRequired": "DISC-2025.03"
},
"cover": {
"sumAssured": 500000,
"sumAssuredCurrency": "USD",
"premiumPayingYears": 20,
"premiumMode": "ANNUAL",
"termToAge": 100,
"riders": [
{ "code": "RIDER-WAIVER", "count": 1, "sumAssured": 500000 },
{ "code": "RIDER-CI", "count": 1, "sumAssured": 200000 }
]
},
"premium": {
"baseAnnual": 12800.00,
"baseMonthly": 1066.67,
"loadingsApplied": [],
"discount": { "pct": 0.35, "agencyTier": "T3", "labelZh": "首年佣金折扣 35%" },
"netAnnual": 8320.00,
"netMonthly": 693.33,
"grossBeforeDiscount": 12800.00,
"currency": "HKD"
},
"commission": {
"estimate": {
"fycAmount": 3494.40,
"fycPctOfNetAnnual": 0.42,
"renewalPctFromYear2": 0.10,
"clawbackWindowMonths": 13,
"estimateOnly": true,
"noteZh": "此為預估,實際佣金以保險公司月結單為準"
}
},
"immutability": {
"payloadHash": "9f2c1a44e0b7",
"supersedes": null,
"supersededBy": null
},
"provenance": { "agentRef": "AG-4471", "deviceId": "TBLT-07", "channel": "FACE_TO_FACE" }
}
Two things to notice before we get into the arithmetic:
loadingsAppliedis an empty array here. That is deliberate — this client got standard terms, and an empty array is meaningful data. A quote with"loadingsApplied": []is a statement.commission.estimate.estimateOnly: true. Lesson 12 covers why commission can never be authoritative in a POS.
Rate Tables
In practice: you quote a 38-year-old non-smoking female, occupation class 2, and the engine looks up three separate tables — one for the base, one for the waiver rider, one for the CI rider — and adds them.
A rate table (費率表) is the carrier's published mapping from risk factors to premium rates. In Hong Kong it ships to the POS as part of a rate card, versioned on an effective date.
Rate table structure (JSON)
{
"rateCardId": "PRC-LIFE-2025-03-14",
"carrierCode": "PRC-HK",
"productCode": "HK-whole-life-spp-2024",
"productVersion": 4,
"currency": "HKD",
"effectiveFrom": "2025-03-14T00:00:00+08:00",
"effectiveTo": null,
"supersedes": "PRC-LIFE-2025-01-01",
"basis": {
"coverageUnit": "1000_SUM_ASSURED",
"premiumMode": "ANNUAL",
"tableType": "COMMISSIONABLE_RATES",
"netOfTax": false
},
"baseRates": {
"description": "每 HK$1,000 基本保額年繳保費,按吸煙狀態及性別",
"rows": [
{ "sex": "F", "smoker": false, "ageFrom": 35, "ageTo": 39, "ratePer1000": 25.60 },
{ "sex": "F", "smoker": false, "ageFrom": 40, "ageTo": 44, "ratePer1000": 32.80 },
{ "sex": "F", "smoker": true, "ageFrom": 35, "ageTo": 39, "ratePer1000": 48.10 },
{ "sex": "F", "smoker": true, "ageFrom": 40, "ageTo": 44, "ratePer1000": 58.90 },
{ "sex": "M", "smoker": false, "ageFrom": 35, "ageTo": 39, "ratePer1000": 30.40 },
{ "sex": "M", "smoker": false, "ageFrom": 40, "ageTo": 44, "ratePer1000": 38.70 },
{ "sex": "M", "smoker": true, "ageFrom": 35, "ageTo": 39, "ratePer1000": 57.20 },
{ "sex": "M", "smoker": true, "ageFrom": 40, "ageTo": 44, "ratePer1000": 69.90 }
]
},
"loadingFactors": {
"description": "以保費百分比加費,寫入合約,不可於日後取消",
"factors": [
{ "code": "OCC-4", "condition": "職業類別 4", "pctOfPremium": 0.25 },
{ "code": "OCC-5", "condition": "職業類別 5(需轉介核保)", "pctOfPremium": 0.75 },
{ "code": "HEALTH-PES", "condition": "有既往症狀(加費後承保)", "pctOfPremium": 0.50 },
{ "code": "LIFESTYLE-NOSMOKE", "condition": "非吸煙(現況良好)", "pctOfPremium": -0.10 },
{ "code": "BAND-BMI", "condition": "BMI 18.5–24.9 標準範圍", "pctOfPremium": -0.05 }
]
},
"payTermFactors": {
"description": "以基本保額百分比計算的固定費用,作為「保費」的組成部分",
"rows": [
{ "payYears": 10, "factorPer1000": 10.40 },
{ "payYears": 15, "factorPer1000": 11.90 },
{ "payYears": 20, "factorPer1000": 13.60 },
{ "payYears": 30, "factorPer1000": 17.20 }
]
},
"riderRates": [
{
"code": "RIDER-WAIVER",
"nameZh": "豁免保費",
"description": "受保人身故或全殘時豁免未繳保費",
"rows": [
{ "sex": "F", "smoker": false, "ageFrom": 18, "ageTo": 40, "ratePer1000": 6.20 },
{ "sex": "F", "smoker": false, "ageFrom": 41, "ageTo": 60, "ratePer1000": 14.80 },
{ "sex": "F", "smoker": true, "ageFrom": 18, "ageTo": 40, "ratePer1000": 13.40 },
{ "sex": "M", "smoker": false, "ageFrom": 18, "ageTo": 40, "ratePer1000": 9.10 },
{ "sex": "M", "smoker": true, "ageFrom": 18, "ageTo": 40, "ratePer1000": 18.70 }
]
},
{
"code": "RIDER-CI",
"nameZh": "附加危疾保障",
"description": "確診指定疾病時額外一次過賠付",
"rows": [
{ "ageFrom": 18, "ageTo": 30, "ratePer1000OfRiderSa": 3.10 },
{ "ageFrom": 31, "ageTo": 40, "ratePer1000OfRiderSa": 6.80 },
{ "ageFrom": 41, "ageTo": 50, "ratePer1000OfRiderSa": 14.20 }
]
}
],
"modalFactors": {
"description": "非年繳模式的年度化係數(年繳 = 1.00)",
"MONTHLY": 1.0833,
"QUARTERLY": 1.0300,
"SEMI_ANNUAL": 1.0150,
"ANNUAL": 1.0000,
"SINGLE": 0.9400
},
"discounts": {
"agencyTiers": [
{ "tier": "T1", "firstYearPct": 0.20, "labelZh": "首年 20%" },
{ "tier": "T2", "firstYearPct": 0.28, "labelZh": "首年 28%" },
{ "tier": "T3", "firstYearPct": 0.35, "labelZh": "首年 35%" }
],
"promotional": [
{ "code": "PROMO-Q2-2025", "firstYearPct": 0.05, "expiresAt": "2025-06-30T23:59:59+08:00",
"stackable": false, "labelZh": "首年額外 5% 推廣折扣" }
]
},
"commission": {
"fycSchedule": [
{ "tier": "T1", "pct": 0.32 }, { "tier": "T2", "pct": 0.38 }, { "tier": "T3", "pct": 0.42 }
],
"renewal": { "fromYear": 2, "toYear": 5, "pct": 0.10 },
"clawbackWindowMonths": 13,
"notes": "佣金以實際收取之保費計算,並受續期率影響;POS 只可顯示預估"
}
}
Reading the rate table as an actuary would
Three features in that JSON are doing real work and are worth naming:
coverageUnit: 1000_SUM_ASSURED— the rate is per HK$1,000 of sum assured, so the formula isratePer1000 × (SA / 1000). The engine never multiplies by the full SA; that error produces a 1000× overcharge.- Negative factors exist (
-0.10for non-smoker attainment,-0.05for standard BMI). Loadings are not one-directional. A POS that clamps negatives to zero is quietly overcharging. payTermFactorsare stated separately frombaseRatesbecause they represent a fixed cost of the base SA spread over the paying term, not a mortality rate. Confusing the two is the classic rookie bug.
Loading Factors
In practice: a 45-year-old commercial diver is quoted at standard rates in a naive POS, then the carrier issues a 75% loading, and the client finds out the premium has tripled — after she has already told her family they could afford it.
A loading (加費) is a permanent percentage increase to the premium, written into the contract, applied because the risk is worse than the standard table assumes. Four sources stack.
| Loading source | 中文 | Typical range | Reversible? | Who decides |
|---|---|---|---|---|
| Occupation | 職業加費 | +10% to +100%, or referral | No, unless occupation changes | Underwriter |
| Health | 體況加費 / 既往症狀加費 | +25% to +200%, or exclusion | No | Underwriter |
| Lifestyle / attainment | 生活方式 / 體格加費 | −25% to +25% | Can improve over time if re-underwritten | Underwriter |
| Financial / face amount | 財務核保加費 | +10% to +50% | No | Underwriter |
| Declined-exclusion variant | 除外承保 | 0% premium change | Exclusion instead of loading | Underwriter |
Loading is not a discount mechanism
This is the single most damaging misconception in agency selling. A loading is permanent and contractual. It is visible on the client's policy schedule for the whole term. An agent who says "加費加兩年就好" has said something false.
The legitimate alternatives a client can be offered:
export type UnderwritingOutcome =
| { kind: 'STANDARD'; premiumMultiplier: 1.00 }
| { kind: 'LOADED'; premiumMultiplier: number; loadingCode: string; permanent: true }
| { kind: 'EXCLUSION_APPLIED'; premiumMultiplier: 1.00; excludedConditionZh: string; permanent: true }
| { kind: 'REFERRED'; premiumMultiplier: null; referTo: 'UNDERWRITER' }
| { kind: 'DECLINED'; premiumMultiplier: null };
/**
* Turn an underwriting decision into something the POS is allowed to do.
* The one rule: only STANDARD may go straight to submission.
*/
export function resolveDecision(d: UnderwritingOutcome): {
action: 'STRAIGHT_THROUGH' | 'NOTIFY_AND_REQUOTE' | 'HOLD' | 'TERMINATE';
clientMessageZh: string;
agentMessageZh: string;
requiresNewQuote: boolean;
} {
switch (d.kind) {
case 'STANDARD':
return {
action: 'STRAIGHT_THROUGH',
clientMessageZh: '已按標準條款承保。',
agentMessageZh: '標準承保,可直接提交。',
requiresNewQuote: false,
};
case 'LOADED':
return {
action: 'NOTIFY_AND_REQUOTE',
clientMessageZh: `保費將按原報價的 ${(d.premiumMultiplier! - 1) * 100}% 加費計算,並於整個保單年期內維持有效。`,
agentMessageZh: `已加費 ${d.loadingCode}(${(d.premiumMultiplier! - 1) * 100}%,永久)。必須重新報價並再次簽署,勿沿用舊建議書。`,
requiresNewQuote: true,
};
case 'EXCLUSION_APPLIED':
return {
action: 'NOTIFY_AND_REQUOTE',
clientMessageZh: `此保單不保 ${d.excludedConditionZh},此項除外條款將永久寫入合約。`,
agentMessageZh: '已加除外條款,保費不變但保障範圍收窄。必須重新報價、重新講解、重新簽署。',
requiresNewQuote: true,
};
case 'REFERRED':
return {
action: 'HOLD',
clientMessageZh: '個案已交核保師審批,我們會盡快通知你結果。',
agentMessageZh: '已轉介核保。此期間不可向客戶承諾任何保費或條款。',
requiresNewQuote: false,
};
case 'DECLINED':
return {
action: 'TERMINATE',
clientMessageZh: '保險公司未能接受此投保申請,我們會協助你檢視其他方案。',
agentMessageZh: '已拒保。停止推進本案,改用其他產品方案,切勿自行修改健康申報。',
requiresNewQuote: false,
};
}
}
The stacking rule
When multiple loadings apply, carriers differ. The three conventions in use:
| Convention | Rule | Example | Effect on a 12,800 base |
|---|---|---|---|
| Additive | Sum the percentages | +25% + 50% = +75% | 12,800 → 22,400 |
| Multiplicative | Chain them | 1.25 × 1.50 = 1.875 | 12,800 → 24,000 |
| Capped | Additive, but total capped (commonly 100% or 200%) | min(75%, 100%) = 75% | 12,800 → 22,400 |
The rate card must state which. The POS must not assume. The worked example below uses additive, and shows what changes if the card says multiplicative.
Age Bands & Occupation Classes
In practice: the client is 39 and turns 40 next month. The POS shows a premium based on age 40 because carriers assess age at the in-force date, and the agent now knows the premium will step up at the anniversary — she needs to hear that from you, not from the carrier.
Age bands
| Aspect | Rule |
|---|---|
| Which age? | Age at the policy's in-force date, i.e. age at the next birthday as of issuance |
| Band resolution | Age band that contains the assessed age, at the band boundary inclusive |
| Non-near-age | Age band by whole years; some carriers use "age nearest birthday" — check the card |
| Band boundary trap | A 40-year-old is not priced as a 39-year-old, even one day before her birthday, if the rule is age-at-next-birthday |
| Repricing | Level premium locks the rate at issue; annual-premium medical re-prices each year by age |
The age-banded re-pricing on medical plans is the one that surprises clients most:
| Age at renewal | Annual premium (level benefit, no discounts) | Change |
|---|---|---|
| 35 | 3,120 | — |
| 40 | 4,480 | +44% |
| 45 | 6,720 | +50% |
| 50 | 10,560 | +57% |
| 55 | 17,280 | +64% |
| 60 | 28,160 | +63% |
An agent who sells an age-banded renewable medical plan without showing this table has created a foreseeable complaint. The POS should render it at quote time as a mandatory disclosure.
Occupation classes
Occupation banding exists because mortality risk correlates with job hazard, and because it is cheap to administer. Hong Kong agencies use a 1–6 banding.
| Class | 中文 | Typical roles | Typical treatment |
|---|---|---|---|
| 1 | 內勤 / 專業 | Office worker, teacher, accountant, software engineer | Standard |
| 2 | 輕體力 / 戶外輕量 | Salesperson, nurse, chef, junior technician | Standard or +10–15% |
| 3 | 中度風險 | Site supervisor, delivery driver, fitness trainer | +15–30% |
| 4 | 高風險 | Roofer, commercial driver, offshore technician | +25–50% |
| 5 | 極高風險 | Professional athlete, offshore diver, logger | Referral; +75–100% if accepted |
| 6 | 不承保 | Full-time military, nuclear industry, commercial diving (some carriers) | Declined |
Three practical notes:
- The class is carrier-specific. A general agent cannot answer "what class is a roofer" — they must answer "what class is a roofer for this carrier". The POS solves this by storing occupation → class mappings per carrier, not globally.
- Reclassification happens. An office worker promoted to a site supervisor may move class 1 → class 3 at the next policy anniversary, triggering a re-rating and a premium increase the client did not expect. The POS should warn the agent when a client's occupation on file would reclassify.
- Class 5 and 6 are where agents get caught. Quoting a class-6 occupation "optimistically" because the client wants a price produces a declined case after the client has emotionally committed.
{
"occupationMappings": [
{
"carrierCode": "PRC-HK",
"effectiveFrom": "2025-01-01",
"source": "PRC-HK-OCCUPATION-GUIDE-2025",
"mappings": [
{ "roleEn": "Office Manager", "roleZh": "辦公室經理", "class": 1 },
{ "roleEn": "Software Engineer", "roleZh": "軟件工程師", "class": 1 },
{ "roleEn": "Registered Nurse", "roleZh": "註冊護士", "class": 2 },
{ "roleEn": "Chef", "roleZh": "廚師", "class": 2 },
{ "roleEn": "Delivery Driver", "roleZh": "送貨司機", "class": 3 },
{ "roleEn": "Personal Trainer", "roleZh": "私人教練", "class": 3 },
{ "roleEn": "Roofer", "roleZh": "屋頂工人", "class": 4 },
{ "roleEn": "Commercial Driver", "roleZh": "商業車司機", "class": 4 },
{ "roleEn": "Offshore Technician", "roleZh": "海上技術員", "class": 5 },
{ "roleEn": "Commercial Diver", "roleZh": "商業潛水員", "class": 6 }
]
},
{
"carrierCode": "FWD-HK",
"effectiveFrom": "2025-01-01",
"source": "FWD-OCC-2025",
"mappings": [
{ "roleEn": "Office Manager", "roleZh": "辦公室經理", "class": 1 },
{ "roleEn": "Software Engineer", "roleZh": "軟件工程師", "class": 1 },
{ "roleEn": "Registered Nurse", "roleZh": "註冊護士", "class": 2 },
{ "roleEn": "Chef", "roleZh": "廚師", "class": 3 },
{ "roleEn": "Delivery Driver", "roleZh": "送貨司機", "class": 2 },
{ "roleEn": "Personal Trainer", "roleZh": "私人教練", "class": 4 },
{ "roleEn": "Roofer", "roleZh": "屋頂工人", "class": 3 },
{ "roleEn": "Commercial Driver", "roleZh": "商業車司機", "class": 3 },
{ "roleEn": "Offshore Technician", "roleZh": "海上技術員", "class": 4 },
{ "roleEn": "Commercial Diver", "roleZh": "商業潛水員", "class": 5 }
]
}
]
}
Look at the two blocks for the same roles. Chef is class 2 at one carrier and class 3 at another. Roofer is 4 and 3. Commercial Diver is declined at one and merely referred at the other. This is precisely why a single-carrier POS is a competitive problem and a multi-carrier POS is a client-value proposition, and it is the pivot into the comparison matrix below.
The Calculation Function
In practice: you can reproduce any quote the POS has ever shown you on a piece of paper. If you cannot, you do not trust the number, and you should not tell the client it is a guarantee.
Here is the calculation function in full, followed by a worked example traced through it.
// ─── Types ───────────────────────────────────────────────────────────────────
export interface RateCard {
rateCardId: string;
productCode: string;
productVersion: number;
effectiveFrom: string;
basis: { coverageUnit: number; premiumMode: string; tableType: string };
baseRates: {
rows: Array<{ sex: 'M' | 'F'; smoker: boolean; ageFrom: number; ageTo: number; ratePer1000: number }>;
};
loadingFactors: { factors: Array<{ code: string; pctOfPremium: number; labelZh: string }> };
payTermFactors: { rows: Array<{ payYears: number; factorPer1000: number }> };
riderRates: Array<{
code: string; nameZh: string;
rows: Array<Record<string, number> & { ageFrom: number; ageTo: number }>;
}>;
modalFactors: Record<string, number>;
discounts: { agencyTiers: Array<{ tier: string; firstYearPct: number }> };
commission: {
fycSchedule: Array<{ tier: string; pct: number }>;
renewal: { fromYear: number; toYear: number; pct: number };
clawbackWindowMonths: number;
};
loadingStacking: 'ADDITIVE' | 'MULTIPLICATIVE' | 'CAPPED_ADDITIVE';
loadingCapPct?: number;
}
export interface QuoteInput {
ageAtIssue: number;
sex: 'M' | 'F';
smoker: boolean;
occupationClass: number;
bmi: number;
sumAssured: number;
sumAssuredCurrency: 'HKD' | 'USD';
premiumPayingYears: number;
premiumMode: 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUAL' | 'ANNUAL' | 'SINGLE';
riders: Array<{ code: string; count: number; sumAssured?: number }>;
agencyTier: string;
usePromotionalDiscount: boolean;
/** Applied by the underwriter's decision, not by the agent. */
loadingCodes: string[];
}
export interface PremiumBreakdown {
basePer1000: number;
baseComponent: number;
payTermComponent: number;
riderComponents: Array<{ code: string; nameZh: string; amount: number }>;
grossBeforeDiscount: number;
loadingPct: number;
loadingAmount: number;
afterLoading: number;
discountPct: number;
discountAmount: number;
netAnnual: number;
netPeriodic: number;
currency: 'HKD';
fycEstimate: number;
computationTrace: string[];
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Look up an age-banded row. Bands are inclusive on both ends. */
function bandRow<T extends { ageFrom: number; ageTo: number }>(
rows: T[], age: number, label: string,
): T {
const hit = rows.find((r) => age >= r.ageFrom && age <= r.ageTo);
if (!hit) {
throw new Error(
`NO_RATE_ROW: ${label} has no row covering age ${age}. ` +
`Do NOT extrapolate — reject the quote and ask the carrier to extend the table.`,
);
}
return hit;
}
const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100;
// ─── The function ────────────────────────────────────────────────────────────
export function calculatePremium(card: RateCard, input: QuoteInput): PremiumBreakdown {
const trace: string[] = [];
const unit = card.basis.coverageUnit; // 1000 for sum-assured products
// 1 ── Base mortality component, per 1,000 of sum assured
const baseRow = bandRow(
card.baseRates.rows.filter((r) => r.sex === input.sex && r.smoker === input.smoker),
input.ageAtIssue,
`baseRates (sex=${input.sex}, smoker=${input.smoker})`,
);
const basePer1000 = baseRow.ratePer1000;
const baseComponent = basePer1000 * (input.sumAssured / unit);
trace.push(
`Base: age band ${baseRow.ageFrom}-${baseRow.ageTo}, ` +
`rate ${basePer1000}/1000 × ${input.sumAssured}/${unit} = ${round2(baseComponent)}`,
);
// 2 ── Paying-term component (fixed cost of the sum assured, spread over term)
const payRow = bandRow(card.payTermFactors.rows, input.premiumPayingYears, 'payTermFactors');
const payTermComponent = payRow.factorPer1000 * (input.sumAssured / unit);
trace.push(
`Pay-term: ${input.premiumPayingYears}yr × ${payRow.factorPer1000}/1000 ` +
`× ${input.sumAssured}/${unit} = ${round2(payTermComponent)}`,
);
// 3 ── Riders, each priced on its own basis
const riderComponents = input.riders.map((rider) => {
const spec = card.riderRates.find((r) => r.code === rider.code);
if (!spec) {
throw new Error(
`NO_RIDER_RATE: rider ${rider.code} is not on card ${card.rateCardId}. ` +
`Never price a rider from another product's table.`,
);
}
const row = bandRow(
spec.rows as Array<Record<string, number> & { ageFrom: number; ageTo: number }>,
input.ageAtIssue,
`rider ${rider.code}`,
);
// A rider may price off its own SA or off the basic SA.
const basisSA = rider.sumAssured ?? input.sumAssured;
const rateKey =
(row.ratePer1000OfRiderSa !== undefined ? 'ratePer1000OfRiderSa' : 'ratePer1000');
const rate = row[rateKey];
const amount = rate * (basisSA / unit) * rider.count;
trace.push(`Rider ${rider.code} (${spec.nameZh}): ${rate}/1000 × ${basisSA} × ${rider.count} = ${round2(amount)}`);
return { code: rider.code, nameZh: spec.nameZh, amount: round2(amount) };
});
// 4 ── Gross before discount
const gross = baseComponent + payTermComponent + riderComponents.reduce((a, r) => a + r.amount, 0);
trace.push(`Gross before loading/discount: ${round2(gross)}`);
// 5 ── Loadings — stacking rule comes from the card, never from assumption
const factors = card.loadingFactors.factors;
const applied = input.loadingCodes
.map((code) => {
const f = factors.find((x) => x.code === code);
if (!f) throw new Error(`UNKNOWN_LOADING_CODE: ${code} not on card ${card.rateCardId}`);
return f;
})
// Attainment factors (negative) only apply automatically if explicitly requested.
.filter((f) => f.pctOfPremium !== 0);
let loadingPct: number;
switch (card.loadingStacking) {
case 'ADDITIVE':
loadingPct = applied.reduce((a, f) => a + f.pctOfPremium, 0);
break;
case 'MULTIPLICATIVE': {
const mult = applied.reduce((a, f) => a * (1 + f.pctOfPremium), 1);
loadingPct = mult - 1;
break;
}
case 'CAPPED_ADDITIVE': {
const raw = applied.reduce((a, f) => a + f.pctOfPremium, 0);
const cap = card.loadingCapPct ?? 1.0;
loadingPct = Math.min(raw, cap);
break;
}
}
// A POS must never emit a negative "loading" as a surcharge line; attainment
// rebates belong in the discount block, where the client can see them as such.
loadingPct = Math.max(0, loadingPct);
const loadingAmount = gross * loadingPct;
const afterLoading = gross + loadingAmount;
trace.push(
`Loading (${card.loadingStacking}): ` +
(applied.length ? applied.map((f) => `${f.code} ${(f.pctOfPremium * 100).toFixed(0)}%`).join(' + ') : 'none') +
` = ${(loadingPct * 100).toFixed(1)}% → ${round2(loadingAmount)}; after loading ${round2(afterLoading)}`,
);
// 6 ── Discount
const tier = card.discounts.agencyTiers.find((t) => t.tier === input.agencyTier);
if (!tier) throw new Error(`UNKNOWN_AGENCY_TIER: ${input.agencyTier}`);
const discountPct = tier.firstYearPct;
const discountAmount = afterLoading * discountPct;
const netAnnual = afterLoading - discountAmount;
trace.push(
`Agency discount ${input.agencyTier} ${(discountPct * 100).toFixed(0)}%: ` +
`-${round2(discountAmount)} → net annual ${round2(netAnnual)}`,
);
// 7 ── Modal conversion (annual → chosen period)
const modalFactor = card.modalFactors[input.premiumMode];
if (modalFactor === undefined) {
throw new Error(`UNSUPPORTED_MODE: ${input.premiumMode} not offered by card ${card.rateCardId}`);
}
const netPeriodic = round2((netAnnual / 12) * modalFactor);
trace.push(`Mode ${input.premiumMode} (×${modalFactor}): ${round2(netAnnual)}/12 × ${modalFactor} = ${netPeriodic}`);
// 8 ── FYC estimate, explicitly an estimate
const fycPct = card.commission.fycSchedule.find((c) => c.tier === input.agencyTier)?.pct ?? 0;
const fycEstimate = round2(netAnnual * fycPct);
trace.push(`FYC estimate: ${round2(netAnnual)} × ${fycPct} = ${fycEstimate} (ESTIMATE ONLY)`);
return {
basePer1000, baseComponent: round2(baseComponent), payTermComponent: round2(payTermComponent),
riderComponents, grossBeforeDiscount: round2(gross), loadingPct: round2(loadingPct),
loadingAmount: round2(loadingAmount), afterLoading: round2(afterLoading),
discountPct, discountAmount: round2(discountAmount), netAnnual: round2(netAnnual),
netPeriodic, currency: 'HKD', fycEstimate, computationTrace: trace,
};
}
Four deliberate design choices in that function
| Choice | Why |
|---|---|
NO_RATE_ROW throws instead of extrapolating | A guessed rate is a mis-sold contract. Failing loudly is correct. |
NO_RIDER_RATE throws instead of falling back to the base table | Riders differ per carrier and per product; borrowing a rate is how wrong premiums ship. |
| Loading stacking is a card property | The POS must not encode a carrier's convention as a code-level assumption. |
computationTrace is returned | This is what makes the quote reproducible months later. It is also what you read aloud when a client disputes a number. |
Worked Numeric Example
In practice: a client asks "how did you get HK$27,222?" and instead of guessing, you read the six-line trace off the screen. That is the whole job.
Input: 38-year-old non-smoking female, occupation class 2, BMI 21.4, HK$500,000 sum assured (USD-denominated base, quoted in HKD), 20-year premium paying, annual mode, agency tier T3. Riders: premium waiver ×1 on HK$500,000, critical illness rider ×1 on HK$200,000. No loadings from the underwriter.
Step 1 — Base. Age 38 → band 35–39, non-smoker, female → ratePer1000 = 25.60.
25.60 × (500,000 / 1,000) = 25.60 × 500 = HK$12,800.00
Step 2 — Pay term. 20 years → factorPer1000 = 13.60.
13.60 × 500 = HK$6,800.00
Step 3 — Riders.
| Rider | Basis SA | Row | Rate | Calculation | Amount |
|---|---|---|---|---|---|
| Premium waiver | 500,000 | F / non-smoker / 18–40 | 6.20 / 1,000 | 6.20 × 500 × 1 | HK$3,100.00 |
| CI rider | 200,000 | age 31–40 | 6.80 / 1,000 | 6.80 × 200 × 1 | HK$1,360.00 |
| Riders total | HK$4,460.00 |
Step 4 — Gross before loading and discount.
12,800.00 + 6,800.00 + 4,460.00 = HK$24,060.00
Step 5 — Loading. loadingCodes: [] → loadingPct = 0 → loading amount HK$0.00.
After loading: HK$24,060.00
Step 6 — Discount. T3 → 35%.
24,060.00 × 0.35 = HK$8,421.00 discount.
Net annual = 24,060.00 − 8,421.00 = HK$15,639.00
Step 7 — Mode. Annual → factor 1.0000.
15,639.00 / 12 × 1.0000 = HK$1,303.25/month
Step 8 — FYC estimate. T3 → 42%.
15,639.00 × 0.42 = HK$6,568.38 (estimate only)
What changes if a loading arrives
If the underwriter applies an occupation loading of +25% and a health loading of +50%, on an additive card:
| Standard | Loaded (+25% +50%) | Delta | |
|---|---|---|---|
| Gross before loading | 24,060.00 | 24,060.00 | — |
| Loading % | 0% | 75% | +75pp |
| Loading amount | 0.00 | 18,045.00 | +18,045.00 |
| After loading | 24,060.00 | 42,105.00 | +18,045.00 |
| Discount (T3, 35%) | 8,421.00 | 14,736.75 | +6,315.75 |
| Net annual | 15,639.00 | 27,368.25 | +11,729.25 (+75%) |
| Net monthly | 1,303.25 | 2,280.69 | +977.44 |
| FYC estimate | 6,568.38 | 11,494.67 | +4,926.29 |
On the same inputs with a multiplicative card: 1.25 × 1.50 = 1.875, i.e. +87.5%.
Net annual = 24,060 × 1.875 × 0.65 = HK$29,323.13 — HK$1,954.88/year more than the additive result, for the identical risk. That is why the stacking convention is card data.
And the agent behaviour this must change: the loaded quote is not a revision, it is a new proposal. The client signed for HK$15,639. They now need to be told, in person, that it is HK$27,368 and why — before they sign anything new.
The reverse comparison: what the client pays monthly vs what they were shown online
| Source | Monthly figure | Why it differs from HK$1,303.25 |
|---|---|---|
| Consumer comparison site | HK$312.00 | Base benefit only, gross list price, no riders, cheapest possible SA |
| Carrier brochure illustration | HK$980.00 | Base only, discounted, no riders, age 30 not 38 |
| POS quote, base only | HK$1,066.67 | Base + pay-term only, riders excluded |
| POS quote, complete | HK$1,303.25 | Base + pay-term + both riders, T3 discount, correct age band |
The gap between HK$312 and HK$1,303 is not a pricing failure. It is four different questions. An agent who cannot explain that gap will lose either the case or the client.
Multi-Carrier Comparison Matrix
In practice: you have four quotes in front of the client. Before you can put them side by side, you must establish that all four are answering the same question — because two of them are not, and the client can tell.
The comparison matrix (Lesson 07 covers the full discipline) has one prerequisite: normalisation. Two annual premiums are only comparable if the cover behind them is the same cover.
What must be normalised before comparing
| Dimension | Typical divergence across HK carriers | Normalisation approach |
|---|---|---|
| Sum assured definition | "Sum assured" vs "basic sum assured" vs "death benefit floor" | Restate everything as lump sum payable on death of a non-smoker, standard occupation |
| Critical illness definition | Core definition (2019+) vs pre-2019 list; early-stage CI paid or not | Align on a single CI definition; treat the difference as a rider line |
| Waiting period | 30 / 60 / 90 / 180 days for CI | Report days explicitly; do not assume equivalence |
| Pre-existing condition (PES) | Full exclusion vs 2-year look-back vs loaded acceptance | Model as a scenario, not as a footnote |
| Benefit structure | Single benefit vs multi-claim vs 1× SA + 50% on recurrence | Restate to "lump sum on first claim" |
| Premium paying term vs coverage term | 10-yr pay to age 100 vs level premium to age 70 | Show total premium payable over each structure |
| Currency | HKD vs USD quoting; FX at different fixing times | Fix to a single currency with a stated FX date |
| Rider availability | Waiver offered by some, not others | Show riders as separate rows, not folded in |
| Fee treatment | Policy fee embedded vs deducted from benefit | Show net and gross benefit separately |
| Attribution | 1st-year, 2nd-year, or lifetime commission | Not comparable — do not put it in the matrix |
The matrix an agent actually shows
| Carrier A | Carrier B | Carrier C | Carrier D | |
|---|---|---|---|---|
| Product / version | Whole Life SPP v4 | Whole Life v7 | Life Secure v2 | Universal Life v1 |
| Rate card | 2025-03-14 | 2025-02-01 | 2025-03-01 | 2025-01-15 |
| Basic sum assured | HK$500,000 | HK$500,000 | HK$500,000 | HK$500,000 |
| Lump sum on death (normalised) | HK$500,000 | HK$505,000 | HK$500,000 | HK$500,000 |
| Total premium payable | HK$312,780 | HK$341,500 | HK$284,400 | HK$268,000 |
| Year 1 premium | HK$15,639 | HK$18,240 | HK$13,980 | HK$26,700 |
| Premium paying term | 20 yr to age 58 | 20 yr to age 58 | 18 yr to age 56 | Pay-as-you-go |
| Guaranteed cash value, yr 10 | HK$286,000 | HK$274,000 | HK$258,000 | n/a (segregated) |
| Guaranteed cash value, yr 20 | HK$498,000 | HK$486,000 | HK$412,000 | n/a |
| CI rider available? | Yes, 90-day wait | Yes, 60-day wait | Yes, 90-day wait | No (separate product) |
| Premium waiver available? | Yes | Yes | No | Yes |
| Guaranteed vs non-guaranteed | 51% / 49% | 68% / 32% | 74% / 26% | 20% / 80% |
| Occupation class 3 treatment | +25% | +20% | Referral | +40% |
| Estimated FYC | HK$6,568 | HK$7,900 | HK$5,400 | HK$5,340 |
| Quote valid until | 2025-04-13 | 2025-03-31 | 2025-04-01 | 2025-03-20 |
Read that row by row and three conclusions jump out that a naive side-by-side would miss:
- Carrier D is not comparable at all on a year-1 basis. Its year-1 premium is 71% higher than A's, but its total payable is lower than A's — because the two structures have different shapes. Putting it in a "cheapest annual premium" shortlist is a mistake.
- Carrier C looks cheapest on year 1 but has the weakest guaranteed value. Year-1 premium alone would recommend C; total guaranteed cash value at year 20 would recommend A by HK$86,000.
- Quote validity dates are wildly different. A's quote is good until 13 April; D's expires on 20 March. If the client's decision takes three weeks, the whole matrix is stale. The POS must show validity dates prominently — which brings us to the lifecycle.
The bid sheet rule
A comparison matrix is a POS artefact and a client artefact, but it must be clear which is which:
- The bid sheet shown to the client is versioned, timestamped, watermarked "報價有效期至 …", and carries the assumptions page.
- The internal matrix carries more columns (occupation treatment, commission estimate, referral likelihood) that are useful to the agent and irrelevant — and occasionally counterproductive — to the client.
- Neither may be regenerated after the fact. A revised matrix is a new matrix.
Quote to Proposal Lifecycle
In practice: the client says "I'll think about it". The POS sets a 30-day expiry, a CRM task for day 25, and — crucially — does not let you reuse the quote number on day 40.
The state machine
export type QuoteState =
| 'DRAFT' // inputs being collected, no number yet
| 'CALCULATED' // premium computed, not yet presented
| 'ISSUED' // shown to client, valid until validUntil
| 'SUPERSEDED' // replaced by a newer quote on the same case
| 'EXPIRED' // past validUntil, not submitted
| 'CONVERTED' // became a proposal; this quote is now historical
| 'WITHDRAWN' // product version withdrawn from sale
| 'INVALIDATED'; // rate card superseded or eligibility changed
export const QUOTE_TRANSITIONS: Record<QuoteState, QuoteState[]> = {
DRAFT: ['CALCULATED', 'WITHDRAWN'],
CALCULATED: ['ISSUED', 'DRAFT', 'INVALIDATED'],
ISSUED: ['SUPERSEDED', 'EXPIRED', 'CONVERTED', 'INVALIDATED', 'WITHDRAWN'],
SUPERSEDED: ['EXPIRED'],
EXPIRED: [], // terminal — a new quote is the only way forward
CONVERTED: ['SUPERSEDED'], // if the proposal is revised pre-submission
WITHDRAWN: ['INVALIDATED'],
INVALIDATED: [],
};
export function canTransition(from: QuoteState, to: QuoteState): boolean {
return QUOTE_TRANSITIONS[from].includes(to);
}
/** Triggers that force a NEW quote rather than an edit of the old one. */
export type InvalidationTrigger =
| 'RATE_CARD_SUPERSEDED'
| 'PRODUCT_VERSION_SUPERSEDED'
| 'LOADING_CHANGED'
| 'SMOKER_STATUS_CHANGED'
| 'OCCUPATION_CLASS_CHANGED'
| 'HEALTH_DECLARATION_CHANGED'
| 'SUM_ASSURED_CHANGED'
| 'ELIGIBILITY_FAILED'
| 'QUOTE_EXPIRED'
| 'PROMO_WINDOW_CLOSED';
export function evaluateInvalidation(
trigger: InvalidationTrigger,
q: { issuedAt: Date; validUntil: Date; rateCardVersion: string; productVersion: number; currentRateCardVersion: string; currentProductVersion: number },
now: Date,
): { invalidated: boolean; reasonZh: string } {
if (now > q.validUntil) {
return { invalidated: true, reasonZh: '報價已過有效期,請重新報價' };
}
if (q.rateCardVersion !== q.currentRateCardVersion) {
return { invalidated: true, reasonZh: '費率表版本已更新,舊報價不可提交,請重新報價' };
}
if (q.productVersion !== q.currentProductVersion) {
return { invalidated: true, reasonZh: '產品版本已更新,請按新條款重新報價' };
}
const map: Partial<Record<InvalidationTrigger, string>> = {
LOADING_CHANGED: '核保結果有變更,保費已不同,請重新報價',
SMOKER_STATUS_CHANGED: '吸煙狀況有變更,費率不同,請重新報價',
OCCUPATION_CLASS_CHANGED: '職業類別有變更,費率不同,請重新報價',
HEALTH_DECLARATION_CHANGED: '健康申報有變更,核保結果可能不同,請重新報價',
SUM_ASSURED_CHANGED: '保額已變更,請重新報價',
ELIGIBILITY_FAILED: '已不符合投保資格,舊報價不可用',
PROMO_WINDOW_CLOSED: '推廣折扣期已結束,請按現行費率重新報價',
};
return { invalidated: false, reasonZh: map[trigger] ?? '請重新報價' };
}
The lifecycle in prose
| Stage | 中文 | What happens | What the agent does | POS state change |
|---|---|---|---|---|
| Collect | 收集資料 | Age, sex, smoker, occupation, sum assured, term, riders | Fills the quote form; eligibility runs (Lesson 02) | DRAFT → CALCULATED |
| Present | 呈報報價 | Premium, benefits, exclusions, assumptions, validity date | Reads the numbers; shows the guaranteed/non-guaranteed split | CALCULATED → ISSUED |
| Compare | 比較方案 | 3–5 carriers normalised | Builds the matrix; the client chooses | (stays ISSUED) |
| Decide | 決定 | Client accepts, defers, or declines | Accept → build proposal; defer → CRM task; decline → record reason | ISSUED → CONVERTED or EXPIRED |
| Propose | 建議書 | Formal recommendation document + illustration + disclosure | Confirms the recommendation is tied to the exact quote | (stays CONVERTED) |
| Sign | 簽署 | Client signs proposal + disclosure + application | Witness if required; capture per Lesson 08 | (stays CONVERTED) |
| Submit | 提交 | Carrier receives, returns application ref | No further agent action | CONVERTED → (carrier owns it) |
The re-quote triggers, ranked by how often they bite
| Trigger | Frequency | Who notices | Damage if missed |
|---|---|---|---|
QUOTE_EXPIRED | Very high | Agent, at submit | Submit rejected; client told "sorry, we need to re-do it" |
OCCUPATION_CLASS_CHANGED | Medium | Underwriter | Quoted as standard, underwritten as class 3 → loading surprise |
RATE_CARD_SUPERSEDED | Medium | POS, at submit | Carrier rejects with RATE_CARD_SUPERSEDED; must re-sign |
LOADING_CHANGED | Medium | Underwriter | The Lesson 01 step 18 scenario |
SMOKER_STATUS_CHANGED | Low–medium | Agent | Mis-pricing, and an avoidable declination |
PROMO_WINDOW_CLOSED | Low | POS | Discount disappears; premium higher than quoted |
PRODUCT_VERSION_SUPERSEDED | Low | POS | Terms changed since the quote was issued |
HEALTH_DECLARATION_CHANGED | Low | Agent | Serious: a client changing an answer post-quote must be re-underwritten, and the POS must log why |
That last row is the one that needs a hard rule rather than a table entry: a client who changes a health answer after a quote has been issued must trigger a fresh health declaration, a fresh underwriting submission, and a logged reason. Editing a disclosure answer to make a case pass underwriting is fraud, and the POS audit log in Lesson 08 is what makes it provable either way.
Why quotes expire at all
A POS that let an agent reuse a six-month-old quote would be more convenient and less truthful. The expiry is not administrative: it bounds the agent's exposure to a stale rate card, a withdrawn product version, a changed eligibility rule, and a client whose circumstances have moved. Thirty days is a compromise between convenience and exposure, and promotional rates shorten it because the rate card itself has a fixed life.
Key Takeaways
In practice: nine sentences that let you defend any number the POS puts in front of a client.
- A quote is not a price, it is a reproducible promise — bound to five version pointers (product, rate card, benefit table, assumptions, disclosure), issued to a named agent, on a named device, with an expiry date.
- Rate tables price per HK$1,000 of sum assured, so the formula is
ratePer1000 × (SA / 1000). Multiplying by the full sum assured is the classic 1000× overcharge. - Loadings are permanent and contractual, not temporary price adjustments. They run for the whole policy term and appear on the client's schedule forever; the legitimate alternatives are an exclusion or a referral, not a promise that "the loading goes away".
- Loadings stack by the card's own rule — additive, multiplicative, or capped — and the same 25% + 50% risk costs HK$27,368 additive but HK$29,323 multiplicative. The convention is carrier data, never a code assumption.
- Age is assessed at the in-force date, so a client turning 40 next month is priced at 40 today; and age-banded renewable medical plans can rise 60%+ between age 35 and 60, which must be disclosed at quote time.
- Occupation classes are carrier-specific. A chef is class 2 at one carrier and class 3 at another; a commercial diver is declined by one and referred by another. This is the strongest argument for a multi-carrier POS.
- The calculation function must throw rather than extrapolate.
NO_RATE_ROWandNO_RIDER_RATEare the two guards that stop a POS shipping a wrong premium as if it were right. - Normalise before you compare. Sum assured definition, critical illness definition, waiting period, PES treatment, benefit structure, currency and fee treatment must be aligned first — otherwise the comparison matrix is comparing four different questions.
- Quote lifecycle: DRAFT → CALCULATED → ISSUED → (SUPERSEDED | EXPIRED | CONVERTED | WITHDRAWN | INVALIDATED). Once CONVERTED the quote is historical; a loading from underwriting produces a new proposal and a new signature, never an edit.
Glossary (zh-Hant-HK)
| Term | 中文 | Note |
|---|---|---|
| Quote | 報價 | A computed premium bound to version pointers |
| Rate card | 費率表 | The carrier's versioned risk → rate mapping |
| Rate table | 費率表 | Same thing, per-product granularity |
| Loading | 加費 | A permanent premium increase in the contract |
| Exclusion (underwriting) | 除外承保 | Cover accepted with a condition excluded |
| Standard terms | 標準條款 | Accepted at the table rate |
| Decline | 拒保 | Not accepted |
| Referral | 轉介核保 | Sent to an underwriter for a decision |
| Attainment factor | 體格/生活因素 | A bonus or penalty on lifestyle factors |
| Rate per 1,000 | 每千元費率 | The standard unit of life pricing |
| Attained age | 實際年齡 | Age used for rating |
| Mode / premium mode | 繳費模式 | Monthly, quarterly, annual, single |
| Agency tier | 代理級別 | Determines discount and FYC rate |
| Bid sheet | 報價單 / 比價表 | The comparison artefact shown to a client |
| Normalisation | 標準化 | Making two products answer the same question |
| Persistency | 續期率 | Share of policies still in force at year N |
| Assumptions | 假設 | The rates/returns behind a non-guaranteed illustration |