Class: TypedEAV::Field::Currency

Inherits:
Base show all
Defined in:
app/models/typed_eav/field/currency.rb

Overview

Two-cell field type: stores ‘BigDecimal, currency: String` across decimal_value (amount) + string_value (currency ISO 4217 code).

Phase 05 contract:

  • value_columns: [:decimal_value, :string_value] — propagates through versioning’s snapshot loop (Phase 04) and the Value _dispatch_value_change_update filter so a change to either cell correctly fires the :update event.

  • operator_column: :currency_eq → :string_value; everything else →:decimal_value. Routed via QueryBuilder.filter (plan 05-01).

  • read_value / write_value / apply_default_to: paired overrides that compose / unpack the currency hash across the two physical columns. Without all three, single-cell defaults would write a Hash to decimal_value and raise TypeMismatch.

  • cast: Hash input only. Bare Numeric/String is invalid — explicit currency dimension is required at write time. Silently defaulting to default_currency would invite bugs where users forget the currency dimension entirely.

Operators (explicit narrowing — does NOT inherit string-search ops like :contains/:starts_with from decimal_value’s default since those don’t apply to amount-numeric or currency-code searches):

  • :eq, :gt, :lt, :gteq, :lteq, :between target the amount.

  • :currency_eq targets the currency code (registered ONLY on this class — QueryBuilder’s operator-validation gate rejects it on any non-Currency field).

  • :is_null / :is_not_null target the amount column (a Currency value is considered null when its amount is null).

Options:

  • default_currency: String ISO 4217 code (e.g., “USD”). Used as the currency fallback when cast input has amount but no currency. Never applies as a global silent default — only when cast input already has an amount and no explicit currency.

  • allowed_currencies: Array<String> of ISO codes. When set, validate_typed_value enforces inclusion.

Constant Summary

Constants inherited from Base

Base::RESERVED_NAMES

Constants included from ColumnMapping

ColumnMapping::DEFAULT_OPERATORS_BY_COLUMN, ColumnMapping::FALLBACK_OPERATORS

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Base

#allowed_option_values, #apply_default_to, #array_field?, #backfill_default!, #clear_option_cache!, #default_value, #default_value=, export_schema, #field_type_name, import_schema, #insert_at, #move_higher, #move_lower, #move_to_bottom, #move_to_top, #optionable?, #read_value, #storage_contract, storage_contract_class, #write_value

Class Method Details

.operator_column(operator) ⇒ Object



56
57
58
# File 'app/models/typed_eav/field/currency.rb', line 56

def self.operator_column(operator)
  storage_contract_class.query_column(operator)
end

.value_columnsObject



52
53
54
# File 'app/models/typed_eav/field/currency.rb', line 52

def self.value_columns
  storage_contract_class.value_columns
end

Instance Method Details

#cast(raw) ⇒ Object

Cast Hash input → [BigDecimal, currency: String, false] or [nil, false] for nil/blank, or [nil, true] for unparseable input. Bare Numeric or String input is [nil, true] — users MUST pass a hash to make the currency dimension explicit (locked plan-time decision; preventing silent default_currency reliance in the ergonomic-but-error-prone scalar-cast case). rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity – one cast method with linear branches for nil/non-hash/amount-parse/currency-coercion/currency-shape; splitting hides the cast contract from a single read.



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'app/models/typed_eav/field/currency.rb', line 67

def cast(raw)
  return [nil, false] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
  return [nil, true] unless raw.is_a?(Hash)

  amount_raw   = raw[:amount]   || raw["amount"]
  currency_raw = raw[:currency] || raw["currency"]

  amount_bd = nil
  if amount_raw.present?
    amount_bd = BigDecimal(amount_raw.to_s, exception: false)
    return [nil, true] if amount_bd.nil?
  end

  currency_str = currency_raw.is_a?(String) ? currency_raw.upcase : nil
  # default_currency fallback applies ONLY when the hash has an
  # amount but no currency. When the hash has neither, the result is
  # {amount: nil, currency: nil} — falsy enough that read_value
  # returns nil. When the hash has only a currency, the fallback
  # does NOT trigger (amount stays nil); validation will catch the
  # co-population requirement at save time.
  currency_str ||= default_currency if amount_bd && default_currency.present?
  # Reject non-3-letter currency codes (validation also catches this
  # on save; cast catches it earlier so :invalid is set on the cast
  # result and Value#validate_value surfaces :invalid promptly).
  return [nil, true] if currency_str && currency_str !~ /\A[A-Z]{3}\z/

  [{ amount: amount_bd, currency: currency_str }, false]
end

#validate_typed_value(record, val) ⇒ Object

Co-population validation + allowed_currencies inclusion. When val is a Hash, requires both :amount and :currency populated. When allowed_currencies is set, val must be in the list. Without the co-population check, a half-populated row (amount-only or currency-only) would silently round-trip.



102
103
104
105
106
107
108
109
110
111
112
# File 'app/models/typed_eav/field/currency.rb', line 102

def validate_typed_value(record, val)
  return if val.nil?

  unless val.is_a?(Hash) && val[:amount].present? && val[:currency].present?
    record.errors.add(:value, "must have both amount and currency")
    return
  end

  allowed = options_hash[:allowed_currencies]
  record.errors.add(:value, :inclusion) if allowed.present? && Array(allowed).exclude?(val[:currency])
end