Skip to content

Class WhittakerTech::Midas::Coin

Inherits: WhittakerTech::Midas::ApplicationRecord Includes: Poly::Joins, Poly::Owners, Poly::Role, WhittakerTech::Midas::Coin::Arithmetic, WhittakerTech::Midas::Coin::Bidi, WhittakerTech::Midas::Coin::Converter, WhittakerTech::Midas::Coin::Presenter

Coin is the canonical, persisted representation of a monetary value.

Conceptual model

  • A Coin represents payable money: an exact integer count of minor units (cents, pence, etc.) in a specific currency.
  • A Coin is immutable by convention: arithmetic operations return new frozen Coins rather than mutating the receiver.
  • A Coin owns arithmetic truth — not presentation, pricing interpretation, or currency conversion.

Storage

Every Coin is persisted in midas_coins and belongs polymorphically to any domain object (resource_type / resource_id). The resource_role distinguishes multiple coins on the same resource (e.g. "price", "cost", "tax").

Modules

Behavior is composed via mixins:

+------------+----------------------------------------------+
| Module     | Responsibility                               |
+------------+----------------------------------------------+
| Arithmetic | +, -, *, /, %, negate, equality              |
| Bidi       | Unicode bidirectional text isolation         |
| Converter  | Currency conversion (reserved, not yet live) |
| Presenter  | Token-based formatting grammar               |
+------------+----------------------------------------------+

Usage via Bankable

The typical entry point is the WhittakerTech::Midas::Bankable concern; direct Coin construction is mostly used in service objects and tests.

product.set_price(amount: 29.99, currency_code: 'USD')
product.price         # => #<WhittakerTech::Midas::Coin ...>
product.price_amount  # => #<Money @fractional=2999 @currency="USD">

See also:

  • WhittakerTech::Midas::Bankable
  • WhittakerTech::Midas::Coin::Arithmetic
  • WhittakerTech::Midas::Coin::Allocation rubocop:disable Metrics/ClassLength

  • @since 0.1.0

Public Class Methods

for_label(label)

  • @deprecated Use for_role instead. Will be removed in v0.3.0.
  • @since 0.1.0

parse(value, currency_code: = nil)

Parses a heterogeneous input into a Coin using Coin::Parser.

Accepted input types: Coin, Money, Numeric, String.

  • @param value [Coin, Money, Numeric, String] the value to parse
  • @param currency_code [String, nil] required for Numeric and bare String inputs
  • @raise [TypeError] if the input type cannot be converted
  • @raise [ArgumentError] if a currency_code is required but not provided
  • @return [Coin]
  • @since 0.1.0

@example

Coin.parse(Money.new(2999, 'USD'))
Coin.parse(29.99, currency_code: 'USD')
Coin.parse('$29.99')

value(currency_minor, currency_code)

Constructs a Coin directly from an integer minor-unit count and an ISO currency code.

This is the lowest-level factory. It enforces that currency_minor is an Integer (fractional minor units are not permitted).

  • @param currency_minor [Integer] the amount in minor units (e.g., cents)
  • @param currency_code [String] ISO 4217 currency code, e.g. "USD"
  • @raise [TypeError] if currency_minor is not an Integer
  • @return [Coin]
  • @since 0.1.0

@example

Coin.value(2999, 'USD')  # => $29.99
Coin.value(0, 'JPY')     # => ¥0

zero(currency_code)

Returns a zero-valued Coin in the given currency.

  • @param currency_code [String] ISO 4217 currency code
  • @return [Coin]
  • @since 0.1.0

@example

Coin.zero('USD')  # => $0.00

Public Instance Methods

%(other)

Returns the remainder after dividing by an integer.

  • @param other [Integer] the divisor
  • @raise [TypeError] if other is not an Integer
  • @raise [ZeroDivisionError] if other is zero
  • @return [Coin] a new, frozen Coin representing the remainder
  • @since 0.1.0

@example

Coin.value(1001, 'USD') % 3  # => Coin($0.02)  (1001 % 3 == 2 cents)

*(other)

Multiplies the Coin by an integer scalar.

  • @param other [Integer] the multiplier
  • @raise [TypeError] if other is not an Integer
  • @return [Coin] a new, frozen Coin
  • @since 0.1.0

