Skip to content

Policy Lifecycle

The @openinsure/policy package implements a deterministic finite state machine for every insurance policy. All transitions are server-enforced — the API returns a 422 Unprocessable Entity for any invalid transition attempt.

┌─────────────┐
│ draft │ ◄── Submission created
└──────┬──────┘
│ POST /submissions/:id/quote
┌──────▼──────┐
│ quoted │ ◄── Premium calculated, expires in 30 days
└──────┬──────┘
│ POST /submissions/:id/bind
┌──────▼──────┐
│ bound │ ◄── DA checks passed, policy number assigned
└──────┬──────┘
│ POST /policies/:id/issue (underwriter review)
┌──────▼──────┐
┌────► issued │ ◄── Dec page finalized
│ └──────┬──────┘
│ │ (effective date reached)
│ ┌──────▼──────┐
reinstate ───────┤ │ active │
│ └──────┬──────┘
│ │
┌────┘ ┌──────┴──────────┬───────────────┐
│ │ │ │
┌──────▼──┐ ┌─────▼──────┐ ┌──────▼──────┐ ┌────▼────────┐
│cancelled│ │ endorsed │ │ non-renewed │ │ expired │
└─────────┘ └────────────┘ └─────────────┘ └──────┬──────┘
┌──────▼──────┐
│ renewed │ ◄── New policy term created
└─────────────┘
StateDescriptionBillable
draftSubmission data being collectedNo
quotedPremium calculated, awaiting acceptanceNo
boundAccepted by producer, pending underwriter reviewYes (pro-rata from effective date)
issuedUnderwriter reviewed and finalizedYes
activeCurrently in forceYes
endorsedMid-term change applied (policy remains active)Yes (adjusted premium)
cancelledTerminated before expirationPartial (by cancellation type)
expiredPolicy period ended without renewalNo
non-renewedCarrier elected not to renewNo
renewedNew term policy createdYes (new term)

All transitions are enforced in packages/policy/src/index.ts. The valid transitions are:

const VALID_TRANSITIONS: Record<PolicyStatus, PolicyStatus[]> = {
draft: ['quoted'],
quoted: ['bound', 'draft'], // draft = re-rate
bound: ['issued', 'cancelled'],
issued: ['active', 'cancelled'],
active: ['cancelled', 'expired', 'non-renewed'],
endorsed: ['active', 'cancelled'], // endorsement resolves back to active
cancelled: ['active'], // via reinstatement
expired: ['renewed'],
non-renewed: [],
renewed: [],
};

Any call that attempts an unlisted transition returns:

{
"error": "invalid_transition",
"message": "Cannot transition from 'draft' to 'active'. Valid next states: ['quoted']",
"currentStatus": "draft",
"requestedStatus": "active"
}

Endorsements apply changes to an active policy and produce a new premium calculation for the remaining policy term. OpenInsure models the policy as a timeline of segments — each endorsement creates a new segment with its own annual premium rate, pro-rated to the days it was in effect.

TypeCodeDescription
Limit changeLIMIT_CHANGEIncrease or decrease the policy limit
Deductible changeDEDUCTIBLE_CHANGEModify the per-occurrence deductible
Additional insuredADD_INSUREDAdd a named additional insured
Location changeLOCATION_CHANGEAdd or remove scheduled locations
Coverage addCOVERAGE_ADDAdd an endorsement form (e.g., EPLI)
Coverage removeCOVERAGE_REMOVERemove a coverage endorsement
Named insured changeNAME_CHANGEUpdate the named insured (e.g., after acquisition)
Driver add/removeDRIVER_ADD/REMOVEAdd or remove a scheduled driver (auto/trucking)
Vehicle add/removeVEHICLE_ADD/REMOVEAdd or remove a scheduled vehicle
CorrectionCORRECTIONAdministrative fix — no premium impact
Terminal window
POST /v1/policies/:id/endorsements
Authorization: Bearer <token>
Content-Type: application/json
{
"type": "LIMIT_CHANGE",
"effectiveDate": "2025-04-15",
"description": "Increase per-occurrence limit for new GC contract",
"changes": {
"occurrenceLimit": 2000000,
"aggregateLimit": 4000000
},
"requestedBy": "usr_01J8..."
}

The response includes the pro-rata premium adjustment, timeline segment data, and out-of-sequence detection:

{
"endorsement": {
"id": "end_01J8...",
"policyId": "pol_01J8...",
"endorsementNumber": "ENT-003",
"type": "LIMIT_CHANGE",
"status": "pending",
"effectiveDate": "2025-04-15",
"sequenceNumber": 3,
"isOutOfSequence": false,
"priorAnnualPremium": 12500,
"newAnnualPremium": 15200,
"pastPeriodAdj": 0,
"futurePeriodAdj": 1842.0,
"netPremiumAdjustment": 1842.0
},
"timeline": { "segments": [...], "totalEarnedPremium": 14342 }
}

A policy is a function of time. The timeline engine in @openinsure/rating (timeline.ts) models this as an ordered sequence of segments:

