ledger_accountable 0.0.10.pre → 0.1.4.pre

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5185deffdccca1762bd55e3bf11b7ec79b9201607fa2d6bc8a4602283b39f9bf
4
- data.tar.gz: 2fa716533f24eca0310aeafb8fb8aac0a569c06073e26f71580d1db5139c7940
3
+ metadata.gz: f94845e66b1f81f8b7897908a9c88fe5c12d63d91882045e721ade66851dca5b
4
+ data.tar.gz: 32851e373387e7da3ad970b810cd4b445f9a033e72bf77883698939e1c2caeea
5
5
  SHA512:
6
- metadata.gz: 60cd04d24b22c787305f3f323b028633120acb7aeae914621c2b6485caa31613c48ae64f2e700e18e717407a277a2cf3d1e4afd1478a2c7a4f775e479787715e
7
- data.tar.gz: 89952404a101ca76e8bf6563ea8a41eb22df5fb30967f9a2924b4865ec9d6d328b9f14dad6d85179991e404b6518395584de3a6c6a848cd95cdff93d64a3ea0b
6
+ metadata.gz: c652558bbafd396ed31cf47c2f354c16d986b9f6a087f09c8601e7d93e12772feb850f103b2e265abf51184bba80e5c6af19089f71b431ea438cea1054883da2
7
+ data.tar.gz: 2b9395f031cddb37a6392608f77c038382b8f6d45d625edebec41fb7248302a7d5d2ef1a6b8919b5bc58ea3708fcdda6111337fa9f0596b734bacaa445f4abf0
data/README.md CHANGED
@@ -26,17 +26,37 @@ $ rails generate ledger_accountable
26
26
 
27
27
  ## Usage
28
28
 
29
- Include `LedgerAccountable` in any model to enable ledger accounting functionality.
29
+ ### `LedgerAccountable::LedgerOwner`
30
+
31
+ Include **`LedgerAccountable::LedgerOwner`** in any model which maintains a ledger - that is, a model whose instances have a sum balance of debits and credits based on the values of Ledger Item associations.
30
32
 
31
33
  ```ruby
32
- class Order < ApplicationRecord
33
- has_many :ledger_entries, as: :owner
34
+ class Order < ActiveRecord::Base
35
+ include LedgerAccountable::LedgerOwner
36
+
34
37
  has_many :order_items, dependent: :destroy
35
38
  has_many :payments, dependent: :destroy
36
39
  end
40
+ ```
41
+
42
+ This provides methods for tracking the ledger:
43
+
44
+ - **`balance`** - Net sum of all ledger entries
45
+ - **`credit_total`** - Sum of all credit entries (e.g., payments)
46
+ - **`debit_total`** - Sum of all debit entries (e.g., charges)
47
+
48
+ ### `LedgerAccountable::LedgerItem`
37
49
 
38
- class OrderItem < ApplicationRecord
39
- include LedgerAccountable
50
+ Include `LedgerAccountable::LedgerItem` in any associated model of a `LedgerAccountable::LedgerOwner` to trigger ledger entries when:
51
+
52
+ - Instances of that model are associated to the Ledger Owner
53
+ - Instances of that model are unassociated from the Ledger Owner
54
+ - Associated instances of that model are changed
55
+ - Associated instances of that model are destroyed
56
+
57
+ ```ruby
58
+ class OrderItem < ActiveRecord::Base
59
+ include LedgerAccountable::LedgerItem
40
60
 
41
61
  belongs_to :order
42
62
 
@@ -46,6 +66,7 @@ class OrderItem < ApplicationRecord
46
66
  amount: :cost,
47
67
  net_amount: :net_cost_change,
48
68
  type: :debit
69
+
49
70
  def cost
50
71
  quantity * unit_price
51
72
  end
@@ -57,13 +78,40 @@ class OrderItem < ApplicationRecord
57
78
  end
58
79
  end
59
80
 
60
-
61
- class Payment < ApplicationRecord
62
- include LedgerAccountable
81
+ class Payment < ActiveRecord::Base
82
+ include LedgerAccountable::LedgerItem
63
83
 
64
84
  belongs_to :order
65
85
 
66
86
  # Track ledger changes on order with the amount attribute and mark it as a credit
67
87
  track_ledger :order, amount: :amount, type: :credit
68
88
  end
89
+
90
+ class Refund < ActiveRecord::Base
91
+ include LedgerAccountable::LedgerItem
92
+
93
+ belongs_to :order
94
+
95
+ # Track ledger changes on order with the amount attribute and mark it as a debit
96
+ track_ledger :order, amount: :amount, type: :debit
97
+ end
69
98
  ```
99
+
100
+ The track_ledger method accepts the following options:
101
+
102
+ - **`amount`**: Method or attribute that determines the ledger amount (required)
103
+ - **`type`**: :debit or :credit (defaults to :credit)
104
+ - **`net_amount`**: Method to calculate amount changes for updates (optional)
105
+ - **`ledger_attributes`**: Additional attributes that should trigger ledger entries when changed (optional)
106
+
107
+ ### Ledger Entry Types
108
+
109
+ Ledger entries are automatically created in the following scenarios:
110
+
111
+ - **`:addition`** - When a new item is added to a ledger
112
+ - **`:deletion`** - When an item is removed from a ledger
113
+ - **`:modification`** - When an item's amount changes
114
+
115
+ When a LedgerItem changes owners, both a `deletion` entry for the old owner and an `addition` entry for the new owner are created in the same transaction
116
+
117
+ <!-- TODO: documentation for alternate object destruction libraries: callbacks to trigger ledger removal for objects that aren't destroyed -->
@@ -16,6 +16,7 @@ class LedgerEntry < ActiveRecord::Base
16
16
 
