第 03 課 · Lesson 03

報價引擎 | The Quoting Engine

Rate tables、年齡分層、職業等級、佣金;報價 → 建議書生命週期。

Plays in the sticky player at the bottom of the page

課堂筆記

學習目標 · Learning Objectives

  1. 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.
  2. Explain the loading stack (occupation, health, lifestyle, attainment) and why a loading is a permanent contract term rather than a temporary price adjustment.
  3. 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:

  1. 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.
  2. 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.
  3. 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:

  • loadingsApplied is 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 is ratePer1000 × (SA / 1000). The engine never multiplies by the full SA; that error produces a 1000× overcharge.
  • Negative factors exist (-0.10 for non-smoker attainment, -0.05 for standard BMI). Loadings are not one-directional. A POS that clamps negatives to zero is quietly overcharging.
  • payTermFactors are stated separately from baseRates because 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 rangeReversible?Who decides
Occupation職業加費+10% to +100%, or referralNo, unless occupation changesUnderwriter
Health體況加費 / 既往症狀加費+25% to +200%, or exclusionNoUnderwriter
Lifestyle / attainment生活方式 / 體格加費−25% to +25%Can improve over time if re-underwrittenUnderwriter
Financial / face amount財務核保加費+10% to +50%NoUnderwriter
Declined-exclusion variant除外承保0% premium changeExclusion instead of loadingUnderwriter

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:

ConventionRuleExampleEffect on a 12,800 base
AdditiveSum the percentages+25% + 50% = +75%12,800 → 22,400
MultiplicativeChain them1.25 × 1.50 = 1.87512,800 → 24,000
CappedAdditive, 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

AspectRule
Which age?Age at the policy's in-force date, i.e. age at the next birthday as of issuance
Band resolutionAge band that contains the assessed age, at the band boundary inclusive
Non-near-ageAge band by whole years; some carriers use "age nearest birthday" — check the card
Band boundary trapA 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
RepricingLevel 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 renewalAnnual premium (level benefit, no discounts)Change
353,120—
404,480+44%
456,720+50%
5010,560+57%
5517,280+64%
6028,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 rolesTypical treatment
1內勤 / 專業Office worker, teacher, accountant, software engineerStandard
2輕體力 / 戶外輕量Salesperson, nurse, chef, junior technicianStandard 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, loggerReferral; +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

ChoiceWhy
NO_RATE_ROW throws instead of extrapolatingA guessed rate is a mis-sold contract. Failing loudly is correct.
NO_RIDER_RATE throws instead of falling back to the base tableRiders differ per carrier and per product; borrowing a rate is how wrong premiums ship.
Loading stacking is a card propertyThe POS must not encode a carrier's convention as a code-level assumption.
computationTrace is returnedThis 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.

RiderBasis SARowRateCalculationAmount
Premium waiver500,000F / non-smoker / 18–406.20 / 1,0006.20 × 500 × 1HK$3,100.00
CI rider200,000age 31–406.80 / 1,0006.80 × 200 × 1HK$1,360.00
Riders totalHK$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:

StandardLoaded (+25% +50%)Delta
Gross before loading24,060.0024,060.00—
Loading %0%75%+75pp
Loading amount0.0018,045.00+18,045.00
After loading24,060.0042,105.00+18,045.00
Discount (T3, 35%)8,421.0014,736.75+6,315.75
Net annual15,639.0027,368.25+11,729.25 (+75%)
Net monthly1,303.252,280.69+977.44
FYC estimate6,568.3811,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

SourceMonthly figureWhy it differs from HK$1,303.25
Consumer comparison siteHK$312.00Base benefit only, gross list price, no riders, cheapest possible SA
Carrier brochure illustrationHK$980.00Base only, discounted, no riders, age 30 not 38
POS quote, base onlyHK$1,066.67Base + pay-term only, riders excluded
POS quote, completeHK$1,303.25Base + 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

DimensionTypical divergence across HK carriersNormalisation 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 definitionCore definition (2019+) vs pre-2019 list; early-stage CI paid or notAlign on a single CI definition; treat the difference as a rider line
Waiting period30 / 60 / 90 / 180 days for CIReport days explicitly; do not assume equivalence
Pre-existing condition (PES)Full exclusion vs 2-year look-back vs loaded acceptanceModel as a scenario, not as a footnote
Benefit structureSingle benefit vs multi-claim vs 1× SA + 50% on recurrenceRestate to "lump sum on first claim"
Premium paying term vs coverage term10-yr pay to age 100 vs level premium to age 70Show total premium payable over each structure
CurrencyHKD vs USD quoting; FX at different fixing timesFix to a single currency with a stated FX date
Rider availabilityWaiver offered by some, not othersShow riders as separate rows, not folded in
Fee treatmentPolicy fee embedded vs deducted from benefitShow net and gross benefit separately
Attribution1st-year, 2nd-year, or lifetime commissionNot comparable — do not put it in the matrix

