Back to Spree

EU Legal Compliance (GDPR, Omnibus, Consumer Rights)

docs/plans/5.4-6.0-eu-legal-compliance.md

5.4.223.5 KB
Original Source

EU Legal Compliance (GDPR, Omnibus, Consumer Rights)

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

Summary

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.

Key Decisions (do not deviate without discussion)

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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.

Design Details

Phase 1: Spree 5.4 — Omnibus Price History + GDPR Foundations

1.1 Spree::PriceHistory Model

ruby
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:

ColumnTypeNotes
price_idbigint, NOT NULLFK to spree_prices
variant_idbigint, NOT NULLDenormalized for fast queries without join
amountdecimal(10,2), NOT NULLPrice at this point in time
compare_at_amountdecimal(10,2)Compare-at price at this point in time
currencystring, NOT NULLISO currency code
recorded_atdatetime, NOT NULLWhen this price took effect
created_atdatetime

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 cleanup

1.2 Price Callback — Automatic History Recording

Callback on Spree::Price with saved_change_to_amount? guard — only records history when the amount actually changed, avoiding duplicate records from unrelated attribute updates.

ruby
# 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

1.3 Lowest Price Query

ruby
# 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.

1.4 Seed Existing Prices

On migration, a Rake task snapshots all current prices as baseline history:

ruby
# 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

1.5 Retention Cleanup

ruby
# 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

1.6 Store API — Price History Exposure

Add prior_price to variant and product serializers:

Opt-in via ?expand=prior_price — only on product/variant show, not on listings:

ruby
# 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
ruby
# 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

1.7 Marketing Consent Timestamp

Migration adds email_marketing_consent_updated_at:

ruby
add_column :spree_users, :email_marketing_consent_updated_at, :datetime

Callback on user:

ruby
# 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:

ruby
# In Spree::Api::V3::CustomerSerializer
attributes :accepts_email_marketing, :email_marketing_consent_updated_at

1.8 Customer Data Export API Endpoint

New Store API endpoint that returns the customer's data as structured JSON:

ruby
# 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
ruby
# 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

1.9 Customer Data Anonymization Service

Extends the existing scramble_email_and_names into a comprehensive anonymizer:

ruby
# 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

1.10 Customer Data Erasure API Endpoint

ruby
# 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).

2.1 Market Legal Configuration

When the Market model lands (from channels-catalogs-b2b plan), legal settings move to per-market:

ruby
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:

ruby
# In Spree::Store preferences
preference :withdrawal_period_days, :integer, default: 14

2.2 Withdrawal Period on Orders

ruby
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:

ruby
# In Spree::Api::V3::OrderSerializer
typelize withdrawal_eligible_until: 'string | null'
attribute :withdrawal_eligible_until do |order|
  order.withdrawal_eligible_until&.iso8601
end

2.3 Enhanced Data Anonymization (6.0)

With the Customer/Staff rename (from platform-auth plan), the anonymizer gains:

  • Automatic anonymization after configurable retention period (via data_retention_days on Market)
  • Batch anonymization Rake task for inactive customers
  • Admin API endpoint to trigger anonymization of specific customers

Enterprise Hooks

The core services publish events that spree_enterprise can subscribe to:

Core EventEnterprise Feature
price.updated + PriceHistory recordOmnibus compliance dashboard, price change audit log
customer.anonymizedGDPR compliance log, DPO notification
customer.data_exportedData access audit trail (Art. 30)
Marketing consent changeConsent management timeline

Enterprise features built on core primitives:

FeatureCore PrimitiveEnterprise Layer
GDPR dashboardDataExporter, DataAnonymizerAdmin UI: search customers, trigger export/anonymization, view audit log
Automated data retentionDataAnonymizer serviceScheduled job: anonymize customers inactive for N days
Omnibus price dashboardPriceHistory modelAdmin UI: price change timeline, Omnibus compliance status per product
Multi-jurisdiction legal textStore/Market preferencesAdmin UI: per-market legal text editor, withdrawal policy templates
Breach notification workflowEvents systemIncident tracker, 72-hour notification templates, DPA contact management
Data processing register (Art. 30)Events systemAudit log of all PII access, processing purpose documentation
Automated withdrawal handlingwithdrawal_eligible_until on OrderReturn workflow auto-approval within withdrawal period

Migration

5.4 Migration

ruby
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

Post-migration Rake task

bash
bundle exec rake spree:price_history:seed   # Snapshot existing prices

Constraints on Current Work

  • Always record price changes. Any code that modifies Spree::Price#amount must go through the model (not raw SQL update_columns), so the after_save callback fires and history is recorded.
  • Don't bypass DataAnonymizer. Any customer deletion or data cleanup must use the anonymizer service, not direct record deletion or scramble_email_and_names.
  • Preserve order financial data on anonymization. Never delete or modify: order totals, tax amounts, line item prices, payment amounts. Only PII fields (names, addresses, emails, IPs).
  • Don't add PII to order metadata without considering anonymization. If storing customer-identifiable data in public_metadata or private_metadata on orders, ensure the anonymizer clears it.

Open Questions

  1. Rate limiting on data export endpoint. Should we enforce a cooldown (e.g., one export per 24 hours) to prevent abuse, or is standard API rate limiting sufficient?
  2. Admin-initiated anonymization in core vs enterprise. The current plan puts admin-triggered anonymization in enterprise. Should core include an Admin API endpoint for it as well (with appropriate authorization)?

References

  • GDPR full text: Regulation (EU) 2016/679
  • Omnibus Directive: Directive (EU) 2019/2161
  • Consumer Rights Directive: 2011/83/EU
  • Related plan: 6.0-platform-auth.md — Customer/Staff model rename
  • Related plan: 6.0-channels-catalogs-b2b.md — Market model for per-market legal config
  • Existing code: Spree::UserMethods#scramble_email_and_names (to be superseded by DataAnonymizer)
  • Existing code: Spree::Exports::Customers + Spree::CSV::CustomerPresenter (CSV export — DataExporter builds on same data)
  • Existing code: Spree::Price#compare_at_amount (Omnibus "was" price — history complements this)