Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
47 changes: 47 additions & 0 deletions app/controllers/admin/finance/voids_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
module Admin
module Finance
class VoidsController < Admin::Finance::BaseController
layout "full"

before_action :set_declaration
before_action :set_teacher
before_action :set_void_declaration_form

def new
end

def create
if @void_declaration_form.void!
flash[:alert] = "Declaration voided"
redirect_to admin_teacher_declarations_path(@teacher)
else
render :new, status: :unprocessable_content
end
end

private

def set_declaration
@declaration = Declaration.find(params[:declaration_id])
end

def set_teacher
@teacher = @declaration.teacher
end

def set_void_declaration_form
@void_declaration_form = VoidDeclarationForm.new(
declaration: @declaration,
author: current_user,
**void_declaration_form_params
)
end

def void_declaration_form_params
return {} unless params.key?(:admin_finance_void_declaration_form)

params.expect(admin_finance_void_declaration_form: [:confirmed])
end
end
end
end
54 changes: 54 additions & 0 deletions app/forms/admin/finance/void_declaration_form.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
module Admin
module Finance
class VoidDeclarationForm
include ActiveModel::Model
include ActiveModel::Attributes

attribute :declaration
attribute :author
attribute :confirmed, :boolean

validates :confirmed, acceptance: { message: "Confirm you want to void this declaration", allow_nil: false }

validate :clawback_statement_available, if: :paid_declaration?

def void!
return false unless valid?

if paid_declaration?
clawback_service.clawback
else
void_service.void
end
end

private

def paid_declaration?
declaration&.payment_status_paid?
end

def clawback_statement_available
return if clawback_service.next_available_output_fee_statement.present?

errors.add(:base, "This declaration has been paid, and no future statement exists for clawback")
end

def clawback_service
@clawback_service ||= Declarations::Clawback.new(**service_attributes)
end

def void_service
@void_service ||= Declarations::Void.new(**service_attributes)
end

def service_attributes
{
author:,
declaration:,
voided_by_user_id: author&.id
}
end
end
end
end
12 changes: 12 additions & 0 deletions app/helpers/declaration_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,16 @@ def declaration_course_identifier(declaration)
"ecf-mentor"
end
end

def declaration_voided_by_caption(declaration, event)
return unless event.event_type.in?(%w[teacher_declaration_voided teacher_declaration_clawed_back])

text = if declaration.voided_by_user.present?
"Voided by #{declaration.voided_by_user.name} (#{declaration.voided_by_user.email})"
else
"Voided by lead provider"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the row is "Clawed back", should the caption read "Clawed back by..." instead of "Voided by..."?

At the moment the wording is inconsistent, it reads a bit off to me. Did you have any thoughts on that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I had a similar thought 🤔 I can see the argument either way, but concluded that the verb/action/button the user took is to void the declaration, and clawback is something the system does in certain situations, but either way the declaration is still indeed voided. So since voided isn't wrong, IMO it's fine to defer to the design on this one.

end

tag.span(text, class: "govuk-caption-m")
end
end
2 changes: 1 addition & 1 deletion app/models/declaration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ class Declaration < ApplicationRecord
validates :declaration_type, inclusion: { in: Declaration.declaration_types.keys, message: "Choose a valid declaration type" }
validates :evidence_type, inclusion: { in: Declaration.evidence_types.keys, message: "Choose a valid evidence type" }, allow_nil: true
validates :mentorship_period, absence: { message: "Mentor teacher can only be assigned to declarations for ECTs" }, if: :for_mentor?
validates :payment_statement, presence: { message: "Payment statement must be associated for declarations with a payment status" }, unless: :payment_status_no_payment?
validates :payment_statement, presence: { message: "Payment statement must be associated for declarations with a payment status" }, unless: -> { payment_status_no_payment? || payment_status_voided? }
validates :clawback_statement, presence: { message: "Clawback statement must be associated for declarations with a clawback status" }, unless: :clawback_status_no_clawback?
validates :delivery_partner_when_created, presence: { message: "Delivery partner when the declaration was created must be specified" }
validate :mentorship_period_belongs_to_teacher
Expand Down
28 changes: 9 additions & 19 deletions app/services/api/declarations/clawback.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,18 @@ class Clawback
def clawback
return false unless valid?

