double_entry 0.0.1.pre → 0.1.0.pre.pre.alpha
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +13 -5
- data/.gitignore +5 -6
- data/.rspec +1 -0
- data/.travis.yml +19 -0
- data/.yardopts +2 -0
- data/Gemfile +0 -1
- data/LICENSE.md +19 -0
- data/README.md +221 -14
- data/Rakefile +12 -0
- data/double_entry.gemspec +30 -15
- data/gemfiles/Gemfile.rails-3.2.0 +5 -0
- data/gemfiles/Gemfile.rails-4.0.0 +5 -0
- data/gemfiles/Gemfile.rails-4.1.0 +5 -0
- data/lib/active_record/locking_extensions.rb +61 -0
- data/lib/double_entry.rb +267 -2
- data/lib/double_entry/account.rb +82 -0
- data/lib/double_entry/account_balance.rb +31 -0
- data/lib/double_entry/aggregate.rb +118 -0
- data/lib/double_entry/aggregate_array.rb +65 -0
- data/lib/double_entry/configurable.rb +52 -0
- data/lib/double_entry/day_range.rb +38 -0
- data/lib/double_entry/hour_range.rb +40 -0
- data/lib/double_entry/line.rb +147 -0
- data/lib/double_entry/line_aggregate.rb +37 -0
- data/lib/double_entry/line_check.rb +118 -0
- data/lib/double_entry/locking.rb +187 -0
- data/lib/double_entry/month_range.rb +92 -0
- data/lib/double_entry/reporting.rb +16 -0
- data/lib/double_entry/time_range.rb +55 -0
- data/lib/double_entry/time_range_array.rb +43 -0
- data/lib/double_entry/transfer.rb +70 -0
- data/lib/double_entry/version.rb +3 -1
- data/lib/double_entry/week_range.rb +99 -0
- data/lib/double_entry/year_range.rb +39 -0
- data/lib/generators/double_entry/install/install_generator.rb +22 -0
- data/lib/generators/double_entry/install/templates/migration.rb +68 -0
- data/script/jack_hammer +201 -0
- data/script/setup.sh +8 -0
- data/spec/active_record/locking_extensions_spec.rb +54 -0
- data/spec/double_entry/account_balance_spec.rb +8 -0
- data/spec/double_entry/account_spec.rb +23 -0
- data/spec/double_entry/aggregate_array_spec.rb +75 -0
- data/spec/double_entry/aggregate_spec.rb +168 -0
- data/spec/double_entry/double_entry_spec.rb +391 -0
- data/spec/double_entry/line_aggregate_spec.rb +8 -0
- data/spec/double_entry/line_check_spec.rb +88 -0
- data/spec/double_entry/line_spec.rb +72 -0
- data/spec/double_entry/locking_spec.rb +154 -0
- data/spec/double_entry/month_range_spec.rb +131 -0
- data/spec/double_entry/reporting_spec.rb +25 -0
- data/spec/double_entry/time_range_array_spec.rb +149 -0
- data/spec/double_entry/time_range_spec.rb +43 -0
- data/spec/double_entry/week_range_spec.rb +88 -0
- data/spec/generators/double_entry/install/install_generator_spec.rb +33 -0
- data/spec/spec_helper.rb +47 -0
- data/spec/support/accounts.rb +26 -0
- data/spec/support/blueprints.rb +34 -0
- data/spec/support/database.example.yml +16 -0
- data/spec/support/database.travis.yml +18 -0
- data/spec/support/double_entry_spec_helper.rb +19 -0
- data/spec/support/reporting_configuration.rb +6 -0
- data/spec/support/schema.rb +71 -0
- metadata +277 -18
- data/LICENSE.txt +0 -22
@@ -0,0 +1,65 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module DoubleEntry
|
3
|
+
class AggregateArray < Array
|
4
|
+
# An AggregateArray is awesome
|
5
|
+
# It is useful for making reports
|
6
|
+
# It is basically an array of aggregate results,
|
7
|
+
# representing a column of data in a report.
|
8
|
+
#
|
9
|
+
# For example, you could request all sales
|
10
|
+
# broken down by month and it would return an array of values
|
11
|
+
attr_reader :function, :account, :code, :filter, :range_type, :start, :finish
|
12
|
+
|
13
|
+
def initialize(function, account, code, options)
|
14
|
+
@function = function
|
15
|
+
@account = account
|
16
|
+
@code = code
|
17
|
+
@filter = options[:filter]
|
18
|
+
@range_type = options[:range_type]
|
19
|
+
@start = options[:start]
|
20
|
+
@finish = options[:finish]
|
21
|
+
|
22
|
+
retrieve_aggregates
|
23
|
+
fill_in_missing_aggregates
|
24
|
+
populate_self
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def populate_self
|
30
|
+
all_periods.each do |period|
|
31
|
+
self << @aggregates[period.key]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def fill_in_missing_aggregates
|
36
|
+
# some aggregates may not have been previously calculated, so we can request them now
|
37
|
+
# (this includes aggregates for the still-running period)
|
38
|
+
all_periods.each do |period|
|
39
|
+
unless @aggregates[period.key]
|
40
|
+
@aggregates[period.key] = DoubleEntry.aggregate(function, account, code, :filter => filter, :range => period)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# get any previously calculated aggregates
|
46
|
+
def retrieve_aggregates
|
47
|
+
raise ArgumentError.new("Invalid range type '#{range_type}'") unless %w(year month week day hour).include? range_type
|
48
|
+
@aggregates = LineAggregate.
|
49
|
+
where(:function => function.to_s).
|
50
|
+
where(:range_type => 'normal').
|
51
|
+
where(:account => account.to_s).
|
52
|
+
where(:code => code.to_s).
|
53
|
+
where(:filter => filter.inspect).
|
54
|
+
where(LineAggregate.arel_table[range_type].not_eq(nil)).
|
55
|
+
inject({}) do |hash, result|
|
56
|
+
hash[result.key] = Aggregate.formatted_amount(function, result.amount)
|
57
|
+
hash
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def all_periods
|
62
|
+
TimeRangeArray.make(range_type, start, finish)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module DoubleEntry
|
3
|
+
|
4
|
+
# Make configuring a module or a class simple.
|
5
|
+
#
|
6
|
+
# class MyClass
|
7
|
+
# include Configurable
|
8
|
+
#
|
9
|
+
# class Configuration
|
10
|
+
# attr_accessor :my_config_option
|
11
|
+
#
|
12
|
+
# def initialize #:nodoc:
|
13
|
+
# @my_config_option = "default value"
|
14
|
+
# end
|
15
|
+
# end
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# Then in an initializer (or environments/*.rb) do:
|
19
|
+
#
|
20
|
+
# MyClass.configure do |config|
|
21
|
+
# config.my_config_option = "custom value"
|
22
|
+
# end
|
23
|
+
#
|
24
|
+
# And inside methods in your class you can access your config:
|
25
|
+
#
|
26
|
+
# class MyClass
|
27
|
+
# def my_method
|
28
|
+
# puts configuration.my_config_option
|
29
|
+
# end
|
30
|
+
# end
|
31
|
+
#
|
32
|
+
# This is all based on this article:
|
33
|
+
#
|
34
|
+
# http://robots.thoughtbot.com/post/344833329/mygem-configure-block
|
35
|
+
#
|
36
|
+
module Configurable
|
37
|
+
def self.included(base) #:nodoc:
|
38
|
+
base.extend(ClassMethods)
|
39
|
+
end
|
40
|
+
|
41
|
+
module ClassMethods #:nodoc:
|
42
|
+
def configuration
|
43
|
+
@configuration ||= self::Configuration.new
|
44
|
+
end
|
45
|
+
|
46
|
+
def configure
|
47
|
+
yield(configuration)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module DoubleEntry
|
3
|
+
class DayRange < TimeRange
|
4
|
+
attr_reader :year, :week, :day
|
5
|
+
|
6
|
+
def initialize(options)
|
7
|
+
super options
|
8
|
+
|
9
|
+
@week = options[:week]
|
10
|
+
@day = options[:day]
|
11
|
+
week_range = WeekRange.new(options)
|
12
|
+
|
13
|
+
@start = week_range.start + (options[:day] - 1).days
|
14
|
+
@finish = @start.end_of_day
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.from_time(time)
|
18
|
+
week_range = WeekRange.from_time(time)
|
19
|
+
DayRange.new(:year => week_range.year, :week => week_range.week, :day => time.wday == 0 ? 7 : time.wday)
|
20
|
+
end
|
21
|
+
|
22
|
+
def previous
|
23
|
+
DayRange.from_time(@start - 1.day)
|
24
|
+
end
|
25
|
+
|
26
|
+
def next
|
27
|
+
DayRange.from_time(@start + 1.day)
|
28
|
+
end
|
29
|
+
|
30
|
+
def ==(other)
|
31
|
+
(self.week == other.week) and (self.year == other.year) and (self.day == other.day)
|
32
|
+
end
|
33
|
+
|
34
|
+
def to_s
|
35
|
+
start.strftime('%Y, %a %b %d')
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module DoubleEntry
|
3
|
+
class HourRange < TimeRange
|
4
|
+
attr_reader :year, :week, :day, :hour
|
5
|
+
|
6
|
+
def initialize(options)
|
7
|
+
super options
|
8
|
+
|
9
|
+
@week = options[:week]
|
10
|
+
@day = options[:day]
|
11
|
+
@hour = options[:hour]
|
12
|
+
|
13
|
+
day_range = DayRange.new(options)
|
14
|
+
|
15
|
+
@start = day_range.start + options[:hour].hours
|
16
|
+
@finish = @start.end_of_hour
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.from_time(time)
|
20
|
+
day = DayRange.from_time(time)
|
21
|
+
HourRange.new :year => day.year, :week => day.week, :day => day.day, :hour => time.hour
|
22
|
+
end
|
23
|
+
|
24
|
+
def previous
|
25
|
+
HourRange.from_time(@start - 1.hour)
|
26
|
+
end
|
27
|
+
|
28
|
+
def next
|
29
|
+
HourRange.from_time(@start + 1.hour)
|
30
|
+
end
|
31
|
+
|
32
|
+
def ==(other)
|
33
|
+
(self.week == other.week) and (self.year == other.year) and (self.day == other.day) and (self.hour == other.hour)
|
34
|
+
end
|
35
|
+
|
36
|
+
def to_s
|
37
|
+
"#{start.hour}:00:00 - #{start.hour}:59:59"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module DoubleEntry
|
4
|
+
|
5
|
+
# This is the table to end all tables!
|
6
|
+
#
|
7
|
+
# Every financial transaction gets two entries in here: one for the source
|
8
|
+
# account, and one for the destination account. Normal double-entry
|
9
|
+
# accounting principles are followed.
|
10
|
+
#
|
11
|
+
# This is a log table, and should (ideally) never be updated.
|
12
|
+
#
|
13
|
+
# ## Indexes
|
14
|
+
#
|
15
|
+
# The indexes on this table are carefully chosen, as it's both big and heavily loaded.
|
16
|
+
#
|
17
|
+
# ### lines_scope_account_id_idx
|
18
|
+
#
|
19
|
+
# ```sql
|
20
|
+
# ADD INDEX `lines_scope_account_id_idx` (scope, account, id)
|
21
|
+
# ```
|
22
|
+
#
|
23
|
+
# This is the important one. It's used primarily for querying the current
|
24
|
+
# balance of an account. eg:
|
25
|
+
#
|
26
|
+
# ```sql
|
27
|
+
# SELECT * FROM `lines` WHERE scope = ? AND account = ? ORDER BY id DESC LIMIT 1
|
28
|
+
# ```
|
29
|
+
#
|
30
|
+
# ### lines_scope_account_created_at_idx
|
31
|
+
#
|
32
|
+
# ```sql
|
33
|
+
# ADD INDEX `lines_scope_account_created_at_idx` (scope, account, created_at)
|
34
|
+
# ```
|
35
|
+
#
|
36
|
+
# Used for querying historic balances:
|
37
|
+
#
|
38
|
+
# ```sql
|
39
|
+
# SELECT * FROM `lines` WHERE scope = ? AND account = ? AND created_at < ? ORDER BY id DESC LIMIT 1
|
40
|
+
# ```
|
41
|
+
#
|
42
|
+
# And for reporting on account changes over a time period:
|
43
|
+
#
|
44
|
+
# ```sql
|
45
|
+
# SELECT SUM(amount) FROM `lines` WHERE scope = ? AND account = ? AND created_at BETWEEN ? AND ?
|
46
|
+
# ```
|
47
|
+
#
|
48
|
+
# ### lines_account_created_at_idx and lines_account_code_created_at_idx
|
49
|
+
#
|
50
|
+
# ```sql
|
51
|
+
# ADD INDEX `lines_account_created_at_idx` (account, created_at);
|
52
|
+
# ADD INDEX `lines_account_code_created_at_idx` (account, code, created_at);
|
53
|
+
# ```
|
54
|
+
#
|
55
|
+
# These two are used for generating reports, which need to sum things
|
56
|
+
# by account, or account and code, over a particular period.
|
57
|
+
#
|
58
|
+
class Line < ActiveRecord::Base
|
59
|
+
extend EncapsulateAsMoney
|
60
|
+
|
61
|
+
belongs_to :detail, :polymorphic => true
|
62
|
+
before_save :check_balance_will_not_be_sent_negative
|
63
|
+
|
64
|
+
encapsulate_as_money :amount, :balance
|
65
|
+
|
66
|
+
def code=(code)
|
67
|
+
self[:code] = code.try(:to_s)
|
68
|
+
code
|
69
|
+
end
|
70
|
+
|
71
|
+
def code
|
72
|
+
self[:code].try(:to_sym)
|
73
|
+
end
|
74
|
+
|
75
|
+
def meta=(meta)
|
76
|
+
self[:meta] = Marshal.dump(meta)
|
77
|
+
meta
|
78
|
+
end
|
79
|
+
|
80
|
+
def meta
|
81
|
+
meta = self[:meta]
|
82
|
+
meta ? Marshal.load(meta) : {}
|
83
|
+
end
|
84
|
+
|
85
|
+
def account=(account)
|
86
|
+
self[:account] = account.identifier.to_s
|
87
|
+
self.scope = account.scope_identity
|
88
|
+
account
|
89
|
+
end
|
90
|
+
|
91
|
+
def account
|
92
|
+
DoubleEntry.account(self[:account].to_sym, :scope => scope)
|
93
|
+
end
|
94
|
+
|
95
|
+
def partner_account=(partner_account)
|
96
|
+
self[:partner_account] = partner_account.identifier.to_s
|
97
|
+
self.partner_scope = partner_account.scope_identity
|
98
|
+
partner_account
|
99
|
+
end
|
100
|
+
|
101
|
+
def partner_account
|
102
|
+
DoubleEntry.account(self[:partner_account].to_sym, :scope => partner_scope)
|
103
|
+
end
|
104
|
+
|
105
|
+
def partner
|
106
|
+
self.class.find(partner_id)
|
107
|
+
end
|
108
|
+
|
109
|
+
def pair
|
110
|
+
if credit?
|
111
|
+
[self, partner]
|
112
|
+
else
|
113
|
+
[partner, self]
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def credit?
|
118
|
+
amount < Money.empty
|
119
|
+
end
|
120
|
+
|
121
|
+
def debit?
|
122
|
+
amount > Money.empty
|
123
|
+
end
|
124
|
+
|
125
|
+
def description
|
126
|
+
DoubleEntry.describe(self)
|
127
|
+
end
|
128
|
+
|
129
|
+
# Query out just the id and created_at fields for lines, without
|
130
|
+
# instantiating any ActiveRecord objects.
|
131
|
+
def self.find_id_and_created_at(options)
|
132
|
+
connection.select_rows <<-SQL
|
133
|
+
SELECT id, created_at FROM #{Line.quoted_table_name} #{options[:joins]}
|
134
|
+
WHERE #{sanitize_sql_for_conditions(options[:conditions])}
|
135
|
+
SQL
|
136
|
+
end
|
137
|
+
|
138
|
+
private
|
139
|
+
|
140
|
+
def check_balance_will_not_be_sent_negative
|
141
|
+
if self.account.positive_only and self.balance < Money.new(0)
|
142
|
+
raise AccountWouldBeSentNegative.new(account)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module DoubleEntry
|
3
|
+
class LineAggregate < ActiveRecord::Base
|
4
|
+
extend EncapsulateAsMoney
|
5
|
+
|
6
|
+
def self.aggregate(function, account, code, scope, range, named_scopes)
|
7
|
+
collection = aggregate_collection(named_scopes)
|
8
|
+
collection = collection.where(:account => account)
|
9
|
+
collection = collection.where(:created_at => range.start..range.finish)
|
10
|
+
collection = collection.where(:code => code) if code
|
11
|
+
collection.send(function, :amount)
|
12
|
+
end
|
13
|
+
|
14
|
+
# a lot of the trickier reports will use filters defined
|
15
|
+
# in named_scopes to bring in data from other tables.
|
16
|
+
def self.aggregate_collection(named_scopes)
|
17
|
+
if named_scopes
|
18
|
+
collection = Line
|
19
|
+
named_scopes.each do |named_scope|
|
20
|
+
if named_scope.is_a?(Hash)
|
21
|
+
method_name = named_scope.keys[0]
|
22
|
+
collection = collection.send(method_name, named_scope[method_name])
|
23
|
+
else
|
24
|
+
collection = collection.send(named_scope)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
collection
|
28
|
+
else
|
29
|
+
Line
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def key
|
34
|
+
"#{year}:#{month}:#{week}:#{day}:#{hour}"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require 'set'
|
3
|
+
|
4
|
+
module DoubleEntry
|
5
|
+
class LineCheck < ActiveRecord::Base
|
6
|
+
extend EncapsulateAsMoney
|
7
|
+
|
8
|
+
default_scope -> { order('created_at') }
|
9
|
+
|
10
|
+
def self.perform!
|
11
|
+
new.perform
|
12
|
+
end
|
13
|
+
|
14
|
+
def perform
|
15
|
+
log = ''
|
16
|
+
current_line_id = nil
|
17
|
+
|
18
|
+
active_accounts = Set.new
|
19
|
+
incorrect_accounts = Set.new
|
20
|
+
|
21
|
+
new_lines_since_last_run.find_each do |line|
|
22
|
+
incorrect_accounts << line.account unless running_balance_correct?(line, log)
|
23
|
+
active_accounts << line.account
|
24
|
+
current_line_id = line.id
|
25
|
+
end
|
26
|
+
|
27
|
+
active_accounts.each do |account|
|
28
|
+
incorrect_accounts << account unless cached_balance_correct?(account)
|
29
|
+
end
|
30
|
+
|
31
|
+
incorrect_accounts.each { |account| recalculate_account(account) }
|
32
|
+
|
33
|
+
unless active_accounts.empty?
|
34
|
+
LineCheck.create!(
|
35
|
+
:errors_found => incorrect_accounts.any?,
|
36
|
+
:last_line_id => current_line_id,
|
37
|
+
:log => log,
|
38
|
+
)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def last_run_line_id
|
45
|
+
latest = LineCheck.last
|
46
|
+
latest ? latest.last_line_id : 0
|
47
|
+
end
|
48
|
+
|
49
|
+
def new_lines_since_last_run
|
50
|
+
Line.where('id > ?', last_run_line_id)
|
51
|
+
end
|
52
|
+
|
53
|
+
def running_balance_correct?(line, log)
|
54
|
+
# Another work around for the MySQL 5.1 query optimiser bug that causes the ORDER BY
|
55
|
+
# on the query to fail in some circumstances, resulting in an old balance being
|
56
|
+
# returned. This was biting us intermittently in spec runs.
|
57
|
+
# See http://bugs.mysql.com/bug.php?id=51431
|
58
|
+
force_index = if Line.connection.adapter_name.match /mysql/i
|
59
|
+
"FORCE INDEX (lines_scope_account_id_idx)"
|
60
|
+
else
|
61
|
+
""
|
62
|
+
end
|
63
|
+
|
64
|
+
# yes, it needs to be find_by_sql, because any other find will be affected
|
65
|
+
# by the find_each call in perform!
|
66
|
+
previous_line = Line.find_by_sql(["SELECT * FROM #{Line.quoted_table_name} #{force_index} WHERE account = ? AND scope = ? AND id < ? ORDER BY id DESC LIMIT 1", line.account.identifier.to_s, line.scope, line.id])
|
67
|
+
previous_balance = previous_line.length == 1 ? previous_line[0].balance : Money.empty
|
68
|
+
|
69
|
+
if line.balance != (line.amount + previous_balance)
|
70
|
+
log << line_error_message(line, previous_line, previous_balance)
|
71
|
+
end
|
72
|
+
|
73
|
+
line.balance == previous_balance + line.amount
|
74
|
+
end
|
75
|
+
|
76
|
+
def line_error_message(line, previous_line, previous_balance)
|
77
|
+
<<-END_OF_MESSAGE.strip_heredoc
|
78
|
+
*********************************
|
79
|
+
Error on line ##{line.id}: balance:#{line.balance} != #{previous_balance} + #{line.amount}
|
80
|
+
*********************************
|
81
|
+
#{previous_line.inspect}
|
82
|
+
#{line.inspect}
|
83
|
+
|
84
|
+
END_OF_MESSAGE
|
85
|
+
end
|
86
|
+
|
87
|
+
def cached_balance_correct?(account)
|
88
|
+
DoubleEntry.lock_accounts(account) do
|
89
|
+
return AccountBalance.find_by_account(account).balance == account.balance
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def recalculate_account(account)
|
94
|
+
DoubleEntry.lock_accounts(account) do
|
95
|
+
recalculated_balance = Money.empty
|
96
|
+
|
97
|
+
lines_for_account(account).each do |line|
|
98
|
+
recalculated_balance += line.amount
|
99
|
+
line.update_attribute(:balance, recalculated_balance) if line.balance != recalculated_balance
|
100
|
+
end
|
101
|
+
|
102
|
+
update_balance_for_account(account, recalculated_balance)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def lines_for_account(account)
|
107
|
+
Line.where(
|
108
|
+
:account => account.identifier.to_s,
|
109
|
+
:scope => account.scope_identity.to_s
|
110
|
+
).order(:id)
|
111
|
+
end
|
112
|
+
|
113
|
+
def update_balance_for_account(account, balance)
|
114
|
+
account_balance = Locking.balance_for_locked_account(account)
|
115
|
+
account_balance.update_attribute(:balance, balance)
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|