Module: ApprovalEngine::Approvable

Extended by:
ActiveSupport::Concern
Defined in:
app/models/concerns/approval_engine/approvable.rb

Overview

Mixed into a host model by the ‘has_approvals` macro. Gives the model its approval association, the `exposes_for_approval` anti-corruption DSL, and the trigger that spawns an approval for a domain event.

class Invoice < ApplicationRecord
  has_approvals

  exposes_for_approval do
    attribute :amount, type: :decimal
    attribute :department, type: :string, source: ->(i) { i.department.name }
  end

  def after_approved
    PaymentService.disburse_funds!(self)
  end
end

By default, creating the record evaluates the tenant’s rules and spawns the matching approval. Override ‘trigger_approval?` to gate that, or pass `has_approvals(on: [])` to opt out and trigger manually with `record.run_approval!(event:)`.

Constant Summary collapse

LIFECYCLE_EVENTS =

The ActiveRecord lifecycle events the ‘on:` option understands, mapped to the conventional suffix of the event they route (create -> “<model>.created”). Add a lifecycle here and it’s wired automatically — no new methods.

{ create: "created", update: "updated", destroy: "destroyed" }.freeze

Instance Method Summary collapse

Instance Method Details

#approval_candidates(event:, tenant_id: approval_tenant_id) ⇒ Object

Every approval that would match ‘event` for this record, in priority order — so you can let a user choose which to trigger rather than letting the engine auto-pick the top one. Returns an array of ApprovalPlan; writes nothing.



142
143
144
145
146
147
148
149
# File 'app/models/concerns/approval_engine/approvable.rb', line 142

def approval_candidates(event:, tenant_id: approval_tenant_id)
  ApprovalEngine::RuleEvaluator.candidates(
    event_name: event,
    tenant_id: tenant_id,
    target: self,
    payload: serialize_for_approval
  )
end

#approval_historyObject

A read-only view of everything this record has gone through — approvals, the step tree, and a chronological timeline of actions + comments. The gem assembles it; you decide who may see it and how to render it.



154
155
156
# File 'app/models/concerns/approval_engine/approvable.rb', line 154

def approval_history
  ApprovalEngine::History.for(self)
end

#approval_in_flight?Boolean

Returns:

  • (Boolean)


162
163
164
# File 'app/models/concerns/approval_engine/approvable.rb', line 162

def approval_in_flight?
  approvals.pending.exists?
end

#approval_statusObject



166
167
168
# File 'app/models/concerns/approval_engine/approvable.rb', line 166

def approval_status
  latest_approval&.status
end

#latest_approvalObject



158
159
160
# File 'app/models/concerns/approval_engine/approvable.rb', line 158

def latest_approval
  approvals.order(created_at: :desc).first
end

#preview_approval(event:, tenant_id: approval_tenant_id) ⇒ Object

Preview what ‘event` would trigger for this record, without writing anything — handy for showing a user “this will go to Manager, then CFO” before they commit an action. Works against the in-memory record, so you can preview an unsaved change (`invoice.amount = 20_000; invoice.preview_…`). Returns an ApprovalEngine::ApprovalPlan.



129
130
131
132
133
134
135
136
# File 'app/models/concerns/approval_engine/approvable.rb', line 129

def preview_approval(event:, tenant_id: approval_tenant_id)
  ApprovalEngine::RuleEvaluator.preview(
    event_name: event,
    tenant_id: tenant_id,
    target: self,
    payload: serialize_for_approval
  )
end

#run_approval!(event: nil, templates: nil, approvals_required: "all", tenant_id: approval_tenant_id) ⇒ Object

Start an approval, two ways (pass exactly one):

run_approval!(event: "invoice.created")     # engine routes by rules
run_approval!(templates: [finance, legal])  # you choose explicitly

With ‘event:`, the tenant’s rules are evaluated and the highest-priority match is spawned — returns the approval, a quarantine approval on a rule failure, or nil when nothing matched (or the tenant can’t be resolved).

With ‘templates:`, rule evaluation is skipped and exactly those templates are started (several become parallel tracks of one approval); always returns the spawned approval. Pair with `approval_candidates` to let a user choose instead of the engine. `approvals_required` is the gather consensus across those tracks (default `:all`).

Raises:

  • (ArgumentError)


94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'app/models/concerns/approval_engine/approvable.rb', line 94

def run_approval!(event: nil, templates: nil, approvals_required: "all", tenant_id: approval_tenant_id)
  raise ArgumentError, "pass either event: or templates:, not both" if event && templates

  if templates
    ApprovalEngine::ApprovalBuilder.build_parallel!(templates: Array(templates), target: self, approvals_required: approvals_required)
  elsif event
    return if tenant_id.nil?

    ApprovalEngine::RuleEvaluator.call(
      event_name: event,
      tenant_id: tenant_id,
      target: self,
      payload: serialize_for_approval
    )
  else
    raise ArgumentError, "pass either event: or templates:"
  end
end

#serialize_for_approvalObject

The flat, string-keyed payload derived from ‘exposes_for_approval`.



76
77
78
# File 'app/models/concerns/approval_engine/approvable.rb', line 76

def serialize_for_approval
  approval_exposure.serialize(self)
end

#trigger_approval?(_lifecycle = nil) ⇒ Boolean

Host override hook: return false to skip an automatic trigger. Receives the lifecycle (:create or :update), so you can gate per-event — e.g. only auto-route an update when a specific transition happened:

def trigger_approval?(lifecycle)
  lifecycle == :update ? saved_change_to_status? : true
end

Returns:

  • (Boolean)


120
121
122
# File 'app/models/concerns/approval_engine/approvable.rb', line 120

def trigger_approval?(_lifecycle = nil)
  true
end