17
17
  scope :debits, -> { where(transaction_type: :debit) }
18
18
  scope :credits, -> { where(transaction_type: :credit) }
19
+ scope :with_ledger_item_type, -> (type) { where(ledger_item_type: type) }
19
20
 
20
21
  def to_itemized_s(line_type = :line)
21
22
  I18n.t!("#{TRANSLATION_PREFIX}.#{ledger_item_type.constantize.model_name.param_key}.#{line_type}",
@@ -2,7 +2,7 @@ class CreateLedgerEntries < ActiveRecord::Migration[6.1]
2
2
  def change
3
3
  create_table :ledger_entries do |t|
4
4
  t.references :owner, polymorphic: true, null: false
5
- t.references :ledger_item, polymorphic: true, null: false
5
+ t.references :ledger_item, polymorphic: true, type: :string, null: false
6
6
  t.integer :transaction_type, null: false
7
7
  t.integer :entry_type, null: false
8
8
  t.integer :amount_cents, default: 0, null: false
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LedgerAccountable
4
+ module LedgerItem
5
+ # Creates a single LedgerEntry.
6
+ class EntryCreator
7
+ def self.create(item:, owner:, amount:, type:, entry_type:, metadata: {})
8
+ LedgerEntry.create!(
9
+ # create! will raise if the ledger entry fails to be created,
10
+ # which will rollback the attempt to save the LedgerAccountable object
11
+ owner: owner,
12
+ ledger_item: item,
13
+ transaction_type: type,
14
+ entry_type: entry_type,
15
+ amount_cents: amount,
16
+ metadata: metadata
17
+ )
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LedgerAccountable
4
+ module LedgerItem
5
+ # Manages ledger entry creation during model lifecycle changes.
6
+ # Determines which entries are needed (addition, deletion, modification) and
7
+ # ensures all entries are created within the same transaction as the model changes.
8
+ #
9
+ # Simplified control flow:
10
+ # Am I already in a ledger?
11
+ # YES
12
+ # am I still in that ledger?
13
+ # YES
14
+ # has my amount changed?
15
+ # YES
16
+ # => create entry with the net change
17
+ # NO
18
+ # => no update unless overridden
19
+ # NO
20
+ # => create a deletion ledger entry
21
+ # do I have a new ledger?
22
+ # YES
23
+ # => create a new ledger entry
24
+ # NO
25
+ # => create a new ledger entry
26
+ class StateTransition
27
+ def self.execute(item, &block)
28
+ new(item).execute(&block)
29
+ end
30
+
31
+ def initialize(item)
32
+ @item = item
33
+ end
34
+
35
+ def execute(&block)
36
+ return yield unless should_record_entry?
37
+
38
+ # Compute all values that depend on object state BEFORE the save
39
+ ledger_entries_to_create = determine_required_entries
40
+ metadata = @item.build_ledger_metadata
41
+
42
+ ActiveRecord::Base.transaction do
43
+ with_required_save(&block)
44
+ # Create all necessary entries after the save
45
+ ledger_entries_to_create.each do |entry_params|
46
+ record_entry(
47
+ owner: entry_params[:owner],
48
+ amount: entry_params[:amount],
49
+ entry_type: entry_params[:entry_type],
50
+ metadata: metadata
51
+ )
52
+ end
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def with_required_save
59
+ # attempt to save or destroy the object
60
+ commit_successful = yield
61
+
62
+ # Proceed only if the save or destroy operation was successful
63
+ raise ActiveRecord::Rollback if !commit_successful && LedgerItem.require_successful_entries
64
+ end
65
+
66
+ def should_record_entry?
67
+ return false unless @item.should_persist_in_ledger?
68
+
69
+ if @item.to_be_destroyed?
70
+ true
71
+ else
72
+ should_record_addition? || should_record_update? || should_record_removal?
73
+ end
74
+ end
75
+
76
+ def determine_required_entries
77
+ entries = []
78
+
79
+ if @item.to_be_destroyed?
80
+ entries << build_destruction_entry
81
+ else
82
+ entries << build_removal_entry if should_record_removal?
83
+ entries << build_addition_entry if should_record_addition?
84
+ entries << build_modification_entry if should_record_update?
85
+ end
86
+
87
+ entries
88
+ end
89
+
90
+ def build_destruction_entry
91
+ {
92
+ type: @item.transaction_type,
93
+ entry_type: :deletion,
94
+ owner: @item.current_owner,
95
+ amount: -@item.ledger_amount
96
+ }
97
+ end
98
+
99
+ def build_removal_entry
100
+ {
101
+ type: @item.transaction_type,
102
+ entry_type: :deletion,
103
+ owner: @item.previous_owner,
104
+ amount: -@item.ledger_amount
105
+ }
106
+ end
107
+
108
+ def build_addition_entry
109
+ {
110
+ type: @item.transaction_type,
111
+ entry_type: :addition,
112
+ owner: @item.current_owner,
113
+ amount: @item.ledger_amount
114
+ }
115
+ end
116
+
117
+ def build_modification_entry
118
+ {
119
+ type: @item.transaction_type,
120
+ entry_type: :modification,
121
+ owner: @item.current_owner,
122
+ amount: @item.net_ledger_amount
123
+ }
124
+ end
125
+
126
+ def record_entry(owner:, amount:, entry_type:, metadata:)
127
+ EntryCreator.create(
128
+ item: @item,
129
+ owner: owner,
130
+ amount: amount,
131
+ type: @item.transaction_type,
132
+ entry_type: entry_type,
133
+ metadata: metadata
134
+ )
135
+ rescue ActiveRecord::RecordInvalid => e
136
+ raise e if LedgerItem.require_successful_entries
137
+ end
138
+
139
+ def determine_owner
140
+ case determine_entry_type
141
+ when :deletion
142
+ if @item.to_be_destroyed?
143
+ # the item is being destroyed, so use its current owner
144
+ @item.current_owner
145
+ else
146
+ # the item is removed from the ledger, so use its previous owner
147
+ @item.previous_owner
148
+ end
149
+ else
150
+ @item.current_owner
151
+ end
152
+ end
153
+
154
+ def calculate_amount
155
+ case determine_entry_type
156
+ when :modification
157
+ @item.net_ledger_amount
158
+ when :deletion
159
+ if @item.current_owner.present?
160
+ -@item.ledger_amount
161
+ else
162
+ @item.net_ledger_amount
163
+ end
164
+ else
165
+ @item.ledger_amount
166
+ end
167
+ end
168
+
169
+ def determine_entry_type
170
+ if @item.to_be_destroyed?
171
+ :deletion
172
+ elsif should_record_removal?
173
+ :deletion
174
+ elsif should_record_addition?
175
+ :addition
176
+ else
177
+ :modification
178
+ end
179
+ end
180
+
181
+ def should_record_addition?
182
+ return false unless @item.current_owner.present?
183
+
184
+ !@item.entries_for_current_owner? || @item.last_entry_was?('deletion')
185
+ end
186
+
187
+ def should_record_removal?
188
+ if @item.current_owner.nil?
189
+ @item.entries_for_previous_owner? && !@item.last_entry_was?('deletion')
190
+ elsif @item.previous_owner.present? && @item.current_owner.present?
191
+ @item.entries_for_previous_owner? &&
192
+ @item.ledger_entries.where(owner: @item.previous_owner).last&.entry_type != 'deletion'
193
+ else
194
+ false
195
+ end
196
+ end
197
+
198
+ def should_record_update?
199
+ return false unless @item.should_persist_in_ledger?
200
+
201
+ if @item.entries_for_current_owner?
202
+ @item.ledger_attributes_changed? || @item.would_change_ledger_balance?
203
+ else
204
+ false
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ # LedgerAccountable::LedgerItem adds ledger functionality to any model that acts as an item in a ledger.
4
+ #
5
+ # It supports tracking two types of ledger entries:
6
+ # 1. Debits: outgoing items which decrease the ledger balance (for example, an item being sold)
7
+ # 2. Credits: incoming items which increase the ledger balance (for example, a payment taken)
8
+ #
9
+ # Usage:
10
+ # Include LedgerAccountable in a model to have it generate ledger entries based on its
11
+ # addition/deletion/update events relative to its ledger owner.
12
+ #
13
+ # Specify `ledger_owner`, the owner of the ledger entries, and optionally `ledger_attributes`
14
+ # to track specific attributes changes.
15
+ #
16
+ # Example:
17
+ # ```ruby
18
+ # class Order < ApplicationRecord
19
+ # has_many :ledger_entries, as: :owner
20
+ # has_many :order_items, dependent: :destroy
21
+ # has_many :payments, dependent: :destroy
22
+ # end
23
+ #
24
+ # class OrderItem < ApplicationRecord
25
+ # include LedgerAccountable::LedgerItem
26
+ #
27
+ # belongs_to :order
28
+ #
29
+ # # Track ledger changes on order with the cost method and provide a net_amount
30
+ # # to dynamically compute net changes to its cost
31
+ # track_ledger :order,
32
+ # amount: :cost,
33
+ # net_amount: :net_cost_change,
34
+ # type: :debit
35
+ # def cost
36
+ # quantity * unit_price
37
+ # end
38
+ #
39
+ # private
40
+ #
41
+ # def net_cost_change
42
+ # cost - (quantity_was * unit_price_was)
43
+ # end
44
+ # end
45
+ #
46
+ #
47
+ # class Payment < ApplicationRecord
48
+ # include LedgerAccountable::LedgerItem
49
+ #
50
+ # belongs_to :order
51
+ #
52
+ # # Track ledger changes on order with the amount attribute and mark it as a credit
53
+ # track_ledger :order, amount: :amount, type: :credit
54
+ # end
55
+ # ```
56
+ #
57
+ # Note:
58
+ # - The `ledger_owner` association must exist in the LedgerAccountable::LedgerItem model.
59
+ #
60
+ module LedgerAccountable
61
+ module LedgerItem
62
+ autoload :EntryCreator, 'ledger_accountable/ledger_item/entry_creator'
63
+ autoload :StateTransition, 'ledger_accountable/ledger_item/state_transition'
64
+
65
+ extend ActiveSupport::Concern
66
+
67
+ class InitializationError < StandardError
68
+ end
69
+
70
+ class InvalidLedgerOwnerError < InitializationError
71
+ end
72
+
73
+ # Toggle for whether ledger entry creation should be required for updates to
74
+ # LedgerAccountable objects.
75
+ # If set to `true`, failed ledger entries will roll back all changes.
76
+ # Defaults to all non-production environments.
77
+ mattr_accessor :require_successful_entries
78
+ @@require_successful_entries = !Rails.env.production?
79
+
80
+ # The time at which ledger entries began.
81
+ # Defaults to Unix Epoch (1970-01-01 00:00:00 +0000)
82
+ mattr_accessor :epoch
83
+ @@epoch = Time.at(0)
84
+
85
+ included do
86
+ has_many :ledger_entries, as: :ledger_item
87
+
88
+ # around_[action] are used below to ensure that these callbacks run after model callbacks
89
+ # which may modify object values in a commit lifecycle
90
+ around_create :process_ledger_transition
91
+ around_update :process_ledger_transition
92
+ around_destroy :process_ledger_transition
93
+ # the owner of the ledger entries
94
+ class_attribute :ledger_owner
95
+ # the name of the attribute or method which determines the ledger amount
96
+ class_attribute :ledger_amount_attribute
97
+ # the name of the attribute or method which determines the ledger amount
98
+ class_attribute :ledger_net_amount_method
99
+ # the type of ledger entry to create - debit or credit
100
+ class_attribute :transaction_type
101
+ # attributes of the LedgerAccountable that should trigger a ledger entry when changed
102
+ class_attribute :ledger_attributes
103
+ end
104
+
105
+ class_methods do
106
+ # registers the model as a ledger item for ledger_owner,
107
+ # to be updated when the provided attributes (or ledger owner) are changed
108
+ def track_ledger(ledger_owner, options = {})
109
+ validate_and_assign_ledger_owner(ledger_owner)
110
+ validate_and_assign_transaction_type(options)
111
+ validate_and_assign_ledger_amount_attribute(options)
112
+ validate_net_amount_method(options)
113
+ validate_and_assign_ledger_attributes(options)
114
+ end
115
+
116
+ def validate_and_assign_ledger_owner(ledger_owner)
117
+ # verify that an instance of the LedgerAccountable model can respond to ledger_owner
118
+ unless instance_methods.include?(ledger_owner)
119
+ raise InvalidLedgerOwnerError, "LedgerAccountable::LedgerItem model #{model_name} must respond to the provided value for ledger_owner (#{ledger_owner})."
120
+ end
121
+
122
+ self.ledger_owner = ledger_owner
123
+ end
124
+
125
+ def validate_and_assign_transaction_type(options)
126
+ if options[:type].present?
127
+ raise 'LedgerAccountable type must be :debit or :credit' unless %i[debit credit].include?(options[:type])
128
+
129
+ self.transaction_type = options[:type]
130
+ else
131
+ self.transaction_type = :credit
132
+ end
133
+ end
134
+
135
+ def validate_and_assign_ledger_amount_attribute(options)
136
+ raise "track_ledger :amount is required in #{model_name}" unless options[:amount].present?
137
+ raise 'track_ledger :amount must be a symbol' unless options[:amount].is_a?(Symbol)
138
+
139
+ self.ledger_amount_attribute = options[:amount]
140
+ end
141
+
142
+ def validate_net_amount_method(options)
143
+ return unless options[:net_amount].present?
144
+ raise 'track_ledger :net_amount must be a symbol' unless options[:net_amount].is_a?(Symbol)
145
+
146
+ self.ledger_net_amount_method = options[:net_amount]
147
+ end
148
+
149
+ def validate_and_assign_ledger_attributes(options)
150
+ # verify that provided ledger attributes are correctly formatted
151
+ if options[:ledger_attributes].present? && options[:ledger_attributes].any? { |attr| !attr.is_a?(Symbol) }
152
+ raise "LedgerAccountable attributes must be symbols. Did you mean #{options[:ledger_attributes].map do |attr|
153
+ ":#{attr}"
154
+ end.join(', ')}?"
155
+ end
156
+
157
+ self.ledger_attributes = options[:ledger_attributes]
158
+ end
159
+ end
160
+
161
+ # the amount to be recorded in the ledger entry on creation or deletion; typically the full amount on the
162
+ # LedgerAccountable object
163
+ def ledger_amount
164
+ unless respond_to?(self.class.ledger_amount_attribute)
165
+ raise NotImplementedError,
166
+ "LedgerAccountable::LedgerItem model '#{model_name}' specified #{self.class.ledger_amount_attribute} for track_ledger :amount, but does not implement #{self.class.ledger_amount_attribute}"
167
+ end
168
+
169
+ ledger_amount_multiplier * (send(self.class.ledger_amount_attribute) || 0)
170
+ end
171
+
172
+ # the amount to be recorded in the ledger entry on update; typically a net change to the dollar amount
173
+ # stored on the LedgerAccountable object
174
+ def net_ledger_amount
175
+ if self.class.ledger_net_amount_method
176
+ net_amount_result = send(self.class.ledger_net_amount_method)
177
+ ledger_amount_multiplier * net_amount_result
178
+ else
179
+ unless attribute_method?(self.class.ledger_amount_attribute.to_s)
180
+ # if a method is provided to compute ledger_amount,
181
+ logger.warn "
182
+ LedgerAccountable::LedgerItem model '#{model_name}' appears to use a method for track_ledger :amount, \
183
+ but did not provide an option for :net_amount. This can lead to unexpected ledger entry amounts when modifying #{model_name}.
184
+ "
185
+ end
186
+
187
+ previous_ledger_amount = ledger_amount_multiplier * attribute_was(self.class.ledger_amount_attribute)
188
+ # p 'prev:', previous_ledger_amount, 'cur:', ledger_amount
189
+ ledger_amount - (previous_ledger_amount || 0)
190
+ end
191
+ end
192
+
193
+ def ledger_attributes_changed?
194
+ # if no ledger attributes are specified, only trigger if the balance changed
195
+ return false if ledger_attributes.blank?
196
+
197
+ changed_attributes.keys.any? { |key| ledger_attributes.include?(key.to_sym) }
198
+ end
199
+
200
+ def would_change_ledger_balance?
201
+ net_ledger_amount != 0
202
+ end
203
+
204
+ def current_owner
205
+ send(self.class.ledger_owner)
206
+ end
207
+
208
+ def previous_owner
209
+ if current_owner.nil?
210
+ # if the owner was removed, get the owner of its last ledger entry
211
+ ledger_entries.reload.last&.owner
212
+ else
213
+ # otherwise get the owner from the last entry NOT for its current owner
214
+ ledger_entries.reload.where.not(owner: current_owner).last&.owner
215
+ end
216
+ end
217
+
218
+ def build_ledger_metadata
219
+ # can be overridden to return a hash of metadata values
220
+ {}
221
+ end
222
+
223
+ def last_entry_was?(entry_type)
224
+ if current_owner.present?
225
+ ledger_entries.reload.where(owner: current_owner).last&.entry_type == entry_type
226
+ elsif previous_owner.present?
227
+ ledger_entries.reload.where(owner: previous_owner).last&.entry_type == entry_type
228
+ else
229
+ false
230
+ end
231
+ end
232
+
233
+ # An overrideable method to determine if the object should be persisted in the ledger
234
+ #
235
+ # For example, payments should not be recorded in a ledger until they're finalized
236
+ # by default, a LedgerAccountable should persist in a ledger if has a ledger_owner via
237
+ # track_ledger
238
+ def should_persist_in_ledger?
239
+ # warning log if the ledger owner is not set - the LedgerAccountable model must
240
+ # include track_ledger
241
+ if self.class.ledger_owner.blank?
242
+ Rails.logger.warn "LedgerAccountable::LedgerItem model #{model_name} must include track_ledger to use ledger functionality"
243
+ end
244
+
245
+ self.class.ledger_owner.present? && has_current_or_previous_ledger?
246
+ end
247
+
248
+ def entries_for_current_owner?
249
+ ledger_entries.reload.where(owner: current_owner).any?
250
+ end
251
+
252
+ def entries_for_previous_owner?
253
+ ledger_entries.reload.where(owner: previous_owner).any?
254
+ end
255
+
256
+ def to_be_destroyed?
257
+ @_destroy_callback_already_called ||= false
258
+ end
259
+
260
+ private
261
+
262
+ def process_ledger_transition(&block)
263
+ StateTransition.execute(self, &block)
264
+ end
265
+
266
+ def ledger_amount_multiplier
267
+ self.class.transaction_type == :credit ? 1 : -1
268
+ end
269
+
270
+ def has_current_or_previous_ledger?
271
+ previous_owner.present? || current_owner.present?
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LedgerAccountable
4
+ #
5
+ # LedgerAccountable::LedgerOwner is intended to be included in models
6
+ # which act as the owner of a ledger.
7
+ #
8
+ module LedgerOwner
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ has_many :ledger_entries, as: :owner
13
+ end
14
+
15
+ def balance
16
+ ledger_entries.sum(:amount_cents)
17
+ end
18
+
19
+ # The absolute value of the sum of the LedgerOwner's credit entries.
20
+ # This can be used to determine the total amount credited to the ledger -
21
+ # for example, its total revenue.
22
+ def credit_total
23
+ ledger_entries.credits.sum(:amount_cents).abs
24
+ end
25
+
26
+ # The absolute value of the sum of the LedgerOwner's debit entries.
27
+ # This can be used to determine the total amount owed to the ledger -
28
+ # for example, the aggregate value of the items sold.
29
+ def debit_total
30
+ ledger_entries.debits.sum(:amount_cents).abs
31
+ end
32
+ end
33
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LedgerAccountable
4
- VERSION = "0.0.10.pre".freeze
4
+ VERSION = '0.1.4.pre'
5
5
  end