Policy inception ──→ Endorsement 1 ──→ Endorsement 2 ──→ Expiration
│ Segment 0 │ Segment 1 │ Segment 2 │
│ $10,000/yr annual│ $12,000/yr │ $15,200/yr │
│ 120 days │ 90 days │ 155 days │

Each segment has:

  • An effective date (= endorsement effective date, or policy inception for segment 0)
  • An expiration date (= next segment’s effective date, or policy expiration)
  • A RatingInput snapshot (the rated state for that segment)
  • An annual premium (from rateSubmission())
  • A pro-rated segment premium: annualPremium × segmentDays / 365

Total earned premium = sum of all segment premiums. Penny-perfect allocation uses the largest-remainder method to prevent cent drift across segments.

Key functions (@openinsure/rating):

FunctionSourcePurpose
buildTimeline()timeline.tsBuild full segment timeline from inception + endorsements
insertEndorsement()timeline.tsInsert one endorsement (possibly backdated) and return delta
computeEarnedToDate()timeline.tsEarned premium as of a given date
validateEndorsementDate()timeline.tsValidate effective date against policy bounds
computeCascadeImpact()timeline.tsCompute premium impact on downstream endorsements after OOS insertion

Key functions (@openinsure/policy):

FunctionSourcePurpose
detectOutOfSequence()endorsement-timeline.tsDetect if a new endorsement is out of chronological order
deriveEndorsementChronology()endorsement-timeline.tsDerive chronological ordering of all endorsements
computeScheduleChangePremium()endorsement-timeline.tsPro-rata past/future premium split for schedule changes

An out-of-sequence (OOS) endorsement has an effective date earlier than a previously issued endorsement. This is common in insurance — a backdated driver addition, a retroactive limit increase, or a correction with an earlier effective date.

Example: Policy has ENT-001 (effective June 1) and ENT-002 (effective September 1) already issued. A new endorsement arrives with effective date March 1 — this is out-of-sequence because it predates both existing endorsements.

When an OOS endorsement is created, OpenInsure:

  1. Detects the OOS condition via detectOutOfSequence() — compares the candidate’s effective date against all issued/approved endorsements
  2. Identifies affected endorsements — all downstream endorsements whose premium may change
  3. Rebuilds the timelineinsertEndorsement() re-derives all segments from the insertion point forward
  4. Computes cascade impactcomputeCascadeImpact() calculates the premium delta on each downstream endorsement
  5. Splits past/future adjustments — for backdated endorsements, the premium adjustment is split:
    • Past period: endorsement effective date → processing date (retroactive earned premium delta)
    • Future period: processing date → policy expiration (going-forward billing delta)
  6. Row-locks the policy during the transaction to prevent concurrent endorsement conflicts
import { detectOutOfSequence, computeScheduleChangePremium } from '@openinsure/policy';
import { buildTimeline, insertEndorsement, computeCascadeImpact } from '@openinsure/rating';
// 1. Detect OOS
const oosReport = detectOutOfSequence(existingEndorsements, new Date('2025-03-01'));
// oosReport.isOutOfSequence → true
// oosReport.affectedEndorsements → [ENT-001, ENT-002]
// oosReport.assignedSequenceNumber → 1
// 2. Insert into timeline and get delta
const { updatedTimeline, delta } = insertEndorsement(currentTimeline, newEndorsement, plan);
// delta.pastPeriodAdjustment → retroactive earned premium change
// delta.futurePeriodAdjustment → going-forward billing change
// delta.netAdjustment → total premium impact
// delta.affectedSegments → per-segment breakdown
// 3. Cascade impact on downstream endorsements
const cascade = computeCascadeImpact(timelineBefore, timelineAfter, downstreamEndorsements);
// cascade[i].correctedNetDelta → recalculated premium for downstream endorsement
// cascade[i].deltaShift → difference from previously stored value

Database support: The endorsements table tracks OOS state explicitly:

ColumnTypePurpose
sequence_numberintegerTimeline position (1-based, unique per policy)
is_out_of_sequencebooleanWas this endorsement OOS when created?
supersedes_iduuidSelf-referencing FK for cascade-reshuffled endorsements
past_period_adjnumeric(12,2)Retroactive premium adjustment
future_period_adjnumeric(12,2)Going-forward premium adjustment
prior_rating_inputjsonbRating snapshot before the change
new_rating_inputjsonbRating snapshot after the change
rating_resultjsonbFull rating result for the new state

Multiple endorsements with the same effective date are allowed. They are ordered by sequence number (issue order) and do not trigger OOS detection — only endorsements with a later effective date are considered “downstream.”

Same-day endorsements generate a warning (creates a zero-day segment) but are not blocked, because legitimate scenarios exist (e.g., adding a driver and changing limits on the same day).

Before an endorsement is created, validateEndorsementDate() checks:

CheckResult
Effective date before policy inceptionError (blocked)
Effective date at or after policy expirationError (blocked)
Effective date matches existing endorsementWarning (zero-day segment)
Effective date in the pastWarning (backdated)

Portfolio-wide rate changes (carrier-mandated rate increases, surcharges, factor overrides) are processed via the batch endorsement engine in @openinsure/rating (batch-endorsement.ts):

