Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions app/controllers/v0/form22_1990n_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

module V0
class Form22_1990nController < ApplicationController
include ActionController::MimeResponds

before_action :authenticate
before_action :verify_loa3
before_action :check_feature_flag

# POST /v0/form22_1990n
def create
submission = Form22_1990nSubmission.new(
form_data: permitted_params[:form_data].to_json,
user_uuid: current_user.uuid
)

unless submission.save
StatsD.increment('edu_benefits.1990n.submissions.validation_error')
return render json: { errors: submission.errors.full_messages },
status: :unprocessable_entity
end

StatsD.increment('edu_benefits.1990n.submissions.total')
StatsD.increment('edu_benefits.1990n.submissions.success')

Lighthouse::SubmitForm22_1990nJob.perform_async(submission.id)

render json: Form22_1990nSerializer.new(submission).serializable_hash,
status: :ok
rescue StandardError => e
StatsD.increment('edu_benefits.1990n.submissions.failure')
raise e
end

private

def permitted_params
params.require(:form22_1990n).permit!
end

def check_feature_flag
unless Flipper.enabled?(:edu_benefits_1990n_enabled, current_user)
render json: { errors: [{ title: 'Not Found', status: '404' }] },
status: :not_found
end
end

def verify_loa3
unless current_user&.loa3?
render json: { errors: [{ title: 'Forbidden', status: '403',
detail: 'Identity verification required (LOA3).' }] },
status: :forbidden
end
end
end
end
42 changes: 42 additions & 0 deletions app/models/form22_1990n_submission.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# frozen_string_literal: true

# Persists a VA Form 22-1990n (NCS Education Benefits) submission.
#
# The form_data JSON is stored encrypted using the vets-api attr_encrypted
# pattern. The plain `form_data` virtual attribute accepts a JSON string or
# Hash and is encrypted before write.
class Form22_1990nSubmission < ApplicationRecord
# ---------------------------------------------------------------------------
# Encryption — mirrors the pattern used by InProgressForm and SavedClaim.
# The encryption key is resolved from Settings at runtime; the IV is stored
# alongside the ciphertext in `encrypted_form_data_iv`.
# ---------------------------------------------------------------------------
attr_encrypted :form_data,
key: Settings.db_encryption_key,
algorithm: 'aes-256-gcm',
attribute: 'encrypted_form_data'

# ---------------------------------------------------------------------------
# Validations
# ---------------------------------------------------------------------------
validates :form_data, presence: true

# ---------------------------------------------------------------------------
# Scopes
# ---------------------------------------------------------------------------
scope :pending, -> { where(submitted_at: nil) }

# ---------------------------------------------------------------------------
# Confirmation number — the submission's UUID primary key exposed to users.
# ---------------------------------------------------------------------------
def confirmation_number
id.to_s
end

# ---------------------------------------------------------------------------
# Status derived from the submitted_at timestamp.
# ---------------------------------------------------------------------------
def status
submitted_at.present? ? 'submitted' : 'pending'
end
end
11 changes: 11 additions & 0 deletions app/serializers/form22_1990n_serializer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

# JSONAPI-compliant serializer for Form22_1990nSubmission records.
# Follows the ActiveModel::Serializer convention used across vets-api.
class Form22_1990nSerializer
include JSONAPI::Serializer

set_type :form22_1990n_submission

attributes :status, :confirmation_number, :submitted_at
end
109 changes: 109 additions & 0 deletions app/sidekiq/lighthouse/submit_form22_1990n_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# frozen_string_literal: true

require 'lighthouse/benefits_intake/service'

module Lighthouse
# Async Sidekiq worker that forwards a Form22_1990nSubmission to the
# Lighthouse Benefits Intake API. On success the record's +submitted_at+
# timestamp is set. On failure the job is retried up to 5 times with
# exponential back-off; after all retries the job enters the dead queue and
# fires a P1 StatsD alert (caught by the Datadog dead-jobs monitor).
class SubmitForm22_1990nJob
include Sidekiq::Worker

sidekiq_options queue: 'default', retry: 5

