lite_state 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,300 @@
1
+ # Example 2: E-Commerce Order (Multiple State Columns)
2
+ #
3
+ # This example demonstrates a sophisticated e-commerce order system using
4
+ # multiple independent state machines to manage different aspects of an order:
5
+ # - Order lifecycle (pending -> processing -> completed -> cancelled)
6
+ # - Payment lifecycle (unpaid -> authorized -> paid -> refunded/failed)
7
+ # - Fulfillment lifecycle (unfulfilled -> preparing -> shipped -> delivered -> returned)
8
+ #
9
+ # Key features demonstrated:
10
+ # - Multiple state columns working independently
11
+ # - Guards that check conditions across different state machines
12
+ # - Callbacks that trigger transitions in other state machines
13
+ # - Complex business rules (refund windows, return policies)
14
+ # - Comprehensive notification system
15
+
16
+ class Order < ApplicationRecord
17
+ include LiteState
18
+
19
+ enum :status, {
20
+ pending: "pending",
21
+ processing: "processing",
22
+ completed: "completed",
23
+ cancelled: "cancelled"
24
+ }
25
+
26
+ enum :payment_status, {
27
+ unpaid: "unpaid",
28
+ authorized: "authorized",
29
+ paid: "paid",
30
+ refunded: "refunded",
31
+ failed: "failed"
32
+ }
33
+
34
+ enum :fulfillment_status, {
35
+ unfulfilled: "unfulfilled",
36
+ preparing: "preparing",
37
+ shipped: "shipped",
38
+ delivered: "delivered",
39
+ returned: "returned"
40
+ }
41
+
42
+ # Set default for main order lifecycle
43
+ state_column :status
44
+
45
+ # Order lifecycle transitions
46
+ transition :process,
47
+ from: :pending,
48
+ to: :processing,
49
+ timestamp: :processing_at do
50
+ notify_customer(:order_processing)
51
+ allocate_inventory
52
+ end
53
+
54
+ transition :complete,
55
+ from: :processing,
56
+ to: :completed,
57
+ timestamp: :completed_at,
58
+ guard: -> { payment_status == "paid" && fulfillment_status == "delivered" }
59
+
60
+ transition :cancel,
61
+ from: [:pending, :processing],
62
+ to: :cancelled,
63
+ timestamp: :cancelled_at do
64
+ release_inventory
65
+ notify_customer(:order_cancelled)
66
+ end
67
+
68
+ # Payment lifecycle transitions
69
+ transition :authorize_payment,
70
+ from: :unpaid,
71
+ to: :authorized,
72
+ column: :payment_status,
73
+ timestamp: :payment_authorized_at do
74
+ PaymentProcessor.authorize(self)
75
+ notify_customer(:payment_authorized)
76
+ end
77
+
78
+ transition :capture_payment,
79
+ from: :authorized,
80
+ to: :paid,
81
+ column: :payment_status,
82
+ timestamp: :paid_at do
83
+ PaymentProcessor.capture(self)
84
+ notify_customer(:payment_captured)
85
+ trigger_fulfillment
86
+ end
87
+
88
+ transition :refund_payment,
89
+ from: :paid,
90
+ to: :refunded,
91
+ column: :payment_status,
92
+ timestamp: :refunded_at,
93
+ guard: :can_refund? do
94
+ PaymentProcessor.refund(self)
95
+ notify_customer(:payment_refunded)
96
+ end
97
+
98
+ transition :fail_payment,
99
+ from: [:unpaid, :authorized],
100
+ to: :failed,
101
+ column: :payment_status do
102
+ notify_customer(:payment_failed)
103
+ cancel if pending? || processing?
104
+ end
105
+
106
+ # Fulfillment lifecycle transitions
107
+ transition :prepare_shipment,
108
+ from: :unfulfilled,
109
+ to: :preparing,
110
+ column: :fulfillment_status,
111
+ guard: -> { paid? },
112
+ timestamp: :preparing_at do
113
+ notify_warehouse(:prepare_order, order_id: id)
114
+ end
115
+
116
+ transition :ship_order,
117
+ from: :preparing,
118
+ to: :shipped,
119
+ column: :fulfillment_status,
120
+ timestamp: :shipped_at do
121
+ generate_tracking_number
122
+ notify_customer(:order_shipped, tracking_number:)
123
+ end
124
+
125
+ transition :deliver_order,
126
+ from: :shipped,
127
+ to: :delivered,
128
+ column: :fulfillment_status,
129
+ timestamp: :delivered_at do
130
+ notify_customer(:order_delivered)
131
+ complete if processing?
132
+ end
133
+
134
+ transition :return_order,
135
+ from: [:shipped, :delivered],
136
+ to: :returned,
137
+ column: :fulfillment_status,
138
+ timestamp: :returned_at,
139
+ guard: :can_return? do
140
+ initiate_return_process
141
+ refund_payment if paid?
142
+ end
143
+
144
+ private
145
+
146
+ def can_refund?
147
+ return false if refunded?
148
+ return true if returned?
149
+ paid_at && paid_at >= 90.days.ago
150
+ end
151
+
152
+ def can_return?
153
+ delivered_at && delivered_at >= 30.days.ago
154
+ end
155
+
156
+ def notify_customer(event, **options)
157
+ OrderMailer.public_send(event, self, **options).deliver_later
158
+ end
159
+
160
+ def notify_warehouse(event, **options)
161
+ WarehouseNotification.create!(event:, order: self, metadata: options)
162
+ end
163
+ end
164
+
165
+ # Usage Examples
166
+ # ==============
167
+
168
+ # Example 1: Successful Order Flow
169
+ # ---------------------------------
170
+
171
+ # Create order
172
+ order = Order.create!(
173
+ status: :pending,
174
+ payment_status: :unpaid,
175
+ fulfillment_status: :unfulfilled
176
+ )
177
+
178
+ # Process order
179
+ order.process
180
+ # => status: :processing, processing_at: 2025-01-15 10:00:00 UTC
181
+ # => Allocates inventory, notifies customer
182
+
183
+ # Authorize payment
184
+ order.authorize_payment
185
+ # => payment_status: :authorized, payment_authorized_at: 2025-01-15 10:01:00 UTC
186
+ # => Processes authorization with payment gateway
187
+
188
+ # Capture payment (triggers fulfillment)
189
+ order.capture_payment
190
+ # => payment_status: :paid, paid_at: 2025-01-15 10:05:00 UTC
191
+ # => Captures payment, notifies customer, triggers fulfillment
192
+
193
+ # Prepare for shipping (guard checks payment is captured)
194
+ order.prepare_shipment
195
+ # => fulfillment_status: :preparing, preparing_at: 2025-01-15 11:00:00 UTC
196
+ # => Notifies warehouse to prepare order
197
+
198
+ # Ship order
199
+ order.ship_order
200
+ # => fulfillment_status: :shipped, shipped_at: 2025-01-16 09:00:00 UTC
201
+ # => Generates tracking number, notifies customer
202
+
203
+ # Deliver order
204
+ order.deliver_order
205
+ # => fulfillment_status: :delivered, delivered_at: 2025-01-18 14:30:00 UTC
206
+ # => Also completes the order (status: :completed)
207
+
208
+ # Final state
209
+ order.reload
210
+ order.status # => "completed"
211
+ order.payment_status # => "paid"
212
+ order.fulfillment_status # => "delivered"
213
+
214
+ # Example 2: Payment Failure Flow
215
+ # --------------------------------
216
+
217
+ order = Order.create!(
218
+ status: :pending,
219
+ payment_status: :unpaid,
220
+ fulfillment_status: :unfulfilled
221
+ )
222
+
223
+ order.process
224
+ order.authorize_payment
225
+
226
+ # Payment capture fails
227
+ order.fail_payment
228
+ # => payment_status: :failed
229
+ # => Also cancels the order (status: :cancelled)
230
+ # => Notifies customer of payment failure
231
+
232
+ # Example 3: Customer Return Flow
233
+ # --------------------------------
234
+
235
+ # Order has been delivered
236
+ order.status # => "completed"
237
+ order.payment_status # => "paid"
238
+ order.fulfillment_status # => "delivered"
239
+ order.delivered_at # => 5 days ago
240
+
241
+ # Customer initiates return (within 30 day window)
242
+ order.can_transition?(:return_order) # => true
243
+ order.return_order
244
+ # => fulfillment_status: :returned
245
+ # => Automatically triggers refund_payment
246
+ # => payment_status: :refunded
247
+ # => Initiates return process, notifies customer
248
+
249
+ # Example 4: Order Cancellation
250
+ # ------------------------------
251
+
252
+ order = Order.create!(
253
+ status: :processing,
254
+ payment_status: :authorized,
255
+ fulfillment_status: :unfulfilled
256
+ )
257
+
258
+ order.cancel
259
+ # => status: :cancelled, cancelled_at: 2025-01-15 15:00:00 UTC
260
+ # => Releases inventory, notifies customer
261
+
262
+ # Example 5: Guard Conditions
263
+ # ----------------------------
264
+
265
+ # Cannot prepare shipment without payment
266
+ order = Order.create!(
267
+ status: :processing,
268
+ payment_status: :unpaid,
269
+ fulfillment_status: :unfulfilled
270
+ )
271
+
272
+ order.can_transition?(:prepare_shipment) # => false (payment not captured)
273
+ order.prepare_shipment # => raises LiteState::TransitionError
274
+
275
+ # Cannot complete order without delivery
276
+ order.update!(payment_status: :paid, fulfillment_status: :shipped)
277
+ order.can_transition?(:complete) # => false (not delivered yet)
278
+
279
+ # Cannot refund after 90 days
280
+ order.update!(paid_at: 100.days.ago)
281
+ order.can_transition?(:refund_payment) # => false
282
+ order.refund_payment # => raises LiteState::TransitionError
283
+
284
+ # Example 6: Cross-State Machine Interactions
285
+ # --------------------------------------------
286
+
287
+ # Payment failure triggers order cancellation
288
+ order.fail_payment
289
+ # => payment_status: :failed
290
+ # => Automatically calls cancel if order is pending/processing
291
+
292
+ # Delivery triggers order completion
293
+ order.deliver_order
294
+ # => fulfillment_status: :delivered
295
+ # => Automatically calls complete if order is processing and paid
296
+
297
+ # Return triggers refund
298
+ order.return_order
299
+ # => fulfillment_status: :returned
300
+ # => Automatically calls refund_payment if paid
@@ -0,0 +1,131 @@
1
+ # Example 1: Employee Lifecycle (Single State Column)
2
+ #
3
+ # This example demonstrates a complete employee lifecycle management system
4
+ # using a single state column to track the employee's status through various
5
+ # stages: from creation, through invitation, enrollment, suspension, and
6
+ # potential termination or reactivation.
7
+ #
8
+ # Key features demonstrated:
9
+ # - Single state column (state) managing the entire lifecycle
10
+ # - Timestamp tracking for each transition
11
+ # - Guard conditions for business rules (reactivation eligibility)
12
+ # - Callbacks for notifications and system actions
13
+ # - Complex reactivation logic based on termination date
14
+
15
+ class Employee < ApplicationRecord
16
+ include LiteState
17
+
18
+ state_column :state
19
+
20
+ enum :state, {
21
+ created: "created",
22
+ invited: "invited",
23
+ enrolled: "enrolled",
24
+ suspended: "suspended",
25
+ terminated: "terminated",
26
+ reset_pin: "reset_pin"
27
+ }
28
+
29
+ # Invitation flow
30
+ transition :invite, from: :created, to: :invited, timestamp: :invited_on do
31
+ notify_employee(:employee_invitation)
32
+ end
33
+
34
+ # Suspension
35
+ transition :suspend, from: :enrolled, to: :suspended, timestamp: :suspended_on do
36
+ notify_employee(:employee_suspension)
37
+ disable_access
38
+ end
39
+
40
+ # Termination
41
+ transition :terminate,
42
+ from: [:enrolled, :suspended],
43
+ to: :terminated,
44
+ timestamp: :terminated_on do
45
+ notify_employee(:employee_termination)
46
+ disable_access
47
+ archive_data
48
+ end
49
+
50
+ # Reactivation with business rule
51
+ transition :reactivate,
52
+ from: [:suspended, :terminated],
53
+ to: :enrolled,
54
+ timestamp: :enrolled_on,
55
+ guard: :eligible_for_reactivation? do
56
+ notify_employee(:employee_reactivation)
57
+ restore_access
58
+ end
59
+
60
+ # PIN reset
61
+ transition :reset_pin, from: [:enrolled, :reset_pin], to: :reset_pin do
62
+ pin_code = generate_otp!
63
+ notify_employee(:employee_reset_password, additional_keywords: {temporary_reset_pin: pin_code})
64
+ end
65
+
66
+ private
67
+
68
+ def eligible_for_reactivation?
69
+ return true if suspended?
70
+ return true unless terminated_on
71
+ terminated_on >= 90.days.ago.to_date
72
+ end
73
+
74
+ def notify_employee(message_type, additional_keywords: {})
75
+ Message.create_message(
76
+ to: person,
77
+ message_type:,
78
+ keywords: {
79
+ first_name: person.first_name,
80
+ employer_name: company.name
81
+ }.merge(additional_keywords)
82
+ )
83
+ end
84
+ end
85
+
86
+ # Usage Examples
87
+ # ==============
88
+
89
+ # Create and invite an employee
90
+ employee = Employee.create!(state: :created, person: person, company: company)
91
+ employee.invite
92
+ # => state: :invited, invited_on: 2025-01-15 10:00:00 UTC
93
+ # => Sends invitation notification
94
+
95
+ # Enroll the employee (assumes separate enrollment transition defined)
96
+ employee.enroll
97
+ # => state: :enrolled, enrolled_on: 2025-01-16 09:00:00 UTC
98
+
99
+ # Suspend the employee
100
+ employee.suspend
101
+ # => state: :suspended, suspended_on: 2025-02-01 14:30:00 UTC
102
+ # => Sends suspension notification
103
+ # => Disables system access
104
+
105
+ # Reactivate from suspension (guard passes)
106
+ employee.reactivate
107
+ # => state: :enrolled, enrolled_on: 2025-02-10 08:00:00 UTC
108
+ # => Sends reactivation notification
109
+ # => Restores system access
110
+
111
+ # Terminate the employee
112
+ employee.terminate
113
+ # => state: :terminated, terminated_on: 2025-03-01 17:00:00 UTC
114
+ # => Sends termination notification
115
+ # => Disables system access
116
+ # => Archives employee data
117
+
118
+ # Attempt reactivation after 100 days (guard fails)
119
+ employee.update!(terminated_on: 100.days.ago)
120
+ employee.can_transition?(:reactivate) # => false
121
+ employee.reactivate # => raises LiteState::TransitionError
122
+
123
+ # Reactivation within 90 days (guard passes)
124
+ employee.update!(terminated_on: 30.days.ago)
125
+ employee.can_transition?(:reactivate) # => true
126
+ employee.reactivate # => state: :enrolled
127
+
128
+ # PIN reset flow
129
+ employee.reset_pin
130
+ # => state: :reset_pin
131
+ # => Generates OTP and sends notification with temporary PIN
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiteState
4
+ VERSION = "0.2.0"
5
+ end
data/lib/lite_state.rb ADDED
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lite_state/version"
4
+ require "active_support/concern"
5
+ require "active_support/notifications"
6
+
7
+ # LiteState is a lightweight state machine module for ActiveRecord models.
8
+ # It supports state transitions, optional guards, timestamps, and emits
9
+ # ActiveSupport::Notifications events for each transition outcome.
10
+ #
11
+ # @example Basic usage
12
+ # class Order < ApplicationRecord
13
+ # include LiteState
14
+ #
15
+ # state_column :status
16
+ #
17
+ # enum status: { pending: 0, processing: 1, completed: 2, cancelled: 3 }
18
+ #
19
+ # transition :process, from: :pending, to: :processing, timestamp: true
20
+ # transition :complete, from: :processing, to: :completed, timestamp: :completed_at
21
+ # transition :cancel, from: [:pending, :processing], to: :cancelled
22
+ # end
23
+ #
24
+ # order = Order.create!(status: :pending)
25
+ # order.process # => true, updates status to :processing and processing_at timestamp
26
+ # order.complete # => true, updates status to :completed and completed_at timestamp
27
+ #
28
+ # @example With guards and callbacks
29
+ # class Employee < ApplicationRecord
30
+ # include LiteState
31
+ #
32
+ # state_column :state
33
+ #
34
+ # enum state: { created: 0, invited: 1, enrolled: 2, suspended: 3, terminated: 4 }
35
+ #
36
+ # transition :reactivate,
37
+ # from: [:suspended, :terminated],
38
+ # to: :enrolled,
39
+ # timestamp: :enrolled_on,
40
+ # guard: :eligible_for_reactivation? do
41
+ # notify_employee(:reactivated)
42
+ # clear_suspension_reason
43
+ # end
44
+ #
45
+ # def eligible_for_reactivation?
46
+ # return true if suspended?
47
+ # return true unless terminated_on
48
+ # terminated_on >= 90.days.ago.to_date
49
+ # end
50
+ # end
51
+ #
52
+ module LiteState
53
+ class Error < StandardError; end
54
+
55
+ # Raised when an invalid transition is attempted
56
+ class TransitionError < Error
57
+ attr_reader :record, :to, :from, :event
58
+
59
+ # @param record [ActiveRecord::Base] the record being transitioned
60
+ # @param to [Symbol] the target state
61
+ # @param from [Symbol] the current state
62
+ # @param event [Symbol] the transition event
63
+ def initialize(record:, to:, from:, event:)
64
+ @record = record
65
+ @to = to
66
+ @from = from
67
+ @event = event
68
+ super("Invalid transition: #{record.class} ##{record.id} from #{from.inspect} -> #{to.inspect} on #{event}")
69
+ end
70
+ end
71
+
72
+ extend ActiveSupport::Concern
73
+
74
+ included do
75
+ class_attribute :lite_state_column, instance_writer: false
76
+ class_attribute :lite_state_transitions, instance_writer: false, default: {}
77
+
78
+ # Performs a state transition
79
+ #
80
+ # @param to [Symbol] target state
81
+ # @param allowed_from [Symbol, Array<Symbol>] states allowed to transition from
82
+ # @param event [Symbol] transition event name
83
+ # @param column [Symbol, nil] column to use for this transition (defaults to lite_state_column)
84
+ # @param timestamp_field [Symbol, true, nil] column to update with current time; true will auto-generate "#{to}_at"
85
+ # @param guard [Symbol, Proc, nil] optional guard method or block that must return true
86
+ # @yield optional block to execute after state update
87
+ # @return [Boolean] true if transition succeeds
88
+ # @raise [TransitionError] if transition is invalid or guard fails
89
+ # @raise [ActiveRecord::RecordInvalid] if update! fails
90
+ def transition_state(to:, allowed_from:, event:, column: nil, timestamp_field: nil, guard: nil, &block)
91
+ allowed_from = Array(allowed_from).map(&:to_sym).freeze
92
+
93
+ # Determine which column to use for this transition
94
+ state_column = column || self.class.lite_state_column
95
+
96
+ raise ArgumentError, "No state column specified. Use 'state_column :column_name' or provide 'column:' parameter" unless state_column
97
+
98
+ current_state_value = public_send(state_column)
99
+
100
+ fail_transition!(to:, from: nil, event:, outcome: :invalid) if current_state_value.nil?
101
+
102
+ current_state = current_state_value.to_sym
103
+
104
+ fail_transition!(to:, from: current_state, event:, outcome: :invalid) unless allowed_from.include?(current_state)
105
+
106
+ if guard
107
+ result = guard.is_a?(Symbol) ? send(guard) : instance_exec(&guard)
108
+ fail_transition!(to:, from: current_state, event:, outcome: :invalid) unless result
109
+ end
110
+
111
+ transaction do
112
+ attrs = {state_column => to}
113
+
114
+ # Simple timestamp support: true => "#{to}_at", or custom column
115
+ if timestamp_field
116
+ timestamp_column = (timestamp_field == true) ? "#{to}_at" : timestamp_field
117
+ attrs[timestamp_column] = Time.current
118
+ end
119
+
120
+ update!(attrs)
121
+
122
+ # Ensure block exceptions are tracked as failures
123
+ begin
124
+ instance_exec(&block) if block
125
+ rescue => e
126
+ publish_state_event(outcome: :failed, to:, from: current_state, event:)
127
+ raise e
128
+ end
129
+
130
+ publish_state_event(outcome: :success, to:, from: current_state, event:)
131
+ end
132
+
133
+ true
134
+ rescue ActiveRecord::RecordInvalid => e
135
+ publish_state_event(outcome: :failed, to:, from: current_state, event:)
136
+ raise e
137
+ end
138
+
139
+ # Checks if the record can perform a given transition
140
+ #
141
+ # @param name [Symbol] transition name
142
+ # @return [Boolean] true if allowed and guard passes
143
+ def can_transition?(name)
144
+ transition = self.class.lite_state_transitions[name.to_sym]
145
+ return false unless transition
146
+
147
+ allowed_from = Array(transition[:from]).map(&:to_sym)
148
+
149
+ # Use the column specified in the transition, or fall back to default
150
+ state_column = transition[:column] || self.class.lite_state_column
151
+ return false unless state_column
152
+
153
+ current_state_value = public_send(state_column)
154
+ return false if current_state_value.nil?
155
+
156
+ current_state = current_state_value.to_sym
157
+ guard = transition[:guard]
158
+
159
+ allowed_from.include?(current_state) &&
160
+ (guard.nil? || (guard.is_a?(Symbol) ? send(guard) : instance_exec(&guard)))
161
+ end
162
+
163
+ private
164
+
165
+ # Publishes a failed transition and raises TransitionError
166
+ #
167
+ # @param to [Symbol] target state
168
+ # @param from [Symbol] current state
169
+ # @param event [Symbol] transition event
170
+ # @param outcome [Symbol] event outcome (:invalid, :failed)
171
+ # @raise [TransitionError]
172
+ def fail_transition!(to:, from:, event:, outcome:)
173
+ publish_state_event(outcome:, to:, from:, event:)
174
+ raise TransitionError.new(record: self, to:, from:, event:)
175
+ end
176
+
177
+ # Publishes an ActiveSupport::Notifications event
178
+ #
179
+ # @param outcome [Symbol] :success, :failed, :invalid
180
+ # @param to [Symbol] target state
181
+ # @param from [Symbol] current state
182
+ # @param event [Symbol] transition event
183
+ def publish_state_event(outcome:, to:, from:, event:)
184
+ event_name = [
185
+ self.class.name.underscore,
186
+ event.to_s.underscore,
187
+ outcome.to_s.underscore
188
+ ].join(".")
189
+
190
+ ActiveSupport::Notifications.instrument(event_name, {
191
+ record: self,
192
+ record_id: id,
193
+ from_state: from,
194
+ to_state: to,
195
+ event:,
196
+ timestamp: Time.current
197
+ })
198
+ end
199
+ end
200
+
201
+ class_methods do
202
+ # Sets the column used for state
203
+ #
204
+ # @param column_name [Symbol] the database column name for state storage
205
+ # @example
206
+ # state_column :status
207
+ # state_column :state
208
+ def state_column(column_name)
209
+ self.lite_state_column = column_name
210
+ end
211
+
212
+ # Validates that a state exists in the enum definition
213
+ #
214
+ # @param state [Symbol] the state to validate
215
+ # @param column [Symbol] the column to validate against
216
+ # @raise [ArgumentError] if state is not defined in the enum
217
+ # @api private
218
+ def validate_state_exists!(state, column:)
219
+ return unless column
220
+
221
+ enum_accessor = column.to_s.pluralize
222
+ return unless respond_to?(enum_accessor)
223
+
224
+ valid_states = public_send(enum_accessor).keys.map(&:to_sym)
225
+ unless valid_states.include?(state.to_sym)
226
+ raise ArgumentError, "Invalid state :#{state} for #{column}. Valid states: #{valid_states.join(", ")}"
227
+ end
228
+ end
229
+
230
+ # Defines a transition method
231
+ #
232
+ # @param name [Symbol] method name for the transition
233
+ # @param to [Symbol] target state
234
+ # @param from [Symbol, Array<Symbol>] allowed source states
235
+ # @param column [Symbol, nil] column to use for this transition (defaults to lite_state_column)
236
+ # @param timestamp [Symbol, true, nil] column to update with current time
237
+ # @param guard [Symbol, Proc, nil] optional guard method/block
238
+ # @yield block executed after state update but within the transaction
239
+ # @example Simple transition
240
+ # transition :activate, from: :pending, to: :active
241
+ # @example With timestamp
242
+ # transition :complete, from: :active, to: :completed, timestamp: true
243
+ # @example With multiple columns
244
+ # transition :pay, from: :unpaid, to: :paid, column: :payment_status
245
+ # @example With guard and callback
246
+ # transition :reactivate, from: :suspended, to: :active, guard: :can_reactivate? do
247
+ # send_notification
248
+ # end
249
+ def transition(name, to:, from:, column: nil, timestamp: nil, guard: nil, &block)
250
+ # Determine which column to validate against
251
+ state_column = column || lite_state_column
252
+
253
+ raise ArgumentError, "No state column specified. Use 'state_column :column_name' or provide 'column:' parameter" unless state_column
254
+
255
+ # Validate that target and source states exist in the enum
256
+ validate_state_exists!(to, column: state_column)
257
+ Array(from).each { |state| validate_state_exists!(state, column: state_column) }
258
+
259
+ self.lite_state_transitions = lite_state_transitions.merge(
260
+ name.to_sym => {to:, from:, column:, timestamp:, guard:, block:}.freeze
261
+ ).freeze
262
+
263
+ define_method(name) do
264
+ transition_state(
265
+ to:,
266
+ allowed_from: from,
267
+ event: name,
268
+ column:,
269
+ timestamp_field: timestamp,
270
+ guard:,
271
+ &block
272
+ )
273
+ end
274
+ end
275
+ end
276
+ end