import { processBatchEndorsement } from '@openinsure/rating';
const results = processBatchEndorsement({
batchId: 'rate-increase-2025-q3',
changes: [
{
id: 'gl-sc-5pct',
description: '5% GL rate increase — SC filing effective 2025-07-01',
changeType: 'percentage',
value: 5,
filters: { lob: 'GL', state: 'SC' },
effectiveDate: '2025-07-01',
},
],
policies: affectedPolicies,
});
// results.applied → policies with new premium
// results.skipped → policies filtered out or below minimum impact

For commercial auto and trucking lines, the @openinsure/rating package (endorsement-changes.ts) provides structured change builders that translate driver/vehicle roster mutations into RatingInput deltas:

import { buildDriverAddChanges, buildDriverRemoveChanges } from '@openinsure/rating';
// Adding a new driver recalculates the fleet's average MVR score and experience
const changes = buildDriverAddChanges(
currentRoster, // { totalDrivers: 12, averageMVRScore: 3.2, averageYearsExperience: 8.5 }
{ mvrScore: 5, yearsExperience: 2 } // new driver with poor MVR
);
// changes → { driverCount: 13, averageMVRScore: 3.34, averageYearsExperience: 8.0 }

When an endorsement is issued, @openinsure/documents generates an Endorsement Certificate — a PDF showing the policy number, endorsement number, effective date, change description, and premium impact. The certificate is stored in R2 and linked to the endorsement record.

The queue consumer handles endorsement.issued events by:

  1. Creating a notification record
  2. Posting a GL journal entry for the premium delta (if non-zero)
  3. Sending an email to the producer via the configured email provider
  4. Posting to the Slack channel (if configured)
  5. Triggering webhook delivery to external systems
TypeCodeReturn Premium Calculation
Flat cancelFLAT100% return — as if policy never incepted
Pro-rataPRO_RATAReturn = full premium × (days remaining / policy days)
Short-rateSHORT_RATEReturn = pro-rata × 90% — penalty for insured-requested cancellations
Terminal window
POST /v1/policies/:id/cancel
Authorization: Bearer <token>
Content-Type: application/json
{
"cancellationType": "PRO_RATA",
"effectiveDate": "2025-07-01",
"reason": "INSURED_REQUEST",
"reasonDetail": "Business sold"
}

Response:

{
"policyId": "pol_01J8...",
"status": "cancelled",
"cancellationDate": "2025-07-01",
"returnPremium": 4127.5,
"returnPremiumInvoiceId": "inv_01J8..."
}

Common cancellation reason codes:

  • NON_PAYMENT — Premium invoice unpaid beyond grace period
  • INSURED_REQUEST — Named insured requested cancellation
  • UNDERWRITING — Risk no longer acceptable (requires advance notice)
  • FRAUD — Material misrepresentation (immediate, state-specific rules apply)
  • REWRITE — Policy being rewritten to another carrier on same terms

A cancelled policy can be reinstated within 30 days of cancellation (state rules may override this window).

Terminal window
POST /v1/policies/:id/reinstate
Authorization: Bearer <token>
Content-Type: application/json
{
"effectiveDate": "2025-07-15",
"paymentConfirmation": "pi_3NkX...",
"lapseCoverage": false
}

If lapseCoverage: false, the reinstated policy covers the gap period (subject to underwriter approval). If true, the policy is reinstated with a coverage gap — common for non-payment reinstatements.

Renewals are created automatically 90 days before expiration by the nightly renewal scheduler job. The scheduler:

  1. Identifies policies expiring within 90 days.
  2. Creates a draft renewal submission with the current policy’s data pre-filled.
  3. Re-rates using the current rate tables (the new rates may differ from the expiring term).
  4. Sends renewal offer to the producer via the notification system.

The producer has until 30 days before expiration to accept, modify, or decline the renewal. If no action is taken, the policy is auto-renewed on the same terms (if configured in the program settings).

A renewal maintains the same policy number with a new term suffix (e.g., GL-2025-000001-R1). A rewrite creates a new policy number — used when the carrier, program, or coverage terms change materially.

Terminal window
# 1. Create submission
SUB=$(curl -s -X POST $API/v1/submissions \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"insuredName":"Acme Roofing","naicsCode":"238160","annualRevenue":2500000,"requestedLimit":1000000,"effectiveDate":"2025-06-01","state":"VT","lineOfBusiness":"GL"}' \
| jq -r '.id')
# 2. Quote
QUOTE=$(curl -s -X POST $API/v1/submissions/$SUB/quote \
-H "Authorization: Bearer $TOKEN" | jq -r '.id')
# 3. Bind
POL=$(curl -s -X POST $API/v1/submissions/$SUB/bind \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"installmentPlan":"monthly"}' | jq -r '.id')
# 4. Endorse (add additional insured)
curl -s -X POST $API/v1/policies/$POL/endorse \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"ADD_INSURED","effectiveDate":"2025-07-01","changes":{"additionalInsureds":[{"name":"Vermont Contractors LLC","relationship":"contract"}]}}'