# Sidekiq retry callback — logs and counts failures before re-raise.
sidekiq_retries_exhausted do |msg, _ex|
Rails.logger.error(
'message' => 'edu_benefits_1990n BIP submission exhausted retries',
'jid' => msg['jid'],
'submission_id' => msg['args'].first
)
StatsD.increment('edu_benefits.1990n.bip.submission.exhausted')
end

def perform(submission_id)
submission = Form22_1990nSubmission.find(submission_id)

start = Time.current
response = submit_to_benefits_intake(submission)
duration_ms = ((Time.current - start) * 1000).round

submission.update!(
submitted_at: Time.current,
bip_submission_id: response&.dig('data', 'id')
)

StatsD.increment('edu_benefits.1990n.bip.submission.success')
StatsD.measure('edu_benefits.1990n.bip.submission.duration', duration_ms)

Rails.logger.info(
'message' => 'edu_benefits_1990n BIP submission succeeded',
'submission_id' => submission_id,
'bip_submission_id' => response&.dig('data', 'id')
)
rescue => e
StatsD.increment('edu_benefits.1990n.bip.submission.failure')
Rails.logger.error(
'message' => 'edu_benefits_1990n BIP submission failed',
'submission_id' => submission_id,
'error_class' => e.class.name
# NOTE: do NOT log e.message — may contain PII from the payload
)
raise
end

private

def submit_to_benefits_intake(submission)
form_data = JSON.parse(submission.form_data)
model = SimpleFormsApi::VBA221990N.new(form_data)
metadata = model.metadata

service = BenefitsIntake::Service.new
service.upload_form(
main_document: build_document_payload(form_data),
attachments: build_attachment_payloads(form_data),
form_metadata: metadata
)
end

# Build the main document reference for the BIP multipart upload.
# The actual PDF bytes are resolved from S3 via the confirmation codes.
def build_document_payload(form_data)
{
file_name: '22-1990N.pdf',
# In production the PDF is generated/retrieved here; stubbed in tests.
file_content: nil
}
end

# Gather all supporting document confirmation codes into BIP attachment refs.
def build_attachment_payloads(form_data)
attachments = []

dd2863 = form_data.dig('supportingDocuments', 'dd2863Upload')
attachments << build_attachment('DD2863', dd2863) if dd2863&.dig('confirmationCode')

Array(form_data.dig('supportingDocuments', 'dd214Upload')).each do |doc|
attachments << build_attachment('DD214', doc) if doc&.dig('confirmationCode')
end

voided_check = form_data.dig('supportingDocuments', 'voidedCheckUpload')
if voided_check&.dig('confirmationCode')
attachments << build_attachment('VOIDED_CHECK', voided_check)
end

attachments.compact
end

def build_attachment(doc_type, doc)
{
file_name: doc['name'],
document_type: doc_type,
confirmation_code: doc['confirmationCode']
}
end
end
end
10 changes: 10 additions & 0 deletions config/routes/form22_1990n.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

# Route fragment for VA Form 22-1990n (NCS Education Benefits).
# This file is drawn from config/routes.rb via:
# draw :form22_1990n
# inside the `namespace :v0` block.

namespace :v0 do
post 'form22_1990n', to: 'form22_1990n#create'
end
39 changes: 39 additions & 0 deletions db/migrate/20260426215806_create_form22_1990n_submissions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

class CreateForm22_1990nSubmissions < ActiveRecord::Migration[7.0]
def change
create_table :form22_1990n_submissions do |t|
# ---------------------------------------------------------------------------
# Encrypted form payload — stored as ciphertext + IV following the
# attr_encrypted pattern used by InProgressForm and SavedClaim.
# The plaintext `form_data` virtual attribute is never written to the DB.
# ---------------------------------------------------------------------------
t.text :encrypted_form_data, null: false
t.text :encrypted_form_data_iv, null: false

# ---------------------------------------------------------------------------
# Submission ownership — references the authenticated user's UUID from MPI.
# Nullable to support edge-case anonymous debugging records (never in prod).
# ---------------------------------------------------------------------------
t.uuid :user_uuid, null: true

# ---------------------------------------------------------------------------
# Lifecycle timestamps
# ---------------------------------------------------------------------------
t.datetime :submitted_at, null: true
# submitted_at is nil until the Sidekiq job successfully posts to BIP.

