Repository: DFE-Digital/register-early-career-teachers-public
Pull request #604: Add `Statement` and `Statement::Adjustment` models
Author: login: ethax-ross
Created: 2025-05-19T08:45:06Z | Merged: 2025-05-20T09:15:57Z

[Issue #1605](https://github.com/DFE-Digital/register-ects-project-board/issues/1605)

### Overview

We want to add the `Statement` and `Statement::Adjustment` models that we spiked in #587. In addition, we want to include validation for these new models. The `Statement::Item` model will come later (when we have declarations in place).

### Changes

- Add Statement and Statement::Adjustment models

Add the `Statement` and `Statement::Adjustment` models. The fields that are relevant have been ported from ECF.

- Add validation to Statement and Statement::Adjustment

Add validation rules to `Statement` and `Statement::Adjustment`.

Add basic factories for both models.

-  Filter changed files

Add states to Statement model. 

Add the `open`, `payable` and `paid` states to the `Statement` model using `state_machines-activerecord` gem.

- Add seed data for statements

Add the seed data for statements following what we add in ECF.

For each lead provider active for a given registration year we will add statements for each month of the year.

Creates statements in all states and with a range of output fee status.

### Model diagram

```mermaid
erDiagram
    LeadProvider ||--|{ ActiveLeadProvider : ""
    RegistrationPeriod ||--|{ ActiveLeadProvider : ""
    ActiveLeadProvider ||--o{ Statement : ""
    Statement ||--o{ "Statement::Adjustment" : ""
```

Note: we are not bringing across the `reconcile_amount` from ECF as this appears to be an earlier version of manual adjustments. Instead, we will migrate these values into adjustments as part of the data migration.

### State diagram

```mermaid
stateDiagram-v2
  [*] --> open
  open --> payable : mark_as_payable
  payable --> paid : mark_as_paid
```

### Example seed output

<img width="301" alt="Screenshot 2025-05-19 at 09 37 42" src="https://github.com/user-attachments/assets/2ba44bb8-b808-4631-ba18-7464384ecce3" />



Diff:
diff --git a/Gemfile b/Gemfile
index 9c0002d4d..519f0e840 100644
--- a/Gemfile
+++ b/Gemfile
@@ -28,6 +28,7 @@ gem "sentry-rails"
 gem "sentry-ruby"
 gem "solid_queue"
 gem "stackprof"
+gem "state_machines-activerecord"
 
 # DfE Sign-In
 gem "omniauth"
diff --git a/Gemfile.lock b/Gemfile.lock
index 95b75ba4a..00cf019a1 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -645,6 +645,13 @@ GEM
       rbtree
       set (~> 1.0)
     stackprof (0.2.27)
+    state_machines (0.6.0)
+    state_machines-activemodel (0.9.0)
+      activemodel (>= 6.0)
+      state_machines (>= 0.6.0)
+    state_machines-activerecord (0.9.0)
+      activerecord (>= 6.0)
+      state_machines-activemodel (>= 0.9.0)
     stimulus-rails (1.3.4)
       railties (>= 6.0.0)
     stringio (3.1.7)
@@ -765,6 +772,7 @@ DEPENDENCIES
   shoulda-matchers
   solid_queue
   stackprof
+  state_machines-activerecord
   super_diff
   turbo-rails
   tzinfo-data
diff --git a/app/models/active_lead_provider.rb b/app/models/active_lead_provider.rb
index a0b4f294d..718e5f9ad 100644
--- a/app/models/active_lead_provider.rb
+++ b/app/models/active_lead_provider.rb
@@ -1,6 +1,7 @@
 class ActiveLeadProvider < ApplicationRecord
   belongs_to :registration_period, inverse_of: :active_lead_providers
   belongs_to :lead_provider, inverse_of: :active_lead_providers
+  has_many :statements
 
   validates :registration_period_id,
             presence: { message: 'Choose a registration period' },
diff --git a/app/models/statement.rb b/app/models/statement.rb
new file mode 100644
index 000000000..c603360bc
--- /dev/null
+++ b/app/models/statement.rb
@@ -0,0 +1,26 @@
+class Statement < ApplicationRecord
+  belongs_to :active_lead_provider
+  has_many :adjustments
+
+  def self.maximum_year = Date.current.year + 5
+
+  validates :output_fee, inclusion: { in: [true, false], message: "Output fee must be true or false" }
+  validates :month, numericality: { in: 1..12, only_integer: true, message: "Month must be a number between 1 and 12" }
+  validates :year, numericality: { greater_than_or_equal_to: 2020, is_less_than_or_equal_to: :maximum_year, only_integer: true, message: "Year must be on or after 2020 and on or before #{maximum_year}" }
+  validates :active_lead_provider_id, uniqueness: { scope: %i[year month], message: "Statement with the same month and year already exists for the lead provider" }
+  validates :api_id, uniqueness: { case_sensitive: false, message: "API id already exists for another statement" }
+
+  state_machine :state, initial: :open do
+    state :open
+    state :payable
+    state :paid
+
+    event :mark_as_payable do
+      transition [:open] => :payable
+    end
+
+    event :mark_as_paid do
+      transition [:payable] => :paid
+    end
+  end
+end
diff --git a/app/models/statement/adjustment.rb b/app/models/statement/adjustment.rb
new file mode 100644
index 000000000..89bd7522f
--- /dev/null
+++ b/app/models/statement/adjustment.rb
@@ -0,0 +1,9 @@
+class Statement::Adjustment < ApplicationRecord
+  belongs_to :statement
+
+  validates :payment_type, presence: { message: "Payment type is required" }
+  validates :amount, presence: { message: "Amount is required" }
+  validates :amount, numericality: { other_than: 0, message: "Amount must be greater than 0" }
+  # Only needed for migrating data from ECF; can be removed later.
+  validates :api_id, uniqueness: { case_sensitive: false, message: "API id already exists for another statement adjustment" }
+end
diff --git a/config/analytics_blocklist.yml b/config/analytics_blocklist.yml
index 7523612b6..5a67ee25e 100644
--- a/config/analytics_blocklist.yml
+++ b/config/analytics_blocklist.yml
@@ -373,3 +373,24 @@
   - registration_period_id
   - created_at
   - updated_at
+  :statement_adjustments:
+  - id
+  - statement_id
+  - api_id
+  - payment_type
+  - amount
+  - created_at
+  - updated_at
+  :statements:
+  - id
+  - active_lead_provider_id
+  - api_id
+  - month
+  - year
+  - deadline_date
+  - payment_date
+  - marked_as_paid_at
+  - output_fee
+  - state
+  - created_at
+  - updated_at
diff --git a/db/migrate/20250515142532_create_statements.rb b/db/migrate/20250515142532_create_statements.rb
new file mode 100644
index 000000000..d7b1331b1
--- /dev/null
+++ b/db/migrate/20250515142532_create_statements.rb
@@ -0,0 +1,20 @@
+class CreateStatements < ActiveRecord::Migration[8.0]
+  def change
+    create_enum :statement_states, %w[open payable paid]
+
+    create_table :statements do |t|
+      t.references :active_lead_provider, null: false, foreign_key: true
+
+      t.uuid :api_id, null: false, default: -> { "gen_random_uuid()" }
+      t.integer :month, null: false
+      t.integer :year, null: false
+      t.date :deadline_date, null: false
+      t.date :payment_date, null: false
+      t.datetime :marked_as_paid_at
+      t.boolean :output_fee, default: true, null: false
+      t.enum :state, enum_type: "statement_states", default: "open", null: false
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20250515142629_create_statement_adjustments.rb b/db/migrate/20250515142629_create_statement_adjustments.rb
new file mode 100644
index 000000000..6b28530a9
--- /dev/null
+++ b/db/migrate/20250515142629_create_statement_adjustments.rb
@@ -0,0 +1,13 @@
+class CreateStatementAdjustments < ActiveRecord::Migration[8.0]
+  def change
+    create_table :statement_adjustments do |t|
+      t.references :statement, null: false, foreign_key: true
+
+      t.uuid :api_id, null: false, default: -> { "gen_random_uuid()" }
+      t.string :payment_type, null: false
+      t.decimal :amount, null: false
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index cc4b6b0e9..11c96737d 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -30,6 +30,7 @@
   create_enum "induction_programme", ["cip", "fip", "diy", "unknown", "pre_september_2021"]
   create_enum "mentor_became_ineligible_for_funding_reason", ["completed_declaration_received", "completed_during_early_roll_out", "started_not_completed"]
   create_enum "programme_type", ["provider_led", "school_led"]
+  create_enum "statement_states", ["open", "payable", "paid"]
   create_enum "working_pattern", ["part_time", "full_time"]
 
   create_table "active_lead_providers", force: :cascade do |t|
@@ -529,6 +530,31 @@
     t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true
   end
 
+  create_table "statement_adjustments", force: :cascade do |t|
+    t.bigint "statement_id", null: false
+    t.uuid "api_id", default: -> { "gen_random_uuid()" }, null: false
+    t.string "payment_type", null: false
+    t.decimal "amount", null: false
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["statement_id"], name: "index_statement_adjustments_on_statement_id"
+  end
+
+  create_table "statements", force: :cascade do |t|
+    t.bigint "active_lead_provider_id", null: false
+    t.uuid "api_id", default: -> { "gen_random_uuid()" }, null: false
+    t.integer "month", null: false
+    t.integer "year", null: false
+    t.date "deadline_date", null: false
+    t.date "payment_date", null: false
+    t.datetime "marked_as_paid_at"
+    t.boolean "output_fee", default: true, null: false
+    t.enum "state", default: "open", null: false, enum_type: "statement_states"
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["active_lead_provider_id"], name: "index_statements_on_active_lead_provider_id"
+  end
+
   create_table "teacher_migration_failures", force: :cascade do |t|
     t.bigint "teacher_id"
     t.string "message", null: false
@@ -638,6 +664,8 @@
   add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
   add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
   add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+  add_foreign_key "statement_adjustments", "statements"
+  add_foreign_key "statements", "active_lead_providers"
   add_foreign_key "teacher_migration_failures", "teachers"
   add_foreign_key "training_periods", "ect_at_school_periods"
   add_foreign_key "training_periods", "mentor_at_school_periods"
diff --git a/db/seeds.rb b/db/seeds.rb
index 2462f3221..234b7603c 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -1,5 +1,10 @@
 ECT_COLOUR = :magenta
 MENTOR_COLOUR = :yellow
+STATEMENT_STATE_COLOURS = {
+  open: :blue,
+  payable: :cyan,
+  paid: :green,
+}.freeze
 
 def print_seed_info(text, indent: 0, colour: nil)
   if colour
@@ -126,6 +131,21 @@ def describe_teacher(teacher)
   print_seed_info("#{teacher_name} (early roll out mentor: #{ero_status})", indent: 2)
 end
 
+def describe_statement(statement)
+  statement_name = "#{Date::MONTHNAMES[statement.month]} #{statement.year}"
+  state = Colourize.text(statement.state, STATEMENT_STATE_COLOURS[statement.state.to_sym])
+  print_seed_info("#{statement_name} #{state}", indent: 4)
+end
+
+def describe_group_of_statements(active_lead_provider)
+  lead_provider_name = active_lead_provider.lead_provider.name
+  year = active_lead_provider.registration_period.year
+  colour_index = Digest::MD5.hexdigest(lead_provider_name).to_i(16) % Colourize::COLOURS.size
+  lead_provider = Colourize.text(lead_provider_name, Colourize::COLOURS.keys[colour_index])
+
+  print_seed_info("#{lead_provider} #{year}", indent: 2)
+end
+
 print_seed_info("Adding teachers")
 
 early_roll_out_mentor_attrs = { mentor_became_ineligible_for_funding_reason: 'completed_during_early_roll_out', mentor_became_ineligible_for_funding_on: Date.new(2021, 4, 19) }
@@ -735,4 +755,41 @@ def describe_teacher(teacher)
       .then { |user| describe_user(user) }
 end
 
-print_seed_info('Adding funding exemptions:')
+print_seed_info('Adding statements:')
+
+active_lead_providers = ActiveLeadProvider
+  .joins(:registration_period)
+  .order(registration_period: { year: :asc })
+
+active_lead_providers.each do |active_lead_provider|
+  registration_year = active_lead_provider.registration_period.year
+  months = (1..12).to_a
+  years = [registration_year, registration_year + 1]
+
+  describe_group_of_statements(active_lead_provider)
+
+  years.product(months).collect do |year, month|
+    deadline_date = Time.zone.local(year, month).end_of_month
+    payment_date = Time.zone.at(rand(deadline_date.to_i..(deadline_date + 2.months).to_i))
+    output_fee = [true, false].sample
+    state = if payment_date < Date.current
+              :paid
+            elsif Date.current.between?(deadline_date, payment_date)
+              :payable
+            else
+              :open
+            end
+
+    statement = Statement.create!(
+      active_lead_provider:,
+      month:,
+      year:,
+      deadline_date:,
+      payment_date:,
+      output_fee:,
+      state:
+    )
+
+    describe_statement(statement)
+  end
+end
diff --git a/documentation/domain-model.png b/documentation/domain-model.png
index 994f518e1..5508f1c3c 100644
Binary files a/documentation/domain-model.png and b/documentation/domain-model.png differ
diff --git a/spec/factories/statement/adjustment_factory.rb b/spec/factories/statement/adjustment_factory.rb
new file mode 100644
index 000000000..6a022e4c5
--- /dev/null
+++ b/spec/factories/statement/adjustment_factory.rb
@@ -0,0 +1,9 @@
+FactoryBot.define do
+  factory(:statement_adjustment, class: "Statement::Adjustment") do
+    statement
+
+    api_id { SecureRandom.uuid }
+    sequence(:payment_type) { |n| "Payment #{n}" }
+    amount { 100 }
+  end
+end
diff --git a/spec/factories/statement_factory.rb b/spec/factories/statement_factory.rb
new file mode 100644
index 000000000..b3d26fabc
--- /dev/null
+++ b/spec/factories/statement_factory.rb
@@ -0,0 +1,25 @@
+FactoryBot.define do
+  factory(:statement) do
+    active_lead_provider
+
+    api_id { SecureRandom.uuid }
+    month { Faker::Number.between(from: 1, to: 12) }
+    year { Faker::Number.between(from: 2021, to: Date.current.year) }
+    deadline_date { Faker::Date.forward(days: 30) }
+    payment_date { Faker::Date.forward(days: 30) }
+    output_fee { true }
+    open
+
+    trait :open do
+      state { :open }
+    end
+
+    trait :payable do
+      state { :payable }
+    end
+
+    trait :paid do
+      state { :paid }
+    end
+  end
+end
diff --git a/spec/models/active_lead_provider_spec.rb b/spec/models/active_lead_provider_spec.rb
index 94be2f4a2..9d3eec7fb 100644
--- a/spec/models/active_lead_provider_spec.rb
+++ b/spec/models/active_lead_provider_spec.rb
@@ -2,6 +2,7 @@
   describe "associations" do
     it { is_expected.to belong_to(:registration_period) }
     it { is_expected.to belong_to(:lead_provider) }
+    it { is_expected.to have_many(:statements) }
   end
 
   describe "validations" do
diff --git a/spec/models/statement/adjustment_spec.rb b/spec/models/statement/adjustment_spec.rb
new file mode 100644
index 000000000..ab83bbc8b
--- /dev/null
+++ b/spec/models/statement/adjustment_spec.rb
@@ -0,0 +1,14 @@
+describe Statement::Adjustment do
+  describe "associations" do
+    it { is_expected.to belong_to(:statement) }
+  end
+
+  describe "validations" do
+    subject { FactoryBot.create(:statement_adjustment) }
+
+    it { is_expected.to validate_presence_of(:payment_type).with_message("Payment type is required") }
+    it { is_expected.to validate_presence_of(:amount).with_message("Amount is required") }
+    it { is_expected.to validate_numericality_of(:amount).is_other_than(0).with_message("Amount must be greater than 0") }
+    it { is_expected.to validate_uniqueness_of(:api_id).case_insensitive.with_message("API id already exists for another statement adjustment") }
+  end
+end
diff --git a/spec/models/statement_spec.rb b/spec/models/statement_spec.rb
new file mode 100644
index 000000000..0dab249d1
--- /dev/null
+++ b/spec/models/statement_spec.rb
@@ -0,0 +1,42 @@
+describe Statement do
+  describe "associations" do
+    it { is_expected.to belong_to(:active_lead_provider) }
+    it { is_expected.to have_many(:adjustments) }
+  end
+
+  describe "validations" do
+    subject { FactoryBot.create(:statement) }
+
+    it { is_expected.to validate_inclusion_of(:output_fee).in_array([true, false]).with_message("Output fee must be true or false") }
+    it { is_expected.to validate_numericality_of(:month).only_integer.is_greater_than_or_equal_to(1).is_less_than_or_equal_to(12).with_message("Month must be a number between 1 and 12") }
+    it { is_expected.to validate_numericality_of(:year).only_integer.is_greater_than_or_equal_to(2020).with_message("Year must be on or after 2020 and on or before #{described_class.maximum_year}") }
+    it { is_expected.to validate_uniqueness_of(:active_lead_provider_id).scoped_to(:year, :month).with_message("Statement with the same month and year already exists for the lead provider") }
+    it { is_expected.to validate_uniqueness_of(:api_id).case_insensitive.with_message("API id already exists for another statement") }
+  end
+
+  describe ".maximum_year" do
+    subject { described_class.maximum_year }
+
+    it { is_expected.to eq(Date.current.year + 5) }
+  end
+
+  describe "state transitions" do
+    context "when transitioning from open to payable" do
+      let(:statement) { FactoryBot.create(:statement, :open) }
+
+      it { expect { statement.mark_as_payable! }.to change(statement, :state).from("open").to("payable") }
+    end
+
+    context "when transitioning from payable to paid" do
+      let(:statement) { FactoryBot.create(:statement, :payable) }
+
+      it { expect { statement.mark_as_paid! }.to change(statement, :state).from("payable").to("paid") }
+    end
+
+    context "when transitioning to an invalid state" do
+      let(:statement) { FactoryBot.create(:statement, :paid) }
+
+      it { expect { statement.mark_as_payable! }.to raise_error(StateMachines::InvalidTransition) }
+    end
+  end
+end


Reviews:
user: login: peteryates
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-19T09:46:07Z
user: login: peteryates
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-19T13:27:29Z
user: login: peteryates
body: Looks spot on.

It does make the seeds output quite long but I think we can make that a bit more concise later.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-19T13:32:18Z
user: login: ethax-ross
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T13:48:48Z
user: login: leandroalemao
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T14:28:44Z
user: login: mooktakim
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T15:58:12Z
user: login: mooktakim
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T16:00:31Z
user: login: claire-hughez
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T17:21:37Z
user: login: peteryates
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-19T17:33:45Z
user: login: cwrw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T20:49:33Z
user: login: cwrw
body: looks great just some minor comments
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T20:51:26Z
user: login: cwrw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T20:52:12Z
user: login: ethax-ross
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T07:26:50Z
user: login: cwrw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T07:28:25Z
user: login: cwrw
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T08:03:44Z
user: login: ethax-ross
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T08:10:50Z

Review comments:
diff_hunk: @@ -1,5 +1,10 @@
 ECT_COLOUR = :magenta
 MENTOR_COLOUR = :yellow
+STATEMENT_STATE_COLOURS = {
+  open: :blue,
+  payable: :cyan,
+  paid: :green,
+}.freeze
path: db/seeds.rb
user: login: peteryates
body: :smiling_face_with_tear: 
created_at: 2025-05-19T09:46:07Z
author_association: MEMBER
diff_hunk: @@ -529,6 +530,31 @@
     t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true
   end
 
+  create_table "statement_adjustments", force: :cascade do |t|
+    t.bigint "statement_id", null: false
+    t.uuid "api_id", default: -> { "gen_random_uuid()" }, null: false
+    t.string "payment_type", null: false
+    t.decimal "amount", default: "0.0", null: false
path: db/schema.rb
user: login: peteryates
body: Out of interest, why would we want a default amount? Is an adjustment of `0.0` actually an adjustment?
created_at: 2025-05-19T13:27:28Z
author_association: MEMBER
diff_hunk: @@ -529,6 +530,31 @@
     t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true
   end
 
+  create_table "statement_adjustments", force: :cascade do |t|
+    t.bigint "statement_id", null: false
+    t.uuid "api_id", default: -> { "gen_random_uuid()" }, null: false
+    t.string "payment_type", null: false
+    t.decimal "amount", default: "0.0", null: false
path: db/schema.rb
user: login: ethax-ross
body: Good point, we wouldn't (and we prevent it explicitly in the validation). I've just copied this from ECF without thinking 🙂 I'll fixup a change
created_at: 2025-05-19T13:48:48Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,8 @@
+class Statement::Adjustment < ApplicationRecord
+  belongs_to :statement
+
+  validates :payment_type, presence: { message: "Payment type is required" }
+  validates :amount, presence: { message: "Amount is required" }
+  validates :amount, numericality: { other_than: 0, message: "Amount must be greater than 0" }
+  validates :api_id, uniqueness: { case_sensitive: false, message: "API id already exists for another statement adjustment" }
path: app/models/statement/adjustment.rb
user: login: mooktakim
body: Should we include a comment on these `api_id`'s columns that are only needed for migration? so we know which ones to cleanup later.
created_at: 2025-05-19T15:58:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,5 +1,10 @@
 ECT_COLOUR = :magenta
 MENTOR_COLOUR = :yellow
+STATEMENT_STATE_COLOURS = {
+  open: :blue,
+  payable: :cyan,
+  paid: :green,
+}.freeze
path: db/seeds.rb
user: login: claire-hughez
body: just out of interest, what do these colours impact haha?
created_at: 2025-05-19T17:21:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,5 +1,10 @@
 ECT_COLOUR = :magenta
 MENTOR_COLOUR = :yellow
+STATEMENT_STATE_COLOURS = {
+  open: :blue,
+  payable: :cyan,
+  paid: :green,
+}.freeze
path: db/seeds.rb
user: login: peteryates
body: We have [fancy output in the seeds](https://github.com/DFE-Digital/register-early-career-teachers-public/actions/runs/15114768799/job/42482721099#step:3:404) - it makes it easier to read than a wall of white-on-black text.

![image](https://github.com/user-attachments/assets/d0a23bbd-4aee-488d-a266-3618abd60f90)

created_at: 2025-05-19T17:33:44Z
author_association: MEMBER
diff_hunk: @@ -735,4 +755,40 @@ def describe_teacher(teacher)
       .then { |user| describe_user(user) }
 end
 
-print_seed_info('Adding funding exemptions:')
+print_seed_info('Adding statements:')
+
+active_lead_providers = ActiveLeadProvider
+  .joins(:registration_period)
+  .order(registration_period: { year: :asc })
+
+active_lead_providers.each do |active_lead_provider|
+  months = 1..12
path: db/seeds.rb
user: login: cwrw
body: can we set those to 24 months as usually its 2 years?
created_at: 2025-05-19T20:49:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,26 @@
+class Statement < ApplicationRecord
+  belongs_to :active_lead_provider
+  has_many :adjustments
+
+  def self.maximum_year = Date.current.year + 1
+
+  validates :output_fee, inclusion: { in: [true, false], message: "Output fee must be true or false" }
+  validates :month, numericality: { in: 1..12, only_integer: true, message: "Month must be a number between 1 and 12" }
+  validates :year, numericality: { greater_than_or_equal_to: 2020, is_less_than_or_equal_to: :maximum_year, only_integer: true, message: "Year must be on or after 2020 and on or before #{maximum_year}" }
path: app/models/statement.rb
user: login: cwrw
body: not sure about maximum year here, as we do add statements for 2+ years for a cohort, maybe we add 5 instead of 1?
created_at: 2025-05-19T20:52:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,8 @@
+class Statement::Adjustment < ApplicationRecord
+  belongs_to :statement
+
+  validates :payment_type, presence: { message: "Payment type is required" }
+  validates :amount, presence: { message: "Amount is required" }
+  validates :amount, numericality: { other_than: 0, message: "Amount must be greater than 0" }
+  validates :api_id, uniqueness: { case_sensitive: false, message: "API id already exists for another statement adjustment" }
path: app/models/statement/adjustment.rb
user: login: ethax-ross
body: I'm not sure that we will remove them after migration 🤔 we will need to retain them for some entities like `Statement` as we expose it via `GET /api/v3/statements/{id}`. The `Statement::Adjustment` id could be removed, but I'm not sure if we wouldn't just leave it for consistency... maybe one for @cwrw - happy to add a comment if we do intend to remove them though
created_at: 2025-05-20T07:26:50Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,8 @@
+class Statement::Adjustment < ApplicationRecord
+  belongs_to :statement
+
+  validates :payment_type, presence: { message: "Payment type is required" }
+  validates :amount, presence: { message: "Amount is required" }
+  validates :amount, numericality: { other_than: 0, message: "Amount must be greater than 0" }
+  validates :api_id, uniqueness: { case_sensitive: false, message: "API id already exists for another statement adjustment" }
path: app/models/statement/adjustment.rb
user: login: cwrw
body: As Ross mentioned those need to stay on models exposed in the API, in the models that aren't we can add a comment to remove them. Statements however since exposed in the API will always have this field and we won't remove it
created_at: 2025-05-20T07:28:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,8 @@
+class Statement::Adjustment < ApplicationRecord
+  belongs_to :statement
+
+  validates :payment_type, presence: { message: "Payment type is required" }
+  validates :amount, presence: { message: "Amount is required" }
+  validates :amount, numericality: { other_than: 0, message: "Amount must be greater than 0" }
+  validates :api_id, uniqueness: { case_sensitive: false, message: "API id already exists for another statement adjustment" }
path: app/models/statement/adjustment.rb
user: login: ethax-ross
body: I've added a comment to the `Statement::Adjustment` model 👍 
created_at: 2025-05-20T08:10:50Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-19T09:26:07Z
body: Review app deployed to https://cpd-ec2-review-604-web.test.teacherservices.cloud
<!-- Sticky Pull Request Comment -->
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-05-20T08:12:22Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=DFE-Digital_register-early-career-teachers-public&pullRequest=604) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=DFE-Digital_register-early-career-teachers-public&pullRequest=604&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=DFE-Digital_register-early-career-teachers-public&pullRequest=604&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=DFE-Digital_register-early-career-teachers-public&pullRequest=604&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=DFE-Digital_register-early-career-teachers-public&pullRequest=604&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=DFE-Digital_register-early-career-teachers-public&pullRequest=604&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=DFE-Digital_register-early-career-teachers-public&pullRequest=604)


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-20T09:16:56Z
body: Review app ECF2 deployed to <https://ec2-review-604-web.test.teacherservices.cloud> was deleted
<!-- Sticky Pull Request Commentaks -->
author_association: NONE