Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions app/jobs/gpo_expiration_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
class GpoExpirationJob < ApplicationJob
queue_as :low

def initialize(analytics: nil)
@analytics = analytics
end

def perform(as_of: Time.zone.now, limit: nil)
Comment thread
matthinz marked this conversation as resolved.
Outdated
profiles = gpo_profiles_that_should_be_expired(as_of: as_of)

if limit.present?
profiles = profiles.limit(limit)
end

profiles.find_each do |profile|
profile.deactivate_due_to_gpo_expiration

analytics.idv_gpo_expired(
user_id: profile.user.uuid,
Comment thread
matthinz marked this conversation as resolved.
Outdated
user_has_active_profile: profile.user.active_profile.present?,
letters_sent: profile.gpo_confirmation_codes.count,
)
end
end

def gpo_profiles_that_should_be_expired(as_of:)
Profile.
Comment thread
matthinz marked this conversation as resolved.
and(are_pending_gpo_verification).
and(user_cant_request_more_letters(as_of: as_of)).
and(most_recent_code_has_expired(as_of: as_of))
end

private

def analytics
@analytics ||= Analytics.new(user: AnonymousUser.new, request: nil, session: {}, sp: nil)
end

def are_pending_gpo_verification
Profile.where.not(gpo_verification_pending_at: nil)
end

def most_recent_code_has_expired(as_of:)
# Any Profile where the most recent code was sent *before*
# usps_confirmation_max_days days ago is now expired
max_code_sent_at = as_of - IdentityConfig.store.usps_confirmation_max_days.days

Profile.where(
id: GpoConfirmationCode.
select(:profile_id).
group(:profile_id).
having('max(code_sent_at) < ?', max_code_sent_at),
)
end

def user_cant_request_more_letters(as_of:)
max_created_at = as_of - IdentityConfig.store.gpo_max_profile_age_to_send_letter_in_days.days
Profile.where(created_at: [..max_created_at])
end
end
9 changes: 9 additions & 0 deletions app/models/profile.rb
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,15 @@ def in_person_verification_pending?
in_person_verification_pending_at.present?
end

def deactivate_due_to_gpo_expiration
Comment thread
matthinz marked this conversation as resolved.
raise 'Profile is not pending GPO verification' if gpo_verification_pending_at.nil?
update!(
active: false,
gpo_verification_pending_at: nil,
gpo_verification_expired_at: Time.zone.now,
Comment thread
matthinz marked this conversation as resolved.
)
end

def deactivate_for_in_person_verification
update!(active: false, in_person_verification_pending_at: Time.zone.now)
end
Expand Down
14 changes: 14 additions & 0 deletions app/services/analytics_events.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1459,6 +1459,20 @@ def idv_gpo_confirm_start_over_visited(**extra)
track_event('IdV: gpo confirm start over visited', **extra)
end

# The user ran out of time to complete their address verification by mail.
# @param [String] user_id UUID of the user who expired
# @param [Boolean] user_has_active_profile Whether the user current has an active profile
# @param [Integer] letters_sent Total # of GPO letters sent for this profile
def idv_gpo_expired(user_id:, user_has_active_profile:, letters_sent:, **extra)
Comment thread
matthinz marked this conversation as resolved.
Outdated
Comment thread
matthinz marked this conversation as resolved.
Outdated
track_event(
:idv_gpo_expired,
user_id: user_id,
user_has_active_profile: user_has_active_profile,
letters_sent: letters_sent,
**extra,
)
end

# A GPO reminder email was sent to the user
# @param [String] user_id UUID of user who we sent a reminder to
def idv_gpo_reminder_email_sent(user_id:, **extra)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class AddGpoVerificationExpiredAtToProfiles < ActiveRecord::Migration[7.1]
disable_ddl_transaction!

