docs/plans/5.4-6.0-eu-legal-compliance.md
Status: Draft Target: Spree 5.4 (Phase 1), Spree 6.0 (Phase 2) Depends on: 6.0-platform-auth.md (Customer/Staff rename), 6.0-channels-catalogs-b2b.md (Market-level config) Author: Damian + Claude Last updated: 2026-03-23
Add core infrastructure for EU legal compliance across three regulations: GDPR (data portability, erasure, consent), Omnibus Directive (30-day price history, sale price transparency), and Consumer Rights Directive (withdrawal period tracking). The goal is to make Spree viable for any EU merchant out of the box, while providing hooks for spree_enterprise to layer on management dashboards, automation, and multi-jurisdiction workflows.
Design principle: Core provides the data model, services, and API. Enterprise provides the management UI and automation.
Cookie consent is left to storefront implementations (Next.js, etc.) — not part of this plan.
Price history is a first-class model in core, not a plugin. Every EU merchant needs Omnibus compliance. A Spree::PriceHistory record is created via the PriceHistorySubscriber on every base price change. Enabled by default (track_price_history store preference). US/APAC merchants who don't need Omnibus can turn it off per store.
Customer data export and anonymization are core services. Spree::Customers::DataExporter and Spree::Customers::DataAnonymizer live in core with Store API endpoints. These are not enterprise-only — any merchant processing EU customer data needs them.
Anonymization preserves business records. Orders, tax records, and financial data are retained with PII scrubbed. The order itself is never deleted — only the identifying information is replaced. This satisfies both GDPR erasure and accounting/tax retention requirements.
Marketing consent gets a timestamp. The existing accepts_email_marketing boolean stays, but gains a companion email_marketing_consent_updated_at column. The timestamp is legally required to prove when consent was given or withdrawn.
Price history retention is configurable but defaults to 30 days. Matches the Omnibus Directive requirement. Merchants who need longer retention can increase via store preference. A Rake task prunes old records.
prior_price is opt-in via ?expand=prior_price. Omnibus lowest price is a PDP detail — no one shows it on product listings. A single MIN query per price on show is fine. No preloading needed.
Withdrawal period is tracked per-Market in 6.0. In 5.4, it's a store-level preference. In 6.0, the Market model (from channels-catalogs-b2b plan) owns it, since different markets may have different legal requirements.
No encryption of PII at rest in this plan. Application-level PII encryption is a separate concern (and often handled at the infrastructure level with encrypted-at-rest databases). This plan focuses on data lifecycle management, not storage encryption.
class Spree::PriceHistory < Spree.base_class
belongs_to :price, class_name: 'Spree::Price'
belongs_to :variant, class_name: 'Spree::Variant'
validates :amount, presence: true
validates :currency, presence: true
validates :recorded_at, presence: true
scope :for_variant, ->(variant_id) { where(variant_id: variant_id) }
scope :for_currency, ->(currency) { where(currency: currency) }
scope :in_period, ->(from, to = Time.current) { where(recorded_at: from..to) }
scope :recent, ->(days = 30) { in_period(days.days.ago) }
end
Columns:
| Column | Type | Notes |
|---|---|---|
price_id | bigint, NOT NULL | FK to spree_prices |
variant_id | bigint, NOT NULL | Denormalized for fast queries without join |
amount | decimal(10,2), NOT NULL | Price at this point in time |
compare_at_amount | decimal(10,2) | Compare-at price at this point in time |
currency | string, NOT NULL | ISO currency code |
recorded_at | datetime, NOT NULL | When this price took effect |
created_at | datetime |
Only base prices (where price_list_id IS NULL) are tracked. Price list prices are internal segmentation — the Omnibus Directive applies to the price the customer sees, which is always the base price.
Indexes:
[variant_id, currency, recorded_at] — primary query path (lowest price lookup)[price_id, recorded_at] — price-specific history[recorded_at] — for retention cleanupCallback on Spree::Price with saved_change_to_amount? guard — only records history when the amount actually changed, avoiding duplicate records from unrelated attribute updates.
# In Spree::Price
class Spree::Price < Spree.base_class
after_save :record_price_history, if: :should_record_price_history?
private
def should_record_price_history?
price_list_id.nil? &&
amount.present? &&
saved_change_to_amount? &&
Spree::Config[:track_price_history]
end
def record_price_history
Spree::PriceHistory.create!(
price: self,
variant_id: variant_id,
amount: amount,
compare_at_amount: compare_at_amount,
currency: currency,
recorded_at: Time.current
)
end
end
# In Spree::Price
class Spree::Price < Spree.base_class
has_many :price_histories, class_name: 'Spree::PriceHistory'
def prior_price
price_histories.where(recorded_at: 30.days.ago..).minimum(:amount)
end
end
This is only used on PDP (single product show), not on PLP (product listings). Omnibus lowest price is a sale-price detail — nobody displays it on collection pages. A single MIN query per price on show is fine.
On migration, a Rake task snapshots all current prices as baseline history:
# lib/tasks/price_history.rake
namespace :spree do
desc 'Seed price history from existing prices (run once after migration)'
task seed_price_history: :environment do
Spree::Price.where(deleted_at: nil, price_list_id: nil).find_each do |price|
next if price.amount.nil?
next if Spree::PriceHistory.exists?(price_id: price.id)
Spree::PriceHistory.create!(
price: price,
variant_id: price.variant_id,
amount: price.amount,
compare_at_amount: price.compare_at_amount,
currency: price.currency,
recorded_at: price.updated_at || Time.current
)
end
end
end
# lib/tasks/price_history.rake
namespace :spree do
desc 'Prune price history older than retention period (per-store)'
task prune_price_history: :environment do
Spree::Store.find_each do |store|
retention_days = store.preferences[:price_history_retention_days] || 30
variant_ids = Spree::Product.joins(:stores).where(spree_stores: { id: store.id }).
joins(:variants).pluck('spree_variants.id')
next if variant_ids.empty?
deleted = Spree::PriceHistory
.where(variant_id: variant_ids)
.where('recorded_at < ?', retention_days.days.ago)
.delete_all
puts "Store #{store.name}: pruned #{deleted} price history records older than #{retention_days} days"
end
end
end
Add prior_price to variant and product serializers:
Opt-in via ?expand=prior_price — only on product/variant show, not on listings:
# In Spree::Api::V3::ProductSerializer
typelize prior_price: 'number | null'
attribute :prior_price,
if: proc { params[:expand]&.include?('prior_price') } do |product, params|
currency = params[:currency] || Spree::Current.currency
product.default_variant.price_in(currency)&.prior_price
end
# In Spree::Api::V3::VariantSerializer
typelize prior_price: 'number | null'
attribute :prior_price,
if: proc { params[:expand]&.include?('prior_price') } do |variant, params|
currency = params[:currency] || Spree::Current.currency
variant.price_in(currency)&.prior_price
end
Usage: GET /api/v3/store/products/:id?expand=prior_price
Migration adds email_marketing_consent_updated_at:
add_column :spree_users, :email_marketing_consent_updated_at, :datetime
Callback on user:
# In Spree::UserMethods
before_save :track_marketing_consent_change, if: -> { will_save_change_to_attribute?('accepts_email_marketing') }
private
def track_marketing_consent_change
self.email_marketing_consent_updated_at = Time.current
end
Update Store API customer serializer:
# In Spree::Api::V3::CustomerSerializer
attributes :accepts_email_marketing, :email_marketing_consent_updated_at
New Store API endpoint that returns the customer's data as structured JSON:
# GET /api/v3/store/customer/data_export
module Spree::Api::V3::Store
class CustomerDataExportsController < BaseController
before_action :require_authentication
def show
exporter = Spree::Customers::DataExporter.new(current_customer)
render json: exporter.call
end
end
end
# core/app/services/spree/customers/data_exporter.rb
module Spree
module Customers
class DataExporter
attr_reader :customer
def initialize(customer)
@customer = customer
end
def call
{
account: account_data,
addresses: addresses_data,
orders: orders_data,
payment_sources: payment_sources_data,
wishlists: wishlists_data,
store_credits: store_credits_data,
metafields: metafields_data,
marketing_consent: marketing_consent_data,
exported_at: Time.current.iso8601
}
end
private
def account_data
{
email: customer.email,
first_name: customer.first_name,
last_name: customer.last_name,
phone: customer.try(:phone),
created_at: customer.created_at&.iso8601,
selected_locale: customer.try(:selected_locale)
}
end
def addresses_data
customer.addresses.not_deleted.map do |addr|
{
label: addr.label,
first_name: addr.firstname,
last_name: addr.lastname,
address1: addr.address1,
address2: addr.address2,
city: addr.city,
state: addr.state_text,
postal_code: addr.zipcode,
country: addr.country&.name,
phone: addr.phone,
company: addr.company
}
end
end
def orders_data
customer.orders.complete.map do |order|
{
number: order.number,
email: order.email,
total: order.total&.to_s,
currency: order.currency,
completed_at: order.completed_at&.iso8601,
special_instructions: order.special_instructions,
billing_address: address_hash(order.bill_address),
shipping_address: address_hash(order.ship_address),
line_items: order.line_items.map { |li|
{ name: li.name, quantity: li.quantity, price: li.price&.to_s, currency: li.currency }
}
}
end
end
def payment_sources_data
customer.credit_cards.not_removed.map do |card|
{
type: card.cc_type,
last_digits: card.last_digits,
name: card.name,
expiry_month: card.month,
expiry_year: card.year
}
end
end
def wishlists_data
return [] unless customer.respond_to?(:wishlists)
customer.wishlists.map do |wishlist|
{
name: wishlist.name,
items: wishlist.wished_items.map { |wi| { variant_id: wi.variant&.prefixed_id } }
}
end
end
def store_credits_data
return [] unless customer.respond_to?(:store_credits)
customer.store_credits.map do |credit|
{
amount: credit.amount&.to_s,
currency: credit.currency,
created_at: credit.created_at&.iso8601
}
end
end
def metafields_data
return [] unless customer.respond_to?(:metafields)
customer.metafields.where(visibility: 'public').map do |mf|
{ key: mf.key, value: mf.value, kind: mf.kind }
end
end
def marketing_consent_data
{
accepts_email_marketing: customer.accepts_email_marketing,
consent_updated_at: customer.try(:email_marketing_consent_updated_at)&.iso8601
}
end
def address_hash(addr)
return nil unless addr
{
first_name: addr.firstname,
last_name: addr.lastname,
address1: addr.address1,
address2: addr.address2,
city: addr.city,
state: addr.state_text,
postal_code: addr.zipcode,
country: addr.country&.name,
phone: addr.phone
}
end
end
end
end
Extends the existing scramble_email_and_names into a comprehensive anonymizer:
# core/app/services/spree/customers/data_anonymizer.rb
module Spree
module Customers
class DataAnonymizer
attr_reader :customer
def initialize(customer)
@customer = customer
end
# Returns true if anonymization was performed, false if blocked
def call
ActiveRecord::Base.transaction do
anonymize_account
anonymize_addresses
anonymize_order_addresses
anonymize_payment_sources
anonymize_identities
clear_metadata
record_anonymization
customer.publish_event('customer.anonymized')
end
true
end
private
def anonymize_account
anonymous_email = "anonymized+#{SecureRandom.uuid}@removed.invalid"
customer.update!(
email: anonymous_email,
first_name: 'Anonymized',
last_name: 'Customer',
phone: nil,
login: anonymous_email,
accepts_email_marketing: false,
email_marketing_consent_updated_at: Time.current,
public_metadata: {},
private_metadata: {}
)
end
def anonymize_addresses
customer.addresses.find_each do |addr|
addr.update_columns(
firstname: 'Anonymized',
lastname: 'Customer',
address1: 'Removed',
address2: nil,
city: 'Removed',
phone: nil,
company: nil,
alternative_phone: nil,
zipcode: addr.zipcode&.first(2)&.ljust(5, '*'), # Keep partial for tax jurisdiction
deleted_at: Time.current
)
end
end
def anonymize_order_addresses
# Anonymize billing/shipping addresses on completed orders
# Preserve: country, state (needed for tax records), partial zip
order_address_ids = customer.orders
.pluck(:bill_address_id, :ship_address_id)
.flatten.compact.uniq
Spree::Address.where(id: order_address_ids).find_each do |addr|
addr.update_columns(
firstname: 'Anonymized',
lastname: 'Customer',
address1: 'Removed',
address2: nil,
phone: nil,
company: nil,
alternative_phone: nil,
zipcode: addr.zipcode&.first(2)&.ljust(5, '*'),
# Keep: city, state, country — needed for tax records
)
end
end
def anonymize_payment_sources
customer.credit_cards.find_each do |card|
card.update_columns(
name: 'Anonymized',
deleted_at: Time.current
)
end
if customer.respond_to?(:gateway_customers)
customer.gateway_customers.delete_all
end
end
def anonymize_identities
if customer.respond_to?(:user_identities)
customer.user_identities.delete_all
end
end
def clear_metadata
# Clear order-level PII that isn't financial
customer.orders.find_each do |order|
order.update_columns(
email: customer.email, # Already anonymized
special_instructions: nil,
last_ip_address: nil
)
end
end
def record_anonymization
# Store anonymization record for compliance audit
# Uses private_metadata on the user since they're already anonymized
metadata = (customer.private_metadata || {}).merge(
anonymized_at: Time.current.iso8601,
anonymization_version: '1.0'
)
customer.update_column(:private_metadata, metadata)
end
end
end
end
# DELETE /api/v3/store/customer
# Requires authentication + password confirmation
module Spree::Api::V3::Store
class CustomersController < ResourceController
# ... existing actions ...
def destroy
unless current_customer.valid_password?(params[:current_password])
render json: { error: 'Invalid password' }, status: :unauthorized
return
end
anonymizer = Spree::Customers::DataAnonymizer.new(current_customer)
anonymizer.call
head :no_content
end
end
end
After anonymization, the customer's JWT is invalidated (tokens reference an email that no longer matches).
When the Market model lands (from channels-catalogs-b2b plan), legal settings move to per-market:
class Spree::Market < Spree.base_class
# Legal settings
attribute :withdrawal_period_days, :integer, default: 14
attribute :price_history_display_days, :integer, default: 30
attribute :data_retention_days, :integer # nil = indefinite
attribute :legal_entity_name, :string
attribute :legal_entity_address, :text
end
Until Markets land in 6.0, these are store-level preferences in 5.4:
# In Spree::Store preferences
preference :withdrawal_period_days, :integer, default: 14
class Spree::Order < Spree.base_class
# Set on completion based on market/store config
attribute :withdrawal_eligible_until, :datetime
after_transition to: :complete, do: :set_withdrawal_deadline
private
def set_withdrawal_deadline
days = store.preferred_withdrawal_period_days || 14
update_column(:withdrawal_eligible_until, completed_at + days.days)
end
end
Store API exposes this on completed orders:
# In Spree::Api::V3::OrderSerializer
typelize withdrawal_eligible_until: 'string | null'
attribute :withdrawal_eligible_until do |order|
order.withdrawal_eligible_until&.iso8601
end
With the Customer/Staff rename (from platform-auth plan), the anonymizer gains:
data_retention_days on Market)The core services publish events that spree_enterprise can subscribe to:
| Core Event | Enterprise Feature |
|---|---|
price.updated + PriceHistory record | Omnibus compliance dashboard, price change audit log |
customer.anonymized | GDPR compliance log, DPO notification |
customer.data_exported | Data access audit trail (Art. 30) |
| Marketing consent change | Consent management timeline |
Enterprise features built on core primitives:
| Feature | Core Primitive | Enterprise Layer |
|---|---|---|
| GDPR dashboard | DataExporter, DataAnonymizer | Admin UI: search customers, trigger export/anonymization, view audit log |
| Automated data retention | DataAnonymizer service | Scheduled job: anonymize customers inactive for N days |
| Omnibus price dashboard | PriceHistory model | Admin UI: price change timeline, Omnibus compliance status per product |
| Multi-jurisdiction legal text | Store/Market preferences | Admin UI: per-market legal text editor, withdrawal policy templates |
| Breach notification workflow | Events system | Incident tracker, 72-hour notification templates, DPA contact management |
| Data processing register (Art. 30) | Events system | Audit log of all PII access, processing purpose documentation |
| Automated withdrawal handling | withdrawal_eligible_until on Order | Return workflow auto-approval within withdrawal period |
class AddEuLegalComplianceFields < ActiveRecord::Migration[7.2]
def change
# Price history
create_table :spree_price_histories do |t|
t.references :price, null: false
t.references :variant, null: false
t.decimal :amount, precision: 10, scale: 2, null: false
t.decimal :compare_at_amount, precision: 10, scale: 2
t.string :currency, null: false
t.datetime :recorded_at, null: false
t.datetime :created_at, null: false
end
add_index :spree_price_histories, [:variant_id, :currency, :recorded_at],
name: 'idx_price_histories_variant_currency_recorded'
add_index :spree_price_histories, [:price_id, :recorded_at],
name: 'idx_price_histories_price_recorded'
add_index :spree_price_histories, :recorded_at,
name: 'idx_price_histories_recorded_at'
# Marketing consent timestamp
add_column :spree_users, :email_marketing_consent_updated_at, :datetime
# Withdrawal period
add_column :spree_orders, :withdrawal_eligible_until, :datetime
end
end
bundle exec rake spree:price_history:seed # Snapshot existing prices
Spree::Price#amount must go through the model (not raw SQL update_columns), so the after_save callback fires and history is recorded.scramble_email_and_names.public_metadata or private_metadata on orders, ensure the anonymizer clears it.6.0-platform-auth.md — Customer/Staff model rename6.0-channels-catalogs-b2b.md — Market model for per-market legal configSpree::UserMethods#scramble_email_and_names (to be superseded by DataAnonymizer)Spree::Exports::Customers + Spree::CSV::CustomerPresenter (CSV export — DataExporter builds on same data)Spree::Price#compare_at_amount (Omnibus "was" price — history complements this)