Class: Certification::InsuranceEscalation
- Inherits:
-
Object
- Object
- Certification::InsuranceEscalation
- 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.
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
-
.decide(today:, expiration_date:, sent:, pending_upload:, final_sent_on:) ⇒ Symbol
Pure scheduling decision — no I/O, safe to unit-test exhaustively.
Instance Method Summary collapse
-
#call ⇒ void
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.
-
#handle(cert) ⇒ Symbol
Evaluates and executes the next action for one certification.
-
#initialize(now: Time.current, logger: Sidekiq.logger) ⇒ InsuranceEscalation
constructor
A new instance of InsuranceEscalation.
Constructor Details
#initialize(now: Time.current, logger: Sidekiq.logger) ⇒ InsuranceEscalation
Returns a new instance of InsuranceEscalation.
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.
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
#call ⇒ void
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.}") end end end |
#handle(cert) ⇒ Symbol
Evaluates and executes the next action for one certification.
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 |