diff --git a/admin/app/components/solidus_admin/stores/form/component.html.erb b/admin/app/components/solidus_admin/stores/form/component.html.erb
index f22c6c5328b..a82f303ec16 100644
--- a/admin/app/components/solidus_admin/stores/form/component.html.erb
+++ b/admin/app/components/solidus_admin/stores/form/component.html.erb
@@ -11,6 +11,15 @@
<% end %>
<% end %>
+ <%= render component('ui/panel').new(title: t('.address')) do |panel| %>
+ <% panel.with_section do %>
+ <%= render component('ui/forms/address').new(
+ addressable: address,
+ form_field_name: "store[address_attributes]",
+ excludes: address_excludes
+ ) %>
+ <% end %>
+ <% end %>
<%= render component('ui/panel').new(title: t('.localization')) do |panel| %>
<% panel.with_section(class: "flex flex-col gap-4") do %>
diff --git a/admin/app/components/solidus_admin/stores/form/component.rb b/admin/app/components/solidus_admin/stores/form/component.rb
index 7ff6fbb4198..0d849e46bad 100644
--- a/admin/app/components/solidus_admin/stores/form/component.rb
+++ b/admin/app/components/solidus_admin/stores/form/component.rb
@@ -9,6 +9,16 @@ def initialize(store:, id:, url:)
@url = url
end
+ def address
+ @store.address || Spree::Address.new(country: Spree::Country.find_by(iso: Spree::Config.default_country_iso))
+ end
+
+ def address_excludes
+ excludes = [:email, :street_contd]
+ excludes << :phone unless Spree::Config.address_requires_phone
+ excludes
+ end
+
def available_locales
Spree.i18n_available_locales.map do |locale|
[I18n.t("spree.i18n.this_file_language", locale: locale, default: locale.to_s, fallback: false), locale]
diff --git a/admin/app/components/solidus_admin/stores/form/component.yml b/admin/app/components/solidus_admin/stores/form/component.yml
index 9f8ce0ff5b5..72fdf85aadf 100644
--- a/admin/app/components/solidus_admin/stores/form/component.yml
+++ b/admin/app/components/solidus_admin/stores/form/component.yml
@@ -1,4 +1,5 @@
en:
+ address: Address
localization: Localization
plugins:
install: Install
diff --git a/admin/app/controllers/solidus_admin/stores_controller.rb b/admin/app/controllers/solidus_admin/stores_controller.rb
index d86d03f6e97..c6fcba3cd5b 100644
--- a/admin/app/controllers/solidus_admin/stores_controller.rb
+++ b/admin/app/controllers/solidus_admin/stores_controller.rb
@@ -55,8 +55,12 @@ def permitted_resource_params
:mail_from_address,
:default_currency,
:cart_tax_country_iso,
- available_locales: []
- )
+ available_locales: [],
+ address_attributes: Spree::PermittedAttributes.address_attributes
+ ).tap do |store_params|
+ without_address_parameter = store_params[:address_attributes]&.except(:country_id, :state_id, :reverse_charge_status)&.compact_blank&.empty?
+ store_params.delete(:address_attributes) if without_address_parameter
+ end
end
end
end
diff --git a/admin/spec/features/stores_spec.rb b/admin/spec/features/stores_spec.rb
index 40ea5a9f4cc..7382909693d 100644
--- a/admin/spec/features/stores_spec.rb
+++ b/admin/spec/features/stores_spec.rb
@@ -83,6 +83,7 @@
available_locales: %w[en])
create(:country, iso: "US")
+ create(:country, iso: "DE", states_required: false)
visit "/admin/stores"
click_on "B2C Store"
expect(page).to be_axe_clean
@@ -111,6 +112,28 @@
expect(solidus_select_control("Default Currency")).to have_content("USD")
expect(solidus_select_control("Tax Country")).to have_content("United States")
end
+
+ it "updates store address" do
+ expect(page).not_to have_field("Email", exact: true)
+
+ fill_in "Name", with: "B2C Store Inc."
+ fill_in "Street Address", with: "1 Store Street"
+ fill_in "City", with: "Storetown"
+ fill_in "Zip Code", with: "12345"
+ fill_in "Phone", with: "555-555-0199"
+ solidus_select("Germany", from: "Country")
+
+ within("header") { click_on "Save" }
+
+ expect(page).to have_content("Store was successfully updated")
+ expect(Spree::Store.find_by(name: "B2C Store").address).to have_attributes(
+ name: "B2C Store Inc.",
+ address1: "1 Store Street",
+ city: "Storetown",
+ phone: "555-555-0199",
+ country: Spree::Country.find_by(iso: "DE")
+ )
+ end
end
describe "clicking Discard" do
diff --git a/admin/spec/requests/solidus_admin/stores_spec.rb b/admin/spec/requests/solidus_admin/stores_spec.rb
index 0c8718bd945..f409528d807 100644
--- a/admin/spec/requests/solidus_admin/stores_spec.rb
+++ b/admin/spec/requests/solidus_admin/stores_spec.rb
@@ -11,4 +11,53 @@
let(:valid_attributes) { {name: "Store", code: "store", url: "store.com", mail_from_address: "store@example.com"} }
let(:invalid_attributes) { {name: ""} }
end
+
+ describe "address" do
+ let(:admin_user) { create(:admin_user) }
+ let(:store) { create(:store, default: false) }
+ let(:country) { create(:country, states_required: false) }
+ let(:address_attributes) do
+ {name: "Store Inc.", address1: "1 Store Street", city: "Storetown", zipcode: "12345", phone: "555-555-0199", vat_id: "DE123456789", country_id: country.id}
+ end
+
+ before { allow_any_instance_of(SolidusAdmin::BaseController).to receive(:spree_current_user).and_return(admin_user) }
+
+ it "renders the address fields without email, address2 and VAT-ID" do
+ get solidus_admin.edit_store_path(store)
+ expect(response.body).to include('name="store[address_attributes][address1]"')
+ expect(response.body).to include('name="store[address_attributes][phone]"')
+ expect(response.body).not_to include('name="store[address_attributes][email]"')
+ expect(response.body).not_to include('name="store[address_attributes][address2]"')
+ expect(response.body).not_to include('name="store[address_attributes][vat_id]"')
+ end
+
+ it "does not render the phone field if phone is not required" do
+ allow(Spree::Config).to receive(:address_requires_phone).and_return(false)
+ get solidus_admin.edit_store_path(store)
+ expect(response.body).not_to include('name="store[address_attributes][phone]"')
+ end
+
+ it "renders the VAT-ID once if reverse charge fields are shown" do
+ allow(Spree::Backend::Config).to receive(:show_reverse_charge_fields).and_return(true)
+ get solidus_admin.edit_store_path(store)
+ expect(response.body.scan('name="store[address_attributes][vat_id]"').size).to eq(1)
+ end
+
+ it "saves the address" do
+ patch solidus_admin.store_path(store), params: {store: {address_attributes:}}
+ expect(store.reload.address).to have_attributes(address_attributes)
+ end
+
+ it "does not create an address from blank fields" do
+ patch solidus_admin.store_path(store), params: {store: {address_attributes: {name: "", address1: "", country_id: country.id, reverse_charge_status: "disabled"}}}
+ expect(response).to have_http_status(:see_other)
+ expect(store.reload.address).to be_nil
+ end
+
+ it "creates a store without address from blank fields" do
+ post solidus_admin.stores_path, params: {store: {name: "New Store", code: "new-store", url: "new-store.com", mail_from_address: "store@example.com", address_attributes: {name: "", address1: "", country_id: country.id}}}
+ expect(response).to have_http_status(:see_other)
+ expect(Spree::Store.find_by!(code: "new-store").address).to be_nil
+ end
+ end
end
diff --git a/api/app/views/spree/api/stores/_store.json.jbuilder b/api/app/views/spree/api/stores/_store.json.jbuilder
new file mode 100644
index 00000000000..138b5ab5bcd
--- /dev/null
+++ b/api/app/views/spree/api/stores/_store.json.jbuilder
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+json.call(store, *store_attributes)
+json.address do
+ if store.address
+ json.partial!("spree/api/addresses/address", address: store.address)
+ else
+ json.nil!
+ end
+end
diff --git a/api/app/views/spree/api/stores/index.json.jbuilder b/api/app/views/spree/api/stores/index.json.jbuilder
index 35f4557e9ef..c08b8810b9b 100644
--- a/api/app/views/spree/api/stores/index.json.jbuilder
+++ b/api/app/views/spree/api/stores/index.json.jbuilder
@@ -1,3 +1,3 @@
# frozen_string_literal: true
-json.stores(@stores) { |store| json.call(store, *store_attributes) }
+json.stores(@stores) { |store| json.partial!("spree/api/stores/store", store:) }
diff --git a/api/app/views/spree/api/stores/show.json.jbuilder b/api/app/views/spree/api/stores/show.json.jbuilder
index 84c286ae281..183b546599f 100644
--- a/api/app/views/spree/api/stores/show.json.jbuilder
+++ b/api/app/views/spree/api/stores/show.json.jbuilder
@@ -1,3 +1,3 @@
# frozen_string_literal: true
-json.call(@store, *store_attributes)
+json.partial!("spree/api/stores/store", store: @store)
diff --git a/api/openapi/solidus-api.oas.yml b/api/openapi/solidus-api.oas.yml
index a59c2662b8f..624a1847d99 100644
--- a/api/openapi/solidus-api.oas.yml
+++ b/api/openapi/solidus-api.oas.yml
@@ -6474,6 +6474,8 @@ components:
type: string
url:
type: string
+ address:
+ $ref: '#/components/schemas/address'
taxonomy:
type: object
properties:
@@ -6955,6 +6957,8 @@ components:
type: string
cart_tax_country_iso:
type: string
+ address_attributes:
+ $ref: '#/components/schemas/address-input'
taxonomy-input:
type: object
title: Taxonomy input
diff --git a/api/spec/requests/spree/api/stores_spec.rb b/api/spec/requests/spree/api/stores_spec.rb
index e036e081d9c..2c34dbda8e7 100644
--- a/api/spec/requests/spree/api/stores_spec.rb
+++ b/api/spec/requests/spree/api/stores_spec.rb
@@ -38,7 +38,8 @@ module Spree::Api
"default_currency" => nil,
"code" => store.code,
"default" => true,
- "available_locales" => ["en"]
+ "available_locales" => ["en"],
+ "address" => nil
},
{
"id" => non_default_store.id,
@@ -53,7 +54,8 @@ module Spree::Api
"default_currency" => nil,
"code" => non_default_store.code,
"default" => false,
- "available_locales" => ["en"]
+ "available_locales" => ["en"],
+ "address" => nil
}
])
end
@@ -73,7 +75,8 @@ module Spree::Api
"default_currency" => nil,
"code" => store.code,
"default" => true,
- "available_locales" => ["en"]
+ "available_locales" => ["en"],
+ "address" => nil
)
end
@@ -101,6 +104,50 @@ module Spree::Api
expect(store.reload.bcc_email).to eql "bcc@example.net"
end
+ context "address" do
+ let(:country) { create(:country, states_required: false) }
+ let(:address_params) do
+ attributes_for(:address).slice(:name, :address1, :city, :zipcode, :phone).merge(country_id: country.id)
+ end
+
+ it "includes the address in the store details" do
+ store.update!(address: create(:address))
+ get spree.api_store_path(store)
+ expect(json_response["address"]).to include(
+ "id" => store.address.id,
+ "address1" => store.address.address1,
+ "city" => store.address.city
+ )
+ expect(json_response["address"]["country"]).to include("iso" => store.address.country.iso)
+ end
+
+ it "can create a new store with an address" do
+ store_hash = {
+ code: "spree123",
+ name: "Hack0rz",
+ url: "spree123.example.com",
+ mail_from_address: "me@example.com",
+ address_attributes: address_params
+ }
+ post spree.api_stores_path, params: {store: store_hash}
+ expect(response.status).to eq(201)
+ expect(json_response["address"]).to include("address1" => address_params[:address1])
+ end
+
+ it "can update the address of an existing store" do
+ put spree.api_store_path(store), params: {store: {address_attributes: address_params}}
+ expect(response.status).to eq(200)
+ expect(store.reload.address.address1).to eq(address_params[:address1])
+ expect(store.address.country).to eq(country)
+ end
+
+ it "responds with an error for an invalid address" do
+ put spree.api_store_path(store), params: {store: {address_attributes: address_params.merge(city: "")}}
+ expect(response.status).to eq(422)
+ expect(json_response["errors"]).to include("address")
+ end
+ end
+
context "deleting a store" do
it "will fail if it's the default Store" do
delete spree.api_store_path(store)
diff --git a/backend/app/controllers/spree/admin/stores_controller.rb b/backend/app/controllers/spree/admin/stores_controller.rb
index 6c78c6f8752..3fc5c9789fc 100644
--- a/backend/app/controllers/spree/admin/stores_controller.rb
+++ b/backend/app/controllers/spree/admin/stores_controller.rb
@@ -13,6 +13,13 @@ def index
private
+ def permitted_resource_params
+ super.tap do |store_params|
+ without_address_parameters = store_params[:address_attributes]&.except(:country_id, :state_id, :reverse_charge_status)&.compact_blank&.empty?
+ store_params.delete(:address_attributes) if without_address_parameters
+ end
+ end
+
def store_params
params.require(:store).permit(permitted_params)
end
diff --git a/backend/app/views/spree/admin/shared/_address_form.html.erb b/backend/app/views/spree/admin/shared/_address_form.html.erb
index b595ef47142..e94fa2cea14 100644
--- a/backend/app/views/spree/admin/shared/_address_form.html.erb
+++ b/backend/app/views/spree/admin/shared/_address_form.html.erb
@@ -18,10 +18,12 @@
<%= f.text_field :address1, class: 'fullwidth' %>
- ">
- <%= f.label :address2 %>
- <%= f.text_field :address2, class: 'fullwidth' %>
-
+ <% unless local_assigns.fetch(:excludes, []).include?(:address2) %>
+ ">
+ <%= f.label :address2 %>
+ <%= f.text_field :address2, class: 'fullwidth' %>
+
+ <% end %>
">
<%= f.label :city %>
@@ -59,15 +61,19 @@
- ">
- <%= f.label :phone %>
- <%= f.phone_field :phone, class: 'fullwidth' %>
-
+ <% unless local_assigns.fetch(:excludes, []).include?(:phone) %>
+ ">
+ <%= f.label :phone %>
+ <%= f.phone_field :phone, class: 'fullwidth' %>
+
+ <% end %>
- ">
- <%= f.label :email %>
- <%= f.email_field :email, class: 'fullwidth' %>
-
+ <% unless local_assigns.fetch(:excludes, []).include?(:email) %>
+ ">
+ <%= f.label :email %>
+ <%= f.email_field :email, class: 'fullwidth' %>
+
+ <% end %>
<% if Spree::Backend::Config.show_reverse_charge_fields %>
">
diff --git a/backend/app/views/spree/admin/stores/_form.html.erb b/backend/app/views/spree/admin/stores/_form.html.erb
index 52cdaf346e8..ae615d900ae 100644
--- a/backend/app/views/spree/admin/stores/_form.html.erb
+++ b/backend/app/views/spree/admin/stores/_form.html.erb
@@ -41,6 +41,13 @@
<%= f.error_message_on :reverse_charge_status %>
<% end %>
<% end %>
+
+
<%= f.field_container :url do %>
diff --git a/backend/spec/controllers/spree/admin/stores_controller_spec.rb b/backend/spec/controllers/spree/admin/stores_controller_spec.rb
new file mode 100644
index 00000000000..05ecd695acd
--- /dev/null
+++ b/backend/spec/controllers/spree/admin/stores_controller_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+describe Spree::Admin::StoresController, type: :controller do
+ stub_authorization!
+
+ let(:store) { create(:store) }
+ let(:country) { create(:country, states_required: false) }
+ let(:address_attributes) do
+ {name: "Store Inc.", address1: "1 Store Street", city: "Storetown", zipcode: "12345", phone: "555-555-0199", country_id: country.id}
+ end
+
+ describe "#edit" do
+ render_views
+
+ it "renders the address fields without email and address2" do
+ get :edit, params: {id: store.id}
+ expect(response.body).to include('name="store[address_attributes][address1]"')
+ expect(response.body).to include('name="store[address_attributes][phone]"')
+ expect(response.body).not_to include('name="store[address_attributes][email]"')
+ expect(response.body).not_to include('name="store[address_attributes][address2]"')
+ end
+
+ it "does not render the phone field if phone is not required" do
+ stub_spree_preferences(address_requires_phone: false)
+ get :edit, params: {id: store.id}
+ expect(response.body).not_to include('name="store[address_attributes][phone]"')
+ end
+ end
+
+ describe "#update" do
+ it "saves the address" do
+ put :update, params: {id: store.id, store: {address_attributes:}}
+ expect(store.reload.address).to have_attributes(address_attributes)
+ end
+
+ it "does not create an address from blank fields" do
+ put :update, params: {id: store.id, store: {name: "New Name", address_attributes: {name: "", address1: "", country_id: country.id, reverse_charge_status: "disabled"}}}
+ expect(store.reload).to have_attributes(name: "New Name", address: nil)
+ end
+ end
+
+ describe "#create" do
+ it "creates a store without address from blank fields" do
+ post :create, params: {store: {name: "New Store", code: "new-store", url: "new-store.com", mail_from_address: "store@example.com", address_attributes: {name: "", address1: "", country_id: country.id}}}
+ expect(Spree::Store.find_by!(code: "new-store").address).to be_nil
+ end
+ end
+end
diff --git a/backend/spec/features/admin/configuration/store_spec.rb b/backend/spec/features/admin/configuration/store_spec.rb
index 1a566c241d0..7fd5e36400c 100644
--- a/backend/spec/features/admin/configuration/store_spec.rb
+++ b/backend/spec/features/admin/configuration/store_spec.rb
@@ -11,6 +11,7 @@
end
let!(:vat_country) { create(:country, iso: "DE", name: "Germany") }
+ let!(:store_country) { create(:country, iso: "AT", states_required: false) }
before(:each) do
visit spree.admin_path
@@ -47,6 +48,28 @@
end
end
+ context "editing the store address" do
+ it "should be able to save the address" do
+ within("[data-hook='store_address_wrapper']") do
+ expect(page).not_to have_field("Email")
+ fill_in "Name", with: "Test Store Inc."
+ fill_in "Street Address", with: "1 Store Street"
+ fill_in "City", with: "Storetown"
+ fill_in "Zip Code", with: "12345"
+ fill_in "Phone", with: "555-555-0199"
+ select "Austria", from: "Country"
+ end
+ click_button "Update"
+
+ expect(page).to have_content("has been successfully updated")
+ expect(store.reload.address).to have_attributes(
+ name: "Test Store Inc.",
+ address1: "1 Store Street",
+ country: store_country
+ )
+ end
+ end
+
context "update fails" do
it "should display the error" do
fill_in "Site Name", with: " "
diff --git a/core/app/models/spree/store.rb b/core/app/models/spree/store.rb
index 75902a43cb7..96866402e87 100644
--- a/core/app/models/spree/store.rb
+++ b/core/app/models/spree/store.rb
@@ -17,6 +17,8 @@ class Store < Spree::Base
has_many :orders, class_name: "Spree::Order"
+ belongs_to :address, class_name: "Spree::Address", optional: true, validate: true
+
validates :code, presence: true, uniqueness: {allow_blank: true, case_sensitive: true}
validates :name, presence: true
validates :url, presence: true
@@ -51,6 +53,10 @@ def available_locales=(locales)
end
end
+ def address_attributes=(attributes)
+ self.address = Spree::Address.immutable_merge(address, attributes)
+ end
+
def self.default
where(default: true).first || new
end
diff --git a/core/db/migrate/20260924120000_add_address_to_spree_stores.rb b/core/db/migrate/20260924120000_add_address_to_spree_stores.rb
new file mode 100644
index 00000000000..db8c02dc290
--- /dev/null
+++ b/core/db/migrate/20260924120000_add_address_to_spree_stores.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddAddressToSpreeStores < ActiveRecord::Migration[7.0]
+ def change
+ change_table :spree_stores do |t|
+ t.references :address, type: :integer, foreign_key: {to_table: :spree_addresses}
+ end
+ end
+end
diff --git a/core/lib/spree/permitted_attributes.rb b/core/lib/spree/permitted_attributes.rb
index 63e007446de..2e43fd003b2 100644
--- a/core/lib/spree/permitted_attributes.rb
+++ b/core/lib/spree/permitted_attributes.rb
@@ -125,7 +125,8 @@ module PermittedAttributes
@@store_attributes = [:name, :url, :seo_title, :meta_keywords,
:meta_description, :default_currency,
:mail_from_address, :cart_tax_country_iso,
- :bcc_email, :reverse_charge_status]
+ :bcc_email, :reverse_charge_status,
+ address_attributes:]
@@taxonomy_attributes = [:name]
diff --git a/core/lib/spree/testing_support/factories/store_factory.rb b/core/lib/spree/testing_support/factories/store_factory.rb
index 008e2c9a823..747054adb7a 100644
--- a/core/lib/spree/testing_support/factories/store_factory.rb
+++ b/core/lib/spree/testing_support/factories/store_factory.rb
@@ -6,5 +6,9 @@
sequence(:name) { |i| "Spree Test Store #{i}" }
sequence(:url) { |i| "www.example#{i}.com" }
mail_from_address { "solidus@example.org" }
+
+ trait :with_address do
+ address
+ end
end
end
diff --git a/core/spec/models/spree/store_spec.rb b/core/spec/models/spree/store_spec.rb
index 24b0d3e404d..c03e25d58eb 100644
--- a/core/spec/models/spree/store_spec.rb
+++ b/core/spec/models/spree/store_spec.rb
@@ -137,6 +137,52 @@
end
end
+ describe "#address" do
+ it "is optional" do
+ expect(build(:store, address: nil)).to be_valid
+ end
+
+ it "can be assigned" do
+ store = create(:store, :with_address)
+ expect(store.reload.address).to be_a(Spree::Address)
+ end
+ end
+
+ describe "#address_attributes=" do
+ let(:address_attributes) { attributes_for(:address).merge(country_id: create(:country, states_required: false).id) }
+
+ it "creates an address" do
+ store = create(:store, address_attributes:)
+ expect(store.reload.address).to have_attributes(address_attributes.slice(:name, :address1, :city))
+ end
+
+ it "invalidates the store if the address is invalid" do
+ store = build(:store, address_attributes: address_attributes.merge(city: nil))
+ expect(store).not_to be_valid
+ expect(store.errors).to include(:address)
+ end
+
+ it "does not modify the existing address on change" do
+ store = create(:store, :with_address)
+ original_address = store.address
+
+ store.update!(address_attributes: {city: "New City"})
+
+ expect(store.reload.address).not_to eq(original_address)
+ expect(store.address.city).to eq("New City")
+ expect(original_address.reload.city).not_to eq("New City")
+ end
+
+ it "keeps the existing address if nothing changed" do
+ store = create(:store, :with_address)
+ original_address = store.address
+
+ store.update!(address_attributes: {city: original_address.city})
+
+ expect(store.reload.address).to eq(original_address)
+ end
+ end
+
describe "#validate_not_default" do
context "when deleting a default store" do
it "prevents deletion" do