Payment split

Live sum-to-total rebalancer: nested bracketed params, a disabled computed field, auto-collected siblings — the pattern behind #64–#67.

Editing one amount rebalances the others so they always sum to the total.

Balanced — 700 + 200 + 100 = 1000
# Editing one amount rebalances the peers so they always sum to the
# total. The fields are model-scoped (split[allowance], …), so the
# action schema nests under `split:` to match them:
render PaymentSplitComponent.new(allowance: 700, cash: 200, leasing: 100, total: 1000)
# frozen_string_literal: true

# A live "sum-to-total" split calculator — the pattern behind issues #64–#67.
# Three amounts (allowance / cash / leasing) must always add up to a fixed
# `total`. Editing one field rebalances the OTHER two server-side (spill the
# difference into the largest peer, clamped at zero), and the component
# re-renders in place so every field shows the reconciled number.
#
# It is STATE-backed (no DB): the three amounts + the total ride in the signed
# token, and each rebalance is pure computation over the values the client
# collected from the form — nothing is persisted, nothing is broadcast. In a
# real app you would swap `reactive_state` for `reactive_record :invoice` and
# authorize the row (identity + auth only), keeping the same "compute over the
# live fields, don't persist" shape — see the README "Record-authorized,
# transient-state actions" section.
#
# What each issue maps to, made concrete:
#   #67  the fields are named `split[allowance]`, … (model-scoped brackets), so
#        the action schema NESTS under `split:` to match — a flat schema would
#        silently collect nothing.
#   #65  editing one field fires `rebalance` with the CURRENT value of every
#        sibling (read at dispatch time), so the server sees the whole form.
#   #66  `total` is a DISABLED display field, yet it is still collected and read
#        by the action (deliberately unlike a native form submit).
#   #64  the record (here: state) is authority for the numbers; the action
#        recomputes and re-renders without persisting or broadcasting.
class PaymentSplitComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  FIELDS = %i[allowance cash leasing].freeze

  # All three amounts + the total are signed into the identity token.
  reactive_state :allowance, :cash, :leasing, :total

  # ONE nested schema, reused by all three triggers. The inputs are named
  # `split[allowance]`, `split[cash]`, `split[leasing]`, `split[total]`, so the
  # schema nests under `split:` to match the bracket-expanded params (#67). A
  # `changed` scalar param rides alongside to say WHICH field the user edited.
  SPLIT_PARAMS = {
    changed: :string,
    split: { allowance: :integer, cash: :integer, leasing: :integer, total: :integer }
  }.freeze

  action :rebalance, params: SPLIT_PARAMS

  def initialize(allowance: 700, cash: 200, leasing: 100, total: 1000)
    @allowance = allowance.to_i
    @cash      = cash.to_i
    @leasing   = leasing.to_i
    @total     = total.to_i
  end

  def id = 'payment-split'

  # The client collected every named field of this root — including the DISABLED
  # `total` (#66) — and bracket-expanded them into `split:` (#67). `changed`
  # names the edited field. We recompute over those live values (#65), assign the
  # reconciled amounts, and morph in place so the edited field keeps its caret
  # and the peers show their new numbers. No persist, no broadcast (#64).
  def rebalance(changed:, split:)
    edited = changed.to_sym
    edited = :allowance unless FIELDS.include?(edited)

    apply(split)
    reconcile(edited)
    reply.morph
  end

  def view_template
    div(**reactive_root(class: 'flex flex-col gap-3', data: { testid: 'payment-split' })) do
      p(class: 'text-sm opacity-70') do
        plain 'Editing one amount rebalances the others so they always sum to the total.'
      end
      FIELDS.each { amount_row(it) }
      total_row
      remainder_note
    end
  end

  private

  # Coerce the collected split hash back onto our state. Missing keys keep their
  # current value (defensive — the schema drops anything undeclared already).
  def apply(split)
    @allowance = int(split[:allowance], @allowance)
    @cash      = int(split[:cash], @cash)
    @leasing   = int(split[:leasing], @leasing)
    @total     = int(split[:total], @total)
  end

  def int(value, fallback)
    value.nil? ? fallback : value.to_i
  end

  # Fold the whole difference back into the peers so the three amounts sum to
  # `total`. Clamp the edited field into [0, total], then spill the remainder
  # across the other two (largest first), never below zero.
  def reconcile(edited)
    set(edited, current(edited).clamp(0, @total))
    remainder = @total - current(edited)

    peers = (FIELDS - [edited]).sort_by { |f| -current(f) }
    peers.each_with_index do |field, index|
      share = index == peers.size - 1 ? remainder : [current(field), remainder].min
      share = share.clamp(0, remainder)
      set(field, share)
      remainder -= share
    end
  end

  def current(field) = instance_variable_get(:"@#{field}")
  def set(field, value) = instance_variable_set(:"@#{field}", value)

  def sum = FIELDS.sum { current(it) }

  def amount_row(field)
    label(class: 'flex items-center justify-between gap-3') do
      span(class: 'w-28 capitalize') { field.to_s }
      # A change-triggered rebalance carrying `changed:` so the server knows which
      # field the user edited. The field NAME is bracketed (`split[allowance]`) so
      # it bracket-expands into the nested `split:` schema (#67). mix() keeps
      # on()'s data-action from being clobbered by our name/value/class.
      input(**mix(
        on(:rebalance, event: 'change', changed: field.to_s),
        type: 'number',
        name: "split[#{field}]",
        value: current(field),
        min: 0,
        class: 'input input-bordered input-sm w-32 text-right tabular-nums',
        data: { testid: "split-#{field}" }
      ))
    end
  end

  # The total is a DISABLED, computed display field. It is still a named control
  # of the reactive root, so the client collects it and the action reads it (#66)
  # — a native <form> submit would omit it. It's disabled because the user edits
  # the amounts, not the target.
  def total_row
    label(class: 'flex items-center justify-between gap-3 border-t border-base-300 pt-2') do
      span(class: 'w-28 font-semibold') { 'Total' }
      input(
        type: 'number',
        name: 'split[total]',
        value: @total,
        disabled: true,
        class: 'input input-bordered input-sm w-32 text-right font-semibold tabular-nums',
        data: { testid: 'split-total' }
      )
    end
  end

  # A tiny live proof the invariant holds after every rebalance.
  def remainder_note
    balanced = sum == @total
    div(class: ['text-sm', (balanced ? 'text-success' : 'text-error')],
        data: { testid: 'split-remainder' }) do
      plain(balanced ? "Balanced — #{@allowance} + #{@cash} + #{@leasing} = #{@total}" : "Off by #{@total - sum}")
    end
  end
end