Class: Certification::InsuranceEscalation

Inherits:
Object
  • Object
show all
Defined in:
app/services/certification/insurance_escalation.rb

Overview

Drives the customer-facing escalation when a certified installer's liability
insurance lapses, and suspends the certification if it is never renewed.

The cadence is anchored to the insurance card's +expiration_date+:

exp − 14d → +CERT_INS_WARN+ proactive: "your card expires soon"
exp → +CERT_INS_LAPSED+ "your card expired, your certification is at risk"
exp + 14d → +CERT_INS_FINAL+ final notice, states the suspension deadline
final + 14d → +cert.expire!+ suspends the certification, which fires the
existing +CERT_EXPIRED+ customer email and the
+certification_expired+ rep email (with a manage link)

Off-ramps:

  • The instant the customer has active, in-date coverage again they fall out of
    scope (#covered?) and the sequence stops.
  • A good-faith upload — a +new+/+processing+ card submitted since the window
    opened — pauses reminders and blocks suspension (#pending_upload?).
  • When the customer is unreachable (no email on file) or has no insurance record
    at all, the rep is alerted instead (throttled), never silently dropped.

This replaces the former per-run +TrainingMailer.certification_without_liability_insurance+
rep alert and the generic +LIAB_INSUR_EXPIRES10+ / +LIAB_INSUR_EXPIRED+ customer
emails. Runs daily from CertificationCheckWorker.

See Also:

Constant Summary collapse

WARN_LEAD_DAYS =

First customer email lands this many days BEFORE the card expires.

14
FINAL_LEAD_DAYS =

Final notice lands this many days AFTER the card expires.

14
EXPIRE_GRACE_DAYS =

The certification is suspended this many days AFTER the final notice.

14
UPLOAD_PATH =

Customer-facing upload form (front-end host).

'/my_account/liability_insurances/new'
REP_ALERT_THROTTLE =

Don't re-alert the rep about the same certification more often than this.

7.days
PENDING_UPLOAD_LOOKBACK =

A pending upload counts as a good-faith renewal only if it's this recent.

45.days
LOCK_KEY =

Advisory-lock key serializing the daily sweep against overlapping runs.

'certification_insurance_escalation'
STAGE_TEMPLATES =

Escalation stage => the EmailTemplate system_code it sends.

{ warn: 'CERT_INS_WARN', lapsed: 'CERT_INS_LAPSED', final: 'CERT_INS_FINAL' }.freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(now: Time.current, logger: Sidekiq.logger) ⇒ InsuranceEscalation

Returns a new instance of InsuranceEscalation.

Parameters:

  • now (Time) (defaults to: Time.current)

    evaluation time (injectable for tests)

  • logger (Logger) (defaults to: Sidekiq.logger)


50
51
52
53
54
# File 'app/services/certification/insurance_escalation.rb', line 50

def initialize(now: Time.current, logger: Sidekiq.logger)
  @now = now
  @today = now.to_date
  @logger = logger
end

Class Method Details

.decide(today:, expiration_date:, sent:, pending_upload:, final_sent_on:) ⇒ Symbol

Pure scheduling decision — no I/O, safe to unit-test exhaustively.

Parameters:

  • today (Date)
  • expiration_date (Date, Time)

    the lapsing card's expiration

  • sent (Array<Symbol>)

    stages already emailed this cycle

  • pending_upload (Boolean)

    a good-faith renewal is awaiting review

  • final_sent_on (Date, Time, nil)

    when the final notice went out, if ever

Returns:

  • (Symbol)

    :warn | :lapsed | :final | :expire | :noop



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'app/services/certification/insurance_escalation.rb', line 110

def self.decide(today:, expiration_date:, sent:, pending_upload:, final_sent_on:)
  return :noop if pending_upload # good-faith upload → pause reminders & block suspension

  exp = expiration_date.to_date
  window_start = exp - WARN_LEAD_DAYS

  if final_sent_on && today >= final_sent_on.to_date + EXPIRE_GRACE_DAYS
    :expire
  elsif today >= exp + FINAL_LEAD_DAYS && sent.exclude?(:final)
    :final
  elsif today >= exp && !sent.intersect?(%i[lapsed final])
    :lapsed
  elsif today >= window_start && !sent.intersect?(%i[warn lapsed final])
    :warn
  else
    :noop
  end
end

Instance Method Details

#callvoid

This method returns an undefined value.

Processes every active certification, guarded by an advisory lock so an
overlapping run (Sidekiq retry, duplicate cron trigger, a slow run still in
flight) can't race two sweeps into double-sending a stage email or
double-suspending. A run that can't grab the lock skips entirely — the next
daily run resumes from the persisted Communication state. One failing
certification never aborts the run.



63
64
65
66
67
68
69
70
71
# File 'app/services/certification/insurance_escalation.rb', line 63

def call
  Certification.with_advisory_lock(LOCK_KEY, timeout_seconds: 0) do
    Certification.active.find_each do |cert|
      handle(cert)
    rescue StandardError => e
      ErrorReporting.error("[InsuranceEscalation] cert ##{cert.id}: #{e.class}: #{e.message}")
    end
  end
end

#handle(cert) ⇒ Symbol

Evaluates and executes the next action for one certification.

Parameters:

Returns:

  • (Symbol)

    the action taken (:warn, :lapsed, :final, :expire, :rep_alert, :noop)



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'app/services/certification/insurance_escalation.rb', line 76

def handle(cert)
  customer = cert.customer
  return :noop unless customer
  return :noop if covered?(customer) # active + in-date coverage → self-resolved

  insurance = latest_insurance(customer)
  if insurance.nil?
    alert_rep(cert, :no_insurance_on_file)
    return :rep_alert
  end

  action = self.class.decide(
    today: @today,
    expiration_date: insurance.expiration_date,
    sent: sent_stages(customer, insurance),
    pending_upload: pending_upload?(customer),
    final_sent_on: stage_sent_on(customer, insurance, :final)
  )

  case action
  when :warn, :lapsed, :final then send_stage(cert, customer, insurance, action)
  when :expire                then suspend(cert)
  end
  action
end