@@ -1,387 +1,10 @@
1
- # LedgerAccountable adds ledger functionality to any model that acts as an item in a ledger.
2
- #
3
- # It supports tracking two types of ledger entries:
4
- # 1. Debits: outgoing items which decrease the ledger balance (for example, an item being sold)
5
- # 2. Credits: incoming items which increase the ledger balance (for example, a payment taken)
6
- #
7
- # Usage:
8
- # Include LedgerAccountable in a model to have it generate ledger entries based on its
9
- # addition/deletion/update events relative to its ledger owner.
10
- #
11
- # Specify `ledger_owner`, the owner of the ledger entries, and optionally `ledger_attributes`
12
- # to track specific attributes changes.
13
- #
14
- # Example:
15
- # ```ruby
16
- # class Order < ApplicationRecord
17
- # has_many :ledger_entries, as: :owner
18
- # has_many :order_items, dependent: :destroy
19
- # has_many :payments, dependent: :destroy
20
- # end
21
- #
22
- # class OrderItem < ApplicationRecord
23
- # include LedgerAccountable
24
- #
25
- # belongs_to :order
26
- #
27
- # # Track ledger changes on order with the cost method and provide a net_amount
28
- # # to dynamically compute net changes to its cost
29
- # track_ledger :order,
30
- # amount: :cost,
31
- # net_amount: :net_cost_change,
32
- # type: :debit
33
- # def cost
34
- # quantity * unit_price
35
- # end
36
- #
37
- # private
38
- #
39
- # def net_cost_change
40
- # cost - (quantity_was * unit_price_was)
41
- # end
42
- # end
43
- #
44
- #
45
- # class Payment < ApplicationRecord
46
- # include LedgerAccountable
47
- #
48
- # belongs_to :order
49
- #
50
- # # Track ledger changes on order with the amount attribute and mark it as a credit
51
- # track_ledger :order, amount: :amount, type: :credit
52
- # end
53
- # ```
54
- #
55
- # Note:
56
- # - The `ledger_owner` association must exist in the LedgerAccountable model.
57
- #
58
1
  module LedgerAccountable