def change
add_column :profiles, :gpo_verification_expired_at, :datetime
Comment thread
matthinz marked this conversation as resolved.
add_index :profiles, :gpo_verification_expired_at, algorithm: :concurrently
end
end
6 changes: 5 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.1].define(version: 2023_08_31_124437) do
ActiveRecord::Schema[7.1].define(version: 2023_11_02_211426) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "pgcrypto"
Expand Down Expand Up @@ -450,9 +450,11 @@
t.datetime "in_person_verification_pending_at"
t.text "encrypted_pii_multi_region"
t.text "encrypted_pii_recovery_multi_region"
t.datetime "gpo_verification_expired_at"
t.index ["fraud_pending_reason"], name: "index_profiles_on_fraud_pending_reason"
t.index ["fraud_rejection_at"], name: "index_profiles_on_fraud_rejection_at"
t.index ["fraud_review_pending_at"], name: "index_profiles_on_fraud_review_pending_at"
t.index ["gpo_verification_expired_at"], name: "index_profiles_on_gpo_verification_expired_at"
t.index ["gpo_verification_pending_at"], name: "index_profiles_on_gpo_verification_pending_at"
t.index ["name_zip_birth_year_signature"], name: "index_profiles_on_name_zip_birth_year_signature"
t.index ["ssn_signature"], name: "index_profiles_on_ssn_signature"
Expand Down Expand Up @@ -621,6 +623,8 @@
t.datetime "updated_at", precision: nil, null: false
t.datetime "bounced_at", precision: nil
t.datetime "reminder_sent_at", precision: nil
t.datetime "expiration_notice_sent_at", precision: nil
t.index ["expiration_notice_sent_at"], name: "index_usps_confirmation_codes_on_expiration_notice_sent_at"
Comment thread
matthinz marked this conversation as resolved.
t.index ["otp_fingerprint"], name: "index_usps_confirmation_codes_on_otp_fingerprint"
t.index ["profile_id"], name: "index_usps_confirmation_codes_on_profile_id"
t.index ["reminder_sent_at"], name: "index_usps_confirmation_codes_on_reminder_sent_at"
Expand Down
1 change: 1 addition & 0 deletions spec/factories/gpo_confirmation_codes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
factory :gpo_confirmation_code do
profile
otp_fingerprint { Pii::Fingerprinter.fingerprint('ABCDE12345') }
code_sent_at { 1.day.ago }
end
end
30 changes: 24 additions & 6 deletions spec/factories/users.rb
Original file line number Diff line number Diff line change
Expand Up @@ -205,12 +205,30 @@
end

trait :with_pending_gpo_profile do
after :build do |user|
profile = create(:profile, :with_pii, gpo_verification_pending_at: 1.day.ago, user: user)
gpo_code = create(:gpo_confirmation_code)
profile.gpo_confirmation_codes << gpo_code
device = create(:device, user: user)
create(:event, user: user, device: device, event_type: :gpo_mail_sent)
transient do
code_sent_at { created_at }
end
Comment thread
matthinz marked this conversation as resolved.

after :create do |user, context|
profile = create(
:profile,
:with_pii,
gpo_verification_pending_at: context.code_sent_at,
user: user,
created_at: context.created_at,
)
create(
:gpo_confirmation_code,
profile: profile,
code_sent_at: context.code_sent_at,
)
create(
:event,
user: user,
device: create(:device, user: user),
event_type: :gpo_mail_sent,
created_at: context.code_sent_at,
)
end
end

Expand Down
168 changes: 168 additions & 0 deletions spec/jobs/gpo_expiration_job_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
require 'rails_helper'

RSpec.describe GpoExpirationJob do
include Rails.application.routes.url_helpers

subject(:job) { described_class.new(analytics: analytics) }

let(:analytics) { FakeAnalytics.new }

let(:usps_confirmation_max_days) { 30 }

let(:gpo_max_profile_age_to_send_letter_in_days) { 30 }

let(:expired_timestamp) { Time.zone.now - usps_confirmation_max_days.days - 1.hour }

let(:not_expired_timestamp) { Time.zone.now - (usps_confirmation_max_days / 2).days }

let!(:user_with_one_expired_gpo_profile) do
create(
:user,
:with_pending_gpo_profile,
created_at: expired_timestamp,
)
end

let!(:user_with_one_unexpired_gpo_profile) do
create(
:user,
:with_pending_gpo_profile,
created_at: not_expired_timestamp,
)
end

let!(:user_with_one_expired_code_and_one_unexpired_code) do
create(
:user,
:with_pending_gpo_profile,
created_at: expired_timestamp,
).tap do |user|
profile = user.gpo_verification_pending_profile
create(:gpo_confirmation_code, profile: profile, code_sent_at: not_expired_timestamp)
end
end

before do
allow(IdentityConfig.store).to receive(:gpo_max_profile_age_to_send_letter_in_days).and_return(
gpo_max_profile_age_to_send_letter_in_days,
)
allow(IdentityConfig.store).to receive(:usps_confirmation_max_days).and_return(
usps_confirmation_max_days,
)
end

