diff --git a/Gemfile b/Gemfile
index cae60fd679..e011f86a46 100644
--- a/Gemfile
+++ b/Gemfile
@@ -155,7 +155,8 @@ gem 'will_paginate'
gem 'chronic'
# Salesforce
-gem 'openstax_salesforce'
+gem 'restforce'
+gem 'openstax_active_force'
# Allows 'ap' alternative to 'pp', used in a mailer
gem 'awesome_print'
diff --git a/Gemfile.lock b/Gemfile.lock
index a8ba590718..3779f9bb22 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -518,10 +518,6 @@ GEM
rails (>= 3.0)
openstax_rescue_from (4.3.0)
rails (>= 3.1, < 7.0)
- openstax_salesforce (8.3.0)
- openstax_active_force
- rails (>= 5.0, < 7.0)
- restforce
openstax_transaction_isolation (2.0.0)
activerecord (>= 6)
openstax_transaction_retry (2.0.0)
@@ -876,11 +872,11 @@ DEPENDENCIES
omniauth-google-oauth2
omniauth-identity
omniauth-twitter
+ openstax_active_force
openstax_api
openstax_healthcheck
openstax_path_prefixer!
openstax_rescue_from
- openstax_salesforce
openstax_transaction_isolation
openstax_transaction_retry
openstax_utilities
@@ -903,6 +899,7 @@ DEPENDENCIES
recaptcha
redis-rails
representable
+ restforce
rexml
rspec-instafail
rspec-rails
diff --git a/app/assets/javascripts/newflow/newflow_ui.js.coffee b/app/assets/javascripts/newflow/newflow_ui.js.coffee
index 9a0b9352cf..421b655947 100644
--- a/app/assets/javascripts/newflow/newflow_ui.js.coffee
+++ b/app/assets/javascripts/newflow/newflow_ui.js.coffee
@@ -31,12 +31,14 @@ NewflowUi = do () ->
@disableButton(targetSelector)
enableOnChecked: (targetSelector, sourceSelector) ->
+ # Run synchronously inside $(document).ready: set the initial disabled
+ # state from the checkbox, then attach the click handler. The previous
+ # version deferred the initial check by 500ms via setTimeout, which
+ # raced with feature specs that `check`-ed the box before the timer
+ # fired (the click handler is attached, but the deferred check then
+ # disabled the button right after the spec saw it as enabled).
$(document).ready =>
-
- enable_disable_continue = () =>
- this.checkCheckedButton(targetSelector, sourceSelector)
-
- setTimeout(enable_disable_continue, 500)
+ this.checkCheckedButton(targetSelector, sourceSelector)
$(sourceSelector).on 'click', =>
this.checkCheckedButton(targetSelector, sourceSelector)
diff --git a/app/controllers/admin/salesforce_drift_findings_controller.rb b/app/controllers/admin/salesforce_drift_findings_controller.rb
new file mode 100644
index 0000000000..59a4ff87fe
--- /dev/null
+++ b/app/controllers/admin/salesforce_drift_findings_controller.rb
@@ -0,0 +1,17 @@
+module Admin
+ class SalesforceDriftFindingsController < BaseController
+ def index
+ scope = SalesforceDriftFinding.open.includes(:user).order(last_seen_at: :desc)
+ scope = scope.where(category: params[:category]) if params[:category].present?
+ scope = scope.where(user_id: params[:user_id]) if params[:user_id].present?
+ @findings = scope.limit(500)
+ @categories = SalesforceDriftFinding.open.distinct.pluck(:category).sort
+ end
+
+ def update
+ finding = SalesforceDriftFinding.find(params[:id])
+ finding.resolve!
+ redirect_to admin_salesforce_drift_findings_path, notice: 'Finding marked resolved.'
+ end
+ end
+end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index fba9e5e0ae..efa3e6e291 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -12,6 +12,18 @@ def js_search
def index; end
+ def edit
+ # SecurityLog#event_type is an integer-backed Rails enum, so we can't LIKE on it.
+ # Resolve the salesforce_* event_type *values* once and filter by IN.
+ salesforce_event_values = SecurityLog.event_types
+ .select { |name, _| name.to_s.start_with?('salesforce_') }
+ .values
+ @salesforce_timeline = SecurityLog
+ .where(user_id: @user.id, event_type: salesforce_event_values)
+ .order(created_at: :asc)
+ .limit(500)
+ end
+
# Used by full console page
def search
security_log :users_searched_by_admin, search: params[:search]
@@ -108,7 +120,7 @@ def change_salesforce_contact
end
begin
- contact = OpenStax::Salesforce::Remote::Contact.find(new_id)
+ contact = Salesforce::Records::Contact.find(new_id)
if contact.present?
# The contact really exists, so save its ID to the User
diff --git a/app/models/salesforce_drift_finding.rb b/app/models/salesforce_drift_finding.rb
new file mode 100644
index 0000000000..75c8ab662d
--- /dev/null
+++ b/app/models/salesforce_drift_finding.rb
@@ -0,0 +1,43 @@
+class SalesforceDriftFinding < ApplicationRecord
+ belongs_to :user, optional: true
+
+ validates :category, presence: true
+ validates :first_seen_at, presence: true
+ validates :last_seen_at, presence: true
+
+ scope :open, -> { where(resolved_at: nil) }
+ scope :resolved, -> { where.not(resolved_at: nil) }
+ scope :for_category, ->(c) { where(category: c) }
+
+ # Upsert an open finding: if a matching open finding already exists, bump
+ # its last_seen_at (and details, when provided); otherwise create one.
+ def self.upsert_finding!(category:, user: nil, record_type: nil, record_id: nil, details: {})
+ existing = open.find_by(
+ user_id: user&.id,
+ category: category,
+ salesforce_record_type: record_type,
+ salesforce_record_id: record_id
+ )
+
+ if existing
+ attrs = { last_seen_at: Time.current }
+ attrs[:details] = details if details.present?
+ existing.update!(attrs)
+ existing
+ else
+ create!(
+ user: user,
+ category: category,
+ salesforce_record_type: record_type,
+ salesforce_record_id: record_id,
+ details: details,
+ first_seen_at: Time.current,
+ last_seen_at: Time.current
+ )
+ end
+ end
+
+ def resolve!
+ update!(resolved_at: Time.current)
+ end
+end
diff --git a/app/models/security_log.rb b/app/models/security_log.rb
index a90127dc0a..27343a3750 100644
--- a/app/models/security_log.rb
+++ b/app/models/security_log.rb
@@ -113,6 +113,29 @@ class SecurityLog < ApplicationRecord
user_already_has_contact_not_creating_lead
creating_new_salesforce_lead
salesforce_lead_save_failed
+ salesforce_lookup_started
+ salesforce_lookup_matched_by_stored_id
+ salesforce_lookup_matched_by_uuid
+ salesforce_lookup_matched_by_email
+ salesforce_lookup_stored_id_disowned
+ salesforce_lookup_email_collision
+ salesforce_upsert_lead_begin
+ salesforce_upsert_lead_saved
+ salesforce_upsert_lead_save_failed
+ salesforce_upsert_lead_skipped_user_has_contact
+ salesforce_lead_id_persist_retry
+ salesforce_lead_id_persist_failed
+ salesforce_stale_contact_id_cleared
+ salesforce_contact_skipped_merged_or_deleted
+ salesforce_contact_id_swapped
+ salesforce_contact_id_conflict
+ salesforce_user_school_not_cached
+ salesforce_reconcile_user_ok
+ salesforce_reconcile_contact_id_cleared
+ salesforce_reconcile_followed_merge
+ salesforce_reconcile_attached_from_conversion
+ salesforce_link_restored_by_reconcile
+ salesforce_contact_id_orphaned
]
json_serialize :event_data, Hash
diff --git a/app/models/user.rb b/app/models/user.rb
index a0c5a13199..668b0d0ff0 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -162,11 +162,11 @@ class User < ApplicationRecord
attribute :is_not_gdpr_location, :boolean, default: nil
def lead
- OpenStax::Salesforce::Remote::Lead.find_by(email: best_email_address_for_salesforce)
+ Salesforce::Records::Lead.find_by(email: best_email_address_for_salesforce)
end
def contact
- OpenStax::Salesforce::Remote::Contact.find(salesforce_contact_id)
+ Salesforce::Records::Contact.find(salesforce_contact_id)
end
def most_accurate_school_name
diff --git a/app/routines/newflow/create_or_update_salesforce_lead.rb b/app/routines/newflow/create_or_update_salesforce_lead.rb
index a276a2c3bc..b38115345d 100644
--- a/app/routines/newflow/create_or_update_salesforce_lead.rb
+++ b/app/routines/newflow/create_or_update_salesforce_lead.rb
@@ -1,259 +1,20 @@
module Newflow
+ # Thin shim around Salesforce::UpsertLead. Kept so existing callers
+ # (EducatorSignup::CompleteProfile, EducatorSignup::SheeridWebhook,
+ # Admin::UsersController#force_update_lead) don't have to change.
+ # See docs/superpowers/specs/2026-05-20-salesforce-sync-design.md.
class CreateOrUpdateSalesforceLead
-
lev_routine active_job_enqueue_options: { queue: :salesforce }
- LEAD_SOURCE = 'Account Creation'
- DEFAULT_REFERRING_APP_NAME = 'Accounts'
-
- ADOPTION_STATUS_FROM_USER = {
- as_primary: 'Confirmed Adoption Won',
- as_recommending: 'Confirmed Will Recommend',
- as_future: 'High Interest in Adopting'
- }.with_indifferent_access.freeze
-
- private_constant(:ADOPTION_STATUS_FROM_USER)
-
- protected #################
+ protected
def exec(user:)
return unless user
-
status.set_job_name(self.class.name)
status.set_job_args(user: user.to_global_id.to_s)
- SecurityLog.create!(
- user: user,
- event_type: :starting_salesforce_lead_creation
- )
-
- sf_school_id = user.school&.salesforce_id
- # no school attached to user? Set to Find Me A Home
- unless sf_school_id
- fallback_school = OpenStax::Salesforce::Remote::School.find_by(name: 'Find Me A Home')
- raise "Salesforce 'Find Me A Home' school not found — cannot assign fallback school for user #{user.id}" unless fallback_school
-
- sf_school_id = fallback_school.id
- user.school = School.find_by(salesforce_id: sf_school_id)
- end
-
- if user.role == 'student'
- sf_role = 'Student'
- else
- sf_role = 'Instructor'
- sf_position = user.role
- end
-
- # as_future means they are interested, not adopting, so no adoptionJSON for them
- if user.using_openstax_how != 'as_future'
- adoption_json = build_book_adoption_json_for_salesforce(user)
- end
-
- # Check the state of the SheerID response and profile completion to determine faculty status for lead
- sheerid_response = SheeridVerification.find_by(verification_id: user.sheerid_verification_id)
- if user.is_profile_complete?
- user.faculty_status = :pending_faculty
- unless sheerid_response.nil?
- user.faculty_status = sheerid_response.current_step_to_faculty_status
- end
- else
- # User has not completed their profile
- user.faculty_status = :incomplete_signup
- end
- user.save!
-
- lead = nil
- if user.salesforce_lead_id
- begin
- lead = OpenStax::Salesforce::Remote::Lead.find(user.salesforce_lead_id)
- rescue StandardError => e
- # Log when the stored lead ID doesn't correspond to an existing lead or find fails
- SecurityLog.create!(
- user: user,
- event_type: :salesforce_lead_not_found_by_id,
- event_data: {
- salesforce_lead_id: user.salesforce_lead_id,
- error: e.class.name,
- error_message: e.message
- }
- )
- Sentry.capture_message(
- "Salesforce lead ID #{user.salesforce_lead_id} not found for user #{user.id}, will search by UUID and email. Error: #{e.class.name}: #{e.message}"
- )
- end
- end
-
- # If no lead found by stored ID, search for existing lead by UUID
- if lead.nil?
- lead = OpenStax::Salesforce::Remote::Lead.find_by(accounts_uuid: user.uuid)
- if lead
- SecurityLog.create!(
- user: user,
- event_type: :salesforce_lead_found_by_uuid,
- event_data: { lead_id: lead.id }
- )
- end
- end
-
- # If still no lead found, search by email
- if lead.nil?
- lead = OpenStax::Salesforce::Remote::Lead.find_by(email: user.best_email_address_for_salesforce)
- if lead
- SecurityLog.create!(
- user: user,
- event_type: :salesforce_lead_found_by_email,
- event_data: { lead_id: lead.id, email: user.best_email_address_for_salesforce }
- )
- end
- end
-
- # If user has a contact (already converted from lead), don't create a new lead
- if lead.nil? && user.salesforce_contact_id.present?
- begin
- contact = OpenStax::Salesforce::Remote::Contact.find(user.salesforce_contact_id)
- if contact
- SecurityLog.create!(
- user: user,
- event_type: :user_already_has_contact_not_creating_lead,
- event_data: { contact_id: user.salesforce_contact_id }
- )
- # User already has a contact, return without creating a lead
- outputs.lead = nil
- outputs.user = user
- return
- end
- rescue StandardError => e
- # Contact not found, proceed with lead creation
- Sentry.capture_message(
- "Salesforce contact ID #{user.salesforce_contact_id} not found for user #{user.id}, will create lead. Error: #{e.class.name}: #{e.message}"
- )
- end
- end
-
- # Only create a new lead if none exists
- if lead.nil?
- lead = OpenStax::Salesforce::Remote::Lead.new(email: user.best_email_address_for_salesforce)
- SecurityLog.create!(
- user: user,
- event_type: :creating_new_salesforce_lead,
- event_data: { email: user.best_email_address_for_salesforce, uuid: user.uuid }
- )
- end
-
- lead.first_name = user.first_name
- lead.last_name = user.last_name
- lead.phone = user.phone_number
- lead.source = LEAD_SOURCE
- lead.application_source = DEFAULT_REFERRING_APP_NAME
- lead.role = sf_role
- lead.position = sf_position
- lead.title = user.other_role_name
- lead.who_chooses_books = user.who_chooses_books
- lead.subject_interest = user.which_books
- lead.num_students = user.how_many_students
- lead.adoption_status = ADOPTION_STATUS_FROM_USER[user.using_openstax_how]
- lead.expected_start_semester = expected_start_semester_label_for(user.expected_start_semester)
- lead.adoption_json = adoption_json
- lead.os_accounts_id = user.id
- lead.accounts_uuid = user.uuid
- lead.school = user.most_accurate_school_name
- lead.city = user.most_accurate_school_city
- lead.country = user.most_accurate_school_country
- lead.verification_status = user.faculty_status == User::NO_FACULTY_INFO ? nil : user.faculty_status
- lead.b_r_i_marketing = user.is_b_r_i_user?
- lead.title_1_school = user.title_1_school?
- lead.newsletter = user.receive_newsletter?
- lead.newsletter_opt_in = user.receive_newsletter?
- lead.self_reported_school = user.self_reported_school
- lead.sheerid_school_name = user.sheerid_reported_school
- lead.account_id = sf_school_id
- lead.school_id = sf_school_id
- lead.signup_date = user.created_at.strftime("%Y-%m-%dT%T.%L%z")
- lead.tracking_parameters = "#{Rails.application.secrets.openstax_url}/accounts/i/signup/"
-
- state = user.most_accurate_school_state
- unless state.blank?
- state = nil unless US_STATES.map(&:downcase).include? state.downcase
- end
- unless state.nil?
- # Figure out if the State is an abbreviation or the full name
- if state == state.upcase
- lead.state_code = state
- else
- lead.state = state
- end
- end
-
- SecurityLog.create!(
- user: user,
- event_type: :attempting_to_create_user_lead,
- event_data: { lead_data: lead }
- )
-
- if lead.save
- user.salesforce_lead_id = lead.id
- if user.save
- SecurityLog.create!(
- user: user,
- event_type: :created_salesforce_lead,
- event_data: { lead_id: lead.id.to_s }
- )
- else
- SecurityLog.create!(
- user: user,
- event_type: :educator_sign_up_failed,
- event_data: { lead_id: lead.id, user_errors: user.errors.full_messages }
- )
- Sentry.capture_message("User #{user.id} was not successfully saved with lead #{lead.id}: #{user.errors.full_messages.join(', ')}")
- end
- else
- if lead.errors&.full_messages.to_s.include?('INSUFFICIENT_ACCESS_ON_CROSS_REFERENCE_ENTITY')
- Sentry.capture_message("Invalid school (#{user.school&.salesforce_id}) for user (#{user.id})")
- end
- SecurityLog.create!(
- user: user,
- event_type: :salesforce_lead_save_failed,
- event_data: { lead_errors: lead.errors&.full_messages, email: user.best_email_address_for_salesforce }
- )
- Sentry.capture_message("Salesforce lead save failed for user #{user.id}: #{lead.errors&.full_messages&.join(', ')}")
- end
-
- outputs.lead = lead
+ Salesforce::UpsertLead.call(user: user)
outputs.user = user
end
-
- def expected_start_semester_label_for(key)
- return nil if key.blank?
- I18n.t(:'educator_profile_form.expected_start_semester_options')[key.to_sym]
- end
-
- def build_book_adoption_json_for_salesforce(user)
- adoption_json = {}
- books_json = []
- return nil unless user.books_used_details
-
- user.books_used_details.each do |book|
- book_value = book[0]
- if book_value.match(/\[.*\]/)
- book_name = book_value.gsub(/\[.*\]/, '').strip # Calculus Volume 1
- book_language = book_value[/\[(.*?)\]/, 1] # Spanish (no brackets)
- books_json << {
- name: book_name,
- students: book[1]["num_students_using_book"],
- howUsing: book[1]["how_using_book"],
- language: book_language,
- }
- else
- books_json << {
- name: book_value,
- students: book[1]["num_students_using_book"],
- howUsing: book[1]["how_using_book"]
- }
- end
- end
-
- adoption_json['Books'] = books_json
- adoption_json.to_json
- end
end
end
diff --git a/app/routines/update_salesforce_assignable_fields.rb b/app/routines/update_salesforce_assignable_fields.rb
index 3261560fe1..482d8e5eda 100644
--- a/app/routines/update_salesforce_assignable_fields.rb
+++ b/app/routines/update_salesforce_assignable_fields.rb
@@ -16,7 +16,7 @@ def call(created_after)
contact_id = external_id.user.salesforce_contact_id
next if contact_id.nil?
- contact = OpenStax::Salesforce::Remote::Contact.find(contact_id)
+ contact = Salesforce::Records::Contact.find(contact_id)
next if contact.nil?
contact.assignable_interest = 'Fully Integrated'
diff --git a/app/routines/update_school_salesforce_info.rb b/app/routines/update_school_salesforce_info.rb
index b7ef626ed6..eeaee4dd18 100644
--- a/app/routines/update_school_salesforce_info.rb
+++ b/app/routines/update_school_salesforce_info.rb
@@ -1,87 +1,11 @@
+# Thin shim around Salesforce::SyncSchools. Kept so cron entries and any
+# rake/console callers continue to work without changes.
class UpdateSchoolSalesforceInfo
- BATCH_SIZE = 250
-
- SF_TO_DB_CACHE_COLUMNS_MAP = {
- id: :salesforce_id,
- name: :name,
- city: :city,
- state: :state,
- country: :country,
- type: :type,
- school_location: :location,
- is_kip: :is_kip,
- is_child_of_kip: :is_child_of_kip,
- sheerid_school_name: :sheerid_school_name,
- has_assignable_contacts: :has_assignable_contacts
- }
+ # Re-export constants for existing specs/code that referenced them.
+ BATCH_SIZE = Salesforce::SyncSchools::BATCH_SIZE
+ SF_TO_DB_CACHE_COLUMNS_MAP = Salesforce::SyncSchools::SF_TO_DB_CACHE_COLUMNS_MAP
def self.call
- new.call
- end
-
- def log(message, level = :info)
- Rails.logger.tagged(self.class.name) { Rails.logger.public_send level, message }
- end
-
- def call
- check_in_id = Sentry.capture_check_in('update-school-salesforce', :in_progress)
- log('Starting UpdateSchoolSalesforceInfo')
-
- # Check if any Schools that have 0 users have been deleted from Salesforce and remove them
- School.where(
- 'NOT EXISTS (SELECT * FROM "users" WHERE "users"."school_id" = "schools"."id")'
- ).find_in_batches(batch_size: BATCH_SIZE) do |schools|
- salesforce_ids = schools.map(&:salesforce_id)
-
- existing_salesforce_ids = OpenStax::Salesforce::Remote::School.select(:id).where(
- id: salesforce_ids
- ).map(&:id)
-
- deleted_salesforce_ids = salesforce_ids - existing_salesforce_ids
-
- School.where(salesforce_id: deleted_salesforce_ids).delete_all
- end
-
- # Go through all SF Schools and cache their information, if it changed
- schools_updated = 0
- last_id = nil
- loop do
- sf_schools = OpenStax::Salesforce::Remote::School.order(:Id).limit(BATCH_SIZE)
-
- sf_schools = sf_schools.where("Id > '#{last_id}'") unless last_id.nil?
- sf_schools = sf_schools.to_a
- last_id = sf_schools.last&.id
-
- begin
- schools_by_sf_id = School.where(
- salesforce_id: sf_schools.map(&:id)
- ).index_by(&:salesforce_id)
-
- schools = sf_schools.map do |sf_school|
- school = schools_by_sf_id[sf_school.id]
- schools_updated += 1 if school.nil?
- school = School.new(salesforce_id: sf_school.id) if school.nil?
-
- SF_TO_DB_CACHE_COLUMNS_MAP.each do |sf_column, db_column|
- school.public_send "#{db_column}=", sf_school.public_send(sf_column)
- end
-
- school.changed? ? school : nil
- end.compact
-
- School.import(
- schools, validate: false, on_duplicate_key_update: {
- conflict_target: [ :salesforce_id ], columns: SF_TO_DB_CACHE_COLUMNS_MAP.values
- }
- ) unless schools.empty?
- rescue StandardError => se
- Sentry.capture_exception se
- end
-
- break if sf_schools.length < BATCH_SIZE
- end
-
- log("Finished updating #{schools_updated} schools")
- Sentry.capture_check_in('update-school-salesforce', :ok, check_in_id: check_in_id)
+ Salesforce::SyncSchools.call
end
end
diff --git a/app/routines/update_user_contact_info.rb b/app/routines/update_user_contact_info.rb
index 79b3b4d01f..7621a63cd3 100644
--- a/app/routines/update_user_contact_info.rb
+++ b/app/routines/update_user_contact_info.rb
@@ -1,177 +1,12 @@
+# Thin shim around Salesforce::SyncContacts. Kept so cron entries and any
+# rake/console callers continue to work without changes.
+# See docs/superpowers/specs/2026-05-20-salesforce-sync-design.md.
class UpdateUserContactInfo
- class UnknownFacultyVerifiedError < StandardError; end
-
- COLLEGE_TYPES = [
- 'College/University (4)',
- 'Technical/Community College (2)',
- 'Career School/For-Profit (2)'
- ]
- HIGH_SCHOOL_TYPES = [ 'High School' ]
- K12_TYPES = [ 'K-12 School' ]
- HOME_SCHOOL_TYPES = [ 'Home School' ]
-
- DOMESTIC_SCHOOL_LOCATIONS = [ 'Domestic' ]
- FOREIGN_SCHOOL_LOCATIONS = [ 'Foreign' ]
-
- ADOPTION_STATUSES = {
- "Current Adopter" => true,
- "Future Adopter" => true,
- "Past Adopter" => true,
- "Not Adopter" => false
- }
-
def self.call
- new.call
+ Salesforce::SyncContacts.call
end
- def call
- check_in_id = Sentry.capture_check_in('update-user-contact-info', :in_progress)
- log("Starting sync with Salesforce")
- contacts = salesforce_contacts
- log("#{contacts.count} contacts fetched from Salesforce")
- contacts_by_uuid = contacts_by_uuid_hash(contacts)
-
- users ||= User.where(uuid: contacts.map(&:accounts_uuid))
- schools_by_salesforce_id = School.select(:id, :salesforce_id).where(
- salesforce_id: contacts_by_uuid.values.compact.map(&:school_id)
- ).index_by(&:salesforce_id)
-
-
- # loop through users - we keep some counts for logging out
- users_updated = 0
- users_fv_status_changed = 0
- users_without_cached_school = 0
- log("Updating #{users.count} users from Salesforce")
-
- users.each do |user|
- sf_contact = contacts_by_uuid[user.uuid]
- school = schools_by_salesforce_id[sf_contact.school_id]
-
- previous_contact_id = user.salesforce_contact_id
- user.salesforce_contact_id = sf_contact.id
-
- if sf_contact.id != previous_contact_id
- SecurityLog.create!(
- user: user,
- event_type: :user_contact_id_updated_from_salesforce,
- event_data: { previous_contact_id: previous_contact_id, new_contact_id: sf_contact.id }
- )
- end
-
- old_fv_status = user.faculty_status
- # Map Salesforce faculty_verified values to our string-based enum values
- # nil maps to no_faculty_info; unknown values raise an error
- faculty_verified = sf_contact.faculty_verified
- new_status = if faculty_verified.nil?
- "no_faculty_info"
- elsif User::VALID_FACULTY_STATUSES.include?(faculty_verified)
- faculty_verified
- else
- message = "Unknown faculty_verified field: '#{faculty_verified}' on contact #{sf_contact.id}"
- Sentry.capture_message(message)
- raise UnknownFacultyVerifiedError, message
- end
-
- # Don't overwrite confirmed or pending faculty status with incomplete/no_info
- # Don't overwrite confirmed with pending
- # Don't overwrite rejected_faculty with incomplete/no_info
- should_update_status = true
- if user.faculty_status == "confirmed_faculty" &&
- ["pending_faculty", "incomplete_signup", "no_faculty_info"].include?(new_status)
- should_update_status = false
- elsif user.faculty_status == "pending_faculty" &&
- ["incomplete_signup", "no_faculty_info"].include?(new_status)
- should_update_status = false
- elsif user.faculty_status == "rejected_faculty" &&
- ["incomplete_signup", "no_faculty_info"].include?(new_status)
- should_update_status = false
- end
-
- user.faculty_status = new_status if should_update_status
-
- if user.faculty_status_changed?
- users_fv_status_changed += 1
- SecurityLog.create!(
- user: user,
- event_type: :salesforce_updated_faculty_status,
- event_data: { user_id: user.id, salesforce_contact_id: sf_contact.id, old_status: old_fv_status, new_status: user.faculty_status }
- )
- end
-
- user.school_type = case sf_contact.school_type
- when *COLLEGE_TYPES
- :college
- when *HIGH_SCHOOL_TYPES
- :high_school
- when *K12_TYPES
- :k12_school
- when *HOME_SCHOOL_TYPES
- :home_school
- when NilClass
- :unknown_school_type
- else
- :other_school_type
- end
-
- sf_school = sf_contact.school
- user.school_location = case sf_school&.school_location
- when *DOMESTIC_SCHOOL_LOCATIONS
- :domestic_school
- when *FOREIGN_SCHOOL_LOCATIONS
- :foreign_school
- else
- :unknown_school_location
- end
-
- # TODO: This can be removed once OSWeb is migated to using the new adopter_status field for renewal forms
- unless sf_contact.adoption_status.blank?
- user.using_openstax = ADOPTION_STATUSES[sf_contact.adoption_status]
- end
-
- user.adopter_status = sf_contact.adoption_status
- user.is_kip = sf_school&.is_kip || sf_school&.is_child_of_kip
-
- if school.nil? && !sf_school.nil?
- users_without_cached_school += 1
- Sentry.capture_message("User #{user.id} has a school that is in SF but not cached yet #{sf_school.id}")
- else
- user.school = school
- end
-
- user.save! && users_updated += 1 if user.changed?
- end
- log("Completed updating #{users_updated} users.")
- log("#{users_fv_status_changed} users had their faculty status updated.")
- log("#{users_without_cached_school} users had no cached school in accounts. This should update on the next sync (after UpdateSchoolSalesforceInfo runs) or it is missing in Salesforce.")
- Sentry.capture_check_in('update-user-contact-info', :ok, check_in_id: check_in_id)
- end
-
- def salesforce_contacts
- contact_days = Settings::Db.store.number_of_days_contacts_modified ||= 1
- c_date = contact_days.to_i.day.ago.strftime("%Y-%m-%d")
- contacts ||= OpenStax::Salesforce::Remote::Contact.select(
- :id,
- :email,
- :faculty_verified,
- :school_type,
- :adoption_status,
- :accounts_uuid
- )
- .where("Accounts_UUID__c != null")
- .where("LastModifiedDate >= #{DateTime.strptime(c_date,"%Y-%m-%d").utc.iso8601}")
- .includes(:school)
- .to_a
- end
-
- def contacts_by_uuid_hash(contacts)
- contacts_by_uuid = {}
- contacts.each do |contact|
- contacts_by_uuid[contact.accounts_uuid] = contact
- end
- contacts_by_uuid
- end
-
- def log(message, level = :info)
- Rails.logger.tagged(self.class.name) { Rails.logger.public_send level, message }
- end
+ # Backwards compatibility for tests/callers that referenced the old class's
+ # exception.
+ UnknownFacultyVerifiedError = Salesforce::ResolveFacultyStatus::UnknownFacultyVerifiedError
end
diff --git a/app/routines/update_user_lead_info.rb b/app/routines/update_user_lead_info.rb
deleted file mode 100644
index b0f4e7b33a..0000000000
--- a/app/routines/update_user_lead_info.rb
+++ /dev/null
@@ -1,75 +0,0 @@
-class UpdateUserLeadInfo
-
- def self.call
- new.call
- end
- def call
- # TODO: we do want to limit this, but we need to update all the leads and schedule this first
- # we are only using this to check users created in the last month
- # start_date = Time.zone.now - 1.day
- # end_date = Time.zone.now - 30.day
- # for the query below when this is re-added
- # .where("created_at <= ? AND created_at >= ?", start_date, end_date)
-
- users = User.where(salesforce_contact_id: nil)
- .where.not(salesforce_lead_id: nil, role: :student, faculty_status: :rejected_faculty)
-
- leads = OpenStax::Salesforce::Remote::Lead.select(:id, :accounts_uuid, :verification_status)
- .where(accounts_uuid: users.map(&:uuid))
- .to_a
- .index_by(&:accounts_uuid)
-
- users.map do |user|
- lead = leads[user.uuid]
-
- unless lead.nil?
- previous_lead_id = user.salesforce_lead_id
- user.salesforce_lead_id = lead.id # it might change in SF lead merging
-
- if lead.id != previous_lead_id
- SecurityLog.create!(
- user: user,
- event_type: :user_lead_id_updated_from_salesforce,
- event_data: { previous_lead_id: previous_lead_id, new_lead_id: lead.id }
- )
- end
-
- old_fv_status = user.faculty_status
- user.faculty_status = case lead.verification_status
- when "confirmed_faculty"
- :confirmed_faculty
- when "pending_faculty"
- :pending_faculty
- when "rejected_faculty"
- :rejected_faculty
- when "rejected_by_sheerid"
- :rejected_by_sheerid
- when "incomplete_signup"
- :incomplete_signup
- when "no_faculty_info"
- :no_faculty_info
- when NilClass
- :no_faculty_info
- else
- Sentry.capture_message("Unknown faculty_verified field: '#{
- lead.verification_status}'' on lead #{lead.id}")
- end
-
- if user.faculty_status_changed?
- SecurityLog.create!(
- user: user,
- event_type: :salesforce_updated_faculty_status,
- event_data: {
- user_id: user.id,
- salesforce_lead_id: lead.id,
- old_status: old_fv_status,
- new_status: user.faculty_status
- }
- )
- end
-
- user.save if user.changed?
- end
- end
- end
-end
diff --git a/app/services/salesforce.rb b/app/services/salesforce.rb
new file mode 100644
index 0000000000..ed80a322e3
--- /dev/null
+++ b/app/services/salesforce.rb
@@ -0,0 +1,36 @@
+module Salesforce
+ class IllegalState < StandardError; end
+
+ def self.configure
+ yield configuration
+ end
+
+ def self.configuration
+ @configuration ||= Configuration.new
+ end
+
+ def self.reset_configuration!
+ @configuration = nil
+ end
+
+ class Configuration
+ attr_writer :api_version, :login_domain
+ attr_accessor :username, :password, :security_token, :consumer_key, :consumer_secret
+
+ def api_version
+ @api_version ||= '66.0'
+ end
+
+ def login_domain
+ @login_domain ||= 'test.salesforce.com'
+ end
+
+ def validate!
+ raise IllegalState, 'The Salesforce username is missing' if username.nil?
+ raise IllegalState, 'The Salesforce password is missing' if password.nil?
+ raise IllegalState, 'The Salesforce security token is missing' if security_token.nil?
+ raise IllegalState, 'The Salesforce consumer key is missing' if consumer_key.nil?
+ raise IllegalState, 'The Salesforce consumer secret is missing' if consumer_secret.nil?
+ end
+ end
+end
diff --git a/app/services/salesforce/audit.rb b/app/services/salesforce/audit.rb
new file mode 100644
index 0000000000..e2ccae98fa
--- /dev/null
+++ b/app/services/salesforce/audit.rb
@@ -0,0 +1,13 @@
+module Salesforce
+ module Audit
+ EVENT_PREFIX = 'salesforce_'.freeze
+
+ def self.record(user, event_name, **details)
+ full = "#{EVENT_PREFIX}#{event_name}"
+ unless SecurityLog.event_types.key?(full)
+ raise ArgumentError, "Unknown Salesforce audit event: #{full.inspect}. Add it to SecurityLog#event_type."
+ end
+ SecurityLog.create!(user: user, event_type: full, event_data: details)
+ end
+ end
+end
diff --git a/app/services/salesforce/build_lead.rb b/app/services/salesforce/build_lead.rb
new file mode 100644
index 0000000000..af73a716fe
--- /dev/null
+++ b/app/services/salesforce/build_lead.rb
@@ -0,0 +1,107 @@
+module Salesforce
+ # Pure mapping from a `User` to a `Salesforce::Records::Lead`'s attributes.
+ # Side-effect-free (does not save). Always sets `accounts_uuid` so retries
+ # of `UpsertLead` can find a just-created Lead via the UUID branch of
+ # `Salesforce::Lookup`, keeping job retries idempotent.
+ module BuildLead
+ LEAD_SOURCE = 'Account Creation'.freeze
+ DEFAULT_REFERRING_APP_NAME = 'Accounts'.freeze
+
+ ADOPTION_STATUS_FROM_USER = {
+ 'as_primary' => 'Confirmed Adoption Won',
+ 'as_recommending' => 'Confirmed Will Recommend',
+ 'as_future' => 'High Interest in Adopting'
+ }.freeze
+
+ module_function
+
+ def apply(lead, user)
+ sf_school_id = user.school&.salesforce_id
+
+ if user.role == 'student'
+ sf_role = 'Student'
+ sf_position = nil
+ else
+ sf_role = 'Instructor'
+ sf_position = user.role
+ end
+
+ adoption_json = user.using_openstax_how == 'as_future' ? nil : build_adoption_json(user)
+
+ lead.first_name = user.first_name
+ lead.last_name = user.last_name
+ lead.phone = user.phone_number
+ lead.source = LEAD_SOURCE
+ lead.application_source = DEFAULT_REFERRING_APP_NAME
+ lead.role = sf_role
+ lead.position = sf_position
+ lead.title = user.other_role_name
+ lead.who_chooses_books = user.who_chooses_books
+ lead.subject_interest = user.which_books
+ lead.num_students = user.how_many_students
+ lead.adoption_status = ADOPTION_STATUS_FROM_USER[user.using_openstax_how]
+ lead.expected_start_semester = expected_start_semester_label_for(user.expected_start_semester)
+ lead.adoption_json = adoption_json
+ lead.os_accounts_id = user.id
+ lead.accounts_uuid = user.uuid
+ lead.school = user.most_accurate_school_name
+ lead.city = user.most_accurate_school_city
+ lead.country = user.most_accurate_school_country
+ lead.verification_status = user.faculty_status == User::NO_FACULTY_INFO ? nil : user.faculty_status
+ lead.b_r_i_marketing = user.is_b_r_i_user?
+ lead.title_1_school = user.title_1_school?
+ lead.newsletter = user.receive_newsletter?
+ lead.newsletter_opt_in = user.receive_newsletter?
+ lead.self_reported_school = user.self_reported_school
+ lead.sheerid_school_name = user.sheerid_reported_school
+ lead.account_id = sf_school_id
+ lead.school_id = sf_school_id
+ lead.signup_date = user.created_at.strftime('%Y-%m-%dT%T.%L%z')
+ lead.tracking_parameters = "#{Rails.application.secrets.openstax_url}/accounts/i/signup/"
+
+ assign_state(lead, user.most_accurate_school_state)
+
+ lead
+ end
+
+ def assign_state(lead, raw_state)
+ return if raw_state.blank?
+ state = raw_state
+ state = nil unless US_STATES.map(&:downcase).include?(state.downcase)
+ return if state.nil?
+ if state == state.upcase
+ lead.state_code = state
+ else
+ lead.state = state
+ end
+ end
+
+ def expected_start_semester_label_for(key)
+ return nil if key.blank?
+ I18n.t(:'educator_profile_form.expected_start_semester_options')[key.to_sym]
+ end
+
+ def build_adoption_json(user)
+ return nil unless user.books_used_details
+
+ books_json = user.books_used_details.map do |book_value, details|
+ if book_value.match(/\[.*\]/)
+ {
+ name: book_value.gsub(/\[.*\]/, '').strip,
+ students: details['num_students_using_book'],
+ howUsing: details['how_using_book'],
+ language: book_value[/\[(.*?)\]/, 1]
+ }
+ else
+ {
+ name: book_value,
+ students: details['num_students_using_book'],
+ howUsing: details['how_using_book']
+ }
+ end
+ end
+
+ { 'Books' => books_json }.to_json
+ end
+ end
+end
diff --git a/app/services/salesforce/client.rb b/app/services/salesforce/client.rb
new file mode 100644
index 0000000000..a0420fd360
--- /dev/null
+++ b/app/services/salesforce/client.rb
@@ -0,0 +1,20 @@
+require 'restforce'
+
+module Salesforce
+ class Client < ::Restforce::Data::Client
+ def initialize
+ configuration = Salesforce.configuration
+ configuration.validate!
+
+ super(
+ username: configuration.username,
+ password: configuration.password,
+ security_token: configuration.security_token,
+ client_id: configuration.consumer_key,
+ client_secret: configuration.consumer_secret,
+ api_version: configuration.api_version,
+ host: configuration.login_domain
+ )
+ end
+ end
+end
diff --git a/app/services/salesforce/lookup.rb b/app/services/salesforce/lookup.rb
new file mode 100644
index 0000000000..2a97e3755e
--- /dev/null
+++ b/app/services/salesforce/lookup.rb
@@ -0,0 +1,78 @@
+module Salesforce
+ module Lookup
+ Result = Struct.new(:lead, :contact, :matched_by, :rejected, keyword_init: true) do
+ def initialize(**kwargs)
+ kwargs[:rejected] ||= []
+ super(**kwargs)
+ end
+ end
+
+ module_function
+
+ # Resolve a Salesforce::Records::Lead for a user. Tries the stored
+ # salesforce_lead_id first (best), then accounts_uuid, then email (with a
+ # UUID-collision guard). Returns a Result; result.lead may be nil.
+ def lead_for(user)
+ result = Result.new
+
+ if user.salesforce_lead_id.present?
+ lead = safe_find(Salesforce::Records::Lead, user.salesforce_lead_id)
+ if lead.nil?
+ result.rejected << :stored_id_not_found
+ elsif Verify.lead_owns_user?(lead, user)
+ result.lead = lead
+ result.matched_by = :stored_id
+ Audit.record(user, :lookup_matched_by_stored_id, lead_id: lead.id)
+ return result
+ else
+ result.rejected << :stored_id_owned_by_other_user
+ Audit.record(user, :lookup_stored_id_disowned, lead_id: lead.id)
+ end
+ end
+
+ lead = Salesforce::Records::Lead.find_by({ accounts_uuid: user.uuid })
+ if lead
+ result.lead = lead
+ result.matched_by = :uuid
+ Audit.record(user, :lookup_matched_by_uuid, lead_id: lead.id)
+ return result
+ end
+
+ email = user.best_email_address_for_salesforce
+ if email.present?
+ lead = Salesforce::Records::Lead.find_by({ email: email })
+ if lead && Verify.lead_owns_user?(lead, user)
+ result.lead = lead
+ result.matched_by = :email
+ Audit.record(user, :lookup_matched_by_email, lead_id: lead.id, email: email)
+ return result
+ elsif lead
+ result.rejected << :email_match_uuid_conflict
+ Audit.record(user, :lookup_email_collision, lead_id: lead.id, email: email)
+ end
+ end
+
+ result
+ end
+
+ # Resolve a verified Contact for a user, or nil. Stored
+ # salesforce_contact_id is the primary signal; falls back to UUID.
+ def contact_for(user)
+ if user.salesforce_contact_id.present?
+ contact = safe_find(Salesforce::Records::Contact, user.salesforce_contact_id)
+ return contact if Verify.contact_owns_user?(contact, user)
+ end
+
+ return nil if user.uuid.blank?
+
+ candidate = Salesforce::Records::Contact.find_by({ accounts_uuid: user.uuid })
+ Verify.contact_owns_user?(candidate, user) ? candidate : nil
+ end
+
+ def safe_find(klass, id)
+ klass.find(id)
+ rescue StandardError
+ nil
+ end
+ end
+end
diff --git a/app/services/salesforce/metrics.rb b/app/services/salesforce/metrics.rb
new file mode 100644
index 0000000000..44dfc513a0
--- /dev/null
+++ b/app/services/salesforce/metrics.rb
@@ -0,0 +1,63 @@
+module Salesforce
+ # Per-run counter bag for the Salesforce sync routines.
+ #
+ # Routines instantiate one Metrics, increment counters during the run, and
+ # call #emit at the end. emit writes a Sentry check-in (when a slug is
+ # configured) plus a single JSON log line tagged with the run name. #alert!
+ # produces a tagged Sentry message for threshold-based alerts so existing
+ # tag-based alert rules can subscribe to the `salesforce-alert` tag.
+ class Metrics
+ attr_reader :counters, :run, :slug, :started_at
+
+ def initialize(run:, slug: nil)
+ @run = run
+ @slug = slug
+ @counters = {}
+ @started_at = Time.current
+ @check_in_id = nil
+ end
+
+ # Mark a Sentry check-in as in_progress so the eventual #emit can close it.
+ def start!
+ return unless slug
+ @check_in_id = Sentry.capture_check_in(slug, :in_progress)
+ end
+
+ # Increment a counter. With no labels, stores an integer.
+ # With keyword labels, stores a hash with :total plus a per-label tally.
+ def increment(key, by: 1, **labels)
+ if labels.empty?
+ current = @counters[key]
+ @counters[key] = (current.is_a?(Integer) ? current : 0) + by
+ else
+ existing = @counters[key].is_a?(Hash) ? @counters[key] : { total: 0 }
+ existing[:total] = (existing[:total] || 0) + by
+ labels.each_value do |label_value|
+ existing[label_value] = (existing[label_value] || 0) + by
+ end
+ @counters[key] = existing
+ end
+ end
+
+ def emit(status: :ok, extra: {})
+ payload = {
+ run: run,
+ status: status,
+ duration_s: (Time.current - started_at).to_i,
+ counters: counters,
+ **extra
+ }
+ Rails.logger.tagged('salesforce', run) { Rails.logger.info(payload.to_json) }
+ Sentry.capture_check_in(slug, status, check_in_id: @check_in_id) if slug
+ payload
+ end
+
+ def alert!(name, value:, threshold:)
+ Sentry.capture_message(
+ "Salesforce alert: #{name} (value=#{value}, threshold=#{threshold})",
+ tags: { 'salesforce-alert' => name.to_s },
+ extra: { value: value, threshold: threshold, run: run }
+ )
+ end
+ end
+end
diff --git a/app/services/salesforce/reconcile.rb b/app/services/salesforce/reconcile.rb
new file mode 100644
index 0000000000..d7f1710e90
--- /dev/null
+++ b/app/services/salesforce/reconcile.rb
@@ -0,0 +1,317 @@
+module Salesforce
+ # Nightly two-way drift detection + Accounts-side self-heal.
+ #
+ # Pass 1: anchor on users with stored salesforce_contact_id; verify the
+ # SF Contact is alive and owns this user. Heal merges/deletes/
+ # disowned records; open drift findings for everything we can't
+ # resolve on the Accounts side.
+ # Pass 2: anchor on users with stored salesforce_lead_id (no contact).
+ # Attach a Contact when the Lead has been converted; clear and
+ # re-resolve when the Lead is missing or disowned.
+ # Pass 3: discover missing links for profile-complete instructors with
+ # no stored ids, by looking them up in SF by accounts_uuid.
+ # SF-orphan sweep: find SF Leads/Contacts whose accounts_uuid we don't
+ # recognize (last 90 days of LastModifiedDate); open findings.
+ # Finalize: close findings not refreshed during this run; prune ancient
+ # resolved findings; fire the drift_findings_total_open alert.
+ #
+ # Self-heal writes are gated by Settings::FeatureFlags
+ # .salesforce_reconcile_self_heal (default false on first deploy).
+ class Reconcile
+ SLUG = 'reconcile-salesforce'.freeze
+ BATCH_SIZE = 500
+ SWEEP_LOOKBACK = 90.days
+
+ def self.call
+ new.call
+ end
+
+ def initialize
+ @metrics = Metrics.new(run: 'reconcile', slug: SLUG)
+ @self_heal = Settings::FeatureFlags.salesforce_reconcile_self_heal
+ @queries = 0
+ @max_queries = Settings::Salesforce.reconcile_max_queries
+ end
+
+ def call
+ @metrics.start!
+ log "Reconcile starting (self_heal=#{@self_heal})"
+ run_pass_1
+ run_pass_2
+ run_pass_3
+ sweep_sf_orphans
+ finalize_findings
+ @metrics.emit(status: :ok)
+ rescue StandardError => e
+ Sentry.capture_exception(e)
+ @metrics.emit(status: :error, extra: { error: e.class.name })
+ raise
+ end
+
+ # ----- Pass 1: contact-anchored ----- #
+
+ def run_pass_1
+ User.where.not(salesforce_contact_id: nil).find_in_batches(batch_size: BATCH_SIZE) do |users|
+ break if budget_exceeded?
+ contact_ids = users.map(&:salesforce_contact_id).uniq
+ contacts = fetch_contacts_by_id(contact_ids)
+ users.each { |u| reconcile_user_by_stored_contact(u, contacts[u.salesforce_contact_id]) }
+ @metrics.increment(:users_pass_1, by: users.size)
+ end
+ end
+
+ def fetch_contacts_by_id(ids)
+ return {} if ids.empty?
+ @queries += 1
+ Salesforce::Records::Contact
+ .select(:id, :accounts_uuid, :master_record_id, :is_deleted)
+ .where(id: ids).index_by(&:id)
+ end
+
+ def reconcile_user_by_stored_contact(user, sf_contact)
+ if sf_contact.nil?
+ finding(user, 'sf_contact_uuid_mismatch', 'Contact', user.salesforce_contact_id, details: { reason: 'missing_in_sf' })
+ heal_clear_contact_id(user)
+ return
+ end
+
+ if sf_contact.is_deleted
+ finding(user, 'sf_contact_uuid_mismatch', 'Contact', sf_contact.id, details: { reason: 'is_deleted' })
+ heal_clear_contact_id(user)
+ return
+ end
+
+ if sf_contact.master_record_id.present?
+ master = safe_find(Salesforce::Records::Contact, sf_contact.master_record_id)
+ if master && Verify.contact_owns_user?(master, user)
+ heal_swap_contact_id(user, master.id, reason: :merged)
+ else
+ finding(user, 'sf_contact_uuid_mismatch', 'Contact', sf_contact.id, details: { reason: 'merged_no_owner_match' })
+ heal_clear_contact_id(user)
+ end
+ return
+ end
+
+ if sf_contact.accounts_uuid.blank?
+ finding(user, 'sf_contact_missing_uuid', 'Contact', sf_contact.id)
+ return # SF-side problem; do not mutate Accounts
+ end
+
+ if sf_contact.accounts_uuid != user.uuid
+ finding(user, 'sf_contact_uuid_mismatch', 'Contact', sf_contact.id, details: { uuid_in_sf: sf_contact.accounts_uuid })
+ heal_reattach_via_lookup(user)
+ return
+ end
+
+ Audit.record(user, :reconcile_user_ok, contact_id: sf_contact.id)
+ close_findings(user, category: 'sf_contact_uuid_mismatch')
+ end
+
+ def heal_clear_contact_id(user)
+ return unless @self_heal
+ prior = user.salesforce_contact_id
+ user.update!(salesforce_contact_id: nil)
+ Audit.record(user, :reconcile_contact_id_cleared, prior: prior)
+ @metrics.increment(:contact_clears)
+ heal_reattach_via_lookup(user)
+ end
+
+ def heal_swap_contact_id(user, new_id, reason:)
+ return unless @self_heal
+ from = user.salesforce_contact_id
+ user.update!(salesforce_contact_id: new_id)
+ Audit.record(user, :reconcile_followed_merge, from: from, to: new_id, reason: reason)
+ @metrics.increment(:contact_swaps_by_reconcile, reason: reason)
+ end
+
+ def heal_reattach_via_lookup(user)
+ return unless @self_heal
+ contact = Lookup.contact_for(user)
+ if contact
+ user.update!(salesforce_contact_id: contact.id)
+ Audit.record(user, :link_restored_by_reconcile, contact_id: contact.id)
+ @metrics.increment(:links_restored)
+ else
+ prior = user.salesforce_contact_id_was || user.salesforce_contact_id
+ Audit.record(user, :contact_id_orphaned, prior_contact_id: prior)
+ @metrics.increment(:contacts_orphaned)
+ end
+ end
+
+ # ----- Pass 2: lead-anchored ----- #
+
+ def run_pass_2
+ User.where(salesforce_contact_id: nil).where.not(salesforce_lead_id: nil)
+ .find_in_batches(batch_size: BATCH_SIZE) do |users|
+ break if budget_exceeded?
+ lead_ids = users.map(&:salesforce_lead_id).uniq
+ leads = fetch_leads_by_id(lead_ids)
+ users.each { |u| reconcile_user_by_stored_lead(u, leads[u.salesforce_lead_id]) }
+ @metrics.increment(:users_pass_2, by: users.size)
+ end
+ end
+
+ def fetch_leads_by_id(ids)
+ return {} if ids.empty?
+ @queries += 1
+ Salesforce::Records::Lead
+ .select(:id, :accounts_uuid, :is_converted, :converted_contact_id)
+ .where(id: ids).index_by(&:id)
+ end
+
+ def reconcile_user_by_stored_lead(user, lead)
+ if lead.nil?
+ finding(user, 'sf_lead_uuid_mismatch', 'Lead', user.salesforce_lead_id, details: { reason: 'missing_in_sf' })
+ heal_reattach_via_lookup(user) if @self_heal
+ return
+ end
+
+ if lead.is_converted && lead.converted_contact_id.present?
+ contact = safe_find(Salesforce::Records::Contact, lead.converted_contact_id)
+ if contact && Verify.contact_owns_user?(contact, user)
+ if @self_heal
+ user.update!(salesforce_contact_id: contact.id)
+ Audit.record(user, :reconcile_attached_from_conversion, lead_id: lead.id, contact_id: contact.id)
+ @metrics.increment(:contacts_attached_from_lead_conversion)
+ end
+ return
+ end
+ end
+
+ if lead.accounts_uuid != user.uuid
+ finding(user, 'sf_lead_uuid_mismatch', 'Lead', lead.id, details: { uuid_in_sf: lead.accounts_uuid })
+ heal_reattach_via_lookup(user) if @self_heal
+ return
+ end
+
+ Audit.record(user, :reconcile_user_ok, lead_id: lead.id)
+ end
+
+ # ----- Pass 3: missing-link discovery ----- #
+
+ def run_pass_3
+ scope = User.where(salesforce_contact_id: nil, salesforce_lead_id: nil)
+ .where(is_profile_complete: true)
+ .where(role: User.roles[:instructor])
+ .where.not(faculty_status: User.faculty_statuses[:rejected_faculty])
+
+ scope.find_in_batches(batch_size: BATCH_SIZE) do |users|
+ break if budget_exceeded?
+ uuids = users.map(&:uuid)
+ @queries += 2
+ contacts_by_uuid = Salesforce::Records::Contact.where(accounts_uuid: uuids).index_by(&:accounts_uuid)
+ leads_by_uuid = Salesforce::Records::Lead.where(accounts_uuid: uuids).index_by(&:accounts_uuid)
+
+ users.each { |u| attach_missing_link(u, contacts_by_uuid[u.uuid], leads_by_uuid[u.uuid]) }
+ @metrics.increment(:users_pass_3, by: users.size)
+ end
+ end
+
+ def attach_missing_link(user, contact, lead)
+ if contact && Verify.contact_owns_user?(contact, user)
+ if @self_heal
+ user.update!(salesforce_contact_id: contact.id)
+ Audit.record(user, :link_restored_by_reconcile, contact_id: contact.id, via: :pass_3)
+ @metrics.increment(:links_restored)
+ end
+ return
+ end
+
+ if lead && Verify.lead_owns_user?(lead, user) && !lead.is_converted
+ if @self_heal
+ user.update!(salesforce_lead_id: lead.id)
+ Audit.record(user, :link_restored_by_reconcile, lead_id: lead.id, via: :pass_3)
+ @metrics.increment(:links_restored)
+ end
+ return
+ end
+
+ finding(user, 'user_unlinked_eligible', nil, nil)
+ @metrics.increment(:unlinked_eligible)
+ end
+
+ # ----- SF-orphan sweep ----- #
+
+ def sweep_sf_orphans
+ return if budget_exceeded?
+
+ since = SWEEP_LOOKBACK.ago.utc.iso8601
+ @queries += 2
+
+ contact_uuids = pluck_accounts_uuids(Salesforce::Records::Contact, since)
+ lead_uuids = pluck_accounts_uuids(Salesforce::Records::Lead, since)
+
+ all_uuids = (contact_uuids + lead_uuids).uniq
+ known = User.where(uuid: all_uuids).pluck(:uuid).to_set
+
+ (contact_uuids.uniq - known.to_a).each do |uuid|
+ finding(nil, 'sf_orphan_contact', 'Contact', nil, details: { accounts_uuid: uuid })
+ end
+ (lead_uuids.uniq - known.to_a).each do |uuid|
+ finding(nil, 'sf_orphan_lead', 'Lead', nil, details: { accounts_uuid: uuid })
+ end
+ end
+
+ def pluck_accounts_uuids(klass, since)
+ klass.select(:id, :accounts_uuid)
+ .where("Accounts_UUID__c != null AND LastModifiedDate >= #{since}")
+ .pluck(:accounts_uuid)
+ .compact
+ rescue StandardError => e
+ Sentry.capture_exception(e)
+ []
+ end
+
+ # ----- Finalize ----- #
+
+ def finalize_findings
+ cutoff = @metrics.started_at
+ closed = SalesforceDriftFinding.open.where('last_seen_at < ?', cutoff).update_all(resolved_at: Time.current)
+ @metrics.increment(:findings_closed, by: closed)
+
+ SalesforceDriftFinding.resolved.where('resolved_at < ?', 60.days.ago).delete_all
+
+ total_open = SalesforceDriftFinding.open.count
+ @metrics.increment(:findings_total_open, by: total_open) if total_open > 0
+
+ threshold = Settings::Salesforce.alert_drift_open_total
+ if total_open > threshold
+ @metrics.alert!(:drift_findings_total_open, value: total_open, threshold: threshold)
+ end
+ end
+
+ # ----- Helpers ----- #
+
+ def finding(user, category, type, id, details: {})
+ SalesforceDriftFinding.upsert_finding!(
+ category: category, user: user,
+ record_type: type, record_id: id, details: details
+ )
+ @metrics.increment(:findings_opened, category: category)
+ end
+
+ def close_findings(user, category:)
+ SalesforceDriftFinding.open.where(user: user, category: category)
+ .update_all(resolved_at: Time.current)
+ end
+
+ def budget_exceeded?
+ if @queries >= @max_queries
+ @metrics.alert!(:reconcile_budget_exceeded, value: @queries, threshold: @max_queries)
+ true
+ else
+ false
+ end
+ end
+
+ def safe_find(klass, id)
+ klass.find(id)
+ rescue StandardError
+ nil
+ end
+
+ def log(msg)
+ Rails.logger.tagged(self.class.name) { Rails.logger.info(msg) }
+ end
+ end
+end
diff --git a/app/services/salesforce/records/base.rb b/app/services/salesforce/records/base.rb
new file mode 100644
index 0000000000..071764c03f
--- /dev/null
+++ b/app/services/salesforce/records/base.rb
@@ -0,0 +1,47 @@
+require 'active_force'
+
+# Reopen ActiveForce::SObject (provided by the openstax_active_force gem) so
+# our records inherit `find_or_initialize_by` and `save_if_changed` without an
+# intermediate subclass. An intermediate `Salesforce::Records::Base < SObject`
+# fights SObject's `inherited` hook, which auto-adds `field :id, from: 'Id'`
+# to every subclass and would then double-register :id on grandchild records.
+class ActiveForce::SObject
+ def self.find_or_initialize_by(conditions)
+ find_by(conditions) || new(conditions)
+ end
+
+ def save_if_changed
+ save if changed?
+ end
+end
+
+module Salesforce
+ module Records
+ # Records::Base is the type new code should reference. It IS
+ # ActiveForce::SObject under the hood, with the helpers above.
+ Base = ActiveForce::SObject
+ end
+end
+
+# Make ActiveForce build a Salesforce::Client lazily — only on first use, not
+# during Rails boot. This keeps migrations and console boot safe even when
+# Salesforce secrets aren't configured (e.g. in CI or dev).
+module ActiveForce
+ class << self
+ unless singleton_class.method_defined?(:_original_sfdc_client) ||
+ singleton_class.private_method_defined?(:_original_sfdc_client)
+ alias_method :_original_sfdc_client, :sfdc_client
+ end
+
+ def sfdc_client
+ unless _original_sfdc_client.is_a?(::Salesforce::Client)
+ self.sfdc_client = ::Salesforce::Client.new
+ end
+ _original_sfdc_client
+ end
+
+ def clear_sfdc_client!
+ self.sfdc_client = nil
+ end
+ end
+end
diff --git a/app/services/salesforce/records/contact.rb b/app/services/salesforce/records/contact.rb
new file mode 100644
index 0000000000..0784f7f870
--- /dev/null
+++ b/app/services/salesforce/records/contact.rb
@@ -0,0 +1,28 @@
+module Salesforce
+ module Records
+ class Contact < Base
+ self.table_name = 'Contact'
+
+ belongs_to :school, foreign_key: :school_id, model: Salesforce::Records::School
+
+ field :id, from: 'Id'
+ field :name, from: 'Name'
+ field :first_name, from: 'FirstName'
+ field :last_name, from: 'LastName'
+ field :email, from: 'Email'
+ field :faculty_verified, from: 'FV_Status__c'
+ field :last_modified_at, from: 'LastModifiedDate'
+ field :school_id, from: 'AccountId'
+ field :school_type, from: 'School_Type__c'
+ field :all_emails, from: 'All_Emails__c'
+ field :adoption_status, from: 'Adoption_Status__c'
+ field :accounts_uuid, from: 'Accounts_UUID__c'
+ field :lead_source, from: 'LeadSource'
+ field :signup_date, from: 'Signup_Date__c', as: :datetime
+ field :assignable_interest, from: 'Assignable_Interest__c'
+ field :assignable_adoption_date, from: 'Assignable_Adoption_Date__c', as: :datetime
+ field :master_record_id, from: 'MasterRecordId'
+ field :is_deleted, from: 'IsDeleted', as: :boolean
+ end
+ end
+end
diff --git a/app/services/salesforce/records/lead.rb b/app/services/salesforce/records/lead.rb
new file mode 100644
index 0000000000..2b95809165
--- /dev/null
+++ b/app/services/salesforce/records/lead.rb
@@ -0,0 +1,50 @@
+module Salesforce
+ module Records
+ class Lead < Base
+ self.table_name = 'Lead'
+
+ field :id, from: 'Id'
+ field :name, from: 'Name'
+ field :first_name, from: 'FirstName'
+ field :last_name, from: 'LastName'
+ field :salutation, from: 'Salutation'
+ field :title, from: 'Title'
+ field :subject, from: 'Subject__c'
+ field :subject_interest, from: 'Subject_Interest__c'
+ field :school, from: 'Company'
+ field :city, from: 'City'
+ field :state, from: 'State'
+ field :state_code, from: 'StateCode'
+ field :country, from: 'Country'
+ field :phone, from: 'Phone'
+ field :website, from: 'Website'
+ field :status, from: 'Status'
+ field :email, from: 'Email'
+ field :source, from: 'LeadSource'
+ field :newsletter, from: 'Newsletter__c'
+ field :newsletter_opt_in, from: 'Newsletter_Opt_In__c'
+ field :adoption_status, from: 'Adoption_Status__c'
+ field :adoption_json, from: 'AdoptionsJSON__c'
+ field :num_students, from: 'Number_of_Students__c'
+ field :os_accounts_id, from: 'Accounts_ID__c'
+ field :accounts_uuid, from: 'Accounts_UUID__c'
+ field :application_source, from: 'Application_Source__c'
+ field :role, from: 'Role__c'
+ field :position, from: 'Position__c'
+ field :who_chooses_books, from: 'who_chooses_books__c'
+ field :verification_status, from: 'FV_Status__c'
+ field :b_r_i_marketing, from: 'BRI_Marketing__c', as: :boolean
+ field :title_1_school, from: 'Title_1_school__c', as: :boolean
+ field :sheerid_school_name, from: 'SheerID_School_Name__c'
+ field :instant_conversion, from: 'Instant_Conversion__c', as: :boolean
+ field :signup_date, from: 'Signup_Date__c', as: :datetime
+ field :self_reported_school, from: 'Self_Reported_School__c'
+ field :tracking_parameters, from: 'Tracking_Parameters__c'
+ field :expected_start_semester, from: 'Expected_Start_Semester__c'
+ field :account_id, from: 'Account_ID__c'
+ field :school_id, from: 'School__c'
+ field :is_converted, from: 'IsConverted', as: :boolean
+ field :converted_contact_id, from: 'ConvertedContactId'
+ end
+ end
+end
diff --git a/app/services/salesforce/records/school.rb b/app/services/salesforce/records/school.rb
new file mode 100644
index 0000000000..13de64a450
--- /dev/null
+++ b/app/services/salesforce/records/school.rb
@@ -0,0 +1,20 @@
+module Salesforce
+ module Records
+ class School < Base
+ self.table_name = 'Account'
+
+ field :id, from: 'Id'
+ field :name, from: 'Name'
+ field :city, from: 'BillingCity'
+ field :state, from: 'BillingState'
+ field :country, from: 'BillingCountry'
+ field :type, from: 'Type'
+ field :school_location, from: 'School_Location__c'
+ field :sheerid_school_name, from: 'SheerID_School_Name__c'
+ field :is_kip, from: 'K_I_P__c', as: :boolean
+ field :is_child_of_kip, from: 'child_of_kip__c', as: :boolean
+ field :total_school_enrollment, from: 'Total_School_Enrollment__c', as: :integer
+ field :has_assignable_contacts, from: 'Has_Assignable_Contacts__c', as: :boolean
+ end
+ end
+end
diff --git a/app/services/salesforce/resolve_faculty_status.rb b/app/services/salesforce/resolve_faculty_status.rb
new file mode 100644
index 0000000000..3f7b2a71c5
--- /dev/null
+++ b/app/services/salesforce/resolve_faculty_status.rb
@@ -0,0 +1,62 @@
+module Salesforce
+ # Determines a user's faculty_status from signup context or from a Salesforce
+ # Contact, preserving the protection rules that prevent confirmed/pending/
+ # rejected statuses from being downgraded to incomplete/no_info.
+ module ResolveFacultyStatus
+ class UnknownFacultyVerifiedError < StandardError; end
+
+ # Statuses that should not be overwritten by an incoming :incomplete_signup
+ # or :no_faculty_info from Salesforce.
+ PROTECTED_BY_INCOMPLETE_OR_NO_INFO = %w[confirmed_faculty pending_faculty rejected_faculty].freeze
+
+ # An incoming :pending_faculty should not overwrite an existing
+ # :confirmed_faculty.
+ PROTECTED_BY_PENDING = %w[confirmed_faculty].freeze
+
+ DOWNGRADE_VALUES = %w[incomplete_signup no_faculty_info].freeze
+
+ module_function
+
+ # Set faculty_status based on signup state (profile completion + SheerID).
+ # Persists the user.
+ def from_signup(user)
+ if user.is_profile_complete?
+ new_status = :pending_faculty
+ verification = SheeridVerification.find_by(verification_id: user.sheerid_verification_id)
+ new_status = verification.current_step_to_faculty_status if verification
+ user.faculty_status = new_status
+ else
+ user.faculty_status = :incomplete_signup
+ end
+ user.save!
+ end
+
+ # Apply faculty_verified from an SF Contact to user, respecting the
+ # protection rules. Does NOT persist (caller decides).
+ def from_contact(user, sf_contact)
+ faculty_verified = sf_contact.faculty_verified
+ new_status =
+ if faculty_verified.nil?
+ 'no_faculty_info'
+ elsif User::VALID_FACULTY_STATUSES.include?(faculty_verified)
+ faculty_verified
+ else
+ msg = "Unknown faculty_verified field: '#{faculty_verified}' on contact #{sf_contact.id}"
+ Sentry.capture_message(msg)
+ raise UnknownFacultyVerifiedError, msg
+ end
+
+ return if blocked?(user.faculty_status, new_status)
+
+ user.faculty_status = new_status
+ end
+
+ def blocked?(current, incoming)
+ current = current.to_s
+ incoming = incoming.to_s
+ return true if PROTECTED_BY_INCOMPLETE_OR_NO_INFO.include?(current) && DOWNGRADE_VALUES.include?(incoming)
+ return true if PROTECTED_BY_PENDING.include?(current) && incoming == 'pending_faculty'
+ false
+ end
+ end
+end
diff --git a/app/services/salesforce/sync_contacts.rb b/app/services/salesforce/sync_contacts.rb
new file mode 100644
index 0000000000..2745f62764
--- /dev/null
+++ b/app/services/salesforce/sync_contacts.rb
@@ -0,0 +1,215 @@
+module Salesforce
+ # Every-30-min incremental sync of Salesforce Contacts into Accounts users.
+ # Replaces UpdateUserContactInfo's body. Cursor-driven (with overlap),
+ # skips merged/deleted contacts at fetch time, gates every
+ # salesforce_contact_id swap on Verify.contact_can_be_replaced? evidence.
+ class SyncContacts
+ SLUG = 'update-user-contact-info'.freeze
+
+ COLLEGE_TYPES = ['College/University (4)', 'Technical/Community College (2)', 'Career School/For-Profit (2)'].freeze
+ HIGH_SCHOOL_TYPES = ['High School'].freeze
+ K12_TYPES = ['K-12 School'].freeze
+ HOME_SCHOOL_TYPES = ['Home School'].freeze
+
+ DOMESTIC_SCHOOL_LOCATIONS = ['Domestic'].freeze
+ FOREIGN_SCHOOL_LOCATIONS = ['Foreign'].freeze
+
+ ADOPTION_STATUSES = {
+ 'Current Adopter' => true,
+ 'Future Adopter' => true,
+ 'Past Adopter' => true,
+ 'Not Adopter' => false
+ }.freeze
+
+ def self.call
+ new.call
+ end
+
+ def call
+ @metrics = Metrics.new(run: 'sync_contacts', slug: SLUG)
+ @metrics.start!
+ log 'Starting Salesforce contact sync'
+
+ window_start = compute_window_start
+ log "Window start: #{window_start.iso8601}"
+ check_cron_drift(window_start)
+
+ run_started_at = Time.current
+ contacts = fetch_contacts(window_start)
+ @metrics.increment(:contacts_fetched, by: contacts.size)
+ log "#{contacts.size} contacts fetched"
+
+ uuids = contacts.map(&:accounts_uuid).compact.uniq
+ users_by_uuid = User.where(uuid: uuids).index_by(&:uuid)
+ schools_by_sf_id = School
+ .where(salesforce_id: contacts.map { |c| c.school&.id }.compact)
+ .index_by(&:salesforce_id)
+
+ @verify_cache = {}
+
+ contacts.each { |sf_contact| process_contact(sf_contact, users_by_uuid, schools_by_sf_id) }
+
+ Settings::Salesforce.contacts_synced_through = run_started_at
+ emit_threshold_alerts
+ @metrics.emit(status: :ok)
+ rescue StandardError => e
+ Sentry.capture_exception(e)
+ @metrics.emit(status: :error, extra: { error: e.class.name, message: e.message })
+ raise
+ end
+
+ private
+
+ def compute_window_start
+ cursor = Settings::Salesforce.contacts_synced_through ||
+ Settings::Db.store.number_of_days_contacts_modified.to_i.days.ago
+ cursor - Settings::Salesforce.contacts_lookback_overlap_hours.hours
+ end
+
+ def check_cron_drift(window_start)
+ age_hours = ((Time.current - window_start) / 3600.0).to_i
+ threshold = Settings::Salesforce.alert_cron_drift_hours
+ @metrics.alert!(:cron_drift, value: age_hours, threshold: threshold) if age_hours > threshold * 2
+ end
+
+ def fetch_contacts(since)
+ Salesforce::Records::Contact.select(
+ :id, :email, :faculty_verified, :school_type, :adoption_status,
+ :accounts_uuid, :master_record_id, :is_deleted
+ ).where("Accounts_UUID__c != null")
+ .where("LastModifiedDate >= #{since.utc.iso8601}")
+ .includes(:school)
+ .to_a
+ end
+
+ def process_contact(sf_contact, users_by_uuid, schools_by_sf_id)
+ user = users_by_uuid[sf_contact.accounts_uuid]
+ unless user
+ @metrics.increment(:unknown_accounts_uuids)
+ return
+ end
+
+ if sf_contact.master_record_id.present? || sf_contact.is_deleted
+ @metrics.increment(:contacts_skipped_merged_or_deleted)
+ Audit.record(user, :contact_skipped_merged_or_deleted,
+ contact_id: sf_contact.id,
+ master_record_id: sf_contact.master_record_id,
+ is_deleted: sf_contact.is_deleted)
+ return
+ end
+
+ apply(user, sf_contact, schools_by_sf_id)
+ @metrics.increment(:users_matched)
+ end
+
+ def apply(user, sf_contact, schools_by_sf_id)
+ previous = user.salesforce_contact_id
+
+ if previous.blank?
+ user.salesforce_contact_id = sf_contact.id
+ elsif previous != sf_contact.id
+ return unless safe_to_swap?(user, sf_contact, previous)
+ end
+
+ apply_faculty_status(user, sf_contact)
+ apply_school_and_type(user, sf_contact, schools_by_sf_id)
+ apply_adoption_status(user, sf_contact)
+
+ if user.changed?
+ user.save!
+ @metrics.increment(:users_updated)
+ end
+ end
+
+ def safe_to_swap?(user, sf_contact, previous)
+ reason = @verify_cache[previous] ||= Verify.contact_can_be_replaced?(
+ previous_id: previous, by: sf_contact, user: user
+ )
+ case reason
+ when :gone, :merged, :uuid_cleared
+ Audit.record(user, :contact_id_swapped, from: previous, to: sf_contact.id, reason: reason)
+ @metrics.increment(:contact_id_swaps, reason: reason)
+ user.salesforce_contact_id = sf_contact.id
+ true
+ else
+ Audit.record(user, :contact_id_conflict, stored: previous, candidate: sf_contact.id)
+ @metrics.increment(:contact_id_conflicts)
+ Sentry.capture_message(
+ "Salesforce contact conflict for user #{user.id}: stored=#{previous} candidate=#{sf_contact.id}"
+ )
+ false
+ end
+ end
+
+ def apply_faculty_status(user, sf_contact)
+ old_status = user.faculty_status
+ ResolveFacultyStatus.from_contact(user, sf_contact)
+ return unless user.faculty_status_changed?
+ @metrics.increment(:users_fv_status_changed)
+ Audit.record(user, :updated_faculty_status,
+ user_id: user.id, salesforce_contact_id: sf_contact.id,
+ old_status: old_status, new_status: user.faculty_status)
+ end
+
+ def apply_school_and_type(user, sf_contact, schools_by_sf_id)
+ user.school_type = case sf_contact.school_type
+ when *COLLEGE_TYPES then :college
+ when *HIGH_SCHOOL_TYPES then :high_school
+ when *K12_TYPES then :k12_school
+ when *HOME_SCHOOL_TYPES then :home_school
+ when NilClass then :unknown_school_type
+ else :other_school_type
+ end
+
+ sf_school = sf_contact.school
+ user.school_location = case sf_school&.school_location
+ when *DOMESTIC_SCHOOL_LOCATIONS then :domestic_school
+ when *FOREIGN_SCHOOL_LOCATIONS then :foreign_school
+ else :unknown_school_location
+ end
+ user.is_kip = sf_school&.is_kip || sf_school&.is_child_of_kip
+
+ cached_school = schools_by_sf_id[sf_school&.id]
+ if cached_school.nil? && !sf_school.nil?
+ @metrics.increment(:users_school_not_cached)
+ Audit.record(user, :user_school_not_cached, sf_school_id: sf_school.id)
+ else
+ user.school = cached_school
+ end
+ end
+
+ def apply_adoption_status(user, sf_contact)
+ unless sf_contact.adoption_status.blank?
+ user.using_openstax = ADOPTION_STATUSES[sf_contact.adoption_status]
+ end
+ user.adopter_status = sf_contact.adoption_status
+ end
+
+ def emit_threshold_alerts
+ conflicts = @metrics.counters[:contact_id_conflicts].to_i
+ threshold_conflicts = Settings::Salesforce.alert_contact_id_conflict_count
+ if conflicts > threshold_conflicts
+ @metrics.alert!(:contact_id_conflict_count, value: conflicts, threshold: threshold_conflicts)
+ end
+
+ updated = @metrics.counters[:users_updated].to_i
+ swaps_counter = @metrics.counters[:contact_id_swaps]
+ swaps = swaps_counter.is_a?(Hash) ? swaps_counter[:total].to_i : swaps_counter.to_i
+ if updated > 0
+ rate = (swaps.to_f / updated * 100).round(1)
+ threshold = Settings::Salesforce.alert_contact_id_swap_rate_pct
+ @metrics.alert!(:contact_id_swap_rate_high, value: rate, threshold: threshold) if rate > threshold
+ end
+
+ unknown = @metrics.counters[:unknown_accounts_uuids].to_i
+ threshold_unknown = Settings::Salesforce.alert_unknown_uuid_count
+ if unknown > threshold_unknown
+ @metrics.alert!(:unknown_accounts_uuid_count, value: unknown, threshold: threshold_unknown)
+ end
+ end
+
+ def log(msg)
+ Rails.logger.tagged(self.class.name) { Rails.logger.info(msg) }
+ end
+ end
+end
diff --git a/app/services/salesforce/sync_schools.rb b/app/services/salesforce/sync_schools.rb
new file mode 100644
index 0000000000..892ed78c09
--- /dev/null
+++ b/app/services/salesforce/sync_schools.rb
@@ -0,0 +1,100 @@
+module Salesforce
+ # Caches Salesforce Account (School) records into the local schools table.
+ # Logic moved verbatim from UpdateSchoolSalesforceInfo; wrapped with
+ # Salesforce::Metrics for per-run Sentry check-ins.
+ class SyncSchools
+ SLUG = 'update-school-salesforce'.freeze
+ BATCH_SIZE = 250
+
+ SF_TO_DB_CACHE_COLUMNS_MAP = {
+ id: :salesforce_id,
+ name: :name,
+ city: :city,
+ state: :state,
+ country: :country,
+ type: :type,
+ school_location: :location,
+ is_kip: :is_kip,
+ is_child_of_kip: :is_child_of_kip,
+ sheerid_school_name: :sheerid_school_name,
+ has_assignable_contacts: :has_assignable_contacts
+ }.freeze
+
+ def self.call
+ new.call
+ end
+
+ def call
+ metrics = Metrics.new(run: 'sync_schools', slug: SLUG)
+ metrics.start!
+ log 'Starting SyncSchools'
+
+ remove_deleted_schools
+ schools_updated = upsert_schools_from_sf
+
+ metrics.increment(:schools_updated, by: schools_updated)
+ metrics.emit(status: :ok)
+ log "Finished updating #{schools_updated} schools"
+ rescue StandardError => e
+ Sentry.capture_exception(e)
+ metrics.emit(status: :error, extra: { error: e.class.name, message: e.message })
+ raise
+ end
+
+ private
+
+ def remove_deleted_schools
+ School.where(
+ 'NOT EXISTS (SELECT * FROM "users" WHERE "users"."school_id" = "schools"."id")'
+ ).find_in_batches(batch_size: BATCH_SIZE) do |schools|
+ salesforce_ids = schools.map(&:salesforce_id)
+ existing_salesforce_ids = Salesforce::Records::School.select(:id).where(id: salesforce_ids).map(&:id)
+ deleted_salesforce_ids = salesforce_ids - existing_salesforce_ids
+ School.where(salesforce_id: deleted_salesforce_ids).delete_all
+ end
+ end
+
+ def upsert_schools_from_sf
+ schools_updated = 0
+ last_id = nil
+
+ loop do
+ sf_schools = Salesforce::Records::School.order(:Id).limit(BATCH_SIZE)
+ sf_schools = sf_schools.where("Id > '#{last_id}'") unless last_id.nil?
+ sf_schools = sf_schools.to_a
+ last_id = sf_schools.last&.id
+
+ begin
+ schools_by_sf_id = School.where(salesforce_id: sf_schools.map(&:id)).index_by(&:salesforce_id)
+
+ schools = sf_schools.map do |sf_school|
+ school = schools_by_sf_id[sf_school.id]
+ schools_updated += 1 if school.nil?
+ school = School.new(salesforce_id: sf_school.id) if school.nil?
+
+ SF_TO_DB_CACHE_COLUMNS_MAP.each do |sf_col, db_col|
+ school.public_send("#{db_col}=", sf_school.public_send(sf_col))
+ end
+
+ school.changed? ? school : nil
+ end.compact
+
+ School.import(
+ schools, validate: false,
+ on_duplicate_key_update: { conflict_target: [:salesforce_id], columns: SF_TO_DB_CACHE_COLUMNS_MAP.values }
+ ) unless schools.empty?
+ rescue StandardError => se
+ Sentry.capture_exception(se)
+ end
+
+ break if sf_schools.length < BATCH_SIZE
+ end
+
+ schools_updated
+ end
+
+ def log(msg)
+ Rails.logger.tagged(self.class.name) { Rails.logger.info(msg) }
+ end
+ end
+end
diff --git a/app/services/salesforce/upsert_lead.rb b/app/services/salesforce/upsert_lead.rb
new file mode 100644
index 0000000000..f54fea82ee
--- /dev/null
+++ b/app/services/salesforce/upsert_lead.rb
@@ -0,0 +1,102 @@
+module Salesforce
+ # Orchestrates create-or-update of a Salesforce Lead for a User.
+ # Replaces the bulk of the previous Newflow::CreateOrUpdateSalesforceLead.
+ class UpsertLead
+ PERSIST_RETRIES = 3
+ FIND_ME_A_HOME = 'Find Me A Home'.freeze
+
+ def self.call(user:)
+ new(user).call
+ end
+
+ def initialize(user)
+ @user = user
+ end
+
+ def call
+ return if @user.nil?
+
+ Audit.record(@user, :upsert_lead_begin)
+
+ ensure_school_or_fallback
+ ResolveFacultyStatus.from_signup(@user)
+
+ result = Lookup.lead_for(@user)
+ lead = result.lead
+
+ if lead.nil? && existing_contact_owns_user?
+ Audit.record(@user, :upsert_lead_skipped_user_has_contact,
+ contact_id: @user.salesforce_contact_id)
+ return
+ end
+
+ lead ||= Salesforce::Records::Lead.new(
+ email: @user.best_email_address_for_salesforce,
+ accounts_uuid: @user.uuid
+ )
+ BuildLead.apply(lead, @user)
+
+ if lead.save
+ persist_lead_id(lead)
+ Audit.record(@user, :upsert_lead_saved,
+ lead_id: lead.id, matched_by: result.matched_by)
+ else
+ Audit.record(@user, :upsert_lead_save_failed,
+ errors: Array(lead.errors&.full_messages))
+ Sentry.capture_message(
+ "Salesforce lead save failed for user #{@user.id}: " \
+ "#{lead.errors&.full_messages&.join(', ')}"
+ )
+ end
+ end
+
+ private
+
+ def ensure_school_or_fallback
+ return if @user.school&.salesforce_id
+ fallback = Salesforce::Records::School.find_by({ name: FIND_ME_A_HOME })
+ unless fallback
+ raise "Salesforce '#{FIND_ME_A_HOME}' school not found — cannot assign fallback school for user #{@user.id}"
+ end
+ # If the local schools cache doesn't have the fallback yet, create a
+ # stub row with the minimum NOT NULL columns. The next SyncSchools run
+ # will populate the rest. Without this, BuildLead reads
+ # user.school&.salesforce_id as nil and the saved Lead has no
+ # account_id / school_id link to the SF School.
+ @user.school = School.find_or_create_by!(salesforce_id: fallback.id) do |school|
+ school.name = fallback.name
+ school.is_kip = fallback.respond_to?(:is_kip) ? !!fallback.is_kip : false
+ school.is_child_of_kip = fallback.respond_to?(:is_child_of_kip) ? !!fallback.is_child_of_kip : false
+ end
+ end
+
+ def existing_contact_owns_user?
+ return false if @user.salesforce_contact_id.blank?
+ contact = Lookup.contact_for(@user)
+ if contact.nil?
+ prior = @user.salesforce_contact_id
+ @user.salesforce_contact_id = nil
+ @user.save
+ Audit.record(@user, :stale_contact_id_cleared, contact_id: prior)
+ return false
+ end
+ true
+ end
+
+ def persist_lead_id(lead)
+ @user.salesforce_lead_id = lead.id
+ PERSIST_RETRIES.times do |attempt|
+ return if @user.save
+ Audit.record(@user, :lead_id_persist_retry,
+ attempt: attempt + 1, errors: @user.errors.full_messages)
+ @user.reload
+ @user.salesforce_lead_id = lead.id
+ end
+ Audit.record(@user, :lead_id_persist_failed,
+ lead_id: lead.id, errors: @user.errors.full_messages)
+ Sentry.capture_message(
+ "lead_id persist failed for user #{@user.id}, lead #{lead.id}"
+ )
+ end
+ end
+end
diff --git a/app/services/salesforce/verify.rb b/app/services/salesforce/verify.rb
new file mode 100644
index 0000000000..5996f1c6b3
--- /dev/null
+++ b/app/services/salesforce/verify.rb
@@ -0,0 +1,35 @@
+module Salesforce
+ # Ownership and replacement checks. A Salesforce Lead or Contact "owns" a
+ # user when its Accounts_UUID__c matches the user's UUID, or is blank (so we
+ # can adopt a legacy unowned record). Merged-away or deleted Contacts never
+ # own anyone.
+ module Verify
+ module_function
+
+ def lead_owns_user?(lead, user)
+ return false if lead.nil?
+ lead.accounts_uuid.blank? || lead.accounts_uuid == user.uuid
+ end
+
+ def contact_owns_user?(contact, user)
+ return false if contact.nil?
+ return false if contact.master_record_id.present?
+ return false if contact.is_deleted
+ contact.accounts_uuid == user.uuid
+ end
+
+ # Is it safe to replace the stored salesforce_contact_id with `by`?
+ # Returns:
+ # :gone — previous Contact missing or deleted in Salesforce
+ # :merged — previous Contact has been merged into `by`
+ # :uuid_cleared — previous Contact's Accounts_UUID__c is now blank
+ # false — both records are live and own this user (human review)
+ def contact_can_be_replaced?(previous_id:, by:, user:)
+ prev = Salesforce::Records::Contact.find_by({ id: previous_id })
+ return :gone if prev.nil? || prev.is_deleted
+ return :merged if prev.master_record_id.present? && prev.master_record_id == by.id
+ return :uuid_cleared if prev.accounts_uuid.blank? && by.accounts_uuid == user.uuid
+ false
+ end
+ end
+end
diff --git a/app/views/admin/salesforce_drift_findings/index.html.erb b/app/views/admin/salesforce_drift_findings/index.html.erb
new file mode 100644
index 0000000000..b49ae7da35
--- /dev/null
+++ b/app/views/admin/salesforce_drift_findings/index.html.erb
@@ -0,0 +1,60 @@
+
Salesforce drift findings
+
+<%= form_with url: admin_salesforce_drift_findings_path, method: :get, local: true do %>
+
+ <%= submit_tag 'Filter', name: nil %>
+<% end %>
+
+
+
+
+ | Category |
+ User |
+ SF Record |
+ First seen |
+ Last seen |
+ Details |
+ |
+
+
+
+ <% @findings.each do |f| %>
+
+ | <%= f.category %> |
+
+ <% if f.user_id %>
+ <%= link_to f.user_id, edit_admin_user_path(f.user_id) %>
+ <% else %>
+ —
+ <% end %>
+ |
+
+ <% if f.salesforce_record_id.present? %>
+ <% sf_host = Rails.application.secrets.dig(:salesforce, :instance_url) %>
+ <% if sf_host.present? %>
+ <%= link_to "#{f.salesforce_record_type}/#{f.salesforce_record_id}",
+ "#{sf_host}/#{f.salesforce_record_id}", target: '_blank', rel: 'noopener' %>
+ <% else %>
+ <%= "#{f.salesforce_record_type}/#{f.salesforce_record_id}" %>
+ <% end %>
+ <% else %>
+ —
+ <% end %>
+ |
+ <%= f.first_seen_at.strftime('%Y-%m-%d %H:%M') %> |
+ <%= f.last_seen_at.strftime('%Y-%m-%d %H:%M') %> |
+ <%= JSON.pretty_generate(f.details) rescue f.details.inspect %> |
+
+ <%= button_to 'Mark resolved', admin_salesforce_drift_finding_path(f),
+ method: :patch, data: { confirm: 'Mark resolved?' } %>
+ |
+
+ <% end %>
+
+
+
+<%= pluralize(@findings.size, 'open finding') %> shown (max 500).
diff --git a/app/views/admin/users/_salesforce_timeline.html.erb b/app/views/admin/users/_salesforce_timeline.html.erb
new file mode 100644
index 0000000000..bcee0bd3cf
--- /dev/null
+++ b/app/views/admin/users/_salesforce_timeline.html.erb
@@ -0,0 +1,29 @@
+
+ Salesforce timeline
+ <% if @salesforce_timeline.nil? || @salesforce_timeline.empty? %>
+ No Salesforce events recorded for this user.
+ <% else %>
+
+
+
+ | When |
+ Event |
+ Data |
+
+
+
+ <% @salesforce_timeline.each do |event| %>
+
+ | <%= event.created_at.strftime('%Y-%m-%d %H:%M:%S') %> |
+ <%= event.event_type %> |
+ <%= JSON.pretty_generate(event.event_data) rescue event.event_data.inspect %> |
+
+ <% end %>
+
+
+
+ <%= link_to 'Open drift findings for this user',
+ admin_salesforce_drift_findings_path(user_id: @user.id) %>
+
+ <% end %>
+
diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb
index a8fe71b721..0f8c43ce76 100644
--- a/app/views/admin/users/edit.html.erb
+++ b/app/views/admin/users/edit.html.erb
@@ -4,3 +4,5 @@
<% @page_header = 'User Details' %>
<%= render 'form' %>
+
+<%= render 'salesforce_timeline' %>
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
index ac033bf9dc..45d0256d76 100644
--- a/config/initializers/inflections.rb
+++ b/config/initializers/inflections.rb
@@ -14,3 +14,7 @@
# ActiveSupport::Inflector.inflections(:en) do |inflect|
# inflect.acronym 'RESTful'
# end
+
+ActiveSupport::Inflector.inflections do |inflect|
+ inflect.acronym 'OpenStax'
+end
diff --git a/config/initializers/openstax_salesforce.rb b/config/initializers/openstax_salesforce.rb
deleted file mode 100644
index 74682bdd3a..0000000000
--- a/config/initializers/openstax_salesforce.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# This initializer always runs before the engine is loaded, but it can
-# also be copied to the application's initializers by running the install
-# task. Because this code can get run multiple times, make sure to only put
-# code here that is amenable to that.
-
-OpenStax::Salesforce.configure do |config|
- salesforce_secrets = Rails.application.secrets.salesforce
-
- # Username, client id, instance url and private key for connecting to the Salesforce app
- config.username = salesforce_secrets[:username]
- config.password = salesforce_secrets[:password]
- config.security_token = salesforce_secrets[:security_token]
- config.consumer_key = salesforce_secrets[:consumer_key]
- config.consumer_secret = salesforce_secrets[:consumer_secret]
-
- config.api_version = salesforce_secrets.fetch :api_version, '51.0'
- config.login_domain = salesforce_secrets.fetch :login_domain, 'test.salesforce.com'
-end
diff --git a/config/initializers/salesforce.rb b/config/initializers/salesforce.rb
new file mode 100644
index 0000000000..48e9c8c872
--- /dev/null
+++ b/config/initializers/salesforce.rb
@@ -0,0 +1,16 @@
+# Wrap in to_prepare so Zeitwerk doesn't unload Salesforce (and our config
+# with it) after initialization. Without this we hit the Rails autoload-
+# during-init deprecation warning and the configured api_version etc. are
+# lost on first reload, falling back to the Configuration class defaults.
+Rails.application.reloader.to_prepare do
+ Salesforce.configure do |config|
+ secrets = Rails.application.secrets.salesforce
+ config.username = secrets[:username]
+ config.password = secrets[:password]
+ config.security_token = secrets[:security_token]
+ config.consumer_key = secrets[:consumer_key]
+ config.consumer_secret = secrets[:consumer_secret]
+ config.api_version = secrets.fetch(:api_version, '66.0')
+ config.login_domain = secrets.fetch(:login_domain, 'test.salesforce.com')
+ end
+end
diff --git a/config/routes.rb b/config/routes.rb
index 9e8ab38230..9066195a74 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -291,6 +291,8 @@
resource :security_log, only: [:show]
+ resources :salesforce_drift_findings, only: [:index, :update]
+
delete :delete_contact_info, path: '/contact_infos/:id/verify',
controller: :contact_infos, action: :destroy
post :verify_contact_info, path: '/contact_infos/:id/verify',
diff --git a/db/migrate/20260522060416_create_salesforce_drift_findings.rb b/db/migrate/20260522060416_create_salesforce_drift_findings.rb
new file mode 100644
index 0000000000..fe84625840
--- /dev/null
+++ b/db/migrate/20260522060416_create_salesforce_drift_findings.rb
@@ -0,0 +1,19 @@
+class CreateSalesforceDriftFindings < ActiveRecord::Migration[6.1]
+ def change
+ create_table :salesforce_drift_findings do |t|
+ t.references :user, foreign_key: true, null: true
+ t.string :category, null: false
+ t.string :salesforce_record_type
+ t.string :salesforce_record_id
+ t.jsonb :details, default: {}, null: false
+ t.datetime :first_seen_at, null: false
+ t.datetime :last_seen_at, null: false
+ t.datetime :resolved_at
+ t.timestamps
+
+ t.index [:category, :resolved_at]
+ t.index [:user_id, :category]
+ t.index :last_seen_at
+ end
+ end
+end
diff --git a/db/migrate/20260522060438_add_index_to_users_salesforce_lead_id.rb b/db/migrate/20260522060438_add_index_to_users_salesforce_lead_id.rb
new file mode 100644
index 0000000000..d76338865c
--- /dev/null
+++ b/db/migrate/20260522060438_add_index_to_users_salesforce_lead_id.rb
@@ -0,0 +1,7 @@
+class AddIndexToUsersSalesforceLeadId < ActiveRecord::Migration[6.1]
+ disable_ddl_transaction!
+
+ def change
+ add_index :users, :salesforce_lead_id, algorithm: :concurrently, if_not_exists: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 6d3b3e33dc..65ecd5b17e 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2026_04_18_065011) do
+ActiveRecord::Schema.define(version: 2026_05_22_060438) do
# These are extensions that must be enabled in order to support this database
enable_extension "citext"
@@ -349,6 +349,23 @@
t.index ["contact_info_kind"], name: "index_pre_auth_states_on_contact_info_kind"
end
+ create_table "salesforce_drift_findings", force: :cascade do |t|
+ t.bigint "user_id"
+ t.string "category", null: false
+ t.string "salesforce_record_type"
+ t.string "salesforce_record_id"
+ t.jsonb "details", default: {}, null: false
+ t.datetime "first_seen_at", null: false
+ t.datetime "last_seen_at", null: false
+ t.datetime "resolved_at"
+ t.datetime "created_at", precision: 6, null: false
+ t.datetime "updated_at", precision: 6, null: false
+ t.index ["category", "resolved_at"], name: "index_salesforce_drift_findings_on_category_and_resolved_at"
+ t.index ["last_seen_at"], name: "index_salesforce_drift_findings_on_last_seen_at"
+ t.index ["user_id", "category"], name: "index_salesforce_drift_findings_on_user_id_and_category"
+ t.index ["user_id"], name: "index_salesforce_drift_findings_on_user_id"
+ end
+
create_table "schools", force: :cascade do |t|
t.string "salesforce_id", null: false
t.string "name", null: false
@@ -482,6 +499,7 @@
t.index ["login_token"], name: "index_users_on_login_token", unique: true
t.index ["role"], name: "index_users_on_role"
t.index ["salesforce_contact_id"], name: "index_users_on_salesforce_contact_id"
+ t.index ["salesforce_lead_id"], name: "index_users_on_salesforce_lead_id"
t.index ["school_id"], name: "index_users_on_school_id"
t.index ["school_type"], name: "index_users_on_school_type"
t.index ["source_application_id"], name: "index_users_on_source_application_id"
@@ -492,6 +510,7 @@
add_foreign_key "external_ids", "users"
add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id"
add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
+ add_foreign_key "salesforce_drift_findings", "users"
add_foreign_key "users", "oauth_applications", column: "source_application_id"
add_foreign_key "users", "schools"
end
diff --git a/lib/settings.rb b/lib/settings.rb
index ad351ff6c6..3fe6ee6aad 100644
--- a/lib/settings.rb
+++ b/lib/settings.rb
@@ -146,6 +146,20 @@ class Store < RailsSettings::Base
type: :string, default: 'https://offers.sheerid.com/openstax/staging/teacher/?env=dev'
field :number_of_days_contacts_modified, type: :integer, default: 7
field :minimum_recaptcha_score, type: :float, default: 0.2
+
+ # Salesforce sync redesign (see docs/superpowers/specs/2026-05-20-salesforce-sync-design.md)
+ field :salesforce_contacts_synced_through, type: :string, default: nil
+ field :salesforce_contacts_lookback_overlap_hours, type: :integer, default: 1
+ field :salesforce_reconcile_max_queries, type: :integer, default: 5000
+ field :salesforce_reconcile_pass_cursors, type: :hash, default: {}
+ field :salesforce_alert_lead_save_failure_rate_pct, type: :integer, default: 2
+ field :salesforce_alert_contact_id_conflict_count, type: :integer, default: 5
+ field :salesforce_alert_contact_id_swap_rate_pct, type: :integer, default: 5
+ field :salesforce_alert_unknown_uuid_count, type: :integer, default: 50
+ field :salesforce_alert_drift_open_total, type: :integer, default: 100
+ field :salesforce_alert_cron_drift_hours, type: :integer, default: 2
+ field :salesforce_sf_admin_notify_enabled, type: :boolean, default: false
+ field :salesforce_reconcile_self_heal_enabled, type: :boolean, default: false
end
mattr_accessor :store
diff --git a/lib/settings/feature_flags.rb b/lib/settings/feature_flags.rb
index a4bc0500fa..552fac45ae 100644
--- a/lib/settings/feature_flags.rb
+++ b/lib/settings/feature_flags.rb
@@ -38,6 +38,14 @@ def collect_student_count_all_paths=(bool)
Settings::Db.store.collect_student_count_all_paths = bool
end
+ def salesforce_reconcile_self_heal
+ Settings::Db.store.salesforce_reconcile_self_heal_enabled
+ end
+
+ def salesforce_reconcile_self_heal=(bool)
+ Settings::Db.store.salesforce_reconcile_self_heal_enabled = bool
+ end
+
end
end
end
diff --git a/lib/settings/salesforce.rb b/lib/settings/salesforce.rb
index bdcb853fbb..d4d80b056a 100644
--- a/lib/settings/salesforce.rb
+++ b/lib/settings/salesforce.rb
@@ -27,6 +27,62 @@ def show_support_chat=(bool)
Settings::Db.store.show_support_chat = bool
end
+ # SyncContacts cursor — UTC time of the last successful run.
+ def contacts_synced_through
+ v = Settings::Db.store.salesforce_contacts_synced_through
+ v.present? ? Time.iso8601(v) : nil
+ end
+
+ def contacts_synced_through=(time)
+ Settings::Db.store.salesforce_contacts_synced_through = time&.utc&.iso8601
+ end
+
+ def contacts_lookback_overlap_hours
+ Settings::Db.store.salesforce_contacts_lookback_overlap_hours
+ end
+
+ # Reconcile budget — soft cap on Salesforce queries per run.
+ def reconcile_max_queries
+ Settings::Db.store.salesforce_reconcile_max_queries
+ end
+
+ def reconcile_pass_cursors
+ Settings::Db.store.salesforce_reconcile_pass_cursors || {}
+ end
+
+ def reconcile_pass_cursors=(hash)
+ Settings::Db.store.salesforce_reconcile_pass_cursors = hash
+ end
+
+ # Threshold knobs for the per-run alert checks.
+ def alert_lead_save_failure_rate_pct
+ Settings::Db.store.salesforce_alert_lead_save_failure_rate_pct
+ end
+
+ def alert_contact_id_conflict_count
+ Settings::Db.store.salesforce_alert_contact_id_conflict_count
+ end
+
+ def alert_contact_id_swap_rate_pct
+ Settings::Db.store.salesforce_alert_contact_id_swap_rate_pct
+ end
+
+ def alert_unknown_uuid_count
+ Settings::Db.store.salesforce_alert_unknown_uuid_count
+ end
+
+ def alert_drift_open_total
+ Settings::Db.store.salesforce_alert_drift_open_total
+ end
+
+ def alert_cron_drift_hours
+ Settings::Db.store.salesforce_alert_cron_drift_hours
+ end
+
+ def sf_admin_notify_enabled
+ Settings::Db.store.salesforce_sf_admin_notify_enabled
+ end
+
end
end
diff --git a/lib/tasks/accounts/create_leads_for_instructors_not_sent_to_sf.rake b/lib/tasks/accounts/create_leads_for_instructors_not_sent_to_sf.rake
index ac1a7eed99..74384916f8 100644
--- a/lib/tasks/accounts/create_leads_for_instructors_not_sent_to_sf.rake
+++ b/lib/tasks/accounts/create_leads_for_instructors_not_sent_to_sf.rake
@@ -16,12 +16,12 @@ namespace :accounts do
end
users.each do |user|
- lead = OpenStax::Salesforce::Remote::Lead.select(:id, :verification_status).find_by(accounts_uuid: user.uuid)
+ lead = Salesforce::Records::Lead.select(:id, :verification_status).find_by(accounts_uuid: user.uuid)
if lead.nil?
Newflow::CreateOrUpdateSalesforceLead.call(user: user)
else
- # set the lead id, this will update their status in UpdateUserLeadInfo
+ # set the lead id; Reconcile will verify it and attach a Contact when one exists
user.salesforce_lead_id = lead.id
user.save
end
diff --git a/lib/tasks/accounts/update_adopter_status.rake b/lib/tasks/accounts/update_adopter_status.rake
index b1e0611518..10e416e054 100644
--- a/lib/tasks/accounts/update_adopter_status.rake
+++ b/lib/tasks/accounts/update_adopter_status.rake
@@ -5,7 +5,7 @@ namespace :accounts do
loop do
users = User.where(adopter_status: nil).where.not(salesforce_contact_id: nil).limit(250)
- contacts = OpenStax::Salesforce::Remote::Contact.select(
+ contacts = Salesforce::Records::Contact.select(
:id,
:adoption_status,
:accounts_uuid
diff --git a/lib/tasks/cron/day.rake b/lib/tasks/cron/day.rake
index cf2bf8d4dc..a4f74bcc05 100644
--- a/lib/tasks/cron/day.rake
+++ b/lib/tasks/cron/day.rake
@@ -8,6 +8,9 @@ namespace :cron do
Rails.logger.info 'UpdateSalesforceAssignableFields.call'
OpenStax::RescueFrom.this { UpdateSalesforceAssignableFields.call }
+ Rails.logger.info 'Salesforce::Reconcile.call'
+ OpenStax::RescueFrom.this { Salesforce::Reconcile.call }
+
Rails.logger.debug 'Finished daily cron'
end
end
diff --git a/spec/cassettes/Change_Salesforce_contact_manually/can_be_set_if_the_Contact_exists_in_SF.yml b/spec/cassettes/Change_Salesforce_contact_manually/can_be_set_if_the_Contact_exists_in_SF.yml
deleted file mode 100644
index 761ae1c884..0000000000
--- a/spec/cassettes/Change_Salesforce_contact_manually/can_be_set_if_the_Contact_exists_in_SF.yml
+++ /dev/null
@@ -1,249 +0,0 @@
----
-http_interactions:
-- request:
- method: get
- uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20BillingCity,%20BillingState,%20BillingCountry,%20Type,%20School_Location__c,%20SheerID_School_Name__c,%20K_I_P__c,%20child_of_kip__c,%20Total_School_Enrollment__c,%20Has_Assignable_Contacts__c%20FROM%20Account%20WHERE%20(Name%20IN%20(%27RSpec%20University%27))"
- body:
- encoding: US-ASCII
- string: ''
- headers:
- User-Agent:
- - Faraday v1.10.4
- Authorization:
- - OAuth
- Accept-Encoding:
- - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
- Accept:
- - "*/*"
- response:
- status:
- code: 200
- message: OK
- headers:
- Date:
- - Tue, 08 Oct 2024 15:05:06 GMT
- Content-Type:
- - application/json;charset=UTF-8
- Transfer-Encoding:
- - chunked
- Connection:
- - keep-alive
- Set-Cookie:
- - BrowserId=tAbwQIWGEe-i5nnxlZkMbg; domain=.salesforce.com; path=/; expires=Wed,
- 08-Oct-2025 15:05:06 GMT; Max-Age=31536000; secure; SameSite=None
- - CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:06 GMT; Max-Age=31536000;
- secure
- - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:06
- GMT; Max-Age=31536000; secure
- Strict-Transport-Security:
- - max-age=63072000; includeSubDomains
- X-Content-Type-Options:
- - nosniff
- X-Robots-Tag:
- - none
- Vary:
- - Accept-Encoding
- Sforce-Limit-Info:
- - api-usage=73995/305000
- Cache-Control:
- - no-cache,must-revalidate,max-age=0,no-store,private
- Server:
- - sfdcedge
- X-Sfdc-Request-Id:
- - 1d531721139fbff90848e8fed4ca0720
- X-Request-Id:
- - 1d531721139fbff90848e8fed4ca0720
- X-Sfdc-Edge-Cache:
- - MISS
- body:
- encoding: ASCII-8BIT
- string: '{"totalSize":0,"done":true,"records":[]}'
- http_version:
- recorded_at: Tue, 08 Oct 2024 15:05:06 GMT
-- request:
- method: post
- uri: "/services/data/v51.0/sobjects/Account"
- body:
- encoding: UTF-8
- string: '{"Name":"RSpec University"}'
- headers:
- User-Agent:
- - Faraday v1.10.4
- Content-Type:
- - application/json
- Authorization:
- - OAuth
- Accept-Encoding:
- - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
- Accept:
- - "*/*"
- response:
- status:
- code: 201
- message: Created
- headers:
- Date:
- - Tue, 08 Oct 2024 15:05:07 GMT
- Content-Type:
- - application/json;charset=UTF-8
- Transfer-Encoding:
- - chunked
- Connection:
- - keep-alive
- Cache-Control:
- - no-cache,must-revalidate,max-age=0,no-store,private
- Set-Cookie:
- - BrowserId=tDNXG4WGEe-wDwsazMErgw; domain=.salesforce.com; path=/; expires=Wed,
- 08-Oct-2025 15:05:06 GMT; Max-Age=31536000; secure; SameSite=None
- - CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:06 GMT; Max-Age=31536000;
- secure
- - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:06
- GMT; Max-Age=31536000; secure
- Strict-Transport-Security:
- - max-age=63072000; includeSubDomains
- Location:
- - "/services/data/v51.0/sobjects/Account/001Pc00000LuBMUIA3"
- Vary:
- - Accept-Encoding
- X-Robots-Tag:
- - none
- Sforce-Limit-Info:
- - api-usage=73994/305000
- X-Content-Type-Options:
- - nosniff
- Server:
- - sfdcedge
- X-Sfdc-Request-Id:
- - d667f17af657d93ea9ed7db894cbb01a
- X-Request-Id:
- - d667f17af657d93ea9ed7db894cbb01a
- body:
- encoding: ASCII-8BIT
- string: '{"id":"001Pc00000LuBMUIA3","success":true,"errors":[]}'
- http_version:
- recorded_at: Tue, 08 Oct 2024 15:05:07 GMT
-- request:
- method: post
- uri: "/services/data/v51.0/sobjects/Contact"
- body:
- encoding: UTF-8
- string: '{"FirstName":"Ricardo","LastName":"Reynolds","Email":"coy_mante@rennerryan.co"}'
- headers:
- User-Agent:
- - Faraday v1.10.4
- Content-Type:
- - application/json
- Authorization:
- - OAuth
- Accept-Encoding:
- - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
- Accept:
- - "*/*"
- response:
- status:
- code: 201
- message: Created
- headers:
- Date:
- - Tue, 08 Oct 2024 15:05:08 GMT
- Content-Type:
- - application/json;charset=UTF-8
- Transfer-Encoding:
- - chunked
- Connection:
- - keep-alive
- Strict-Transport-Security:
- - max-age=63072000; includeSubDomains
- Set-Cookie:
- - BrowserId=tNi-loWGEe-y9O39Swt3yg; domain=.salesforce.com; path=/; expires=Wed,
- 08-Oct-2025 15:05:07 GMT; Max-Age=31536000; secure; SameSite=None
- - CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:07 GMT; Max-Age=31536000;
- secure
- - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:07
- GMT; Max-Age=31536000; secure
- X-Content-Type-Options:
- - nosniff
- Vary:
- - Accept-Encoding
- Cache-Control:
- - no-cache,must-revalidate,max-age=0,no-store,private
- X-Robots-Tag:
- - none
- Sforce-Limit-Info:
- - api-usage=73994/305000
- Location:
- - "/services/data/v51.0/sobjects/Contact/003Pc00000NsWSHIA3"
- Server:
- - sfdcedge
- X-Sfdc-Request-Id:
- - 489526c71950273d3bb34cf207785209
- X-Request-Id:
- - 489526c71950273d3bb34cf207785209
- body:
- encoding: ASCII-8BIT
- string: '{"id":"003Pc00000NsWSHIA3","success":true,"errors":[]}'
- http_version:
- recorded_at: Tue, 08 Oct 2024 15:05:08 GMT
-- request:
- method: get
- uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20FirstName,%20LastName,%20Email,%20FV_Status__c,%20LastModifiedDate,%20AccountId,%20School_Type__c,%20All_Emails__c,%20Adoption_Status__c,%20Accounts_UUID__c,%20LeadSource,%20Signup_Date__c,%20Assignable_Interest__c,%20Assignable_Adoption_Date__c%20FROM%20Contact%20WHERE%20(Id%20=%20%27003Pc00000NsWSHIA3%27)%20LIMIT%201"
- body:
- encoding: US-ASCII
- string: ''
- headers:
- User-Agent:
- - Faraday v1.10.4
- Authorization:
- - OAuth
- Accept-Encoding:
- - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
- Accept:
- - "*/*"
- response:
- status:
- code: 200
- message: OK
- headers:
- Date:
- - Tue, 08 Oct 2024 15:05:08 GMT
- Content-Type:
- - application/json;charset=UTF-8
- Transfer-Encoding:
- - chunked
- Connection:
- - keep-alive
- Strict-Transport-Security:
- - max-age=63072000; includeSubDomains
- Vary:
- - Accept-Encoding
- Cache-Control:
- - no-cache,must-revalidate,max-age=0,no-store,private
- X-Robots-Tag:
- - none
- Sforce-Limit-Info:
- - api-usage=73995/305000
- Set-Cookie:
- - BrowserId=tYayXIWGEe-ww7eu90yMBg; domain=.salesforce.com; path=/; expires=Wed,
- 08-Oct-2025 15:05:08 GMT; Max-Age=31536000; secure; SameSite=None
- - CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:08 GMT; Max-Age=31536000;
- secure
- - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:08
- GMT; Max-Age=31536000; secure
- X-Content-Type-Options:
- - nosniff
- Server:
- - sfdcedge
- X-Sfdc-Request-Id:
- - 91376a8284ef074935f855444a168604
- X-Request-Id:
- - 91376a8284ef074935f855444a168604
- X-Sfdc-Edge-Cache:
- - MISS
- body:
- encoding: ASCII-8BIT
- string: '{"totalSize":1,"done":true,"records":[{"attributes":{"type":"Contact","url":"/services/data/v51.0/sobjects/Contact/003Pc00000NsWSHIA3"},"Id":"003Pc00000NsWSHIA3","Name":"Ricardo
- Reynolds","FirstName":"Ricardo","LastName":"Reynolds","Email":"coy_mante@rennerryan.co","FV_Status__c":null,"LastModifiedDate":"2024-10-08T15:05:08.000+0000","AccountId":null,"School_Type__c":null,"All_Emails__c":"coy_mante@rennerryan.co","Adoption_Status__c":"Not
- Adopter","Accounts_UUID__c":null,"LeadSource":null,"Signup_Date__c":null,"Assignable_Interest__c":null,"Assignable_Adoption_Date__c":null}]}'
- http_version:
- recorded_at: Tue, 08 Oct 2024 15:05:08 GMT
-recorded_with: VCR 3.0.3
diff --git a/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_Contact_does_not_exist_in_SF.yml b/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_Contact_does_not_exist_in_SF.yml
deleted file mode 100644
index ef863691d0..0000000000
--- a/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_Contact_does_not_exist_in_SF.yml
+++ /dev/null
@@ -1,63 +0,0 @@
----
-http_interactions:
-- request:
- method: get
- uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20FirstName,%20LastName,%20Email,%20FV_Status__c,%20LastModifiedDate,%20AccountId,%20School_Type__c,%20All_Emails__c,%20Adoption_Status__c,%20Accounts_UUID__c,%20LeadSource,%20Signup_Date__c,%20Assignable_Interest__c,%20Assignable_Adoption_Date__c%20FROM%20Contact%20WHERE%20(Id%20=%20%270010v000002Wo0qAAC%27)%20LIMIT%201"
- body:
- encoding: US-ASCII
- string: ''
- headers:
- User-Agent:
- - Faraday v1.10.4
- Authorization:
- - OAuth
- Accept-Encoding:
- - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
- Accept:
- - "*/*"
- response:
- status:
- code: 200
- message: OK
- headers:
- Date:
- - Tue, 08 Oct 2024 15:05:05 GMT
- Content-Type:
- - application/json;charset=UTF-8
- Transfer-Encoding:
- - chunked
- Connection:
- - keep-alive
- Strict-Transport-Security:
- - max-age=63072000; includeSubDomains
- X-Robots-Tag:
- - none
- Sforce-Limit-Info:
- - api-usage=73994/305000
- Cache-Control:
- - no-cache,must-revalidate,max-age=0,no-store,private
- Vary:
- - Accept-Encoding
- X-Content-Type-Options:
- - nosniff
- Set-Cookie:
- - BrowserId=s5KqvoWGEe-oQ-O_-Fe-kw; domain=.salesforce.com; path=/; expires=Wed,
- 08-Oct-2025 15:05:05 GMT; Max-Age=31536000; secure; SameSite=None
- - CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:05 GMT; Max-Age=31536000;
- secure
- - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:05
- GMT; Max-Age=31536000; secure
- Server:
- - sfdcedge
- X-Sfdc-Request-Id:
- - 0f1add6249c71c3786b963e7dbee805c
- X-Request-Id:
- - 0f1add6249c71c3786b963e7dbee805c
- X-Sfdc-Edge-Cache:
- - MISS
- body:
- encoding: ASCII-8BIT
- string: '{"totalSize":0,"done":true,"records":[]}'
- http_version:
- recorded_at: Tue, 08 Oct 2024 15:05:05 GMT
-recorded_with: VCR 3.0.3
diff --git a/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_ID_is_malformed.yml b/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_ID_is_malformed.yml
deleted file mode 100644
index d606ede9e1..0000000000
--- a/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_ID_is_malformed.yml
+++ /dev/null
@@ -1,61 +0,0 @@
----
-http_interactions:
-- request:
- method: get
- uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20FirstName,%20LastName,%20Email,%20FV_Status__c,%20LastModifiedDate,%20AccountId,%20School_Type__c,%20All_Emails__c,%20Adoption_Status__c,%20Accounts_UUID__c,%20LeadSource,%20Signup_Date__c,%20Assignable_Interest__c,%20Assignable_Adoption_Date__c%20FROM%20Contact%20WHERE%20(Id%20=%20%27somethingwonky%27)%20LIMIT%201"
- body:
- encoding: US-ASCII
- string: ''
- headers:
- User-Agent:
- - Faraday v1.10.4
- Authorization:
- - OAuth
- Accept-Encoding:
- - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
- Accept:
- - "*/*"
- response:
- status:
- code: 400
- message: Bad Request
- headers:
- Date:
- - Tue, 08 Oct 2024 15:05:06 GMT
- Content-Type:
- - application/json;charset=UTF-8
- Transfer-Encoding:
- - chunked
- Connection:
- - keep-alive
- X-Content-Type-Options:
- - nosniff
- Cache-Control:
- - no-cache,must-revalidate,max-age=0,no-store,private
- X-Robots-Tag:
- - none
- Sforce-Limit-Info:
- - api-usage=73994/305000
- Strict-Transport-Security:
- - max-age=63072000; includeSubDomains
- Set-Cookie:
- - BrowserId=s9IkRYWGEe-vVKF0vQBeQQ; domain=.salesforce.com; path=/; expires=Wed,
- 08-Oct-2025 15:05:06 GMT; Max-Age=31536000; secure; SameSite=None
- - CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:06 GMT; Max-Age=31536000;
- secure
- - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:06
- GMT; Max-Age=31536000; secure
- Server:
- - sfdcedge
- X-Sfdc-Request-Id:
- - b224126dea1d50eea19f4cbe8c6fa855
- X-Request-Id:
- - b224126dea1d50eea19f4cbe8c6fa855
- body:
- encoding: UTF-8
- string: '[{"message":"\nAssignable_Adoption_Date__c FROM Contact WHERE (Id =
- ''somethingwonky'') LIMIT 1\n ^\nERROR
- at Row:1:Column:258\ninvalid ID field: somethingwonky","errorCode":"INVALID_QUERY_FILTER_OPERATOR"}]'
- http_version:
- recorded_at: Tue, 08 Oct 2024 15:05:06 GMT
-recorded_with: VCR 3.0.3
diff --git a/spec/cassettes/Change_Salesforce_contact_manually/sf_setup.yml b/spec/cassettes/Change_Salesforce_contact_manually/sf_setup.yml
deleted file mode 100644
index 21e182a655..0000000000
--- a/spec/cassettes/Change_Salesforce_contact_manually/sf_setup.yml
+++ /dev/null
@@ -1,53 +0,0 @@
----
-http_interactions:
-- request:
- method: post
- uri: https:///services/oauth2/token
- body:
- encoding: US-ASCII
- string: grant_type=password&client_id=&client_secret=&username=&password=
- headers:
- User-Agent:
- - Faraday v1.10.4
- Content-Type:
- - application/x-www-form-urlencoded
- Accept-Encoding:
- - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
- Accept:
- - "*/*"
- response:
- status:
- code: 200
- message: OK
- headers:
- Date:
- - Tue, 08 Oct 2024 15:05:04 GMT
- Set-Cookie:
- - BrowserId=suMwfYWGEe-7dIfpWPOQQA; domain=.salesforce.com; path=/; expires=Wed,
- 08-Oct-2025 15:05:04 GMT; Max-Age=31536000; secure; SameSite=None
- - CookieConsentPolicy=0:0; path=/; expires=Wed, 08-Oct-2025 15:05:04 GMT; Max-Age=31536000;
- secure
- - LSKey-c$CookieConsentPolicy=0:0; path=/; expires=Wed, 08-Oct-2025 15:05:04
- GMT; Max-Age=31536000; secure
- Strict-Transport-Security:
- - max-age=63072000; includeSubDomains
- X-Content-Type-Options:
- - nosniff
- Cache-Control:
- - no-cache,must-revalidate,max-age=0,no-store,private
- Expires:
- - Thu, 01 Jan 1970 00:00:00 GMT
- X-Readonlymode:
- - 'false'
- Content-Type:
- - application/json;charset=UTF-8
- Vary:
- - Accept-Encoding
- Transfer-Encoding:
- - chunked
- body:
- encoding: ASCII-8BIT
- string: '{"access_token":"","instance_url":"","id":"https:///id/00DU0000000KwchMAC/005U0000005akrEIAQ","token_type":"Bearer","issued_at":"1728399904705","signature":""}'
- http_version:
- recorded_at: Tue, 08 Oct 2024 15:05:04 GMT
-recorded_with: VCR 3.0.3
diff --git a/spec/cassettes/Newflow/Students/student_signup_flow/sf_setup.yml b/spec/cassettes/Newflow/Students/student_signup_flow/sf_setup.yml
deleted file mode 100644
index 484c3538d8..0000000000
--- a/spec/cassettes/Newflow/Students/student_signup_flow/sf_setup.yml
+++ /dev/null
@@ -1,52 +0,0 @@
----
-http_interactions:
-- request:
- method: post
- uri: https:///services/oauth2/token
- body:
- encoding: US-ASCII
- string: grant_type=password&client_id=&client_secret=&username=&password=
- headers:
- User-Agent:
- - Faraday v1.10.4
- Content-Type:
- - application/x-www-form-urlencoded
- Accept-Encoding:
- - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
- Accept:
- - "*/*"
- response:
- status:
- code: 200
- message: OK
- headers:
- Date:
- - Fri, 18 Oct 2024 23:41:46 GMT
- Set-Cookie:
- - BrowserId=iWXHOY2qEe-om4-Ar5tPbw; domain=.salesforce.com; path=/; expires=Sat,
- 18-Oct-2025 23:41:46 GMT; Max-Age=31536000; secure; SameSite=None
- - CookieConsentPolicy=0:0; path=/; expires=Sat, 18-Oct-2025 23:41:46 GMT; Max-Age=31536000;
- secure
- - LSKey-c$CookieConsentPolicy=0:0; path=/; expires=Sat, 18-Oct-2025 23:41:46
- GMT; Max-Age=31536000; secure
- Strict-Transport-Security:
- - max-age=63072000; includeSubDomains
- X-Content-Type-Options:
- - nosniff
- Cache-Control:
- - no-cache,must-revalidate,max-age=0,no-store,private
- Expires:
- - Thu, 01 Jan 1970 00:00:00 GMT
- X-Readonlymode:
- - 'false'
- Content-Type:
- - application/json;charset=UTF-8
- Vary:
- - Accept-Encoding
- Transfer-Encoding:
- - chunked
- body:
- encoding: ASCII-8BIT
- string: '{"access_token":"","instance_url":"","id":"https:///id/00DU0000000KwchMAC/005U0000005akrEIAQ","token_type":"Bearer","issued_at":"1729294906256","signature":""}'
- recorded_at: Fri, 18 Oct 2024 23:41:46 GMT
-recorded_with: VCR 6.3.1
diff --git a/spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/sf_setup.yml b/spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/sf_setup.yml
deleted file mode 100644
index e655045b83..0000000000
--- a/spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/sf_setup.yml
+++ /dev/null
@@ -1,53 +0,0 @@
----
-http_interactions:
-- request:
- method: post
- uri: https:///services/oauth2/token
- body:
- encoding: US-ASCII
- string: grant_type=password&client_id=&client_secret=&username=&password=
- headers:
- User-Agent:
- - Faraday v1.10.4
- Content-Type:
- - application/x-www-form-urlencoded
- Accept-Encoding:
- - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
- Accept:
- - "*/*"
- response:
- status:
- code: 200
- message: OK
- headers:
- Date:
- - Tue, 08 Oct 2024 15:04:38 GMT
- Set-Cookie:
- - BrowserId=o5XNPYWGEe-UUm_L3BTUAA; domain=.salesforce.com; path=/; expires=Wed,
- 08-Oct-2025 15:04:38 GMT; Max-Age=31536000; secure; SameSite=None
- - CookieConsentPolicy=0:0; path=/; expires=Wed, 08-Oct-2025 15:04:38 GMT; Max-Age=31536000;
- secure
- - LSKey-c$CookieConsentPolicy=0:0; path=/; expires=Wed, 08-Oct-2025 15:04:38
- GMT; Max-Age=31536000; secure
- Strict-Transport-Security:
- - max-age=63072000; includeSubDomains
- X-Content-Type-Options:
- - nosniff
- Cache-Control:
- - no-cache,must-revalidate,max-age=0,no-store,private
- Expires:
- - Thu, 01 Jan 1970 00:00:00 GMT
- X-Readonlymode:
- - 'false'
- Content-Type:
- - application/json;charset=UTF-8
- Vary:
- - Accept-Encoding
- Transfer-Encoding:
- - chunked
- body:
- encoding: ASCII-8BIT
- string: '{"access_token":"","instance_url":"","id":"https:///id/00DU0000000KwchMAC/005U0000005akrEIAQ","token_type":"Bearer","issued_at":"1728399879076","signature":""}'
- http_version:
- recorded_at: Tue, 08 Oct 2024 15:04:39 GMT
-recorded_with: VCR 3.0.3
diff --git a/spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/works_on_the_happy_path.yml b/spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/works_on_the_happy_path.yml
deleted file mode 100644
index 42f8ba44cd..0000000000
--- a/spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/works_on_the_happy_path.yml
+++ /dev/null
@@ -1,171 +0,0 @@
----
-http_interactions:
-- request:
- method: get
- uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20FirstName,%20LastName,%20Salutation,%20Title,%20Subject__c,%20Subject_Interest__c,%20Company,%20City,%20State,%20StateCode,%20Country,%20Phone,%20Website,%20Status,%20Email,%20LeadSource,%20Newsletter__c,%20Newsletter_Opt_In__c,%20Adoption_Status__c,%20AdoptionsJSON__c,%20Number_of_Students__c,%20Accounts_ID__c,%20Accounts_UUID__c,%20Application_Source__c,%20Role__c,%20Position__c,%20who_chooses_books__c,%20FV_Status__c,%20BRI_Marketing__c,%20Title_1_school__c,%20SheerID_School_Name__c,%20Instant_Conversion__c,%20Signup_Date__c,%20Self_Reported_School__c,%20Tracking_Parameters__c,%20Account_ID__c,%20School__c%20FROM%20Lead%20WHERE%20(Email%20=%20NULL)%20LIMIT%201"
- body:
- encoding: US-ASCII
- string: ''
- headers:
- User-Agent:
- - Faraday v1.10.3
- Authorization:
- - OAuth
- Accept-Encoding:
- - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
- Accept:
- - "*/*"
- response:
- status:
- code: 200
- message: OK
- headers:
- Date:
- - Wed, 04 Sep 2024 16:55:25 GMT
- Content-Type:
- - application/json;charset=UTF-8
- Transfer-Encoding:
- - chunked
- Connection:
- - keep-alive
- Strict-Transport-Security:
- - max-age=63072000; includeSubDomains
- X-Content-Type-Options:
- - nosniff
- Cache-Control:
- - no-cache,must-revalidate,max-age=0,no-store,private
- X-Robots-Tag:
- - none
- Server:
- - sfdcedge
- body:
- encoding: ASCII-8BIT
- string: '{"totalSize":0,"done":true,"records":[]}'
- http_version:
- recorded_at: Wed, 04 Sep 2024 16:55:25 GMT
-- request:
- method: get
- uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20BillingCity,%20BillingState,%20BillingCountry,%20Type,%20School_Location__c,%20SheerID_School_Name__c,%20K_I_P__c,%20child_of_kip__c,%20Total_School_Enrollment__c,%20Has_Assignable_Contacts__c%20FROM%20Account%20WHERE%20(Name%20=%20%27Find%20Me%20A%20Home%27)%20LIMIT%201"
- body:
- encoding: US-ASCII
- string: ''
- headers:
- User-Agent:
- - Faraday v1.10.3
- Authorization:
- - OAuth
- Accept-Encoding:
- - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
- Accept:
- - "*/*"
- response:
- status:
- code: 200
- message: OK
- headers:
- Date:
- - Wed, 04 Sep 2024 16:55:26 GMT
- Content-Type:
- - application/json;charset=UTF-8
- Transfer-Encoding:
- - chunked
- Connection:
- - keep-alive
- Strict-Transport-Security:
- - max-age=63072000; includeSubDomains
- X-Content-Type-Options:
- - nosniff
- Set-Cookie:
- - BrowserId=fBTaLmreEe-4jc39-qgTow; domain=.salesforce.com; path=/; expires=Thu,
- 04-Sep-2025 16:55:26 GMT; Max-Age=31536000; secure; SameSite=None
- - CookieConsentPolicy=0:1; path=/; expires=Thu, 04-Sep-2025 16:55:26 GMT; Max-Age=31536000;
- secure
- - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Thu, 04-Sep-2025 16:55:26
- GMT; Max-Age=31536000; secure
- Vary:
- - Accept-Encoding
- Sforce-Limit-Info:
- - api-usage=37968/5000000
- Cache-Control:
- - no-cache,must-revalidate,max-age=0,no-store,private
- X-Robots-Tag:
- - none
- Server:
- - sfdcedge
- X-Sfdc-Request-Id:
- - 25f505e7c7c08bdbfdf32fde9d97142b
- X-Sfdc-Edge-Cache:
- - MISS
- body:
- encoding: ASCII-8BIT
- string: '{"totalSize":1,"done":true,"records":[{"attributes":{"type":"Account","url":"/services/data/v51.0/sobjects/Account/0016f00002iPs9mAAC"},"Id":"0016f00002iPs9mAAC","Name":"Find
- Me A Home","BillingCity":"Abu Dhabi","BillingState":null,"BillingCountry":"United
- Arab Emirates","Type":"Other","School_Location__c":"Foreign","SheerID_School_Name__c":"Find
- Me A Home","K_I_P__c":false,"child_of_kip__c":false,"Total_School_Enrollment__c":null,"20Has_Assignable_Contacts__c":false}]}'
- http_version:
- recorded_at: Wed, 04 Sep 2024 16:55:26 GMT
-- request:
- method: post
- uri: "/services/data/v51.0/sobjects/Lead"
- body:
- encoding: UTF-8
- string: '{"FirstName":"Max","LastName":"Liebermann","Subject_Interest__c":"AP
- Macro Econ","Company":"Test University","Country":"United States","Phone":"+17133484799","LeadSource":"Account
- Creation","Newsletter__c":false,"Newsletter_Opt_In__c":false,"Adoption_Status__c":"Confirmed
- Adoption Won","Number_of_Students__c":"35","Accounts_ID__c":1,"Accounts_UUID__c":"86ed61c4-e556-4a05-9c55-5453bbdd9f28","Application_Source__c":"Accounts","Role__c":"Instructor","Position__c":"instructor","who_chooses_books__c":"instructor","FV_Status__c":"pending_faculty","BRI_Marketing__c":false,"Title_1_school__c":false,"Signup_Date__c":"2024-09-04T16:55:26.508+0000","Self_Reported_School__c":"Test
- University","Tracking_Parameters__c":"https://dev.openstax.org/accounts/i/signup/","Account_ID__c":"0016f00002iPs9mAAC","School__c":"0016f00002iPs9mAAC"}'
- headers:
- User-Agent:
- - Faraday v1.10.3
- Content-Type:
- - application/json
- Authorization:
- - OAuth
- Accept-Encoding:
- - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
- Accept:
- - "*/*"
- response:
- status:
- code: 201
- message: Created
- headers:
- Date:
- - Wed, 04 Sep 2024 16:55:32 GMT
- Content-Type:
- - application/json;charset=UTF-8
- Transfer-Encoding:
- - chunked
- Connection:
- - keep-alive
- Strict-Transport-Security:
- - max-age=63072000; includeSubDomains
- Location:
- - "/services/data/v51.0/sobjects/Lead/00QOx00000aLVDoMAO"
- Vary:
- - Accept-Encoding
- Cache-Control:
- - no-cache,must-revalidate,max-age=0,no-store,private
- Sforce-Limit-Info:
- - api-usage=37968/5000000
- Set-Cookie:
- - BrowserId=fEgf2WreEe-pC3-63Rbixw; domain=.salesforce.com; path=/; expires=Thu,
- 04-Sep-2025 16:55:27 GMT; Max-Age=31536000; secure; SameSite=None
- - CookieConsentPolicy=0:1; path=/; expires=Thu, 04-Sep-2025 16:55:27 GMT; Max-Age=31536000;
- secure
- - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Thu, 04-Sep-2025 16:55:27
- GMT; Max-Age=31536000; secure
- X-Content-Type-Options:
- - nosniff
- X-Robots-Tag:
- - none
- Server:
- - sfdcedge
- X-Sfdc-Request-Id:
- - 195fe1b23acf0aa4438508370eda95c5
- body:
- encoding: ASCII-8BIT
- string: '{"id":"00QOx00000aLVDoMAO","success":true,"errors":[]}'
- http_version:
- recorded_at: Wed, 04 Sep 2024 16:55:32 GMT
-recorded_with: VCR 3.0.3
diff --git a/spec/cassettes/Newflow_VerifyEmailByCode/sf_setup.yml b/spec/cassettes/Newflow_VerifyEmailByCode/sf_setup.yml
deleted file mode 100644
index c058ca1651..0000000000
--- a/spec/cassettes/Newflow_VerifyEmailByCode/sf_setup.yml
+++ /dev/null
@@ -1,54 +0,0 @@
----
-http_interactions:
-- request:
- method: post
- uri: https:///services/oauth2/token
- body:
- encoding: US-ASCII
- string: grant_type=password&client_id=&client_secret=&username=&password=
- headers:
- User-Agent:
- - Faraday v1.0.1
- Content-Type:
- - application/x-www-form-urlencoded
- Accept-Encoding:
- - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
- Accept:
- - "*/*"
- response:
- status:
- code: 200
- message: OK
- headers:
- Date:
- - Wed, 15 Nov 2023 16:14:59 GMT
- Set-Cookie:
- - BrowserId=H-5da4PSEe6uFMnuBFn0Jg; domain=.salesforce.com; path=/; expires=Thu,
- 14-Nov-2024 16:14:59 GMT; Max-Age=31536000
- - CookieConsentPolicy=0:0; path=/; expires=Thu, 14-Nov-2024 16:14:59 GMT; Max-Age=31536000
- - LSKey-c$CookieConsentPolicy=0:0; path=/; expires=Thu, 14-Nov-2024 16:14:59
- GMT; Max-Age=31536000
- Strict-Transport-Security:
- - max-age=63072000; includeSubDomains
- X-Content-Type-Options:
- - nosniff
- X-Xss-Protection:
- - 1; mode=block
- Cache-Control:
- - no-cache,must-revalidate,max-age=0,no-store,private
- Expires:
- - Thu, 01 Jan 1970 00:00:00 GMT
- X-Readonlymode:
- - 'false'
- Content-Type:
- - application/json;charset=UTF-8
- Vary:
- - Accept-Encoding
- Transfer-Encoding:
- - chunked
- body:
- encoding: ASCII-8BIT
- string: '{"access_token":"","instance_url":"","id":"https:///id/00D040000003rgWEAQ/005U0000005MXdmIAG","token_type":"Bearer","issued_at":"1700064900732","signature":""}'
- http_version:
- recorded_at: Wed, 15 Nov 2023 16:15:00 GMT
-recorded_with: VCR 3.0.3
diff --git a/spec/controllers/admin/salesforce_drift_findings_controller_spec.rb b/spec/controllers/admin/salesforce_drift_findings_controller_spec.rb
new file mode 100644
index 0000000000..879b22961e
--- /dev/null
+++ b/spec/controllers/admin/salesforce_drift_findings_controller_spec.rb
@@ -0,0 +1,53 @@
+require 'rails_helper'
+
+module Admin
+ describe SalesforceDriftFindingsController, type: :controller do
+ let(:admin) { FactoryBot.create :user, :admin, :terms_agreed }
+
+ before { controller.sign_in! admin }
+
+ describe 'GET #index' do
+ let!(:finding) { FactoryBot.create(:salesforce_drift_finding) }
+
+ it 'lists open findings' do
+ get :index
+ expect(response).to have_http_status(:ok)
+ expect(assigns(:findings)).to include(finding)
+ end
+
+ it 'filters by category' do
+ FactoryBot.create(:salesforce_drift_finding, category: 'sf_orphan_lead')
+ get :index, params: { category: 'sf_orphan_lead' }
+ expect(assigns(:findings).map(&:category)).to all(eq('sf_orphan_lead'))
+ end
+
+ it 'filters by user_id' do
+ user = FactoryBot.create(:user)
+ mine = FactoryBot.create(:salesforce_drift_finding, user: user)
+ get :index, params: { user_id: user.id }
+ expect(assigns(:findings)).to contain_exactly(mine)
+ end
+
+ it 'excludes resolved findings' do
+ resolved = FactoryBot.create(:salesforce_drift_finding, resolved_at: 1.day.ago)
+ get :index
+ expect(assigns(:findings)).not_to include(resolved)
+ end
+ end
+
+ describe 'PATCH #update' do
+ let!(:finding) { FactoryBot.create(:salesforce_drift_finding) }
+
+ it 'marks the finding resolved' do
+ patch :update, params: { id: finding.id }
+ expect(finding.reload.resolved_at).not_to be_nil
+ end
+
+ it 'redirects to the index with a notice' do
+ patch :update, params: { id: finding.id }
+ expect(response).to redirect_to(admin_salesforce_drift_findings_path)
+ expect(flash[:notice]).to match(/resolved/i)
+ end
+ end
+ end
+end
diff --git a/spec/factories/salesforce_drift_findings.rb b/spec/factories/salesforce_drift_findings.rb
new file mode 100644
index 0000000000..be0f36ae75
--- /dev/null
+++ b/spec/factories/salesforce_drift_findings.rb
@@ -0,0 +1,10 @@
+FactoryBot.define do
+ factory :salesforce_drift_finding do
+ category { 'sf_orphan_contact' }
+ salesforce_record_type { 'Contact' }
+ salesforce_record_id { SecureRandom.hex(8) }
+ details { {} }
+ first_seen_at { Time.current }
+ last_seen_at { Time.current }
+ end
+end
diff --git a/spec/features/admin/change_salesforce_contact_manually_spec.rb b/spec/features/admin/change_salesforce_contact_manually_spec.rb
index 07ea6df8d5..c0786b4e24 100644
--- a/spec/features/admin/change_salesforce_contact_manually_spec.rb
+++ b/spec/features/admin/change_salesforce_contact_manually_spec.rb
@@ -1,14 +1,6 @@
require 'rails_helper'
-require 'vcr_helper'
-
-feature 'Change Salesforce contact manually', js: true, vcr: VCR_OPTS do
- before(:all) do
- VCR.use_cassette('Change_Salesforce_contact_manually/sf_setup', VCR_OPTS) do
- @proxy = SalesforceProxy.new
- @proxy.setup_cassette
- end
- end
+feature 'Change Salesforce contact manually', js: true do
before(:each) do
@admin_user = create_admin_user
visit '/'
@@ -21,6 +13,7 @@
it 'can be removed' do
@target_user.update_attribute(:salesforce_contact_id, 'something')
+ visit "/admin/users/#{@target_user.id}/edit"
fill_in 'user_salesforce_contact_id', with: 'remove'
click_button 'Save'
expect(page).to have_content('successfully updated')
@@ -29,29 +22,41 @@
end
it 'can be set if the Contact exists in SF' do
- contact = @proxy.new_contact
- fill_in 'user_salesforce_contact_id', with: contact.id
+ contact = Salesforce::Records::Contact.new(
+ id: '003AB000000XYZ', accounts_uuid: @target_user.uuid,
+ master_record_id: nil, is_deleted: false
+ )
+ allow(Salesforce::Records::Contact).to receive(:find).with('003AB000000XYZ').and_return(contact)
+
+ fill_in 'user_salesforce_contact_id', with: '003AB000000XYZ'
click_button 'Save'
expect(page).to have_content('successfully updated')
@target_user.reload
- expect(@target_user.salesforce_contact_id).to eq contact.id
+ expect(@target_user.salesforce_contact_id).to eq '003AB000000XYZ'
end
it 'cannot be set if the Contact does not exist in SF' do
@target_user.update_attribute(:salesforce_contact_id, 'original')
+ allow(Salesforce::Records::Contact).to receive(:find).with('0010v000002Wo0qAAC').and_return(nil)
+
+ visit "/admin/users/#{@target_user.id}/edit"
fill_in 'user_salesforce_contact_id', with: '0010v000002Wo0qAAC'
click_button 'Save'
expect(page).to have_content("Can't find")
@target_user.reload
- expect(@target_user.salesforce_contact_id).to eq "original"
+ expect(@target_user.salesforce_contact_id).to eq 'original'
end
it 'cannot be set if the ID is malformed' do
@target_user.update_attribute(:salesforce_contact_id, 'original')
+ allow(Salesforce::Records::Contact).to receive(:find).with('somethingwonky')
+ .and_raise(StandardError, 'malformed id')
+
+ visit "/admin/users/#{@target_user.id}/edit"
fill_in 'user_salesforce_contact_id', with: 'somethingwonky'
click_button 'Save'
expect(page).to have_content('Failed')
@target_user.reload
- expect(@target_user.salesforce_contact_id).to eq "original"
+ expect(@target_user.salesforce_contact_id).to eq 'original'
end
end
diff --git a/spec/features/newflow/student_signup_flow_spec.rb b/spec/features/newflow/student_signup_flow_spec.rb
index 9db7c86f13..4bbd2f8c10 100644
--- a/spec/features/newflow/student_signup_flow_spec.rb
+++ b/spec/features/newflow/student_signup_flow_spec.rb
@@ -1,20 +1,12 @@
require 'rails_helper'
-require 'vcr_helper'
require 'byebug'
module Newflow
- feature 'Student signup flow', js: true, vcr: VCR_OPTS do
+ feature 'Student signup flow', js: true do
before do
load 'db/seeds.rb'
turn_on_student_feature_flag
end
- before(:all) do
- VCR.use_cassette('Newflow/Students/student_signup_flow/sf_setup', VCR_OPTS) do
- @proxy = SalesforceProxy.new
- @proxy.setup_cassette
- end
- end
-
let(:email) do
Faker::Internet::email
end
diff --git a/spec/handlers/verify_email_by_code_spec.rb b/spec/handlers/verify_email_by_code_spec.rb
index 2c1a4f18ef..c997c92353 100644
--- a/spec/handlers/verify_email_by_code_spec.rb
+++ b/spec/handlers/verify_email_by_code_spec.rb
@@ -1,20 +1,12 @@
require 'rails_helper'
-require 'vcr_helper'
-describe VerifyEmailByCode, type: :handler, vcr: VCR_OPTS do
+describe VerifyEmailByCode, type: :handler do
subject(:handler_call) { described_class.call(params: params) }
let(:params) { { code: email.confirmation_code } }
let(:email) { FactoryBot.create(:email_address, user: user) }
let(:user) { FactoryBot.create(:user, state: User::UNVERIFIED, role: role) }
- before(:all) do
- VCR.use_cassette('Newflow_VerifyEmailByCode/sf_setup', VCR_OPTS) do
- @proxy = SalesforceProxy.new
- @proxy.setup_cassette
- end
- end
-
context 'when student' do
let(:role) { User.roles[:student] }
diff --git a/spec/lib/settings_salesforce_spec.rb b/spec/lib/settings_salesforce_spec.rb
new file mode 100644
index 0000000000..a8e80a1e0e
--- /dev/null
+++ b/spec/lib/settings_salesforce_spec.rb
@@ -0,0 +1,36 @@
+require 'rails_helper'
+
+RSpec.describe Settings::Salesforce do
+ describe 'contacts_synced_through' do
+ after { Settings::Db.store.salesforce_contacts_synced_through = nil }
+
+ it 'round-trips a UTC time as ISO8601' do
+ t = Time.utc(2026, 5, 1, 12, 0, 0)
+ Settings::Salesforce.contacts_synced_through = t
+ expect(Settings::Salesforce.contacts_synced_through).to eq(t)
+ end
+
+ it 'returns nil when unset' do
+ Settings::Db.store.salesforce_contacts_synced_through = nil
+ expect(Settings::Salesforce.contacts_synced_through).to be_nil
+ end
+ end
+
+ it 'exposes reconcile_max_queries default' do
+ expect(Settings::Salesforce.reconcile_max_queries).to eq(5000)
+ end
+
+ it 'exposes alert thresholds' do
+ expect(Settings::Salesforce.alert_contact_id_conflict_count).to eq(5)
+ expect(Settings::Salesforce.alert_contact_id_swap_rate_pct).to eq(5)
+ expect(Settings::Salesforce.alert_drift_open_total).to eq(100)
+ end
+
+ it 'exposes the feature flag through Settings::FeatureFlags' do
+ expect(Settings::FeatureFlags.salesforce_reconcile_self_heal).to be(false)
+ Settings::FeatureFlags.salesforce_reconcile_self_heal = true
+ expect(Settings::FeatureFlags.salesforce_reconcile_self_heal).to be(true)
+ ensure
+ Settings::FeatureFlags.salesforce_reconcile_self_heal = false
+ end
+end
diff --git a/spec/models/salesforce_drift_finding_spec.rb b/spec/models/salesforce_drift_finding_spec.rb
new file mode 100644
index 0000000000..64c9520183
--- /dev/null
+++ b/spec/models/salesforce_drift_finding_spec.rb
@@ -0,0 +1,80 @@
+require 'rails_helper'
+
+RSpec.describe SalesforceDriftFinding do
+ let(:user) { FactoryBot.create(:user) }
+
+ it 'is valid with category and timestamps' do
+ f = described_class.new(
+ category: 'sf_orphan_contact',
+ first_seen_at: Time.current,
+ last_seen_at: Time.current
+ )
+ expect(f).to be_valid
+ end
+
+ it 'belongs to a user optionally' do
+ f = FactoryBot.create(:salesforce_drift_finding, user: user)
+ expect(f.user).to eq(user)
+ end
+
+ it 'requires category' do
+ f = described_class.new(first_seen_at: Time.current, last_seen_at: Time.current)
+ expect(f).not_to be_valid
+ end
+
+ describe 'scopes' do
+ let!(:open_f) { FactoryBot.create(:salesforce_drift_finding, resolved_at: nil) }
+ let!(:resolved_f) { FactoryBot.create(:salesforce_drift_finding, resolved_at: 1.day.ago) }
+
+ it '.open returns only unresolved findings' do
+ expect(described_class.open).to contain_exactly(open_f)
+ end
+
+ it '.resolved returns only resolved' do
+ expect(described_class.resolved).to contain_exactly(resolved_f)
+ end
+ end
+
+ describe '.upsert_finding!' do
+ it 'creates when no matching open finding exists' do
+ expect {
+ described_class.upsert_finding!(
+ user: user, category: 'sf_orphan_contact',
+ record_type: 'Contact', record_id: 'C1'
+ )
+ }.to change(described_class, :count).by(1)
+ end
+
+ it 'updates last_seen_at when a matching open finding exists' do
+ existing = FactoryBot.create(:salesforce_drift_finding,
+ user: user, category: 'sf_orphan_contact',
+ salesforce_record_type: 'Contact', salesforce_record_id: 'C1',
+ last_seen_at: 2.days.ago, resolved_at: nil)
+ described_class.upsert_finding!(
+ user: user, category: 'sf_orphan_contact',
+ record_type: 'Contact', record_id: 'C1'
+ )
+ expect(existing.reload.last_seen_at).to be_within(2.seconds).of(Time.current)
+ end
+
+ it 'creates a new one when the prior matching finding was resolved' do
+ FactoryBot.create(:salesforce_drift_finding,
+ user: user, category: 'sf_orphan_contact',
+ salesforce_record_type: 'Contact', salesforce_record_id: 'C1',
+ resolved_at: 1.day.ago)
+ expect {
+ described_class.upsert_finding!(
+ user: user, category: 'sf_orphan_contact',
+ record_type: 'Contact', record_id: 'C1'
+ )
+ }.to change(described_class, :count).by(1)
+ end
+ end
+
+ describe '#resolve!' do
+ it 'sets resolved_at' do
+ f = FactoryBot.create(:salesforce_drift_finding, resolved_at: nil)
+ expect { f.resolve! }.to change { f.reload.resolved_at }.from(nil)
+ end
+ end
+end
diff --git a/spec/models/security_log_spec.rb b/spec/models/security_log_spec.rb
index 40e81aa9bc..12cb50ed35 100644
--- a/spec/models/security_log_spec.rb
+++ b/spec/models/security_log_spec.rb
@@ -19,4 +19,25 @@
it 'cannot be destroyed' do
expect{security_log.destroy}.to raise_error ActiveRecord::ReadOnlyRecord
end
+
+ it 'includes the new salesforce event types added in the sync redesign' do
+ %i[
+ salesforce_lookup_started
+ salesforce_lookup_matched_by_uuid
+ salesforce_upsert_lead_saved
+ salesforce_upsert_lead_save_failed
+ salesforce_lead_id_persist_failed
+ salesforce_stale_contact_id_cleared
+ salesforce_contact_id_swapped
+ salesforce_contact_id_conflict
+ salesforce_contact_skipped_merged_or_deleted
+ salesforce_user_school_not_cached
+ salesforce_reconcile_contact_id_cleared
+ salesforce_reconcile_followed_merge
+ salesforce_link_restored_by_reconcile
+ salesforce_contact_id_orphaned
+ ].each do |sym|
+ expect(SecurityLog.event_types).to have_key(sym.to_s)
+ end
+ end
end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index 68e6004dff..068714b573 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -3,7 +3,6 @@
require 'simplecov_helper'
require File.expand_path('../../config/environment', __FILE__)
Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }
-require 'openstax/salesforce/spec_helpers'
require 'rspec/rails'
require 'capybara/rails'
require 'capybara/email/rspec'
@@ -12,8 +11,6 @@
require 'database_cleaner'
require 'spec_helper'
-include OpenStax::Salesforce::SpecHelpers
-
# https://github.com/colszowka/simplecov/issues/369#issuecomment-313493152
# Load rake tasks so they can be tested.
Rails.application.load_tasks unless defined?(Rake::Task) && Rake::Task.task_defined?('environment')
diff --git a/spec/routines/newflow/create_or_update_salesforce_lead_spec.rb b/spec/routines/newflow/create_or_update_salesforce_lead_spec.rb
index 913ffc4011..39705d6e64 100644
--- a/spec/routines/newflow/create_or_update_salesforce_lead_spec.rb
+++ b/spec/routines/newflow/create_or_update_salesforce_lead_spec.rb
@@ -2,7 +2,9 @@
module Newflow
describe CreateOrUpdateSalesforceLead, type: :routine do
- let!(:school) { FactoryBot.create :school, name: 'Find Me A Home', salesforce_id: 'SF_SCHOOL_HOME' }
+ let!(:home_school) do
+ FactoryBot.create :school, name: 'Find Me A Home', salesforce_id: 'SF_SCHOOL_HOME'
+ end
let(:user) do
User.create do |u|
@@ -29,131 +31,152 @@ module Newflow
before do
stub_sentry
- # Stub the school lookup
- allow(OpenStax::Salesforce::Remote::School).to receive(:find_by).with(name: 'Find Me A Home')
+ allow(Salesforce::Records::School).to receive(:find_by)
+ .with({ name: 'Find Me A Home' })
.and_return(OpenStruct.new(id: 'SF_SCHOOL_HOME'))
end
describe 'creating a new lead' do
- it 'creates a new lead when none exists' do
- # Stub all the search methods to return nil (no existing lead)
- allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil)
- allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(nil)
-
- # Create a mock lead that will be "saved"
- mock_lead = OpenStax::Salesforce::Remote::Lead.new(email: user.best_email_address_for_salesforce)
- allow(OpenStax::Salesforce::Remote::Lead).to receive(:new).and_return(mock_lead)
+ it 'creates a new lead when none exists and saves the lead id' do
+ allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil)
+ allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: user.best_email_address_for_salesforce }).and_return(nil)
+
+ mock_lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce, accounts_uuid: user.uuid)
+ allow(Salesforce::Records::Lead).to receive(:new).and_return(mock_lead)
allow(mock_lead).to receive(:save).and_return(true)
allow(mock_lead).to receive(:id).and_return('SF_LEAD_123')
described_class.call(user: user)
- expect(user.salesforce_lead_id).to eq('SF_LEAD_123')
- expect(SecurityLog.where(event_type: :creating_new_salesforce_lead).count).to eq(1)
+ expect(user.reload.salesforce_lead_id).to eq('SF_LEAD_123')
+ expect(SecurityLog.where(event_type: 'salesforce_upsert_lead_saved', user: user).count).to eq(1)
end
end
describe 'finding existing leads' do
let(:existing_lead) do
- lead = OpenStax::Salesforce::Remote::Lead.new(email: user.best_email_address_for_salesforce)
+ lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce, accounts_uuid: user.uuid)
allow(lead).to receive(:id).and_return('SF_LEAD_EXISTING')
allow(lead).to receive(:save).and_return(true)
lead
end
it 'finds and updates existing lead by UUID' do
- # Stub to return existing lead when searched by UUID
- allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(existing_lead)
+ allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(existing_lead)
described_class.call(user: user)
- expect(user.salesforce_lead_id).to eq('SF_LEAD_EXISTING')
- expect(SecurityLog.where(event_type: :salesforce_lead_found_by_uuid).count).to eq(1)
- expect(SecurityLog.where(event_type: :creating_new_salesforce_lead).count).to eq(0)
+ expect(user.reload.salesforce_lead_id).to eq('SF_LEAD_EXISTING')
+ expect(SecurityLog.where(event_type: 'salesforce_lookup_matched_by_uuid', user: user).count).to eq(1)
end
it 'finds and updates existing lead by email when UUID search fails' do
- # Stub UUID search to return nil, email search to return existing lead
- allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil)
- allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(existing_lead)
+ allow(user).to receive(:best_email_address_for_salesforce).and_return('max@example.com')
+ allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil)
+ allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: 'max@example.com' }).and_return(existing_lead)
+ # User must be findable by id (UpsertLead doesn't go through the User class directly,
+ # but ActiveRecord-side persistence needs to keep working)
+ allow(User).to receive(:find).and_return(user)
described_class.call(user: user)
- expect(user.salesforce_lead_id).to eq('SF_LEAD_EXISTING')
- expect(SecurityLog.where(event_type: :salesforce_lead_found_by_email).count).to eq(1)
- expect(SecurityLog.where(event_type: :creating_new_salesforce_lead).count).to eq(0)
+ expect(user.reload.salesforce_lead_id).to eq('SF_LEAD_EXISTING')
+ expect(SecurityLog.where(event_type: 'salesforce_lookup_matched_by_email', user: user).count).to eq(1)
end
- it 'uses stored lead ID if available' do
- user.salesforce_lead_id = 'SF_LEAD_STORED'
- user.save!
-
- # Stub to return existing lead when searched by ID
- allow(OpenStax::Salesforce::Remote::Lead).to receive(:find).with('SF_LEAD_STORED').and_return(existing_lead)
+ it 'uses stored lead ID if available and it still owns the user' do
+ user.update_column(:salesforce_lead_id, 'SF_LEAD_STORED')
allow(existing_lead).to receive(:id).and_return('SF_LEAD_STORED')
+ allow(Salesforce::Records::Lead).to receive(:find).with('SF_LEAD_STORED').and_return(existing_lead)
described_class.call(user: user)
- expect(user.salesforce_lead_id).to eq('SF_LEAD_STORED')
- # Should not search by UUID or email since it found by ID
- expect(SecurityLog.where(event_type: :salesforce_lead_found_by_uuid).count).to eq(0)
- expect(SecurityLog.where(event_type: :salesforce_lead_found_by_email).count).to eq(0)
+ expect(user.reload.salesforce_lead_id).to eq('SF_LEAD_STORED')
+ expect(SecurityLog.where(event_type: 'salesforce_lookup_matched_by_stored_id', user: user).count).to eq(1)
+ expect(SecurityLog.where(event_type: 'salesforce_lookup_matched_by_uuid', user: user).count).to eq(0)
+ expect(SecurityLog.where(event_type: 'salesforce_lookup_matched_by_email', user: user).count).to eq(0)
end
end
describe 'when lead save fails' do
it 'logs to SecurityLog and Sentry' do
- allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil)
- allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(nil)
+ allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil)
+ allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: user.best_email_address_for_salesforce }).and_return(nil)
- mock_lead = OpenStax::Salesforce::Remote::Lead.new(email: user.best_email_address_for_salesforce)
- allow(OpenStax::Salesforce::Remote::Lead).to receive(:new).and_return(mock_lead)
+ mock_lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce, accounts_uuid: user.uuid)
+ allow(Salesforce::Records::Lead).to receive(:new).and_return(mock_lead)
allow(mock_lead).to receive(:save).and_return(false)
allow(mock_lead).to receive(:errors).and_return(double(full_messages: ['Some SF error']))
described_class.call(user: user)
- expect(SecurityLog.where(event_type: :salesforce_lead_save_failed).count).to eq(1)
+ expect(SecurityLog.where(event_type: 'salesforce_upsert_lead_save_failed', user: user).count).to eq(1)
expect(Sentry).to have_received(:capture_message).with(/Salesforce lead save failed for user #{user.id}/)
end
end
- describe 'when user already has a contact' do
- let(:existing_contact) do
- contact = OpenStruct.new(id: 'SF_CONTACT_123')
- contact
+ describe 'when user already has a contact that still owns them' do
+ let(:owning_contact) do
+ Salesforce::Records::Contact.new(
+ id: 'SF_CONTACT_123', accounts_uuid: user.uuid,
+ master_record_id: nil, is_deleted: false
+ )
end
- it 'does not create a lead if user already has a contact' do
- user.salesforce_contact_id = 'SF_CONTACT_123'
- user.save!
+ it 'does not create a lead' do
+ user.update_column(:salesforce_contact_id, 'SF_CONTACT_123')
+
+ allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil)
+ allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: user.best_email_address_for_salesforce }).and_return(nil)
+ allow(Salesforce::Records::Contact).to receive(:find).with('SF_CONTACT_123').and_return(owning_contact)
+
+ expect(Salesforce::Records::Lead).not_to receive(:new)
+ described_class.call(user: user)
+
+ expect(SecurityLog.where(event_type: 'salesforce_upsert_lead_skipped_user_has_contact', user: user).count).to eq(1)
+ end
+ end
- # Stub all lead searches to return nil
- allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil)
- allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(nil)
+ describe 'when stored contact_id no longer owns the user' do
+ it 'clears the stored contact_id and proceeds to create a lead' do
+ user.update_column(:salesforce_contact_id, 'SF_CONTACT_STALE')
- # Stub contact lookup to return existing contact
- allow(OpenStax::Salesforce::Remote::Contact).to receive(:find).with('SF_CONTACT_123').and_return(existing_contact)
+ # Stored contact lookup returns a contact that doesn't own this user
+ foreign_contact = Salesforce::Records::Contact.new(
+ id: 'SF_CONTACT_STALE', accounts_uuid: 'OTHER',
+ master_record_id: nil, is_deleted: false
+ )
+ allow(Salesforce::Records::Contact).to receive(:find).with('SF_CONTACT_STALE').and_return(foreign_contact)
+ # UUID-based contact lookup also returns nothing for this user
+ allow(Salesforce::Records::Contact).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil)
- result = described_class.call(user: user)
+ allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil)
+ allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: user.best_email_address_for_salesforce }).and_return(nil)
- expect(result.outputs.lead).to be_nil
- expect(SecurityLog.where(event_type: :user_already_has_contact_not_creating_lead).count).to eq(1)
- expect(SecurityLog.where(event_type: :creating_new_salesforce_lead).count).to eq(0)
+ mock_lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce, accounts_uuid: user.uuid)
+ allow(Salesforce::Records::Lead).to receive(:new).and_return(mock_lead)
+ allow(mock_lead).to receive(:save).and_return(true)
+ allow(mock_lead).to receive(:id).and_return('SF_LEAD_NEW')
+
+ described_class.call(user: user)
+
+ expect(user.reload.salesforce_contact_id).to be_nil
+ expect(user.reload.salesforce_lead_id).to eq('SF_LEAD_NEW')
+ expect(SecurityLog.where(event_type: 'salesforce_stale_contact_id_cleared', user: user).count).to eq(1)
end
end
describe 'expected_start_semester assignment' do
let(:mock_lead) do
- lead = OpenStax::Salesforce::Remote::Lead.new(email: user.best_email_address_for_salesforce)
+ lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce, accounts_uuid: user.uuid)
allow(lead).to receive(:save).and_return(true)
allow(lead).to receive(:id).and_return('SF_LEAD_999')
lead
end
before do
- allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).and_return(nil)
- allow(OpenStax::Salesforce::Remote::Lead).to receive(:new).and_return(mock_lead)
+ allow(Salesforce::Records::Lead).to receive(:find_by).and_return(nil)
+ allow(Salesforce::Records::Lead).to receive(:new).and_return(mock_lead)
end
[
@@ -171,17 +194,13 @@ module Newflow
it 'assigns nil when user.expected_start_semester is nil' do
user.update_column(:expected_start_semester, nil)
-
described_class.call(user: user)
-
expect(mock_lead.expected_start_semester).to be_nil
end
it 'assigns nil when user.expected_start_semester is an unrecognized value' do
user.update_column(:expected_start_semester, 'garbage')
-
described_class.call(user: user)
-
expect(mock_lead.expected_start_semester).to be_nil
end
end
diff --git a/spec/routines/update_salesforce_assignable_fields_spec.rb b/spec/routines/update_salesforce_assignable_fields_spec.rb
index f30ee1c49e..99705da0fb 100644
--- a/spec/routines/update_salesforce_assignable_fields_spec.rb
+++ b/spec/routines/update_salesforce_assignable_fields_spec.rb
@@ -23,15 +23,15 @@
it "updates Salesforce Contact with Assignable user's info" do
stub_contacts [ non_assignable_instructor, assignable_instructor ]
- expect_any_instance_of(OpenStax::Salesforce::Remote::Contact).to(
+ expect_any_instance_of(Salesforce::Records::Contact).to(
receive(:assignable_interest=).with('Fully Integrated').and_call_original
)
- expect_any_instance_of(OpenStax::Salesforce::Remote::Contact).to(
+ expect_any_instance_of(Salesforce::Records::Contact).to(
receive(:assignable_adoption_date=).with(
assignable_instructor.external_ids.map(&:created_at).min.strftime('%Y-%m-%d')
).and_call_original
)
- expect_any_instance_of(OpenStax::Salesforce::Remote::Contact).to receive(:save!)
+ expect_any_instance_of(Salesforce::Records::Contact).to receive(:save!)
described_class.call
end
@@ -40,9 +40,9 @@
def stub_contacts(users)
sf_contacts = [users].flatten.map do |user|
id = user.salesforce_contact_id
- [ id, OpenStax::Salesforce::Remote::Contact.new(id: id) ]
+ [ id, Salesforce::Records::Contact.new(id: id) ]
end.to_h
- expect(OpenStax::Salesforce::Remote::Contact).to receive(:find) { |id| sf_contacts[id] }
+ expect(Salesforce::Records::Contact).to receive(:find) { |id| sf_contacts[id] }
end
end
diff --git a/spec/routines/update_school_salesforce_info_spec.rb b/spec/routines/update_school_salesforce_info_spec.rb
index 884930671d..2d4214a82d 100644
--- a/spec/routines/update_school_salesforce_info_spec.rb
+++ b/spec/routines/update_school_salesforce_info_spec.rb
@@ -64,17 +64,17 @@ def stub_schools(schools)
attrs['id'] = attrs.delete('salesforce_id')
attrs['school_location'] = attrs.delete('location')
- OpenStax::Salesforce::Remote::School.new attrs
+ Salesforce::Records::School.new attrs
end
select_query = instance_double(ActiveForce::ActiveQuery)
- expect(OpenStax::Salesforce::Remote::School).to(
+ expect(Salesforce::Records::School).to(
receive(:select).with(:id).and_return(select_query)
)
expect(select_query).to receive(:where).with(id: kind_of(Array)).and_return(sf_schools)
order_query = instance_double(ActiveForce::ActiveQuery)
- expect(OpenStax::Salesforce::Remote::School).to(
+ expect(Salesforce::Records::School).to(
receive(:order).with(:Id).and_return(order_query)
)
expect(order_query).to receive(:limit).with(described_class::BATCH_SIZE).and_return(sf_schools)
diff --git a/spec/services/salesforce/audit_spec.rb b/spec/services/salesforce/audit_spec.rb
new file mode 100644
index 0000000000..ac0b03696b
--- /dev/null
+++ b/spec/services/salesforce/audit_spec.rb
@@ -0,0 +1,31 @@
+require 'rails_helper'
+
+RSpec.describe Salesforce::Audit do
+ let(:user) { FactoryBot.create(:user) }
+
+ describe '.record' do
+ it 'prepends salesforce_ to the event name' do
+ expect {
+ described_class.record(user, :upsert_lead_saved, lead_id: 'X')
+ }.to change { SecurityLog.where(event_type: 'salesforce_upsert_lead_saved').count }.by(1)
+ end
+
+ it 'stores details in event_data' do
+ described_class.record(user, :upsert_lead_saved, lead_id: 'X', matched_by: :uuid)
+ log = SecurityLog.where(event_type: 'salesforce_upsert_lead_saved').last
+ expect(log.event_data).to include('lead_id' => 'X', 'matched_by' => 'uuid')
+ end
+
+ it 'raises if the event_type is not registered' do
+ expect {
+ described_class.record(user, :not_a_real_event)
+ }.to raise_error(ArgumentError, /Unknown Salesforce audit event/)
+ end
+
+ it 'allows nil user (system-level events)' do
+ expect {
+ described_class.record(nil, :upsert_lead_saved, lead_id: 'X')
+ }.not_to raise_error
+ end
+ end
+end
diff --git a/spec/services/salesforce/build_lead_spec.rb b/spec/services/salesforce/build_lead_spec.rb
new file mode 100644
index 0000000000..e92e0175e7
--- /dev/null
+++ b/spec/services/salesforce/build_lead_spec.rb
@@ -0,0 +1,85 @@
+require 'rails_helper'
+
+RSpec.describe Salesforce::BuildLead do
+ let(:school) do
+ FactoryBot.create(:school,
+ salesforce_id: 'SF1', name: 'Rice', city: 'Houston',
+ country: 'United States', state: 'Texas')
+ end
+
+ let(:user) do
+ FactoryBot.create(:user,
+ role: 'instructor', first_name: 'A', last_name: 'B',
+ phone_number: '+15555555555', who_chooses_books: 'instructor',
+ which_books: 'AP Macro Econ', how_many_students: '50',
+ using_openstax_how: 'as_primary', expected_start_semester: 'next_semester',
+ receive_newsletter: true, school: school, faculty_status: 'pending_faculty',
+ is_profile_complete: true)
+ end
+
+ let(:lead) { Salesforce::Records::Lead.new(email: 'a@b.com') }
+
+ before do
+ allow(user).to receive(:books_used_details).and_return(
+ { 'Calculus Volume 1' => { 'num_students_using_book' => 50, 'how_using_book' => 'Required' } }
+ )
+ end
+
+ it 'maps role=instructor to Instructor and copies role into position' do
+ described_class.apply(lead, user)
+ expect(lead.role).to eq('Instructor')
+ expect(lead.position).to eq('instructor')
+ end
+
+ it 'maps role=student to Student with nil position' do
+ user.update!(role: 'student')
+ described_class.apply(lead, user)
+ expect(lead.role).to eq('Student')
+ expect(lead.position).to be_nil
+ end
+
+ it 'always sets accounts_uuid (invariant for retry idempotency)' do
+ described_class.apply(lead, user)
+ expect(lead.accounts_uuid).to eq(user.uuid)
+ end
+
+ it 'builds adoption_json for non-as_future users' do
+ described_class.apply(lead, user)
+ parsed = JSON.parse(lead.adoption_json)
+ expect(parsed['Books']).to be_an(Array)
+ expect(parsed['Books'].first).to include('name' => 'Calculus Volume 1', 'students' => 50)
+ end
+
+ it 'skips adoption_json when using_openstax_how is as_future' do
+ user.update!(using_openstax_how: 'as_future')
+ described_class.apply(lead, user)
+ expect(lead.adoption_json).to be_nil
+ end
+
+ it 'maps US state full name to lead.state' do
+ described_class.apply(lead, user)
+ expect(lead.state).to eq('Texas')
+ end
+
+ it 'maps US state abbreviation (uppercase) to lead.state_code' do
+ school.update!(state: 'TX')
+ described_class.apply(lead, user)
+ expect(lead.state_code).to eq('TX')
+ end
+
+ it 'maps adoption_status from using_openstax_how' do
+ described_class.apply(lead, user)
+ expect(lead.adoption_status).to eq('Confirmed Adoption Won')
+ end
+
+ it 'sets verification_status to faculty_status when not no_faculty_info' do
+ described_class.apply(lead, user)
+ expect(lead.verification_status).to eq('pending_faculty')
+ end
+
+ it 'sets verification_status to nil when faculty_status is no_faculty_info' do
+ user.update!(faculty_status: 'no_faculty_info')
+ described_class.apply(lead, user)
+ expect(lead.verification_status).to be_nil
+ end
+end
diff --git a/spec/services/salesforce/client_spec.rb b/spec/services/salesforce/client_spec.rb
new file mode 100644
index 0000000000..186bfd8423
--- /dev/null
+++ b/spec/services/salesforce/client_spec.rb
@@ -0,0 +1,36 @@
+require 'rails_helper'
+
+RSpec.describe Salesforce::Client do
+ before do
+ Salesforce.configure do |c|
+ c.username = 'u'
+ c.password = 'p'
+ c.security_token = 't'
+ c.consumer_key = 'ck'
+ c.consumer_secret = 'cs'
+ c.api_version = '66.0'
+ c.login_domain = 'test.salesforce.com'
+ end
+ end
+
+ after { Salesforce.reset_configuration! }
+
+ it 'is a Restforce client' do
+ expect(described_class.new).to be_a(Restforce::Data::Client)
+ end
+
+ it 'passes configured credentials through to Restforce::Data::Client#initialize' do
+ expect_any_instance_of(Restforce::Data::Client).to receive(:initialize).with(
+ hash_including(
+ username: 'u',
+ password: 'p',
+ security_token: 't',
+ client_id: 'ck',
+ client_secret: 'cs',
+ api_version: '66.0',
+ host: 'test.salesforce.com'
+ )
+ ).and_call_original
+ described_class.new
+ end
+end
diff --git a/spec/services/salesforce/lookup_spec.rb b/spec/services/salesforce/lookup_spec.rb
new file mode 100644
index 0000000000..54579c44a8
--- /dev/null
+++ b/spec/services/salesforce/lookup_spec.rb
@@ -0,0 +1,99 @@
+require 'rails_helper'
+
+RSpec.describe Salesforce::Lookup do
+ let(:user) { FactoryBot.create(:user) }
+ let(:matching_lead) { Salesforce::Records::Lead.new(id: 'L1', accounts_uuid: user.uuid) }
+ let(:foreign_lead) { Salesforce::Records::Lead.new(id: 'L2', accounts_uuid: 'OTHER') }
+
+ before do
+ allow(user).to receive(:best_email_address_for_salesforce).and_return('e@example.com')
+ end
+
+ describe '.lead_for' do
+ context 'with a stored salesforce_lead_id that still owns the user' do
+ it 'matches by stored_id' do
+ user.update!(salesforce_lead_id: 'L1')
+ allow(Salesforce::Records::Lead).to receive(:find).with('L1').and_return(matching_lead)
+ result = described_class.lead_for(user)
+ expect(result.lead).to eq(matching_lead)
+ expect(result.matched_by).to eq(:stored_id)
+ end
+ end
+
+ context 'when stored id belongs to another user' do
+ it 'falls through to uuid match and records the disown' do
+ user.update!(salesforce_lead_id: 'L2')
+ allow(Salesforce::Records::Lead).to receive(:find).with('L2').and_return(foreign_lead)
+ allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(matching_lead)
+ result = described_class.lead_for(user)
+ expect(result.lead).to eq(matching_lead)
+ expect(result.matched_by).to eq(:uuid)
+ expect(result.rejected).to include(:stored_id_owned_by_other_user)
+ expect(SecurityLog.where(event_type: 'salesforce_lookup_stored_id_disowned', user: user)).to exist
+ end
+ end
+
+ context 'when stored id raises (missing in SF)' do
+ it 'falls through to uuid match' do
+ user.update!(salesforce_lead_id: 'L_MISSING')
+ allow(Salesforce::Records::Lead).to receive(:find).with('L_MISSING').and_raise(StandardError, 'gone')
+ allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(matching_lead)
+ result = described_class.lead_for(user)
+ expect(result.matched_by).to eq(:uuid)
+ end
+ end
+
+ context 'when only email matches but its UUID is foreign' do
+ it 'rejects the email match' do
+ allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil)
+ allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: 'e@example.com' }).and_return(foreign_lead)
+ result = described_class.lead_for(user)
+ expect(result.lead).to be_nil
+ expect(result.rejected).to include(:email_match_uuid_conflict)
+ end
+ end
+
+ context 'when email matches and is adoptable (UUID blank)' do
+ it 'matches by email' do
+ adoptable = Salesforce::Records::Lead.new(id: 'L_OLD', accounts_uuid: nil)
+ allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil)
+ allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: 'e@example.com' }).and_return(adoptable)
+ result = described_class.lead_for(user)
+ expect(result.lead).to eq(adoptable)
+ expect(result.matched_by).to eq(:email)
+ end
+ end
+
+ context 'when nothing matches' do
+ it 'returns nil lead with matched_by nil' do
+ allow(Salesforce::Records::Lead).to receive(:find_by).and_return(nil)
+ result = described_class.lead_for(user)
+ expect(result.lead).to be_nil
+ expect(result.matched_by).to be_nil
+ end
+ end
+ end
+
+ describe '.contact_for' do
+ it 'returns the stored Contact when it owns the user' do
+ user.update!(salesforce_contact_id: 'C1')
+ contact = Salesforce::Records::Contact.new(id: 'C1', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false)
+ allow(Salesforce::Records::Contact).to receive(:find).with('C1').and_return(contact)
+ expect(described_class.contact_for(user)).to eq(contact)
+ end
+
+ it 'falls through to UUID lookup when stored id is disowned' do
+ user.update!(salesforce_contact_id: 'C_FOREIGN')
+ foreign = Salesforce::Records::Contact.new(id: 'C_FOREIGN', accounts_uuid: 'OTHER', master_record_id: nil, is_deleted: false)
+ uuid_match = Salesforce::Records::Contact.new(id: 'C_OK', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false)
+ allow(Salesforce::Records::Contact).to receive(:find).with('C_FOREIGN').and_return(foreign)
+ allow(Salesforce::Records::Contact).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(uuid_match)
+ expect(described_class.contact_for(user)).to eq(uuid_match)
+ end
+
+ it 'returns nil when nothing owns the user' do
+ allow(Salesforce::Records::Contact).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil)
+ expect(described_class.contact_for(user)).to be_nil
+ end
+ end
+end
diff --git a/spec/services/salesforce/metrics_spec.rb b/spec/services/salesforce/metrics_spec.rb
new file mode 100644
index 0000000000..e437561fac
--- /dev/null
+++ b/spec/services/salesforce/metrics_spec.rb
@@ -0,0 +1,60 @@
+require 'rails_helper'
+
+RSpec.describe Salesforce::Metrics do
+ before do
+ allow(Sentry).to receive(:capture_check_in).and_return('check_in_id')
+ allow(Sentry).to receive(:capture_message)
+ end
+
+ describe '#increment' do
+ it 'accumulates integer counters' do
+ m = described_class.new(run: 'sync_contacts')
+ m.increment(:users_updated)
+ m.increment(:users_updated, by: 2)
+ expect(m.counters[:users_updated]).to eq(3)
+ end
+
+ it 'labels sub-counters by keyword and tracks a total' do
+ m = described_class.new(run: 'sync_contacts')
+ m.increment(:contact_id_swaps, reason: :merged)
+ m.increment(:contact_id_swaps, reason: :merged)
+ m.increment(:contact_id_swaps, reason: :gone)
+ expect(m.counters[:contact_id_swaps]).to eq(total: 3, merged: 2, gone: 1)
+ end
+ end
+
+ describe '#start! and #emit' do
+ it 'opens and closes a Sentry check-in when a slug is given' do
+ m = described_class.new(run: 'sync_contacts', slug: 'update-user-contact-info')
+ expect(Sentry).to receive(:capture_check_in).with('update-user-contact-info', :in_progress)
+ m.start!
+ expect(Sentry).to receive(:capture_check_in).with('update-user-contact-info', :ok, hash_including(:check_in_id))
+ m.emit(status: :ok)
+ end
+
+ it 'skips Sentry check-in when no slug' do
+ m = described_class.new(run: 'sync_contacts')
+ expect(Sentry).not_to receive(:capture_check_in)
+ m.start!
+ m.emit
+ end
+
+ it 'returns the payload from emit' do
+ m = described_class.new(run: 'sync_contacts')
+ m.increment(:users_updated, by: 5)
+ payload = m.emit(status: :ok)
+ expect(payload).to include(run: 'sync_contacts', status: :ok)
+ expect(payload[:counters][:users_updated]).to eq(5)
+ expect(payload[:duration_s]).to be_a(Integer)
+ end
+ end
+
+ describe '#alert!' do
+ it 'fires a Sentry message tagged with salesforce-alert' do
+ m = described_class.new(run: 'sync_contacts')
+ m.alert!(:contact_id_swap_rate_high, value: 12.0, threshold: 5.0)
+ expect(Sentry).to have_received(:capture_message)
+ .with(/contact_id_swap_rate_high/, hash_including(tags: hash_including('salesforce-alert' => 'contact_id_swap_rate_high')))
+ end
+ end
+end
diff --git a/spec/services/salesforce/reconcile_spec.rb b/spec/services/salesforce/reconcile_spec.rb
new file mode 100644
index 0000000000..142f200012
--- /dev/null
+++ b/spec/services/salesforce/reconcile_spec.rb
@@ -0,0 +1,164 @@
+require 'rails_helper'
+
+RSpec.describe Salesforce::Reconcile do
+ before do
+ Settings::FeatureFlags.salesforce_reconcile_self_heal = true
+ stub_sentry
+ end
+
+ after { Settings::FeatureFlags.salesforce_reconcile_self_heal = false }
+
+ # --- Pass 1: contact-anchored ---
+
+ describe 'Pass 1' do
+ let!(:user) { FactoryBot.create(:user, salesforce_contact_id: 'C1') }
+
+ it 'no-ops when stored contact is live and owns the user' do
+ contact = Salesforce::Records::Contact.new(
+ id: 'C1', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false
+ )
+ allow_any_instance_of(described_class).to receive(:fetch_contacts_by_id).and_return({ 'C1' => contact })
+ allow_any_instance_of(described_class).to receive(:run_pass_2)
+ allow_any_instance_of(described_class).to receive(:run_pass_3)
+ allow_any_instance_of(described_class).to receive(:sweep_sf_orphans)
+ described_class.call
+ expect(user.reload.salesforce_contact_id).to eq('C1')
+ expect(SecurityLog.where(event_type: 'salesforce_reconcile_user_ok', user: user)).to exist
+ end
+
+ it 'follows a merge to the master record when the master owns the user' do
+ merged = Salesforce::Records::Contact.new(
+ id: 'C1', accounts_uuid: user.uuid, master_record_id: 'NEW', is_deleted: false
+ )
+ master = Salesforce::Records::Contact.new(
+ id: 'NEW', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false
+ )
+ allow_any_instance_of(described_class).to receive(:fetch_contacts_by_id).and_return({ 'C1' => merged })
+ allow(Salesforce::Records::Contact).to receive(:find).with('NEW').and_return(master)
+ allow_any_instance_of(described_class).to receive(:run_pass_2)
+ allow_any_instance_of(described_class).to receive(:run_pass_3)
+ allow_any_instance_of(described_class).to receive(:sweep_sf_orphans)
+ described_class.call
+ expect(user.reload.salesforce_contact_id).to eq('NEW')
+ expect(SecurityLog.where(event_type: 'salesforce_reconcile_followed_merge', user: user)).to exist
+ end
+
+ it 'clears + reattaches via Lookup when stored contact is deleted' do
+ allow_any_instance_of(described_class).to receive(:fetch_contacts_by_id).and_return({})
+ allow(Salesforce::Lookup).to receive(:contact_for).with(user).and_return(nil)
+ allow_any_instance_of(described_class).to receive(:run_pass_2)
+ allow_any_instance_of(described_class).to receive(:run_pass_3)
+ allow_any_instance_of(described_class).to receive(:sweep_sf_orphans)
+ described_class.call
+ expect(user.reload.salesforce_contact_id).to be_nil
+ expect(SalesforceDriftFinding.where(user: user, category: 'sf_contact_uuid_mismatch')).to exist
+ end
+
+ it 'with self_heal flag off, opens finding but does not mutate the user' do
+ Settings::FeatureFlags.salesforce_reconcile_self_heal = false
+ allow_any_instance_of(described_class).to receive(:fetch_contacts_by_id).and_return({})
+ allow_any_instance_of(described_class).to receive(:run_pass_2)
+ allow_any_instance_of(described_class).to receive(:run_pass_3)
+ allow_any_instance_of(described_class).to receive(:sweep_sf_orphans)
+ described_class.call
+ expect(user.reload.salesforce_contact_id).to eq('C1')
+ expect(SalesforceDriftFinding.where(user: user, category: 'sf_contact_uuid_mismatch')).to exist
+ end
+ end
+
+ # --- Pass 2: lead-anchored ---
+
+ describe 'Pass 2' do
+ let!(:user) { FactoryBot.create(:user, salesforce_contact_id: nil, salesforce_lead_id: 'L1') }
+
+ it "attaches the converted lead's Contact when it owns the user" do
+ lead = Salesforce::Records::Lead.new(
+ id: 'L1', accounts_uuid: user.uuid, is_converted: true, converted_contact_id: 'CC1'
+ )
+ contact = Salesforce::Records::Contact.new(
+ id: 'CC1', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false
+ )
+ allow_any_instance_of(described_class).to receive(:run_pass_1)
+ allow_any_instance_of(described_class).to receive(:fetch_leads_by_id).and_return({ 'L1' => lead })
+ allow(Salesforce::Records::Contact).to receive(:find).with('CC1').and_return(contact)
+ allow_any_instance_of(described_class).to receive(:run_pass_3)
+ allow_any_instance_of(described_class).to receive(:sweep_sf_orphans)
+ described_class.call
+ expect(user.reload.salesforce_contact_id).to eq('CC1')
+ expect(SecurityLog.where(event_type: 'salesforce_reconcile_attached_from_conversion', user: user)).to exist
+ end
+
+ it 'opens a finding when the stored lead is gone' do
+ allow_any_instance_of(described_class).to receive(:run_pass_1)
+ allow_any_instance_of(described_class).to receive(:fetch_leads_by_id).and_return({})
+ allow(Salesforce::Lookup).to receive(:contact_for).with(user).and_return(nil)
+ allow_any_instance_of(described_class).to receive(:run_pass_3)
+ allow_any_instance_of(described_class).to receive(:sweep_sf_orphans)
+ described_class.call
+ expect(SalesforceDriftFinding.where(user: user, category: 'sf_lead_uuid_mismatch')).to exist
+ end
+ end
+
+ # --- Pass 3 ---
+
+ describe 'Pass 3' do
+ let!(:user3) do
+ FactoryBot.create(:user,
+ salesforce_contact_id: nil, salesforce_lead_id: nil,
+ is_profile_complete: true, role: 'instructor', faculty_status: 'pending_faculty')
+ end
+
+ it 'attaches a Contact found by accounts_uuid' do
+ contact = Salesforce::Records::Contact.new(
+ id: 'C3', accounts_uuid: user3.uuid, master_record_id: nil, is_deleted: false
+ )
+ allow_any_instance_of(described_class).to receive(:run_pass_1)
+ allow_any_instance_of(described_class).to receive(:run_pass_2)
+ allow(Salesforce::Records::Contact).to receive(:where).with(accounts_uuid: [user3.uuid]).and_return([contact])
+ allow(Salesforce::Records::Lead).to receive(:where).with(accounts_uuid: [user3.uuid]).and_return([])
+ allow_any_instance_of(described_class).to receive(:sweep_sf_orphans)
+ described_class.call
+ expect(user3.reload.salesforce_contact_id).to eq('C3')
+ expect(SecurityLog.where(event_type: 'salesforce_link_restored_by_reconcile', user: user3)).to exist
+ end
+
+ it 'opens user_unlinked_eligible finding when neither found' do
+ allow_any_instance_of(described_class).to receive(:run_pass_1)
+ allow_any_instance_of(described_class).to receive(:run_pass_2)
+ allow(Salesforce::Records::Contact).to receive(:where).with(accounts_uuid: [user3.uuid]).and_return([])
+ allow(Salesforce::Records::Lead).to receive(:where).with(accounts_uuid: [user3.uuid]).and_return([])
+ allow_any_instance_of(described_class).to receive(:sweep_sf_orphans)
+ described_class.call
+ expect(SalesforceDriftFinding.where(user: user3, category: 'user_unlinked_eligible')).to exist
+ end
+ end
+
+ # --- Finalize ---
+
+ describe 'finalize_findings' do
+ it 'closes open findings not refreshed during this run' do
+ stale = FactoryBot.create(:salesforce_drift_finding,
+ category: 'sf_orphan_contact', last_seen_at: 2.days.ago, resolved_at: nil)
+ allow_any_instance_of(described_class).to receive(:run_pass_1)
+ allow_any_instance_of(described_class).to receive(:run_pass_2)
+ allow_any_instance_of(described_class).to receive(:run_pass_3)
+ allow_any_instance_of(described_class).to receive(:sweep_sf_orphans)
+ described_class.call
+ expect(stale.reload.resolved_at).not_to be_nil
+ end
+
+ it 'fires drift_findings_total_open alert when threshold exceeded' do
+ Settings::Db.store.salesforce_alert_drift_open_total = 0
+ # Create with last_seen_at in the future so finalize doesn't auto-close it
+ FactoryBot.create(:salesforce_drift_finding, resolved_at: nil, last_seen_at: 1.hour.from_now)
+ allow_any_instance_of(described_class).to receive(:run_pass_1)
+ allow_any_instance_of(described_class).to receive(:run_pass_2)
+ allow_any_instance_of(described_class).to receive(:run_pass_3)
+ allow_any_instance_of(described_class).to receive(:sweep_sf_orphans)
+ expect(Sentry).to receive(:capture_message).with(/drift_findings_total_open/, anything)
+ described_class.call
+ ensure
+ Settings::Db.store.salesforce_alert_drift_open_total = 100
+ end
+ end
+end
diff --git a/spec/services/salesforce/records/base_spec.rb b/spec/services/salesforce/records/base_spec.rb
new file mode 100644
index 0000000000..85a9fdec3d
--- /dev/null
+++ b/spec/services/salesforce/records/base_spec.rb
@@ -0,0 +1,40 @@
+require 'rails_helper'
+
+RSpec.describe Salesforce::Records::Base do
+ it 'aliases to ActiveForce::SObject' do
+ expect(Salesforce::Records::Base).to eq(ActiveForce::SObject)
+ end
+
+ describe '.find_or_initialize_by' do
+ let(:subclass) do
+ Class.new(Salesforce::Records::Base) do
+ self.table_name = 'Dummy'
+ end
+ end
+
+ it 'returns an existing record when found' do
+ existing = subclass.new(id: 'X')
+ allow(subclass).to receive(:find_by).with({ id: 'X' }).and_return(existing)
+ expect(subclass.find_or_initialize_by(id: 'X')).to eq(existing)
+ end
+
+ it 'returns a new instance of the subclass when find_by returns nil' do
+ allow(subclass).to receive(:find_by).with({ id: 'Y' }).and_return(nil)
+ record = subclass.find_or_initialize_by(id: 'Y')
+ expect(record).to be_a(subclass)
+ expect(record.id).to eq('Y')
+ end
+ end
+
+ describe '#save_if_changed' do
+ let(:subclass) do
+ Class.new(Salesforce::Records::Base) do
+ self.table_name = 'Dummy'
+ end
+ end
+
+ it 'is exposed on instances' do
+ expect(subclass.new).to respond_to(:save_if_changed)
+ end
+ end
+end
diff --git a/spec/services/salesforce/records/contact_spec.rb b/spec/services/salesforce/records/contact_spec.rb
new file mode 100644
index 0000000000..3b8ec56f9f
--- /dev/null
+++ b/spec/services/salesforce/records/contact_spec.rb
@@ -0,0 +1,18 @@
+require 'rails_helper'
+
+RSpec.describe Salesforce::Records::Contact do
+ it 'maps to the Contact SObject' do
+ expect(described_class.table_name).to eq('Contact')
+ end
+
+ %i[
+ id name first_name last_name email faculty_verified last_modified_at
+ school_id school_type all_emails adoption_status accounts_uuid lead_source
+ signup_date assignable_interest assignable_adoption_date
+ master_record_id is_deleted
+ ].each do |attr|
+ it "responds to ##{attr}" do
+ expect(described_class.new).to respond_to(attr)
+ end
+ end
+end
diff --git a/spec/services/salesforce/records/lead_spec.rb b/spec/services/salesforce/records/lead_spec.rb
new file mode 100644
index 0000000000..4c3fcfe9bb
--- /dev/null
+++ b/spec/services/salesforce/records/lead_spec.rb
@@ -0,0 +1,21 @@
+require 'rails_helper'
+
+RSpec.describe Salesforce::Records::Lead do
+ it 'maps to the Lead SObject' do
+ expect(described_class.table_name).to eq('Lead')
+ end
+
+ %i[
+ id name first_name last_name email phone source application_source
+ role position title subject_interest school city state state_code country
+ accounts_uuid os_accounts_id verification_status adoption_status adoption_json
+ num_students who_chooses_books b_r_i_marketing title_1_school newsletter
+ newsletter_opt_in self_reported_school sheerid_school_name account_id school_id
+ signup_date tracking_parameters expected_start_semester
+ is_converted converted_contact_id
+ ].each do |attr|
+ it "responds to ##{attr}" do
+ expect(described_class.new).to respond_to(attr)
+ end
+ end
+end
diff --git a/spec/services/salesforce/records/school_spec.rb b/spec/services/salesforce/records/school_spec.rb
new file mode 100644
index 0000000000..2707698d1e
--- /dev/null
+++ b/spec/services/salesforce/records/school_spec.rb
@@ -0,0 +1,16 @@
+require 'rails_helper'
+
+RSpec.describe Salesforce::Records::School do
+ it 'maps to the Account SObject' do
+ expect(described_class.table_name).to eq('Account')
+ end
+
+ %i[
+ id name city state country type school_location sheerid_school_name
+ is_kip is_child_of_kip total_school_enrollment has_assignable_contacts
+ ].each do |attr|
+ it "responds to ##{attr}" do
+ expect(described_class.new).to respond_to(attr)
+ end
+ end
+end
diff --git a/spec/services/salesforce/resolve_faculty_status_spec.rb b/spec/services/salesforce/resolve_faculty_status_spec.rb
new file mode 100644
index 0000000000..617631965b
--- /dev/null
+++ b/spec/services/salesforce/resolve_faculty_status_spec.rb
@@ -0,0 +1,69 @@
+require 'rails_helper'
+
+RSpec.describe Salesforce::ResolveFacultyStatus do
+ let(:user) { FactoryBot.create(:user, faculty_status: :no_faculty_info) }
+
+ describe '.from_signup' do
+ it 'sets pending_faculty when profile complete and no SheerID record' do
+ user.update!(is_profile_complete: true, sheerid_verification_id: nil)
+ described_class.from_signup(user)
+ expect(user.faculty_status).to eq('pending_faculty')
+ end
+
+ it 'sets status from SheerID when present' do
+ user.update!(is_profile_complete: true, sheerid_verification_id: 'V1')
+ v = SheeridVerification.create!(verification_id: 'V1', current_step: 'success')
+ allow(SheeridVerification).to receive(:find_by).with(verification_id: 'V1').and_return(v)
+ allow(v).to receive(:current_step_to_faculty_status).and_return(:confirmed_faculty)
+ described_class.from_signup(user)
+ expect(user.faculty_status).to eq('confirmed_faculty')
+ end
+
+ it 'sets incomplete_signup when profile incomplete' do
+ user.update!(is_profile_complete: false)
+ described_class.from_signup(user)
+ expect(user.faculty_status).to eq('incomplete_signup')
+ end
+ end
+
+ describe '.from_contact' do
+ %w[confirmed_faculty pending_faculty rejected_faculty].each do |protected_status|
+ it "does not overwrite #{protected_status} with no_faculty_info" do
+ user.update!(faculty_status: protected_status)
+ contact = Salesforce::Records::Contact.new(faculty_verified: nil)
+ described_class.from_contact(user, contact)
+ expect(user.faculty_status).to eq(protected_status)
+ end
+
+ it "does not overwrite #{protected_status} with incomplete_signup" do
+ user.update!(faculty_status: protected_status)
+ contact = Salesforce::Records::Contact.new(faculty_verified: 'incomplete_signup')
+ described_class.from_contact(user, contact)
+ expect(user.faculty_status).to eq(protected_status)
+ end
+ end
+
+ it 'does not overwrite confirmed_faculty with pending_faculty' do
+ user.update!(faculty_status: 'confirmed_faculty')
+ contact = Salesforce::Records::Contact.new(faculty_verified: 'pending_faculty')
+ described_class.from_contact(user, contact)
+ expect(user.faculty_status).to eq('confirmed_faculty')
+ end
+
+ it 'updates from no_faculty_info to confirmed_faculty' do
+ user.update!(faculty_status: 'no_faculty_info')
+ contact = Salesforce::Records::Contact.new(faculty_verified: 'confirmed_faculty')
+ described_class.from_contact(user, contact)
+ expect(user.faculty_status).to eq('confirmed_faculty')
+ end
+
+ it 'raises and captures Sentry on unknown faculty_verified value' do
+ user.update!(faculty_status: 'no_faculty_info')
+ contact = Salesforce::Records::Contact.new(faculty_verified: 'weird_value', id: 'C1')
+ expect(Sentry).to receive(:capture_message)
+ expect {
+ described_class.from_contact(user, contact)
+ }.to raise_error(Salesforce::ResolveFacultyStatus::UnknownFacultyVerifiedError)
+ end
+ end
+end
diff --git a/spec/services/salesforce/sync_contacts_spec.rb b/spec/services/salesforce/sync_contacts_spec.rb
new file mode 100644
index 0000000000..6e217895ca
--- /dev/null
+++ b/spec/services/salesforce/sync_contacts_spec.rb
@@ -0,0 +1,106 @@
+require 'rails_helper'
+
+RSpec.describe Salesforce::SyncContacts do
+ let!(:school) { FactoryBot.create(:school, salesforce_id: 'SF_S1') }
+ let!(:user) { FactoryBot.create(:user, salesforce_contact_id: nil) }
+
+ let(:live_contact) do
+ contact = Salesforce::Records::Contact.new(
+ id: 'C1', accounts_uuid: user.uuid,
+ master_record_id: nil, is_deleted: false,
+ faculty_verified: 'confirmed_faculty', school_type: 'College/University (4)',
+ adoption_status: 'Not Adopter'
+ )
+ sf_school = Salesforce::Records::School.new(
+ id: 'SF_S1', school_location: 'Domestic', is_kip: false, is_child_of_kip: false
+ )
+ allow(contact).to receive(:school).and_return(sf_school)
+ allow(contact).to receive(:school_id).and_return('SF_S1')
+ contact
+ end
+
+ let(:merged_contact) do
+ contact = Salesforce::Records::Contact.new(
+ id: 'C2', accounts_uuid: user.uuid,
+ master_record_id: 'CMASTER', is_deleted: false,
+ faculty_verified: nil, school_type: nil, adoption_status: nil
+ )
+ allow(contact).to receive(:school).and_return(nil)
+ allow(contact).to receive(:school_id).and_return(nil)
+ contact
+ end
+
+ before do
+ stub_sentry
+ allow_any_instance_of(described_class).to receive(:fetch_contacts).and_return([live_contact])
+ end
+
+ it 'first-time link sets salesforce_contact_id' do
+ described_class.call
+ expect(user.reload.salesforce_contact_id).to eq('C1')
+ end
+
+ it 'updates faculty_status from the contact' do
+ described_class.call
+ expect(user.reload.faculty_status).to eq('confirmed_faculty')
+ end
+
+ it 'skips merged contacts entirely' do
+ allow_any_instance_of(described_class).to receive(:fetch_contacts).and_return([merged_contact])
+ described_class.call
+ expect(user.reload.salesforce_contact_id).to be_nil
+ expect(SecurityLog.where(event_type: 'salesforce_contact_skipped_merged_or_deleted', user: user)).to exist
+ end
+
+ it 'records a conflict when stored contact still owns the user and a new candidate appears' do
+ user.update!(salesforce_contact_id: 'OLD')
+ stored = Salesforce::Records::Contact.new(
+ id: 'OLD', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false
+ )
+ allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(stored)
+ described_class.call
+ expect(SecurityLog.where(event_type: 'salesforce_contact_id_conflict', user: user)).to exist
+ expect(user.reload.salesforce_contact_id).to eq('OLD')
+ end
+
+ it 'swaps when previous contact has been merged into the new one' do
+ user.update!(salesforce_contact_id: 'OLD')
+ stored = Salesforce::Records::Contact.new(
+ id: 'OLD', accounts_uuid: user.uuid, master_record_id: 'C1', is_deleted: false
+ )
+ allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(stored)
+ described_class.call
+ expect(user.reload.salesforce_contact_id).to eq('C1')
+ expect(SecurityLog.where(event_type: 'salesforce_contact_id_swapped', user: user)).to exist
+ end
+
+ it 'swaps when previous contact has been deleted (no longer found in SF)' do
+ user.update!(salesforce_contact_id: 'OLD')
+ allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(nil)
+ described_class.call
+ expect(user.reload.salesforce_contact_id).to eq('C1')
+ expect(SecurityLog.where(event_type: 'salesforce_contact_id_swapped', user: user)).to exist
+ end
+
+ it 'persists the cursor to Settings on each run' do
+ fixed_time = Time.utc(2026, 5, 22, 12, 0, 0)
+ allow(Time).to receive(:current).and_return(fixed_time)
+ described_class.call
+ expect(Settings::Salesforce.contacts_synced_through).to eq(fixed_time)
+ ensure
+ Settings::Db.store.salesforce_contacts_synced_through = nil
+ end
+
+ it 'records unknown_accounts_uuids when the SF Contact UUID maps to no Accounts user' do
+ foreign = Salesforce::Records::Contact.new(
+ id: 'C_FOREIGN', accounts_uuid: 'UNKNOWN', master_record_id: nil, is_deleted: false,
+ faculty_verified: nil, school_type: nil, adoption_status: nil
+ )
+ allow(foreign).to receive(:school).and_return(nil)
+ allow(foreign).to receive(:school_id).and_return(nil)
+ allow_any_instance_of(described_class).to receive(:fetch_contacts).and_return([foreign])
+ described_class.call
+ # No SecurityLog write because there's no user — but the metric should be bumped (verified indirectly through alert threshold path)
+ expect(user.reload.salesforce_contact_id).to be_nil
+ end
+end
diff --git a/spec/services/salesforce/upsert_lead_spec.rb b/spec/services/salesforce/upsert_lead_spec.rb
new file mode 100644
index 0000000000..07a649cd33
--- /dev/null
+++ b/spec/services/salesforce/upsert_lead_spec.rb
@@ -0,0 +1,142 @@
+require 'rails_helper'
+
+RSpec.describe Salesforce::UpsertLead do
+ let!(:home) { FactoryBot.create(:school, name: 'Find Me A Home', salesforce_id: 'SF_HOME') }
+
+ let(:user) do
+ FactoryBot.create(:user,
+ role: 'instructor', state: 'activated', is_newflow: true,
+ using_openstax_how: 'as_primary', is_profile_complete: true,
+ self_reported_school: 'Test U', school: home,
+ faculty_status: 'pending_faculty')
+ end
+
+ let(:lead) { Salesforce::Records::Lead.new(email: 'x@y.com', accounts_uuid: user.uuid) }
+
+ before do
+ allow(Sentry).to receive(:capture_message)
+ allow(Salesforce::Records::School).to receive(:find_by)
+ .with({ name: 'Find Me A Home' }).and_return(OpenStruct.new(id: 'SF_HOME'))
+ allow(user).to receive(:best_email_address_for_salesforce).and_return('x@y.com')
+ allow(User).to receive(:find).and_return(user)
+ end
+
+ context 'creating a new lead when lookup returns nothing' do
+ before do
+ allow(Salesforce::Lookup).to receive(:lead_for).with(user)
+ .and_return(Salesforce::Lookup::Result.new(lead: nil, matched_by: nil))
+ allow(Salesforce::Records::Lead).to receive(:new).and_return(lead)
+ allow(lead).to receive(:save).and_return(true)
+ allow(lead).to receive(:id).and_return('NEW_LEAD')
+ end
+
+ it 'persists the new lead id on the user' do
+ described_class.call(user: user)
+ expect(user.reload.salesforce_lead_id).to eq('NEW_LEAD')
+ end
+
+ it 'records the upsert_lead_saved audit event' do
+ described_class.call(user: user)
+ expect(SecurityLog.where(event_type: 'salesforce_upsert_lead_saved', user: user)).to exist
+ end
+ end
+
+ context 'when user.save fails the first time and succeeds on retry' do
+ before do
+ allow(Salesforce::Lookup).to receive(:lead_for).with(user)
+ .and_return(Salesforce::Lookup::Result.new(lead: nil, matched_by: nil))
+ allow(Salesforce::Records::Lead).to receive(:new).and_return(lead)
+ allow(lead).to receive(:save).and_return(true)
+ allow(lead).to receive(:id).and_return('NEW_LEAD')
+ end
+
+ it 'retries the local save and records a retry audit event' do
+ call_count = 0
+ original_save = user.method(:save)
+ allow(user).to receive(:save) do
+ call_count += 1
+ call_count >= 2 ? original_save.call : false
+ end
+ described_class.call(user: user)
+ expect(call_count).to be >= 2
+ expect(SecurityLog.where(event_type: 'salesforce_lead_id_persist_retry', user: user)).to exist
+ end
+ end
+
+ context 'when user.save fails 3 times' do
+ before do
+ allow(Salesforce::Lookup).to receive(:lead_for).with(user)
+ .and_return(Salesforce::Lookup::Result.new(lead: nil, matched_by: nil))
+ allow(Salesforce::Records::Lead).to receive(:new).and_return(lead)
+ allow(lead).to receive(:save).and_return(true)
+ allow(lead).to receive(:id).and_return('NEW_LEAD')
+ allow_any_instance_of(User).to receive(:save).and_return(false)
+ allow_any_instance_of(User).to receive(:reload).and_return(user)
+ end
+
+ it 'logs persist_failed loudly and to Sentry' do
+ expect(Sentry).to receive(:capture_message).with(/lead_id persist failed/)
+ described_class.call(user: user)
+ expect(SecurityLog.where(event_type: 'salesforce_lead_id_persist_failed', user: user)).to exist
+ end
+ end
+
+ context 'when user already has a verifying contact' do
+ it 'returns early without saving a lead' do
+ user.update!(salesforce_contact_id: 'C1')
+ owning_contact = Salesforce::Records::Contact.new(
+ id: 'C1', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false
+ )
+ allow(Salesforce::Lookup).to receive(:contact_for).with(user).and_return(owning_contact)
+ allow(Salesforce::Lookup).to receive(:lead_for).with(user)
+ .and_return(Salesforce::Lookup::Result.new(lead: nil, matched_by: nil))
+ expect(Salesforce::Records::Lead).not_to receive(:new)
+ described_class.call(user: user)
+ expect(SecurityLog.where(event_type: 'salesforce_upsert_lead_skipped_user_has_contact', user: user)).to exist
+ end
+ end
+
+ context 'when user has a stored contact_id that no longer owns them' do
+ it 'clears it and proceeds to create a lead' do
+ user.update!(salesforce_contact_id: 'C_STALE')
+ allow(Salesforce::Lookup).to receive(:contact_for).with(user).and_return(nil)
+ allow(Salesforce::Lookup).to receive(:lead_for).with(user)
+ .and_return(Salesforce::Lookup::Result.new(lead: nil, matched_by: nil))
+ allow(Salesforce::Records::Lead).to receive(:new).and_return(lead)
+ allow(lead).to receive(:save).and_return(true)
+ allow(lead).to receive(:id).and_return('NEW_LEAD')
+ described_class.call(user: user)
+ expect(SecurityLog.where(event_type: 'salesforce_stale_contact_id_cleared', user: user)).to exist
+ expect(user.reload.salesforce_contact_id).to be_nil
+ end
+ end
+
+ context 'when the fallback SF School is not yet cached locally' do
+ it 'creates a stub local School so the saved Lead still has account_id / school_id' do
+ # Use a fresh SF id that has no local School row, so we exercise the
+ # cache-miss branch without fighting the foreign key on the let!(:home)
+ # School the outer fixture created.
+ uncached_sf_id = 'SF_UNCACHED_HOME'
+ expect(School.where(salesforce_id: uncached_sf_id)).to be_empty
+ user.update!(school: nil)
+
+ # SF says the fallback exists at the uncached id.
+ allow(Salesforce::Records::School).to receive(:find_by)
+ .with({ name: 'Find Me A Home' })
+ .and_return(OpenStruct.new(id: uncached_sf_id, name: 'Find Me A Home'))
+ allow(Salesforce::Lookup).to receive(:lead_for).with(user)
+ .and_return(Salesforce::Lookup::Result.new(lead: nil, matched_by: nil))
+ allow(Salesforce::Records::Lead).to receive(:new).and_return(lead)
+ allow(lead).to receive(:save).and_return(true)
+ allow(lead).to receive(:id).and_return('NEW_LEAD')
+
+ expect {
+ described_class.call(user: user)
+ }.to change { School.where(salesforce_id: uncached_sf_id).count }.from(0).to(1)
+
+ expect(user.reload.school&.salesforce_id).to eq(uncached_sf_id)
+ expect(lead.account_id).to eq(uncached_sf_id)
+ expect(lead.school_id).to eq(uncached_sf_id)
+ end
+ end
+end
diff --git a/spec/services/salesforce/verify_spec.rb b/spec/services/salesforce/verify_spec.rb
new file mode 100644
index 0000000000..0760f1e4a5
--- /dev/null
+++ b/spec/services/salesforce/verify_spec.rb
@@ -0,0 +1,88 @@
+require 'rails_helper'
+
+RSpec.describe Salesforce::Verify do
+ let(:user) { instance_double(User, uuid: 'USER-UUID') }
+
+ describe '.lead_owns_user?' do
+ it 'true when lead UUID matches' do
+ lead = Salesforce::Records::Lead.new(accounts_uuid: 'USER-UUID')
+ expect(described_class.lead_owns_user?(lead, user)).to be(true)
+ end
+
+ it 'true when lead UUID blank (adoptable)' do
+ lead = Salesforce::Records::Lead.new(accounts_uuid: nil)
+ expect(described_class.lead_owns_user?(lead, user)).to be(true)
+ end
+
+ it 'false when lead UUID belongs to someone else' do
+ lead = Salesforce::Records::Lead.new(accounts_uuid: 'OTHER-UUID')
+ expect(described_class.lead_owns_user?(lead, user)).to be(false)
+ end
+
+ it 'false for nil lead' do
+ expect(described_class.lead_owns_user?(nil, user)).to be(false)
+ end
+ end
+
+ describe '.contact_owns_user?' do
+ it 'true when contact UUID matches and contact is live' do
+ contact = Salesforce::Records::Contact.new(accounts_uuid: 'USER-UUID', master_record_id: nil, is_deleted: false)
+ expect(described_class.contact_owns_user?(contact, user)).to be(true)
+ end
+
+ it 'false if contact has been merged' do
+ contact = Salesforce::Records::Contact.new(accounts_uuid: 'USER-UUID', master_record_id: 'SOMETHING')
+ expect(described_class.contact_owns_user?(contact, user)).to be(false)
+ end
+
+ it 'false if contact is deleted' do
+ contact = Salesforce::Records::Contact.new(accounts_uuid: 'USER-UUID', is_deleted: true)
+ expect(described_class.contact_owns_user?(contact, user)).to be(false)
+ end
+
+ it 'false when UUID mismatches' do
+ contact = Salesforce::Records::Contact.new(accounts_uuid: 'OTHER', master_record_id: nil, is_deleted: false)
+ expect(described_class.contact_owns_user?(contact, user)).to be(false)
+ end
+
+ it 'false when UUID is blank' do
+ contact = Salesforce::Records::Contact.new(accounts_uuid: nil, master_record_id: nil, is_deleted: false)
+ expect(described_class.contact_owns_user?(contact, user)).to be(false)
+ end
+ end
+
+ describe '.contact_can_be_replaced?' do
+ let(:replacement) do
+ Salesforce::Records::Contact.new(id: 'NEW', accounts_uuid: 'USER-UUID', master_record_id: nil, is_deleted: false)
+ end
+
+ it ':gone when previous missing in SF' do
+ allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(nil)
+ expect(described_class.contact_can_be_replaced?(previous_id: 'OLD', by: replacement, user: user)).to eq(:gone)
+ end
+
+ it ':gone when previous is_deleted' do
+ prev = Salesforce::Records::Contact.new(id: 'OLD', is_deleted: true)
+ allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(prev)
+ expect(described_class.contact_can_be_replaced?(previous_id: 'OLD', by: replacement, user: user)).to eq(:gone)
+ end
+
+ it ':merged when previous master_record_id == replacement id' do
+ prev = Salesforce::Records::Contact.new(id: 'OLD', master_record_id: 'NEW', accounts_uuid: 'USER-UUID', is_deleted: false)
+ allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(prev)
+ expect(described_class.contact_can_be_replaced?(previous_id: 'OLD', by: replacement, user: user)).to eq(:merged)
+ end
+
+ it ':uuid_cleared when previous accounts_uuid is now blank and new owns this user' do
+ prev = Salesforce::Records::Contact.new(id: 'OLD', master_record_id: nil, accounts_uuid: nil, is_deleted: false)
+ allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(prev)
+ expect(described_class.contact_can_be_replaced?(previous_id: 'OLD', by: replacement, user: user)).to eq(:uuid_cleared)
+ end
+
+ it 'false when both are live and own this user' do
+ prev = Salesforce::Records::Contact.new(id: 'OLD', master_record_id: nil, accounts_uuid: 'USER-UUID', is_deleted: false)
+ allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(prev)
+ expect(described_class.contact_can_be_replaced?(previous_id: 'OLD', by: replacement, user: user)).to be(false)
+ end
+ end
+end
diff --git a/spec/services/salesforce_spec.rb b/spec/services/salesforce_spec.rb
new file mode 100644
index 0000000000..97548e7162
--- /dev/null
+++ b/spec/services/salesforce_spec.rb
@@ -0,0 +1,30 @@
+require 'rails_helper'
+
+RSpec.describe Salesforce do
+ describe '.configure' do
+ it 'yields the configuration object' do
+ Salesforce.configure do |config|
+ config.username = 'u'
+ config.password = 'p'
+ config.security_token = 't'
+ config.consumer_key = 'ck'
+ config.consumer_secret = 'cs'
+ end
+ expect(Salesforce.configuration.username).to eq('u')
+ end
+ end
+
+ describe Salesforce::Configuration do
+ it 'defaults api_version' do
+ expect(described_class.new.api_version).to eq('66.0')
+ end
+
+ it 'defaults login_domain' do
+ expect(described_class.new.login_domain).to eq('test.salesforce.com')
+ end
+
+ it 'raises if required fields missing on validate!' do
+ expect { described_class.new.validate! }.to raise_error(Salesforce::IllegalState)
+ end
+ end
+end
diff --git a/spec/support/salesforce_spec_helpers.rb b/spec/support/salesforce_spec_helpers.rb
index 5911f1118c..a8053e604a 100644
--- a/spec/support/salesforce_spec_helpers.rb
+++ b/spec/support/salesforce_spec_helpers.rb
@@ -1,16 +1,18 @@
module SalesforceSpecHelpers
# Helper method to create a Salesforce contact mock
def create_sf_contact(uuid:, faculty_verified:, contact_id: 'SF_CONTACT_001', school_id: 'SF_SCHOOL_001')
- contact = OpenStax::Salesforce::Remote::Contact.new(
+ contact = Salesforce::Records::Contact.new(
id: contact_id,
accounts_uuid: uuid,
faculty_verified: faculty_verified,
school_type: 'College/University (4)',
- adoption_status: 'Not Adopter'
+ adoption_status: 'Not Adopter',
+ master_record_id: nil,
+ is_deleted: false
)
# Mock the school association
- sf_school = OpenStax::Salesforce::Remote::School.new(
+ sf_school = Salesforce::Records::School.new(
id: school_id,
school_location: 'Domestic',
is_kip: false,
@@ -22,16 +24,38 @@ def create_sf_contact(uuid:, faculty_verified:, contact_id: 'SF_CONTACT_001', sc
contact
end
- # Helper method to stub the salesforce_contacts method
+ # Helper method to stub the contact fetch in SyncContacts (used by
+ # UpdateUserContactInfo, which is now a one-line shim).
def stub_salesforce_contacts(contacts)
- allow_any_instance_of(UpdateUserContactInfo).to receive(:salesforce_contacts).and_return(contacts)
+ allow_any_instance_of(Salesforce::SyncContacts).to receive(:fetch_contacts).and_return(contacts)
end
# Helper method to stub Sentry methods
def stub_sentry
allow(Sentry).to receive(:capture_check_in).and_return('check_in_id')
allow(Sentry).to receive(:capture_message)
+ allow(Sentry).to receive(:capture_exception)
end
+
+ # Replace ActiveForce's sfdc_client with an in-memory no-op so unit tests
+ # never accidentally talk to the real Salesforce sandbox. Pair with
+ # `restore_salesforce_records!` if your spec needs to revert.
+ def stub_salesforce_records!
+ test_client = Class.new do
+ def query(*) ; [] ; end
+ def create(*) ; nil; end
+ def update(*) ; nil; end
+ def upsert(*) ; nil; end
+ def find(*) ; nil; end
+ def authenticate! ; true; end
+ end.new
+ ActiveForce.sfdc_client = test_client
+ end
+
+ def restore_salesforce_records!
+ ActiveForce.clear_sfdc_client!
+ end
+
end
RSpec.configure do |config|
diff --git a/spec/whenever_spec.rb b/spec/whenever_spec.rb
index 85bcd1315f..26c1ddaaef 100644
--- a/spec/whenever_spec.rb
+++ b/spec/whenever_spec.rb
@@ -12,8 +12,10 @@
end
expect(salesforce_jobs.count).to eq 2
- expect_any_instance_of(UpdateUserContactInfo).to receive(:call)
- expect_any_instance_of(UpdateSchoolSalesforceInfo).to receive(:call)
+ # UpdateUserContactInfo and UpdateSchoolSalesforceInfo became shims
+ # that expose .call as a class method (delegating to Salesforce::*).
+ expect(UpdateUserContactInfo).to receive(:call)
+ expect(UpdateSchoolSalesforceInfo).to receive(:call)
# Executes the actual rake tasks to make sure all constants and methods exist:
salesforce_jobs.each do |job|