59
- extend ActiveSupport::Concern
2
+ autoload :LedgerItem, 'ledger_accountable/ledger_item'
3
+ autoload :LedgerOwner, 'ledger_accountable/ledger_owner'
60
4
 
61
- # Toggle for whether ledger entry creation should be required for updates to
62
- # LedgerAccountable objects.
63
- # If set to `true`, failed ledger entries will roll back all changes.
64
- # Defaults to all non-production environments.
65
- mattr_accessor :require_successful_entries
66
- @@require_successful_entries = !Rails.env.production?
67
-
68
- # The time at which ledger entries began.
69
- # Defaults to Unix Epoch (1970-01-01 00:00:00 +0000)
70
- mattr_accessor :epoch
71
- @@epoch = Time.at(0)
72
-
73
- included do
74
- has_many :ledger_entries, as: :ledger_item
75
-
76
- # around_[action] are used below to ensure that these callbacks run after model callbacks
77
- # which may modify object values in a commit lifecycle
78
- around_create :record_ledger_addition, if: :should_record_ledger_addition? # record a ledger entry when the LedgerAccountable is created or updated
79
- around_update :record_ledger_addition, if: :should_record_ledger_addition? # record a ledger entry when the LedgerAccountable is created or updated
80
- around_update :record_ledger_removal, if: :should_record_ledger_removal? # record a ledger entry when the LedgerAccountable is created or updated
81
- around_update :record_ledger_update, if: :should_record_ledger_update? # record a ledger entry when the LedgerAccountable is updated
82
- around_destroy :record_ledger_destruction, if: :should_persist_in_ledger? # record a ledger entry when the LedgerAccountable is destroyed
83
- # the owner of the ledger entries
84
- class_attribute :ledger_owner
85
- # the name of the attribute or method which determines the ledger amount
86
- class_attribute :ledger_amount_attribute
87
- # the name of the attribute or method which determines the ledger amount
88
- class_attribute :ledger_net_amount_method
89
- # the type of ledger entry to create - debit or credit
90
- class_attribute :transaction_type
91
- # attributes of the LedgerAccountable that should trigger a ledger entry when changed
92
- class_attribute :ledger_attributes
93
- end
94
-
95
- class_methods do
96
- # registers the model as a ledger item for ledger_owner,
97
- # to be updated when the provided attributes (or ledger owner) are changed
98
- def track_ledger(ledger_owner, options = {})
99
- validate_and_assign_ledger_owner(ledger_owner)
100
- validate_and_assign_transaction_type(options)
101
- validate_and_assign_ledger_amount_attribute(options)
102
- validate_net_amount_method(options)
103
- validate_and_assign_ledger_attributes(options)
104
- end
105
-
106
- def validate_and_assign_ledger_owner(ledger_owner)
107
- # verify that an instance of the LedgerAccountable model can respond to ledger_owner
108
- unless instance_methods.include?(ledger_owner)
109
- raise "LedgerAccountable model #{model_name} must respond to the provided value for ledger_owner (#{ledger_owner})."
110
- end
111
-
112
- self.ledger_owner = ledger_owner
113
- end
114
-
115
- def validate_and_assign_transaction_type(options)
116
- if options[:type].present?
117
- raise 'LedgerAccountable type must be :debit or :credit' unless %i[debit credit].include?(options[:type])
118
-
119
- self.transaction_type = options[:type]
120
- else
121
- self.transaction_type = :credit
122
- end
123
- end
124
-
125
- def validate_and_assign_ledger_amount_attribute(options)
126
- raise "track_ledger :amount is required in #{model_name}" unless options[:amount].present?
127
- raise 'track_ledger :amount must be a symbol' unless options[:amount].is_a?(Symbol)
128
-
129
- self.ledger_amount_attribute = options[:amount]
130
- end
131
-
132
- def validate_net_amount_method(options)
133
- return unless options[:net_amount].present?
134
- raise 'track_ledger :net_amount must be a symbol' unless options[:net_amount].is_a?(Symbol)
135
-
136
- self.ledger_net_amount_method = options[:net_amount]
137
- end
138
-
139
- def validate_and_assign_ledger_attributes(options)
140
- # verify that provided ledger attributes are correctly formatted
141
- if options[:ledger_attributes].present? && options[:ledger_attributes].any? { |attr| !attr.is_a?(Symbol) }
142
- raise "LedgerAccountable attributes must be symbols. Did you mean #{options[:ledger_attributes].map do |attr|
143
- ":#{attr}"
144
- end.join(', ')}?"
145
- end
146
-
147
- self.ledger_attributes = options[:ledger_attributes]
148
- end
149
- end
150
-
151
- # Default way to set up LedgerAccountable. Run rails generate ledger_accountable to create
152
- # a fresh initializer with all configuration values.
5
+ # Default way to configure LedgerAccountable. Run rails generate ledger_accountable to create
6
+ # or update an initializer with default configuration values.
153
7
  def self.setup