describe '#gpo_profiles_that_should_be_expired' do
it 'returns the correct profiles' do
profiles = job.gpo_profiles_that_should_be_expired(as_of: Time.zone.now)

expect(
profiles.map do |profile|
user_fixture_method_for(profile: profile)
end,
).to contain_exactly(
:user_with_one_expired_gpo_profile,
)
end

context 'when users can request letters beyond initial code expiration period' do
let(:gpo_max_profile_age_to_send_letter_in_days) { 45 }

it 'returns profiles for the correct users' do
profiles = job.gpo_profiles_that_should_be_expired(as_of: Time.zone.now)
expect(profiles.count).to eql(0)
end
end
end

describe '#perform' do
it 'expires the profile' do
profile = user_with_one_expired_gpo_profile.reload.gpo_verification_pending_profile
freeze_time do
expect { job.perform }.to change {
profile.reload.gpo_verification_expired_at
}.to eql(Time.zone.now)
end
end

it 'clears gpo_verification_pending_at' do
profile = user_with_one_expired_gpo_profile.reload.gpo_verification_pending_profile
expect { job.perform }.to change { profile.reload.gpo_verification_pending_at }.to eql(nil)
end

it 'logged an analytics event' do
job.perform
expect(analytics).to have_logged_event(
:idv_gpo_expired,
user_id: user_with_one_expired_gpo_profile.uuid,
user_has_active_profile: false,
letters_sent: 1,
)
end

context 'when the user has an active profile' do
let!(:active_profile) do
create(:profile, :active, user: user_with_one_expired_gpo_profile)
end
it 'includes that information in analytics event' do
job.perform

expect(analytics).to have_logged_event(
:idv_gpo_expired,
user_id: user_with_one_expired_gpo_profile.uuid,
user_has_active_profile: true,
letters_sent: 1,
)
end
end

context 'when the user has multiple codes sent' do
let!(:extra_code) do
create(
:gpo_confirmation_code,
profile: user_with_one_expired_gpo_profile.gpo_verification_pending_profile,
code_sent_at: expired_timestamp,
)
end

it 'we note that in the analytics event' do
job.perform

expect(analytics).to have_logged_event(
:idv_gpo_expired,
user_id: user_with_one_expired_gpo_profile.uuid,
user_has_active_profile: false,
letters_sent: 2,
)
end
end

describe 'limit' do
let(:limit) { 3 }
before do
(0..limit).each do
create(
:user,
:with_pending_gpo_profile,
created_at: expired_timestamp,
)
end
end
it 'limits the number of records affected' do
initial_count = Profile.where.not(gpo_verification_pending_at: nil).count

job.perform(limit: limit)

expect(Profile.where.not(gpo_verification_pending_at: nil).count).
to eql(initial_count - limit)
end
end
end

def user_fixture_method_for(profile:)
self.methods.
map(&:to_s).
filter { |method| /^user_with_/.match(method) }.
map(&:to_sym).
find { |user_fixture_method| profile.user == send(user_fixture_method) }
end
Comment thread
matthinz marked this conversation as resolved.
Outdated
end
39 changes: 39 additions & 0 deletions spec/models/profile_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1054,6 +1054,45 @@
end
end

describe '#deactivate_due_to_gpo_expiration' do
let(:profile) { create(:profile, :verify_by_mail_pending, user: user) }

it 'sets gpo_verification_expired_at' do
freeze_time do
expect do
profile.deactivate_due_to_gpo_expiration
end.to change { profile.gpo_verification_expired_at }.to eql(Time.zone.now)
end
end

it 'clears gpo_verification_pending_at' do
expect do
profile.deactivate_due_to_gpo_expiration
end.to change { profile.gpo_verification_pending_at }.to eql(nil)
end

it 'maintains active = false' do
expect do
profile.deactivate_due_to_gpo_expiration
end.not_to change { profile.active }.from(false)
end

it 'does not set a deactivation_reason' do
expect do
profile.deactivate_due_to_gpo_expiration
end.not_to change { profile.deactivation_reason }.from(nil)
end

context 'not pending gpo' do
let(:profile) { create(:profile, user: user) }
it 'raises' do
expect do
profile.deactivate_due_to_gpo_expiration
end.to raise_error
end
end
end

describe '#reject_for_fraud' do
before do
# This is necessary because UserMailer reaches into the
Expand Down