Repository: nhsuk/manage-vaccinations-in-schools
Pull request #3312: Support batches without expiration dates
Author: login: thomasleese
Labels: name: feature
description: New functionality
Created: 2025-04-02T18:40:39Z | Merged: 2025-04-08T11:54:39Z

When uploading historical vaccination records it's not guaranteed that the batch expiration date will be provided with the batch number. Therefore, we need to handle batches without expiration dates.

This makes a number of changes to the service to allow for batches without expiration dates (specifically to ensure they will render correctly without the user seeing an error) and also updates the import process to support importing historical vaccination records without batches. Offline recording of vaccinations still requires a batch expiration date to be provided.

We also can't be sure that batches with the same number won't be reused, so we allow batches to exist with the same number, one with an expiration date and one without an expiration date. In the future we might be able to consolidate these batches in to one.


Diff:
diff --git a/app/components/app_vaccination_record_summary_component.rb b/app/components/app_vaccination_record_summary_component.rb
index 8083af7952..6b21e43b05 100644
--- a/app/components/app_vaccination_record_summary_component.rb
+++ b/app/components/app_vaccination_record_summary_component.rb
@@ -258,7 +258,7 @@ def batch_id_value
 
   def batch_expiry_value
     highlight_if(
-      @batch.expiry.to_fs(:long),
+      @batch.expiry&.to_fs(:long) || "Unknown",
       @vaccination_record.batch_id_changed?
     )
   end
diff --git a/app/controllers/batches_controller.rb b/app/controllers/batches_controller.rb
index dd49764767..1ade76f6b9 100644
--- a/app/controllers/batches_controller.rb
+++ b/app/controllers/batches_controller.rb
@@ -7,46 +7,32 @@ class BatchesController < ApplicationController
   before_action :set_batch, except: %i[new create]
 
   def new
-    @batch =
-      Batch.new(organisation: current_user.selected_organisation, vaccine:)
+    @form = BatchForm.new
   end
 
   def create
-    expiry =
-      begin
-        Date.new(
-          batch_params["expiry(1i)"].to_i,
-          batch_params["expiry(2i)"].to_i,
-          batch_params["expiry(3i)"].to_i
-        )
-      rescue StandardError
-        nil
-      end
-
-    @batch =
+    batch =
       Batch.archived.find_or_initialize_by(
-        name: batch_params[:name],
         organisation: current_user.selected_organisation,
-        expiry:,
-        vaccine:
+        vaccine: @vaccine,
+        **batch_form_params
       )
 
-    @batch.archived_at = nil if @batch.archived?
+    batch.archived_at = nil if batch.archived?
 
-    if !expiry_validator.date_params_valid? || @batch.invalid?
-      @batch.expiry = expiry_validator.date_params_as_struct
-      render :new, status: :unprocessable_entity
-    else
-      @batch.save!
+    @form = BatchForm.new(**batch_form_params, batch:)
 
-      redirect_to vaccines_path,
-                  flash: {
-                    success: "Batch #{@batch.name} added"
-                  }
+    if expiry_validator.date_params_valid? && @form.save
+      redirect_to vaccines_path, flash: { success: "Batch #{batch.name} added" }
+    else
+      @form.expiry = expiry_validator.date_params_as_struct
+      render :new, status: :unprocessable_entity
     end
   end
 
   def edit
+    @form =
+      BatchForm.new(batch: @batch, name: @batch.name, expiry: @batch.expiry)
   end
 
   def make_default
@@ -55,14 +41,16 @@ def make_default
   end
 
   def update
-    if !expiry_validator.date_params_valid?
-      @batch.expiry = expiry_validator.date_params_as_struct
-      render :edit, status: :unprocessable_entity
-    elsif !@batch.update(batch_params)
-      render :edit, status: :unprocessable_entity
+    @form = BatchForm.new(**batch_form_params, batch: @batch, name: @batch.name)
+
+    if expiry_validator.date_params_valid? && @form.save
+      redirect_to vaccines_path,
+                  flash: {
+                    success: "Batch #{@batch.name} updated"
+                  }
     else