Declarations::Clawback.new(
author:,
declaration:,
next_available_output_fee_statement:
).clawback
clawback_service.clawback
end

private

def clawback_service
@clawback_service ||= Declarations::Clawback.new(
author:,
declaration:
)
end

def paid
return if errors[:declaration_api_id].any?
return if declaration.payment_status_paid?
Expand All @@ -34,7 +37,7 @@ def not_already_refunded

def output_fee_statement_available
return if errors[:declaration_api_id].any?
return if next_available_output_fee_statement.present?
return if clawback_service.next_available_output_fee_statement.present?

no_output_fee_statement_error_message = <<~TXT.squish
You cannot submit or void declarations for the #{contract_period_year}
Expand All @@ -44,19 +47,6 @@ def output_fee_statement_available
errors.add(:declaration_api_id, no_output_fee_statement_error_message)
end

def next_available_output_fee_statement
@next_available_output_fee_statement ||= Statements::Search
.new(
lead_provider_id: declaration.training_period.lead_provider.id,
contract_period_years: contract_period_year,
fee_type: "output",
deadline_date: Date.current..,
order: :deadline_date
)
.statements
.first
end

def contract_period_year = declaration.training_period.contract_period.year
end
end
27 changes: 24 additions & 3 deletions app/services/declarations/clawback.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
module Declarations
class Clawback
attr_reader :author, :declaration, :voided_by_user_id, :next_available_output_fee_statement
include ActiveModel::Model

def initialize(author:, declaration:, next_available_output_fee_statement:, voided_by_user_id: nil)
attr_reader :author, :declaration, :voided_by_user_id

validates :next_available_output_fee_statement, presence: true

def initialize(author:, declaration:, voided_by_user_id: nil)
@author = author
@declaration = declaration
@voided_by_user_id = voided_by_user_id
@next_available_output_fee_statement = next_available_output_fee_statement
end

def clawback
return false unless valid?

ActiveRecord::Base.transaction do
if voided_by_user_id
declaration.voided_by_user_at = Time.current
Expand All @@ -25,8 +30,24 @@ def clawback
declaration
end

def next_available_output_fee_statement
@next_available_output_fee_statement ||= Statements::Search
.new(
lead_provider_id: lead_provider.id,
contract_period_years: contract_period.year,
fee_type: "output",
deadline_date: Date.current..,
order: :deadline_date
)
.statements
.first
end

private

delegate :training_period, to: :declaration
delegate :contract_period, :lead_provider, to: :training_period

def complete_mentor!
MentorCompletion.new(author:, declaration:).perform
end
Expand Down
64 changes: 64 additions & 0 deletions app/views/admin/finance/voids/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<% page_data(
title: "Void declaration for #{teacher_full_name(@teacher)}",
backlink_href: admin_teacher_declarations_path(@teacher)
) %>

<div class="govuk-grid-row">
<div class="govuk-grid-column-full">
<%=
govuk_summary_list(actions: false) do |summary_list|
summary_list.with_row do |row|
row.with_key { "Declaration ID" }
row.with_value { tag.span(@declaration.api_id, class: "app-mono") }
end

summary_list.with_row do |row|
row.with_key { "Course identifier" }
row.with_value { tag.span(declaration_course_identifier(@declaration), class: "app-mono") }
end

summary_list.with_row do |row|
row.with_key { "Declaration type" }
row.with_value { tag.span(@declaration.declaration_type, class: "app-mono") }
end

summary_list.with_row do |row|
row.with_key { "State" }
row.with_value { declaration_state_tag(@declaration) }
end

summary_list.with_row do |row|
row.with_key { "Declaration date and time" }
row.with_value { @declaration.declaration_date.to_fs(:govuk) }
end