The matrix an agent actually shows

Carrier ACarrier BCarrier CCarrier D
Product / versionWhole Life SPP v4Whole Life v7Life Secure v2Universal Life v1
Rate card2025-03-142025-02-012025-03-012025-01-15
Basic sum assuredHK$500,000HK$500,000HK$500,000HK$500,000
Lump sum on death (normalised)HK$500,000HK$505,000HK$500,000HK$500,000
Total premium payableHK$312,780HK$341,500HK$284,400HK$268,000
Year 1 premiumHK$15,639HK$18,240HK$13,980HK$26,700
Premium paying term20 yr to age 5820 yr to age 5818 yr to age 56Pay-as-you-go
Guaranteed cash value, yr 10HK$286,000HK$274,000HK$258,000n/a (segregated)
Guaranteed cash value, yr 20HK$498,000HK$486,000HK$412,000n/a
CI rider available?Yes, 90-day waitYes, 60-day waitYes, 90-day waitNo (separate product)
Premium waiver available?YesYesNoYes
Guaranteed vs non-guaranteed51% / 49%68% / 32%74% / 26%20% / 80%
Occupation class 3 treatment+25%+20%Referral+40%
Estimated FYCHK$6,568HK$7,900HK$5,400HK$5,340
Quote valid until2025-04-132025-03-312025-04-012025-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 happensWhat the agent doesPOS state change
Collect收集資料Age, sex, smoker, occupation, sum assured, term, ridersFills the quote form; eligibility runs (Lesson 02)DRAFT → CALCULATED
Present呈報報價Premium, benefits, exclusions, assumptions, validity dateReads the numbers; shows the guaranteed/non-guaranteed splitCALCULATED → ISSUED
Compare比較方案3–5 carriers normalisedBuilds the matrix; the client chooses(stays ISSUED)
Decide決定Client accepts, defers, or declinesAccept → build proposal; defer → CRM task; decline → record reasonISSUED → CONVERTED or EXPIRED
Propose建議書Formal recommendation document + illustration + disclosureConfirms the recommendation is tied to the exact quote(stays CONVERTED)
Sign簽署Client signs proposal + disclosure + applicationWitness if required; capture per Lesson 08(stays CONVERTED)
Submit提交Carrier receives, returns application refNo further agent actionCONVERTED → (carrier owns it)

The re-quote triggers, ranked by how often they bite

TriggerFrequencyWho noticesDamage if missed
QUOTE_EXPIREDVery highAgent, at submitSubmit rejected; client told "sorry, we need to re-do it"
OCCUPATION_CLASS_CHANGEDMediumUnderwriterQuoted as standard, underwritten as class 3 → loading surprise
RATE_CARD_SUPERSEDEDMediumPOS, at submitCarrier rejects with RATE_CARD_SUPERSEDED; must re-sign
LOADING_CHANGEDMediumUnderwriterThe Lesson 01 step 18 scenario
SMOKER_STATUS_CHANGEDLow–mediumAgentMis-pricing, and an avoidable declination
PROMO_WINDOW_CLOSEDLowPOSDiscount disappears; premium higher than quoted
PRODUCT_VERSION_SUPERSEDEDLowPOSTerms changed since the quote was issued
HEALTH_DECLARATION_CHANGEDLowAgentSerious: 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.

  1. 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.
  2. 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.
  3. 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".
  4. 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.
  5. 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.
  6. 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.
  7. The calculation function must throw rather than extrapolate. NO_RATE_ROW and NO_RIDER_RATE are the two guards that stop a POS shipping a wrong premium as if it were right.
  8. 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.
  9. 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

課堂測驗 · 8 題

Question 1 of 8Answered 0 / 8
Question 1 of 8

費率表寫住「40 歲男性、標準體格、每千元保額 HK$12.50」。客戶想買 HK$800,000 定期人壽,第一年標準保費應該係幾多?

Pick an answer to lock it in. We'll tell you immediately whether you got it right and show an explanation. Then press Enter or click Next to continue.

Shortcuts:ABCDpick answer on current questionEntergo to next unanswered
8 unanswered