-      flash[:success] = "Batch #{@batch.name} updated"
-      redirect_to vaccines_path
+      @form.expiry = expiry_validator.date_params_as_struct
+      render :edit, status: :unprocessable_entity
     end
   end
 
@@ -85,19 +73,34 @@ def set_vaccine
   end
 
   def set_batch
-    @batch = @vaccine.batches.find(params[:id])
+    @batch = policy_scope(Batch).where(vaccine: @vaccine).find(params[:id])
   end
 
-  def batch_params
-    params.expect(batch: %i[name expiry(3i) expiry(2i) expiry(1i)])
+  def batch_form_params
+    raw_params =
+      params.expect(batch_form: %i[name expiry(3i) expiry(2i) expiry(1i)])
+
+    {
+      name: raw_params[:name],
+      expiry:
+        begin
+          Date.new(
+            raw_params["expiry(1i)"].to_i,
+            raw_params["expiry(2i)"].to_i,
+            raw_params["expiry(3i)"].to_i
+          )
+        rescue StandardError
+          nil
+        end
+    }
   end
 
   def expiry_validator
     @expiry_validator ||=
       DateParamsValidator.new(
         field_name: :expiry,
-        object: @batch,
-        params: batch_params
+        object: @form,
+        params: params.expect(batch_form: %i[expiry(3i) expiry(2i) expiry(1i)])
       )
   end
 end
diff --git a/app/forms/batch_form.rb b/app/forms/batch_form.rb
new file mode 100644
index 0000000000..b3dd441601
--- /dev/null
+++ b/app/forms/batch_form.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class BatchForm
+  include ActiveModel::Model
+  include ActiveModel::Attributes
+
+  attr_accessor :batch
+
+  attribute :name, :string
+  attribute :expiry, :date
+
+  validates :name, presence: true, format: { with: /\A[A-Za-z0-9]+\z/ }
+
+  validates :expiry,
+            comparison: {
+              greater_than: -> { Date.current },
+              less_than: -> { Date.current + 15.years }
+            }
+
+  def save
+    valid? && batch.update(name: name, expiry: expiry)
+  end
+end
diff --git a/app/models/batch.rb b/app/models/batch.rb
index 3a15e7719e..f2681278ca 100644
--- a/app/models/batch.rb
+++ b/app/models/batch.rb
@@ -6,7 +6,7 @@
 #
 #  id              :bigint           not null, primary key
 #  archived_at     :datetime
-#  expiry          :date             not null
+#  expiry          :date
 #  name            :string           not null
 #  created_at      :datetime         not null
 #  updated_at      :datetime         not null
@@ -31,29 +31,22 @@ class Batch < ApplicationRecord
   belongs_to :organisation
   belongs_to :vaccine
 
-  has_many :vaccination_records, -> { kept }
-
   has_and_belongs_to_many :immunisation_imports
 
   has_one :programme, through: :vaccine
 
   scope :order_by_name_and_expiration, -> { order(expiry: :asc, name: :asc) }
 
-  scope :expired, -> { where("expiry <= ?", Time.current) }
-  scope :not_expired, -> { where("expiry > ?", Time.current) }
+  scope :expired,
+        -> { where(expiry: nil).or(where("expiry <= ?", Time.current)) }
+  scope :not_expired,
+        -> { where.not(expiry: nil).where("expiry > ?", Time.current) }
 
   validates :name, presence: true, format: { with: /\A[A-Za-z0-9]+\z/ }
 
   validates :expiry,
-            presence: true,
             uniqueness: {
-              scope: %i[organisation_id name vaccine_id]
+              scope: %i[organisation_id name vaccine_id],
+              allow_nil: true
             }
-
-  validates :expiry,
-            comparison: {
-              greater_than: -> { Date.current },
-              less_than: -> { Date.current + 15.years }
-            },
-            unless: :archived?
 end
diff --git a/app/models/dps_export_row.rb b/app/models/dps_export_row.rb
index 92be50e01f..a73f8554c9 100644
--- a/app/models/dps_export_row.rb
+++ b/app/models/dps_export_row.rb
@@ -156,7 +156,7 @@ def batch_number
   end
 
   def expiry_date