summary_list.with_row do |row|
row.with_key { "Lead provider" }
row.with_value { @declaration.lead_provider&.name }
end

summary_list.with_row do |row|
row.with_key { "Delivery partner" }
row.with_value { @declaration.delivery_partner_when_created&.name || "Delivery partner not recorded" }
end

summary_list.with_row do |row|
row.with_key { "Evidence held" }
row.with_value { tag.span(@declaration.evidence_type, class: "app-mono") }
end
end
%>

<%= form_with model: @void_declaration_form, url: admin_declaration_voids_path(@declaration), method: :post do |f| %>
<%= content_for(:error_summary) { f.govuk_error_summary } %>

<%= f.govuk_check_boxes_fieldset :confirmed, multiple: false, legend: nil do %>
<%= f.govuk_check_box(:confirmed, "1", false, link_errors: true, multiple: false, label: {
text: "I confirm I want to void this declaration and I understand that it cannot be undone"
}) %>
<% end %>

<%= f.govuk_submit "Confirm void declaration", warning: true, class: "govuk-!-margin-top-6" %>
<% end %>
Copy link
Contributor

@avinhurry avinhurry Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want a "Cancel and return..." link here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is one in the ticket screenshot, but it's not there when I click through to the prototype, so looks like there was a decision to remove it.

</div>
</div>
13 changes: 12 additions & 1 deletion app/views/admin/teachers/declarations/_declaration.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,22 @@
declaration.events.earliest_first.each do |event|
body.with_row do |row|
row.with_cell(text: declaration_event_state_name(event), header: true)
row.with_cell(text: event.happened_at.to_fs(:govuk))
row.with_cell do
safe_join([
event.happened_at.to_fs(:govuk),
declaration_voided_by_caption(declaration, event)
].compact)
end
end
end
end
end
%>
<% end %>

<% if declaration.billable_or_changeable? %>
<p class="govuk-body govuk-!-margin-top-6">
<%= govuk_button_link_to "Void declaration", new_admin_declaration_void_path(declaration), warning: true %>
</p>
<% end %>
<% end %>
4 changes: 4 additions & 0 deletions config/routes/admin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@
member { get :delete }
end
end

resources :declarations, only: [] do
resources :voids, only: %i[new create]
end
end
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
RSpec.describe "Void a declaration" do
before { sign_in_as_dfe_user(role: :finance) }

scenario "Void an unpaid declaration" do
given_a_declaration_exists
and_a_future_output_fee_statement_exists

when_i_visit_the_teacher_declarations_page
and_i_click_void_declaration
then_i_see_the_confirmation_page

when_i_click_confirm_void_declaration
then_i_see_the_success_message
and_the_declaration_is_voided
end

private

def given_a_declaration_exists
@declaration = FactoryBot.create(:declaration, :no_payment)
@teacher = @declaration.teacher
end

def and_a_future_output_fee_statement_exists
active_lead_provider = FactoryBot.create(:active_lead_provider, lead_provider: @declaration.training_period.lead_provider, contract_period: @declaration.training_period.contract_period)
FactoryBot.create(:statement, :output_fee, active_lead_provider:)
end

def when_i_visit_the_teacher_declarations_page
page.goto(admin_teacher_declarations_path(@teacher))
end

def and_i_click_void_declaration
page.locator("summary").get_by_text(@declaration.api_id).click
page.get_by_role("link", name: "Void declaration").click
end

def then_i_see_the_confirmation_page
expect(page.get_by_text("Void declaration for")).to be_visible
expect(page.get_by_text(@declaration.api_id)).to be_visible
expect(page.get_by_role("button", name: "Confirm void declaration")).to be_visible
end

def when_i_click_confirm_void_declaration
page.get_by_label("I confirm I want to void this declaration").check
page.get_by_role("button", name: "Confirm void declaration").click
end

def then_i_see_the_success_message
expect(page.get_by_text("Declaration voided")).to be_visible
end

def and_the_declaration_is_voided
expect(@declaration.reload.payment_status).to eq("voided")
end
end
Loading