@example

Coin.value(500, 'USD') * 3  # => Coin($15.00)

+(other)

Adds two Coins and returns the sum as a new Coin.

  • @param other [Coin] must have the same currency code as the receiver
  • @raise [TypeError] if other is not a Coin
  • @raise [ArgumentError] if the currencies do not match
  • @return [Coin] a new, frozen Coin
  • @since 0.1.0

@example

a = Coin.value(1000, 'USD')
b = Coin.value(500,  'USD')
a + b  # => Coin($15.00)

-(other)

Subtracts another Coin from the receiver and returns the difference.

  • @param other [Coin] must have the same currency code as the receiver
  • @raise [TypeError] if other is not a Coin
  • @raise [ArgumentError] if the currencies do not match
  • @return [Coin] a new, frozen Coin
  • @since 0.1.0

@example

a = Coin.value(1000, 'USD')
b = Coin.value(300,  'USD')
a - b  # => Coin($7.00)

-@()

Unary minus operator — shorthand for #negate.

  • @return [Coin]
  • @since 0.1.0

==(other)

Tests value equality with another Coin.

Two Coins are equal when they have the same currency code and the same minor-unit amount. A Coin is never equal to a non-Coin object.

  • @param other [Object] the object to compare
  • @return [Boolean]
  • @since 0.1.0

allocate(per:, rounding_policy: = WhittakerTech::Midas::DEFAULT_ROUNDING_POLICY)

Constructs a Coin::Allocation that interprets this Coin as a per-unit price.

The Coin itself is unchanged; Coin::Allocation encapsulates the per-unit interpretation and rounding policy.

  • @param per [Numeric] number of units this Coin covers (the divisor)
  • @param rounding_policy [Symbol] one of WhittakerTech::Midas::ROUNDING_POLICIES
  • @return [Coin::Allocation]
  • @since 0.1.0

@example Price per item from a bulk price

bulk = Coin.value(10_000, 'USD')  # $100.00 for 6 units
alloc = bulk.allocate(per: 6, rounding_policy: :ceil)
alloc.value          # => Coin($16.67)
alloc.price(qty: 3)  # => Coin($50.00)

amount()

Returns a Money object representing the stored monetary value.

This is a projection, not canonical value. Use #currency_minor and #currency_code as the source of truth.

Memoized for performance. The memo is cleared automatically when #currency_minor= or #currency_code= are called.

  • @return [Money]
  • @since 0.1.0

amount=(value)

Sets the coin's monetary value from a Money object or raw minor-unit integer.

  • @param value [Money, Integer, Numeric] the value to assign
  • Money — copies cents and currency.iso_code directly.
  • Numeric — treated as already-scaled minor units; requires #currency_code to already be set.

  • @raise [ArgumentError] if a Numeric is given without a prior currency_code

  • @raise [ArgumentError] if the value type is not supported
  • @return [void]
  • @since 0.1.0

bidi_currency_dir(currency_code)

Resolves the configured display direction for a currency code.

Reads from WhittakerTech::Midas.currency_directions. Defaults to :ltr for any currency not explicitly configured.

  • @param currency_code [String] ISO 4217 currency code
  • @return [Symbol] :ltr or :rtl
  • @since 0.1.0

bidi_isolate(text, dir:)

Wraps text in a Unicode directional-isolate span.

  • @param text [String, nil] the text to isolate; nil returns ""
  • @param dir [Symbol, nil] :ltr, :rtl, or nil (no wrapping)
  • @raise [ArgumentError] if dir is not :ltr, :rtl, or nil
  • @return [String]
  • @since 0.1.0

@example

bidi_isolate('$29.99', dir: :ltr)  # => "\u2066$29.99\u2069"
bidi_isolate('29.99',  dir: nil)   # => "29.99"

bidi_isolate_number(text)

Wraps a number string with LTR isolation.

Numbers are always rendered LTR. This prevents the bidi algorithm from reordering digit sequences when they appear in an RTL context.

  • @param text [String, Integer, BigDecimal] the numeric value to isolate
  • @return [String]
  • @since 0.1.0

convert_to(currency_code, at: = Time.current, using: = nil)