154
8
  yield self
155
9
  end
156
-
157
- # the amount to be recorded in the ledger entry on creation or deletion; typically the full amount on the
158
- # LedgerAccountable object
159
- def ledger_amount
160
- unless respond_to?(self.class.ledger_amount_attribute)
161
- raise NotImplementedError,
162
- "LedgerAccountable model '#{model_name}' specified #{self.class.ledger_amount_attribute} for track_ledger :amount, but does not implement #{self.class.ledger_amount_attribute}"
163
- end
164
-
165
- ledger_amount_multiplier * (send(self.class.ledger_amount_attribute) || 0)
166
- end
167
-
168
- # the amount to be recorded in the ledger entry on update; typically a net change to the dollar amount
169
- # stored on the LedgerAccountable object
170
- def net_ledger_amount
171
- if self.class.ledger_net_amount_method
172
- net_amount_result = send(self.class.ledger_net_amount_method)
173
- ledger_amount_multiplier * net_amount_result
174
- else
175
- unless attribute_method?(self.class.ledger_amount_attribute.to_s)
176
- # if a method is provided to compute ledger_amount,
177
- logger.warn "
178
- LedgerAccountable model '#{model_name}' appears to use a method for track_ledger :amount, \
179
- but did not provide an option for :net_amount. This can lead to unexpected ledger entry amounts when modifying #{model_name}.
180
- "
181
- end
182
-
183
- previous_ledger_amount = ledger_amount_multiplier * attribute_was(self.class.ledger_amount_attribute)
184
- ledger_amount - (previous_ledger_amount || 0)
185
- end
186
- end
187
-
188
- private
189
-
190
- # Am I already in a ledger?
191
- # YES
192
- # am I still in that ledger?
193
- # YES
194
- # has my amount changed?
195
- # YES
196
- # => create entry with the net change
197
- # NO
198
- # => no update unless overriden
199
- # NO
200
- # => create a deletion ledger entry
201
- # do I have a new ledger?
202
- # YES
203
- # => create a new ledger entry
204
- # NO
205
- # => create a new ledger entry
206
-
207
- # by default, a LedgerAccountable should record a ledger entry when the ledger_attributes are changed
208
- # or if the update would otherwise change the ledger balance
209
- def should_record_ledger_update?
210
- return false unless should_persist_in_ledger?
211
-
212
- if ledger_entries.where(owner: current_owner).any?
213
- ledger_attributes_changed? || would_change_ledger_balance?
214
- else
215
- false
216
- end
217
- end
218
-
219
- def should_record_ledger_addition?
220
- should_persist_in_ledger? && added_to_ledger?
221
- end
222
-
223
- def should_record_ledger_removal?
224
- should_persist_in_ledger? && removed_from_ledger?
225
- end
226
-
227
- def ledger_attributes_changed?
228
- # if no ledger attributes are specified, only trigger if the balance changed
229
- return false if ledger_attributes.blank?
230
-
231
- changed_attributes.keys.any? { |key| ledger_attributes.include?(key.to_sym) }
232
- end
233
-
234
- def removed_from_ledger?
235
- if current_owner.nil?
236
- # if the owner was removed, check if its has non-deletion entries for its
237
- # previous owner
238
- entries_for_previous_owner? && !last_entry_was?('deletion')
239
- elsif previous_owner.present? && current_owner.present?
240
- # if the owner has changed, check if it was removed from its last owner's ledger
241
- entries_for_previous_owner? && ledger_entries.where(owner: previous_owner).last&.entry_type != 'deletion'
242
- else
243
- false
244
- end
245
- end
246
-
247
- def added_to_ledger?
248
- if current_owner.present?
249
- !entries_for_current_owner? || last_entry_was?('deletion')
250
- else
251
- false
252
- end
253
- end
254
-
255
- def would_change_ledger_balance?
256
- net_ledger_amount != 0
257
- end
258
-
259
- # create an :addition ledger entry with the full ledger amount
260
- def record_ledger_addition(&block)
261
- metadata = build_ledger_metadata
262
- owner = send(self.class.ledger_owner)
263
-
264
- record_ledger_entry(owner, ledger_amount, :addition, metadata, &block)
265
- end
266
-
267
- # create a :deletion ledger entry with an amount dependent on its owner's presence
268
- def record_ledger_removal(&block)
269
- metadata = build_ledger_metadata
270
- owner = previous_owner
271
- # if the owner is no longer present, remove the Accountable's full amount
272
- # from the ledger; otherwise, remove the net ledger amount
273
- amount_to_remove = current_owner.present? ? -ledger_amount : net_ledger_amount
274
-
275
- record_ledger_entry(owner, amount_to_remove, :deletion, metadata, &block)
276
- end
277
-
278
- # create a :deletion ledger entry with the full ledger amount
279
- # varies slightly from record_ledger_removal in that:
280
- # - it always records its full negative ledger amount
281
- # - it records an entry on its current ledger owner rather than its previous owner
282
- def record_ledger_destruction(&block)
283
- metadata = build_ledger_metadata
284
- owner = current_owner
285
-
286
- record_ledger_entry(owner, -ledger_amount, :deletion, metadata, &block)
287
- end
288
-
289
- # create a :modification ledger entry with the net change in net_ledger_amount
290
- def record_ledger_update(&block)
291
- metadata = build_ledger_metadata
292
- owner = current_owner
293
-
294
- record_ledger_entry(owner, net_ledger_amount, :modification, metadata, &block)
295
- end
296
-
297
- def record_ledger_entry(owner, amount, entry_type, metadata = nil, &block)
298
- ActiveRecord::Base.transaction do
299
- with_required_save(&block)
300
-
301
- if owner.present? && amount.present?
302
- begin
303
- LedgerEntry.create!(
304
- # create! will raise if the ledger entry fails to be created,
305
- # which will rollback the attempt to save the LedgerAccountable object
306
- owner: owner,
307
- ledger_item: self,
308
- transaction_type: self.transaction_type,
309
- entry_type: entry_type,
310
- amount_cents: amount,
311
- metadata: metadata
312
- )
313
- rescue ActiveRecord::RecordInvalid => e
314
- raise e if @@require_successful_entries
315
- end
316
- end
317
- end
318
- end
319
-
320
- def with_required_save
321
- commit_successful = yield # attempt to save or destroy the object
322
-
323
- # Proceed only if the save or destroy operation was successful
324
- raise ActiveRecord::Rollback if !commit_successful && @@require_successful_entries
325
- end
326
-
327
- def current_owner
328
- send(self.class.ledger_owner)
329
- end
330
-
331
- def previous_owner
332
- if current_owner.nil?
333
- # if the owner was removed, get the owner of its last ledger entry
334
- ledger_entries.last&.owner
335
- else
336
- # otherwise get the owner from the last entry NOT for its current owner
337
- ledger_entries.where.not(owner: current_owner).last&.owner
338
- end
339
- end
340
-
341
- def build_ledger_metadata
342
- # can be overridden to return a hash of metadata values
343
- {}
344
- end
345
-
346
- def last_entry_was?(entry_type)
347
- if current_owner.present?
348
- ledger_entries.where(owner: current_owner).last&.entry_type == entry_type
349
- elsif previous_owner.present?
350
- ledger_entries.where(owner: previous_owner).last&.entry_type == entry_type
351
- else
352
- false
353
- end
354
- end
355
-
356
- def ledger_amount_multiplier
357
- self.class.transaction_type == :credit ? 1 : -1
358
- end
359
-
360
- # An overrideable method to determine if the object should be persisted in the ledger
361
- #
362
- # For example, payments should not be recorded in a ledger until they're finalized
363
- # by default, a LedgerAccountable should persist in a ledger if has a ledger_owner via
364
- # track_ledger
365
- def should_persist_in_ledger?
366
- # warning log if the ledger owner is not set - the LedgerAccountable model must
367
- # include track_ledger
368
- if self.class.ledger_owner.blank?
369
- Rails.logger.warn "LedgerAccountable model #{model_name} must include track_ledger to use ledger functionality"
370
- end
371
-
372
- self.class.ledger_owner.present?
373
- end
374
-
375
- def entries_for_current_owner?
376
- ledger_entries.reload.where(owner: current_owner).any?
377
- end
378
-
379
- def entries_for_previous_owner?
380
- ledger_entries.reload.where(owner: previous_owner).any?
381
- end
382
-
383
- def to_be_destroyed?
384
- @_destroy_callback_already_called ||= false
385
- end
386
10
  end