-    vaccination_record.batch.expiry.to_fs(:dps)
+    vaccination_record.batch.expiry&.to_fs(:dps)
   end
 
   def route_of_vaccination_code
diff --git a/app/models/immunisation_import_row.rb b/app/models/immunisation_import_row.rb
index 66e5ee9d6e..4b9c3cffe0 100644
--- a/app/models/immunisation_import_row.rb
+++ b/app/models/immunisation_import_row.rb
@@ -31,9 +31,6 @@ class ImmunisationImportRow
   validates :batch_number, presence: { if: :batch_expiry_date }
 
   validates :batch_expiry_date,
-            presence: {
-              if: :batch_number
-            },
             comparison: {
               greater_than: -> { Date.new(Date.current.year - 15, 1, 1) },
               less_than: -> { Date.new(Date.current.year + 15, 1, 1) },
@@ -459,18 +456,17 @@ def vaccine
   end
 
   def batch
-    unless valid? && administered && vaccine && batch_expiry_date &&
-             batch_number.present?
-      return
-    end
+    return unless valid?
 
     @batch ||=
-      Batch.create_with(archived_at: Time.current).find_or_create_by!(
-        expiry: batch_expiry_date,
-        name: batch_number,
-        organisation:,
-        vaccine:
-      )
+      if administered && vaccine && batch_number.present?
+        Batch.create_with(archived_at: Time.current).find_or_create_by!(
+          expiry: batch_expiry_date,
+          name: batch_number,
+          organisation:,
+          vaccine:
+        )
+      end
   end
 
   def organisation_ods_code
diff --git a/app/models/vaccine.rb b/app/models/vaccine.rb
index f989c2b70d..eea3717153 100644
--- a/app/models/vaccine.rb
+++ b/app/models/vaccine.rb
@@ -30,8 +30,6 @@
 #  fk_rails_...  (programme_id => programmes.id)
 #
 class Vaccine < ApplicationRecord
-  self.inheritance_column = nil
-
   audited associated_with: :programme
   has_associated_audits
 
diff --git a/app/views/batches/edit.html.erb b/app/views/batches/edit.html.erb
index 788f77dc17..3d3f8eb959 100644
--- a/app/views/batches/edit.html.erb
+++ b/app/views/batches/edit.html.erb
@@ -10,7 +10,7 @@
   <%= page_title %>
 <% end %>
 
-<%= form_with model: @batch, url: vaccine_batch_path(@vaccine, @batch), method: :put do |f| %>
+<%= form_with model: @form, url: vaccine_batch_path(@vaccine, @batch), method: :put do |f| %>
   <% content_for(:before_content) { f.govuk_error_summary } %>
 
   <%= f.govuk_date_field :expiry,
diff --git a/app/views/batches/new.html.erb b/app/views/batches/new.html.erb
index 960bf50fa8..07e09f86fa 100644
--- a/app/views/batches/new.html.erb
+++ b/app/views/batches/new.html.erb
@@ -6,15 +6,16 @@
 
 <%= h1 page_title: do %>
   <span class="nhsuk-caption-l">
-    <%= @batch.vaccine.brand %>
+    <%= @vaccine.brand %>
   </span>
   <%= page_title %>
 <% end %>
 
-<%= form_with model: @batch, url: vaccine_batches_path(@vaccine), method: :post do |f| %>
+<%= form_with model: @form, url: vaccine_batches_path(@vaccine), method: :post do |f| %>
   <% content_for(:before_content) { f.govuk_error_summary } %>
 
   <%= f.govuk_text_field :name, label: { text: "Batch" }, width: 10 %>
+
   <%= f.govuk_date_field :expiry,
                          legend: { text: "Expiry date", size: "s" },
                          hint: { text: "For example, 27 10 2025" },
diff --git a/app/views/draft_vaccination_records/batch.html.erb b/app/views/draft_vaccination_records/batch.html.erb
index af7a046e00..86665ca370 100644
--- a/app/views/draft_vaccination_records/batch.html.erb
+++ b/app/views/draft_vaccination_records/batch.html.erb
@@ -13,7 +13,9 @@
     <% @batches.each do |batch| %>
       <% label = proc do %>
         <span class="app-u-monospace"><%= batch.name %></span>
-        (expires <%= batch.expiry.to_fs(:long) %>)
+        <% if (expiry = batch.expiry) %>
+          (expires <%= expiry.to_fs(:long) %>)
+        <% end %>
       <% end %>
       <%= f.govuk_radio_button(:batch_id, batch.id, label:) do %>
         <% unless @draft_vaccination_record.editing? %>
diff --git a/app/views/sessions/record/batch.html.erb b/app/views/sessions/record/batch.html.erb
index 5139da7322..3a12029fca 100644
--- a/app/views/sessions/record/batch.html.erb
+++ b/app/views/sessions/record/batch.html.erb
@@ -14,7 +14,9 @@
     <% @batches.each_with_index do |batch, index| %>
       <% label = proc do %>
         <span class="app-u-monospace"><%= batch.name %></span>
-        (expires <%= batch.expiry.to_fs(:long) %>)
+        <% if (expiry = batch.expiry) %>
+          (expires <%= expiry.to_fs(:long) %>)
+        <% end %>
       <% end %>
       <%= f.govuk_radio_button :id, batch.id, label:, link_errors: index.zero? %>
     <% end %>
diff --git a/app/views/vaccines/index.html.erb b/app/views/vaccines/index.html.erb
index a0b6bc2008..73b6694615 100644
--- a/app/views/vaccines/index.html.erb
+++ b/app/views/vaccines/index.html.erb
@@ -33,7 +33,7 @@
                 <% end %>
               <% end %>
               <% row.with_cell(text: batch.created_at.to_date.to_fs(:long)) %>
-              <% row.with_cell(text: batch.expiry.to_fs(:long)) %>
+              <% row.with_cell(text: batch.expiry&.to_fs(:long) || "Unknown") %>
               <% row.with_cell do %>
                 <ul class="app-action-list">
                   <li class="app-action-list__item">
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 98f86bf35f..0759c0825e 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -73,6 +73,19 @@ en:
         vaccine_given: <code>VACCINE_GIVEN</code>
     errors:
       models:
+        batch_form:
+          attributes:
+            expiry:
+              blank: Enter an expiry date
+              greater_than: Enter an expiry date after %{count}
+              less_than: Enter an expiry date before %{count}
+              missing_day: Enter a day
+              missing_month: Enter a month
+              missing_year: Enter a year
+              taken: This batch already exists
+            name:
+              blank: Enter a batch
+              invalid: Enter a batch with only letters and numbers
         class_import_row:
           format: "%{attribute}: %{message}"
           attributes:
@@ -425,19 +438,6 @@ en:
           nasal: Nasal spray
     errors:
       models:
-        batch:
-          attributes:
-            expiry:
-              blank: Enter an expiry date
-              greater_than: Enter an expiry date after %{count}
-              less_than: Enter an expiry date before %{count}
-              missing_day: Enter a day
-              missing_month: Enter a month
-              missing_year: Enter a year
-              taken: This batch already exists
-            name:
-              blank: Enter a batch
-              invalid: Enter a batch with only letters and numbers
         class_import:
           attributes:
             csv:
diff --git a/db/migrate/20250402093102_change_batch_expiry_not_null.rb b/db/migrate/20250402093102_change_batch_expiry_not_null.rb
new file mode 100644
index 0000000000..1c7c52aeb0
--- /dev/null
+++ b/db/migrate/20250402093102_change_batch_expiry_not_null.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class ChangeBatchExpiryNotNull < ActiveRecord::Migration[8.0]
+  def change
+    change_column_null :batches, :expiry, true
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 5aa337a5e7..25989acc87 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[8.0].define(version: 2025_03_27_101002) do
+ActiveRecord::Schema[8.0].define(version: 2025_04_02_093102) do
   # These are extensions that must be enabled in order to support this database
   enable_extension "pg_catalog.plpgsql"
   enable_extension "pg_trgm"
@@ -59,7 +59,7 @@
 
   create_table "batches", force: :cascade do |t|
     t.string "name", null: false
-    t.date "expiry", null: false
+    t.date "expiry"
     t.bigint "vaccine_id", null: false
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
diff --git a/spec/factories/batches.rb b/spec/factories/batches.rb
index 4c67c3f3c4..ba3e8ca7b0 100644
--- a/spec/factories/batches.rb
+++ b/spec/factories/batches.rb
@@ -6,7 +6,7 @@
 #
 #  id              :bigint           not null, primary key
 #  archived_at     :datetime
-#  expiry          :date             not null
+#  expiry          :date
 #  name            :string           not null
 #  created_at      :datetime         not null
 #  updated_at      :datetime         not null
@@ -32,10 +32,13 @@
     vaccine
 
     name { "#{prefix}#{Faker::Number.number(digits: 4)}" }
-    expiry { Faker::Time.forward(days: 50, period: :morning) }
 
     trait :expired do
-      expiry { Date.yesterday }
+      expiry { Faker::Time.backward(days: 50, period: :evening) }
+    end
+
+    trait :not_expired do
+      expiry { Faker::Time.forward(days: 50, period: :morning) }
     end
 
     trait :archived do
diff --git a/spec/factories/vaccination_records.rb b/spec/factories/vaccination_records.rb
index 5951a88818..3698de93e7 100644
--- a/spec/factories/vaccination_records.rb
+++ b/spec/factories/vaccination_records.rb
@@ -76,7 +76,15 @@
     end
 
     batch do
-      association(:batch, organisation:, vaccine:, strategy: :create) if vaccine
+      if vaccine
+        association(
+          :batch,
+          :not_expired,
+          organisation:,
+          vaccine:,
+          strategy: :create
+        )
+      end
     end
 
     performed_by
diff --git a/spec/features/doubles_vaccination_administered_spec.rb b/spec/features/doubles_vaccination_administered_spec.rb
index 6f4e55e569..1a11018b1a 100644
--- a/spec/features/doubles_vaccination_administered_spec.rb
+++ b/spec/features/doubles_vaccination_administered_spec.rb
@@ -31,9 +31,19 @@ def given_a_doubles_session_exists
     location = create(:school)
 
     @menacwy_batch =
-      create(:batch, organisation:, vaccine: programmes.first.vaccines.first)
+      create(
+        :batch,
+        :not_expired,
+        organisation:,
+        vaccine: programmes.first.vaccines.first
+      )
     @td_ipv_batch =
-      create(:batch, organisation:, vaccine: programmes.second.vaccines.first)
+      create(
+        :batch,
+        :not_expired,
+        organisation:,
+        vaccine: programmes.second.vaccines.first
+      )
 
     @session = create(:session, organisation:, programmes:, location:)
 
diff --git a/spec/features/edit_vaccination_record_spec.rb b/spec/features/edit_vaccination_record_spec.rb
index 59034893ac..32cb2888cb 100644
--- a/spec/features/edit_vaccination_record_spec.rb
+++ b/spec/features/edit_vaccination_record_spec.rb
@@ -257,7 +257,12 @@ def and_an_hpv_programme_is_underway
     @original_batch =
       create(:batch, organisation: @organisation, vaccine: @vaccine)
     @replacement_batch =
-      create(:batch, organisation: @organisation, vaccine: @vaccine)
+      create(
+        :batch,
+        :not_expired,
+        organisation: @organisation,
+        vaccine: @vaccine
+      )
 
     location = create(:school)
 
diff --git a/spec/features/hpv_vaccination_administered_spec.rb b/spec/features/hpv_vaccination_administered_spec.rb
index 8406056d4c..3485e7ecdf 100644
--- a/spec/features/hpv_vaccination_administered_spec.rb
+++ b/spec/features/hpv_vaccination_administered_spec.rb
@@ -72,7 +72,8 @@ def given_i_am_signed_in
     end
 
     @active_vaccine = programme.vaccines.active.first
-    @active_batch = create(:batch, organisation:, vaccine: @active_vaccine)
+    @active_batch =
+      create(:batch, :not_expired, organisation:, vaccine: @active_vaccine)
     @archived_batch =
       create(:batch, :archived, organisation:, vaccine: @active_vaccine)
 
diff --git a/spec/features/hpv_vaccination_clinic_spec.rb b/spec/features/hpv_vaccination_clinic_spec.rb
index 43a47a959d..2c48d2cf36 100644
--- a/spec/features/hpv_vaccination_clinic_spec.rb
+++ b/spec/features/hpv_vaccination_clinic_spec.rb
@@ -41,7 +41,8 @@ def given_i_am_signed_in
     end
 
     active_vaccine = programme.vaccines.active.first
-    @active_batch = create(:batch, organisation:, vaccine: active_vaccine)
+    @active_batch =
+      create(:batch, :not_expired, organisation:, vaccine: active_vaccine)
 
     @session =
       create(:session, organisation:, programmes: [programme], location:)
diff --git a/spec/features/hpv_vaccination_offline_spec.rb b/spec/features/hpv_vaccination_offline_spec.rb
index 62b4777005..1acb8f2f22 100644
--- a/spec/features/hpv_vaccination_offline_spec.rb
+++ b/spec/features/hpv_vaccination_offline_spec.rb
@@ -62,7 +62,7 @@ def given_an_hpv_programme_is_underway(clinic: false)
     end
 
     vaccine = programmes.first.vaccines.active.first
-    @batch = create(:batch, organisation: @organisation, vaccine:)
+    @batch = create(:batch, :not_expired, organisation: @organisation, vaccine:)
 
     create(:gp_practice, ods_code: "Y12345")
 
diff --git a/spec/features/menacwy_vaccination_administered_spec.rb b/spec/features/menacwy_vaccination_administered_spec.rb
index 94ee0065f5..f436457daf 100644
--- a/spec/features/menacwy_vaccination_administered_spec.rb
+++ b/spec/features/menacwy_vaccination_administered_spec.rb
@@ -67,7 +67,8 @@ def given_i_am_signed_in
     end
 
     @active_vaccine = programme.vaccines.active.first
-    @active_batch = create(:batch, organisation:, vaccine: @active_vaccine)
+    @active_batch =
+      create(:batch, :not_expired, organisation:, vaccine: @active_vaccine)
     @archived_batch =
       create(:batch, :archived, organisation:, vaccine: @active_vaccine)
 
diff --git a/spec/features/td_ipv_vaccination_administered_spec.rb b/spec/features/td_ipv_vaccination_administered_spec.rb
index b4b7a51198..d10d1e5d04 100644
--- a/spec/features/td_ipv_vaccination_administered_spec.rb
+++ b/spec/features/td_ipv_vaccination_administered_spec.rb
@@ -67,7 +67,8 @@ def given_i_am_signed_in
     end
 
     @active_vaccine = programme.vaccines.active.first
-    @active_batch = create(:batch, organisation:, vaccine: @active_vaccine)
+    @active_batch =
+      create(:batch, :not_expired, organisation:, vaccine: @active_vaccine)
     @archived_batch =
       create(:batch, :archived, organisation:, vaccine: @active_vaccine)
 
diff --git a/spec/features/vaccination_todays_batch_spec.rb b/spec/features/vaccination_todays_batch_spec.rb
index b61b2eeef3..b99bef9175 100644
--- a/spec/features/vaccination_todays_batch_spec.rb
+++ b/spec/features/vaccination_todays_batch_spec.rb
@@ -31,7 +31,7 @@ def given_i_am_signed_in
     batches =
       programmes.map do |programme|
         programme.vaccines.flat_map do |vaccine|
-          create_list(:batch, 2, organisation:, vaccine:)
+          create_list(:batch, 2, :not_expired, organisation:, vaccine:)
         end
       end
 
diff --git a/spec/forms/batch_form_spec.rb b/spec/forms/batch_form_spec.rb
new file mode 100644
index 0000000000..6bfaec36bb
--- /dev/null
+++ b/spec/forms/batch_form_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+describe BatchForm do
+  subject(:form) { described_class.new }
+
+  describe "validations" do
+    it { should validate_presence_of(:name) }
+    it { should validate_presence_of(:expiry) }
+
+    it do
+      travel_to(Date.new(2024, 9, 1)) do
+        expect(form).to validate_comparison_of(:expiry).is_greater_than(
+          Date.new(2024, 9, 1)
+        )
+        expect(form).to validate_comparison_of(:expiry).is_less_than(
+          Date.new(2039, 9, 1)
+        )
+      end
+    end
+  end
+end
diff --git a/spec/lib/reports/offline_session_exporter_spec.rb b/spec/lib/reports/offline_session_exporter_spec.rb
index 78125e3e12..dc0c0c5d51 100644
--- a/spec/lib/reports/offline_session_exporter_spec.rb
+++ b/spec/lib/reports/offline_session_exporter_spec.rb
@@ -99,7 +99,9 @@ def validation_formula(worksheet:, column_name:, row: 1)
       subject(:rows) { worksheet_to_hashes(workbook.worksheets[0]) }
 
       let(:performed_at) { Time.zone.local(2024, 1, 1, 12, 5, 20) }
-      let(:batch) { create(:batch, vaccine: programme.vaccines.active.first) }
+      let(:batch) do
+        create(:batch, :not_expired, vaccine: programme.vaccines.active.first)
+      end
       let(:patient_session) { create(:patient_session, patient:, session:) }
       let(:patient) { create(:patient, year_group: 8) }
 
@@ -487,6 +489,7 @@ def validation_formula(worksheet:, column_name:, row: 1)
         subject(:validation) do
           create(
             :batch,
+            :not_expired,
             name: "BATCH12345",
             vaccine: programme.vaccines.active.first,
             organisation:
@@ -532,6 +535,7 @@ def validation_formula(worksheet:, column_name:, row: 1)
         create_list(
           :batch,
           2,
+          :not_expired,
           vaccine: programme.vaccines.active.first,
           organisation:
         )
@@ -539,7 +543,12 @@ def validation_formula(worksheet:, column_name:, row: 1)
 
       before do
         create(:patient, session:)
-        create(:batch, name: "OTHERBATCH", vaccine: create(:vaccine, :flu))
+        create(
+          :batch,
+          :not_expired,
+          name: "OTHERBATCH",
+          vaccine: create(:vaccine, :flu)
+        )
       end
 
       it "lists all the batch numbers for the programme" do
@@ -672,7 +681,9 @@ def validation_formula(worksheet:, column_name:, row: 1)
             school: create(:school, urn: "123456", name: "Waterloo Road")
           )
         end
-        let(:batch) { create(:batch, vaccine: programme.vaccines.active.first) }
+        let(:batch) do
+          create(:batch, :not_expired, vaccine: programme.vaccines.active.first)
+        end
         let(:performed_at) { Time.zone.local(2024, 1, 1, 12, 5, 20) }
         let!(:vaccination_record) do
           create(
@@ -769,6 +780,7 @@ def validation_formula(worksheet:, column_name:, row: 1)
         subject(:validation) do
           create(
             :batch,
+            :not_expired,
             name: "BATCH12345",
             vaccine: programme.vaccines.active.first,
             organisation:
@@ -814,6 +826,7 @@ def validation_formula(worksheet:, column_name:, row: 1)
         create_list(
           :batch,
           2,
+          :not_expired,
           vaccine: programme.vaccines.active.first,
           organisation:
         )
@@ -821,7 +834,12 @@ def validation_formula(worksheet:, column_name:, row: 1)
 
       before do
         create(:patient, session:)
-        create(:batch, name: "OTHERBATCH", vaccine: create(:vaccine, :flu))
+        create(
+          :batch,
+          :not_expired,
+          name: "OTHERBATCH",
+          vaccine: create(:vaccine, :flu)
+        )
       end
 
       it "lists all the batch numbers for the programme" do
diff --git a/spec/models/batch_spec.rb b/spec/models/batch_spec.rb
index e514f8cccb..7ff6215507 100644
--- a/spec/models/batch_spec.rb
+++ b/spec/models/batch_spec.rb
@@ -6,7 +6,7 @@
 #
 #  id              :bigint           not null, primary key
 #  archived_at     :datetime
-#  expiry          :date             not null
+#  expiry          :date
 #  name            :string           not null
 #  created_at      :datetime         not null
 #  updated_at      :datetime         not null
@@ -49,18 +49,6 @@
     it { should be_valid }
 
     it { should validate_presence_of(:name) }
-    it { should validate_presence_of(:expiry) }
-
-    it do
-      travel_to(Date.new(2024, 9, 1)) do
-        expect(batch).to validate_comparison_of(:expiry).is_greater_than(
-          Date.new(2024, 9, 1)
-        )
-        expect(batch).to validate_comparison_of(:expiry).is_less_than(
-          Date.new(2039, 9, 1)
-        )
-      end
-    end
 
     it do
       expect(batch).to validate_uniqueness_of(:expiry).scoped_to(
diff --git a/spec/models/immunisation_import_row_spec.rb b/spec/models/immunisation_import_row_spec.rb
index 154179219e..b9d287117f 100644
--- a/spec/models/immunisation_import_row_spec.rb
+++ b/spec/models/immunisation_import_row_spec.rb
@@ -1654,5 +1654,11 @@
 
       it { should be_nil }
     end
+
+    context "without an expiry date" do
+      let(:data) { valid_data.merge("BATCH_EXPIRY_DATE" => "") }
+
+      it { should_not be_nil }
+    end
   end
 end


Reviews:
user: login: misaka
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T11:15:49Z
user: login: misaka
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T11:16:48Z
user: login: misaka
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-08T11:18:31Z
user: login: thomasleese
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T11:19:52Z

Review comments:
diff_hunk: @@ -32,7 +32,10 @@
     vaccine
 
     name { "#{prefix}#{Faker::Number.number(digits: 4)}" }
-    expiry { Faker::Time.forward(days: 50, period: :morning) }
+
+    trait :not_expired do
path: spec/factories/batches.rb
user: login: misaka
body: Is this `not_expired` or maybe `with_expiry`? Just thinking that `not_expired` sounds like a batch that has expired in the past, but I believe this is setting an expiry date in the future.
created_at: 2025-04-08T11:15:48Z
author_association: COLLABORATOR
diff_hunk: @@ -32,7 +32,10 @@
     vaccine
 
     name { "#{prefix}#{Faker::Number.number(digits: 4)}" }
-    expiry { Faker::Time.forward(days: 50, period: :morning) }
+
+    trait :not_expired do
path: spec/factories/batches.rb
user: login: misaka
body: Actually, ignore that. It suddenly makes more sense with the `expired` trait below.
created_at: 2025-04-08T11:16:48Z
author_association: COLLABORATOR
diff_hunk: @@ -32,7 +32,10 @@
     vaccine
 
     name { "#{prefix}#{Faker::Number.number(digits: 4)}" }
-    expiry { Faker::Time.forward(days: 50, period: :morning) }
+
+    trait :not_expired do
path: spec/factories/batches.rb
user: login: thomasleese
body: Yeah, I did wonder about something like `with_expiry`, but then `expired` is also `with_expiry`. I might swap them around though in the file, I think it's more natural for expired to come first.
created_at: 2025-04-08T11:19:52Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-08T11:27:56Z
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=nhsuk_manage-childrens-vaccinations&pullRequest=3312) **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=nhsuk_manage-childrens-vaccinations&pullRequest=3312&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=nhsuk_manage-childrens-vaccinations&pullRequest=3312&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=nhsuk_manage-childrens-vaccinations&pullRequest=3312&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=nhsuk_manage-childrens-vaccinations&pullRequest=3312&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=nhsuk_manage-childrens-vaccinations&pullRequest=3312&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=nhsuk_manage-childrens-vaccinations&pullRequest=3312)


author_association: NONE