Converts this Coin to another currency.

  • @param currency_code [String] target ISO 4217 currency code
  • @param at [Time] the rate timestamp (for historical conversions)
  • @param using [Object, nil] exchange rate provider / bank override
  • @raise [NotImplementedError] — not yet implemented
  • @return [Coin]
  • @since 0.1.0

currency()

  • @return [String] the ISO currency code (alias for #currency_code)
  • @since 0.1.0

currency_code=(value)

Clears the memoized Money projection when the currency code changes.

  • @param value [String]
  • @return [void]
  • @since 0.1.0

currency_minor=(value)

Clears the memoized Money projection when the minor-unit value changes.

  • @param value [Integer]
  • @return [void]
  • @since 0.1.0

decimals()

Returns the number of decimal places defined by the currency specification.

For example, USD = 2, JPY = 0, BHD = 3 This is informational and does not imply rounding or formatting.

  • @return [Integer]
  • @since 0.1.0

divide(divisor, rounding_policy: = WhittakerTech::Midas::DEFAULT_ROUNDING_POLICY)

Divides the Coin by a numeric divisor and returns the quotient.

Division is the only arithmetic operation that requires a rounding policy because dividing integer minor units can produce a non-integer result.

  • @param divisor [Numeric] the divisor; must be non-zero
  • @param rounding_policy [Symbol] one of the keys in WhittakerTech::Midas::ROUNDING_POLICIES (default: WhittakerTech::Midas::DEFAULT_ROUNDING_POLICY)

  • @raise [TypeError] if divisor is not Numeric

  • @raise [ZeroDivisionError] if divisor is zero
  • @return [Coin] a new, frozen Coin
  • @since 0.1.0

@example

Coin.value(1000, 'USD').divide(3, rounding_policy: :ceil)   # => Coin($3.34)
Coin.value(1000, 'USD').divide(3, rounding_policy: :floor)  # => Coin($3.33)
Coin.value(1000, 'USD').divide(3, rounding_policy: :bankers)# => Coin($3.33)

format(to: = nil)

Formats the Coin for display, optionally converting to another currency first.

This is a convenience wrapper around the Money gem's #format. For richer formatting use #present with a pattern string.

  • @param to [String, nil] target ISO 4217 currency code for conversion, or nil to format in the native currency.

  • @return [String] the formatted monetary string, e.g. "$29.99"

  • @since 0.1.0

fractional()

  • @return [Integer] the raw minor-unit count (alias for #currency_minor)
  • @since 0.1.0

hash()

Returns a hash value consistent with #eql?.

Coins with the same currency code and minor-unit amount produce the same hash, making them interchangeable as Hash keys or Set members.

  • @return [Integer]
  • @since 0.1.0

major()

Returns the major-unit value as a precise BigDecimal.

This is NOT payable money. It is intended for inspection and formatting only. No rounding policy is applied.

  • @return [BigDecimal]
  • @since 0.1.0

@example

Coin.value(2999, 'USD').major  # => BigDecimal("29.99")
Coin.value(100, 'JPY').major   # => BigDecimal("100")

minor()

  • @return [Integer] the raw minor-unit count (alias for #currency_minor)
  • @since 0.1.0

negate()

Returns a new Coin with the sign reversed.

  • @return [Coin] a new, frozen Coin
  • @since 0.1.0

@example

Coin.value(500, 'USD').negate  # => Coin(-$5.00)

present(pattern)

Renders this Coin using a format pattern.

  • @param pattern [String] the format pattern containing %x tokens
  • @param opts [Hash] optional rendering context overrides (see module docs)
  • @raise [ArgumentError] if the pattern contains an unknown or unterminated token
  • @return [String]
  • @since 0.1.0

@example

coin.present('%s%M')          # => "$29.99"
coin.present('%t (%c)')       # => "$29.99 (USD)"
coin.present('~%t', approx: true) # => "≈$29.99"

resource_label()

  • @deprecated Use #resource_role instead. Will be removed in v0.3.0.
  • @since 0.1.0

resource_label=(value)

  • @deprecated Use #resource_role= instead. Will be removed in v0.3.0.
  • @since 0.1.0

scale()

Returns the scaling factor used to convert major units to minor units.

Equivalent to 10 ** decimals. For USD this is 100; for JPY it is 1.

  • @return [Numeric]
  • @since 0.1.0