387
-
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ledger_accountable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.10.pre
4
+ version: 0.1.4.pre
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brendan Maclean
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2024-10-18 00:00:00.000000000 Z
12
+ date: 2024-12-11 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activerecord
@@ -52,47 +52,47 @@ dependencies:
52
52
  - !ruby/object:Gem::Version
53
53
  version: '8'
54
54
  - !ruby/object:Gem::Dependency
55
- name: rake
55
+ name: database_cleaner-active_record
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
- - - "~>"
58
+ - - '='
59
59
  - !ruby/object:Gem::Version
60
- version: '12.0'
60
+ version: 2.1.0
61
61
  type: :development
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
- - - "~>"
65
+ - - '='
66
66
  - !ruby/object:Gem::Version
67
- version: '12.0'
67
+ version: 2.1.0
68
68
  - !ruby/object:Gem::Dependency
69
- name: sqlite3
69
+ name: rake
70
70
  requirement: !ruby/object:Gem::Requirement
71
71
  requirements:
72
- - - '='
72
+ - - "~>"
73
73
  - !ruby/object:Gem::Version
74
- version: 1.6.9
74
+ version: '12.0'
75
75
  type: :development
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
- - - '='
79
+ - - "~>"
80
80
  - !ruby/object:Gem::Version
81
- version: 1.6.9
81
+ version: '12.0'
82
82
  - !ruby/object:Gem::Dependency