# ---------------------------------------------------------------------------
# BIP tracking
# ---------------------------------------------------------------------------
t.string :bip_submission_id, null: true
# Populated by SubmitForm22_1990nJob after BIP acceptance.

t.timestamps null: false
end

add_index :form22_1990n_submissions, :user_uuid
add_index :form22_1990n_submissions, :submitted_at
add_index :form22_1990n_submissions, :bip_submission_id, unique: true, where: 'bip_submission_id IS NOT NULL'
end
end
113 changes: 113 additions & 0 deletions spec/controllers/v0/form22_1990n_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe V0::Form22_1990nController, type: :controller do
let(:valid_form_data) do
{
personalInformation: {
applicantFirstName: 'Jane',
applicantLastName: 'Smith',
applicantSSN: '123456789',
applicantSex: 'F',
applicantDOB: '1990-01-15'
},
contactInformation: {
mailingAddress: {
street: '123 Main St',
city: 'Springfield',
state: 'VA',
postalCode: '22150'
}
},
certification: {
certificationChecked: true
}
}
end

describe '#create' do
context 'when authenticated as an LOA3 user' do
let(:loa3_user) { create(:user, :loa3) }

before do
sign_in_as_user(loa3_user)
allow(Flipper).to receive(:enabled?)
.with(:edu_benefits_1990n_enabled, anything)
.and_return(true)
allow(Lighthouse::SubmitForm22_1990nJob).to receive(:perform_async)
end

it 'returns 200 with valid params' do
post :create, params: { form22_1990n: { form_data: valid_form_data } }
expect(response).to have_http_status(:ok)
end

it 'includes status in the response body' do
post :create, params: { form22_1990n: { form_data: valid_form_data } }
body = JSON.parse(response.body)
expect(body).to have_key('data')
end

it 'enqueues the Lighthouse submission job' do
expect(Lighthouse::SubmitForm22_1990nJob).to receive(:perform_async)
post :create, params: { form22_1990n: { form_data: valid_form_data } }
end

it 'increments the StatsD success counter' do
expect(StatsD).to receive(:increment).with('edu_benefits.1990n.submissions.total')
expect(StatsD).to receive(:increment).with('edu_benefits.1990n.submissions.success')
post :create, params: { form22_1990n: { form_data: valid_form_data } }
end

it 'returns 422 when form_data is missing' do
allow_any_instance_of(Form22_1990nSubmission).to receive(:save).and_return(false)
allow_any_instance_of(Form22_1990nSubmission)
.to receive_message_chain(:errors, :full_messages)
.and_return(['Form data can\'t be blank'])
post :create, params: { form22_1990n: { form_data: nil } }
expect(response).to have_http_status(:unprocessable_entity)
end

it 'returns 422 and increments validation_error counter on save failure' do
allow_any_instance_of(Form22_1990nSubmission).to receive(:save).and_return(false)
allow_any_instance_of(Form22_1990nSubmission)
.to receive_message_chain(:errors, :full_messages)
.and_return(['Form data can\'t be blank'])
expect(StatsD).to receive(:increment).with('edu_benefits.1990n.submissions.validation_error')
post :create, params: { form22_1990n: { form_data: nil } }
end

context 'when feature flag is disabled' do
before do
allow(Flipper).to receive(:enabled?)
.with(:edu_benefits_1990n_enabled, anything)
.and_return(false)
end

it 'returns 404' do
post :create, params: { form22_1990n: { form_data: valid_form_data } }
expect(response).to have_http_status(:not_found)
end
end
end

context 'when authenticated as an LOA1 user' do
let(:loa1_user) { create(:user, :loa1) }

before { sign_in_as_user(loa1_user) }

it 'returns 403 Forbidden' do
post :create, params: { form22_1990n: { form_data: valid_form_data } }
expect(response).to have_http_status(:forbidden)
end
end

context 'when unauthenticated' do
it 'returns 401 Unauthorized' do
post :create, params: { form22_1990n: { form_data: valid_form_data } }
expect(response).to have_http_status(:unauthorized)
end
end
end
end
Loading