83
- name: database_cleaner-active_record
83
+ name: sqlite3
84
84
  requirement: !ruby/object:Gem::Requirement
85
85
  requirements:
86
86
  - - '='
87
87
  - !ruby/object:Gem::Version
88
- version: 2.1.0
88
+ version: 1.6.9
89
89
  type: :development
90
90
  prerelease: false
91
91
  version_requirements: !ruby/object:Gem::Requirement
92
92
  requirements:
93
93
  - - '='
94
94
  - !ruby/object:Gem::Version
95
- version: 2.1.0
95
+ version: 1.6.9
96
96
  description: LedgerAccountable is a gem for recording ledger entries to store an accounting
97
97
  history in your Rails models.
98
98
  email:
@@ -109,6 +109,10 @@ files:
109
109
  - lib/generators/ledger_accountable/templates/ledger_entry.rb
110
110
  - lib/generators/ledger_accountable/templates/migration.rb
111
111
  - lib/ledger_accountable.rb
112
+ - lib/ledger_accountable/ledger_item.rb
113
+ - lib/ledger_accountable/ledger_item/entry_creator.rb
114
+ - lib/ledger_accountable/ledger_item/state_transition.rb
115
+ - lib/ledger_accountable/ledger_owner.rb
112
116
  - lib/ledger_accountable/version.rb
113
117
  - lib/locale/en.yml
114
118
  homepage: https://github.com/bmaclean/ledger-accountable
@@ -117,6 +121,7 @@ metadata:
117
121
  homepage_uri: https://github.com/bmaclean/ledger-accountable
118
122
  source_code_uri: https://github.com/bmaclean/ledger-accountable
119
123
  changelog_uri: https://github.com/bmaclean/ledger-accountable/blob/main/CHANGELOG.md
124
+ rubygems_mfa_required: 'false'
120
125
  post_install_message:
121
126
  rdoc_options: []
122
127
  require_paths:
@@ -125,14 +130,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
125
130
  requirements:
126
131
  - - ">="
127
132
  - !ruby/object:Gem::Version
128
- version: 2.6.5
133
+ version: 3.0.6
129
134
  required_rubygems_version: !ruby/object:Gem::Requirement
130
135
  requirements:
131
136
  - - ">"
132
137
  - !ruby/object:Gem::Version
133
138
  version: 1.3.1
134
139
  requirements: []
135
- rubygems_version: 3.1.6
140
+ rubygems_version: 3.2.33
136
141
  signing_key:
137
142
  specification_version: 4
138
143
  summary: Ledger accounting for Rails models