invoicing 0.1.0 → 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.
Binary file
@@ -0,0 +1,21 @@
1
+ == 0.2.0 2009-04-20
2
+
3
+ * 4 major enhancements:
4
+ * New associated gem invoicing_generator for generating an invoicing component in a Rails project
5
+ (script/generate invoicing_ledger)
6
+ * Generated controller and views for rendering statements and ledger
7
+ * Comes with a nice default stylesheet out of the box
8
+ * More flexible formatting of currency values
9
+ * 1 bugfix:
10
+ * Accidental overwriting of total_amount value on payment LedgerItems without LineItems
11
+
12
+ == 0.1.0 2009-02-10
13
+
14
+ * 2 major enhancements:
15
+ * Core API is now usable
16
+ * RCov reports 100% test coverage
17
+
18
+ == 0.0.1 2009-01-05
19
+
20
+ * 1 major enhancement:
21
+ * Initial public release
@@ -1,29 +1,35 @@
1
- CHANGELOG
1
+ History.txt
2
+ LICENSE
3
+ Manifest.txt
4
+ PostInstall.txt
5
+ README.rdoc
6
+ Rakefile
7
+ lib/invoicing.rb
2
8
  lib/invoicing/cached_record.rb
3
9
  lib/invoicing/class_info.rb
4
10
  lib/invoicing/connection_adapter_ext.rb
5
11
  lib/invoicing/countries/uk.rb
6
12
  lib/invoicing/currency_value.rb
7
13
  lib/invoicing/find_subclasses.rb
14
+ lib/invoicing/ledger_item.rb
8
15
  lib/invoicing/ledger_item/render_html.rb
9
16
  lib/invoicing/ledger_item/render_ubl.rb
10
- lib/invoicing/ledger_item.rb
11
17
  lib/invoicing/line_item.rb
12
18
  lib/invoicing/price.rb
13
19
  lib/invoicing/tax_rate.rb
14
20
  lib/invoicing/taxable.rb
15
21
  lib/invoicing/time_dependent.rb
16
22
  lib/invoicing/version.rb
17
- lib/invoicing.rb
18
- LICENSE
19
- Manifest
20
- Rakefile
21
- README
23
+ script/console
24
+ script/destroy
25
+ script/generate
26
+ tasks/rcov.rake
22
27
  test/cached_record_test.rb
23
28
  test/class_info_test.rb
24
29
  test/connection_adapter_ext_test.rb
25
30
  test/currency_value_test.rb
26
31
  test/find_subclasses_test.rb
32
+ test/fixtures/README
27
33
  test/fixtures/cached_record.sql
28
34
  test/fixtures/class_info.sql
29
35
  test/fixtures/currency_value.sql
@@ -31,7 +37,6 @@ test/fixtures/find_subclasses.sql
31
37
  test/fixtures/ledger_item.sql
32
38
  test/fixtures/line_item.sql
33
39
  test/fixtures/price.sql
34
- test/fixtures/README
35
40
  test/fixtures/tax_rate.sql
36
41
  test/fixtures/taxable.sql
37
42
  test/fixtures/time_dependent.sql
@@ -55,6 +60,3 @@ test/tax_rate_test.rb
55
60
  test/taxable_test.rb
56
61
  test/test_helper.rb
57
62
  test/time_dependent_test.rb
58
- website/curvycorners.js
59
- website/screen.css
60
- website/template.html.erb
@@ -0,0 +1,10 @@
1
+
2
+ Thanks for trying the Ruby Invoicing Gem. Hope you find it useful.
3
+ Do please blog and tweet about it (tag your posts with #invgem).
4
+
5
+ Now please go to the root of your Rails project and type:
6
+ script/generate invoicing_ledger billing --currency=GBP
7
+ (replace GBP with your currency if you do not use Pounds Sterling).
8
+ For more information please go to http://ept.github.com/invoicing/
9
+
10
+
@@ -0,0 +1,55 @@
1
+ = Ruby Invoicing Framework
2
+
3
+ * {Ruby Invoicing Framework website}[http://ept.github.com/invoicing/]
4
+ * {API Reference Docs}[http://invoicing.rubyforge.org/doc/]
5
+ * {Browse the code on GitHub}[http://github.com/ept/invoicing/]
6
+ * {RubyForge project}[http://rubyforge.org/projects/invoicing/]
7
+ * Email: Martin Kleppmann <ept@rubyforge.org>
8
+
9
+ == DESCRIPTION
10
+
11
+ This is a framework for generating and displaying invoices (ideal for
12
+ commercial Rails apps). It allows for flexible business logic; provides tools
13
+ for tax handling, commission calculation etc. It aims to be both
14
+ developer-friendly and accountant-friendly.
15
+
16
+ The Ruby Invoicing Framework is based on
17
+ {ActiveRecord}[http://api.rubyonrails.org/classes/ActiveRecord/Base.html].
18
+
19
+ Please see {the website}[http://ept.github.com/invoicing/] for an introduction
20
+ to using Invoicing, and check the
21
+ {API reference}[http://invoicing.rubyforge.org/doc/] for in-depth details.
22
+
23
+ == FEATURES
24
+
25
+ * TODO
26
+
27
+ == REQUIREMENTS
28
+
29
+ * ActiveRecord >= 2.1
30
+ * Only MySQL and PostgreSQL databases are currently supported
31
+
32
+ == INSTALL
33
+
34
+ sudo gem install invoicing
35
+
36
+ == STATUS
37
+
38
+ So far, the Ruby Invoicing Framework has been tested with ActiveRecord 2.2.2,
39
+ MySQL 5.0.67 and PostgreSQL 8.3.5. We will be testing it across a wider
40
+ variety of versions soon.
41
+
42
+ == CREDITS
43
+
44
+ The Ruby invoicing framework originated as part of the website
45
+ {Bid for Wine}[http://www.bidforwine.co.uk], developed by Patrick Dietrich,
46
+ Conrad Irwin, Michael Arnold and Martin Kleppmann for Ept Computing Ltd.
47
+ It was extracted from the Bid for Wine codebase and substantially extended
48
+ by Martin Kleppmann.
49
+
50
+ == LICENSE
51
+
52
+ Copyright (c) 2009 Martin Kleppmann, Ept Computing Limited.
53
+
54
+ This gem is made publicly available under the terms of the MIT license.
55
+ See LICENSE and/or COPYING for details.
data/Rakefile CHANGED
@@ -1,75 +1,37 @@
1
- require 'rubygems'
2
- require 'echoe'
1
+ %w[rubygems rake rake/clean fileutils newgem rubigen].each { |f| require f }
2
+ require File.dirname(__FILE__) + '/lib/invoicing'
3
3
 
4
- # Add the project's top level directory and the lib directory to the Ruby search path
5
- $: << File.expand_path(File.join(File.dirname(__FILE__), "lib"))
6
- $: << File.expand_path(File.dirname(__FILE__))
7
-
8
- require 'invoicing'
9
-
10
- Echoe.new('invoicing', Invoicing::VERSION) do |p|
11
- p.summary = 'Ruby invoicing framework'
12
- p.description = 'Provides tools for applications which need to generate invoices for customers.'
13
- p.url = 'http://invoicing.rubyforge.org/'
14
- p.author = 'Martin Kleppmann'
15
- p.email = 'rubyforge@eptcomputing.com'
16
- p.dependencies = ['activerecord >=2.1.0', 'builder >= 2.0']
17
- p.docs_host = 'ept@rubyforge.org:/var/www/gforge-projects/'
18
- p.test_pattern = 'test/*_test.rb' # do not include test/models/*.rb
19
- p.rcov_options = "-x '/Library/'"
4
+ # Hoe calls Ruby with the "-w" set by default; unfortunately, ActiveRecord (at version 2.2.2
5
+ # at least) causes a lot of warnings internally, by no fault of our own, which clutters
6
+ # the output. Comment out the following four lines to see those warnings.
7
+ class Hoe
8
+ RUBY_FLAGS = ENV['RUBY_FLAGS'] || "-I#{%w(lib test).join(File::PATH_SEPARATOR)}" +
9
+ (RUBY_DEBUG ? " #{RUBY_DEBUG}" : '')
20
10
  end
21
11
 
12
+ # Generate all the Rake tasks
13
+ # Run 'rake -T' to see list of generated tasks (from gem root directory)
14
+ $hoe = Hoe.new('invoicing', Invoicing::VERSION) do |p|
15
+ p.developer('Martin Kleppmann', 'rubyforge@eptcomputing.com')
16
+ p.changes = p.paragraphs_of("History.txt", 0..1).join("\n\n")
17
+ p.post_install_message = 'PostInstall.txt'
18
+ p.rubyforge_name = p.name
19
+ p.extra_deps = [
20
+ ['activerecord', '>= 2.1.0'],
21
+ ['builder', '>= 2.0']
22
+ ]
23
+ p.extra_dev_deps = [
24
+ ['newgem', ">= #{::Newgem::VERSION}"]
25
+ #['invoicing_generator', "= #{Invoicing::VERSION}"] - causes a circular dependency in rubygems < 1.2
26
+ ]
22
27
 
23
- desc "Generate a new website from README file"
24
- task 'website' do
25
- require 'rubygems'
26
- require 'redcloth'
27
- require 'syntax/convertors/html'
28
- require 'erb'
29
-
30
- version = Invoicing::VERSION
31
- download = 'http://rubyforge.org/projects/invoicing'
32
-
33
- def convert_syntax(syntax, source)
34
- return Syntax::Convertors::HTML.for_syntax(syntax).convert(source).gsub(%r!^<pre>|</pre>$!,'')
35
- end
36
-
37
- template = ERB.new(File.open(File.join(File.dirname(__FILE__), '/website/template.html.erb')).read)
38
-
39
- title = nil
40
- body = nil
41
- File.open(File.join(File.dirname(__FILE__), '/README')) do |fsrc|
42
- title = fsrc.readline.gsub(/^[^ ]* /, '')
43
- body_text = fsrc.read
44
- syntax_items = []
45
- body_text.gsub!(%r!<(pre|code)[^>]*?syntax=['"]([^'"]+)[^>]*>(.*?)</\1>!m){
46
- ident = syntax_items.length
47
- element, syntax, source = $1, $2, $3
48
- syntax_items << "<#{element} class='syntax'>#{convert_syntax(syntax, source)}</#{element}>"
49
- "syntax-temp-#{ident}"
50
- }
51
- body = RedCloth.new(body_text).to_html
52
- body.gsub!(%r!(?:<pre><code>)?syntax-temp-(\d+)(?:</code></pre>)?!){ syntax_items[$1.to_i] }
53
- end
54
-
55
- File.open(File.join(File.dirname(__FILE__), '/website/index.html'), 'w') do |fout|
56
- fout.write(template.result(binding))
57
- end
28
+ p.test_globs = %w[test/*_test.rb] # do not include test/models/*.rb
29
+ p.clean_globs |= %w[**/.DS_Store tmp *.log coverage]
30
+ p.rsync_args = '-av --delete --ignore-errors'
58
31
  end
59
32
 
33
+ require 'newgem/tasks' # load /tasks/*.rake
34
+ Dir['tasks/**/*.rake'].each { |t| load t }
60
35
 
61
- desc "Generate and publish website"
62
- task :publish_website => :website do
63
- require 'net/sftp'
64
- upload_user = 'ept'
65
- upload_host = 'rubyforge.org'
66
- upload_dir = '/var/www/gforge-projects/invoicing'
67
- local_dir = File.join(File.dirname(__FILE__), 'website')
68
- Net::SFTP.start(upload_host, upload_user) do |sftp|
69
- for f in Dir.entries(local_dir)
70
- next if f =~ /^\./
71
- puts "Uploading #{f} to #{upload_user}@#{upload_host}:#{upload_dir}/#{f}"
72
- sftp.upload!(File.join(local_dir, f), "#{upload_dir}/#{f}")
73
- end
74
- end
75
- end
36
+ # Tasks to run by default
37
+ # task :default => [:spec, :features]
@@ -84,6 +84,31 @@ module Invoicing
84
84
  # validates_inclusion_of :currency_code, %w( USD GBP EUR JPY )
85
85
  # acts_as_currency_value :net_amount, :tax_amount, :currency => :currency_code
86
86
  # end
87
+ #
88
+ # You may also specify the <tt>:value_for_formatting</tt> option, passing it the name of a method on
89
+ # your model object. That method will be called when a CurrencyValue method with +_formatted+ suffix
90
+ # is called, and allows you to modify the numerical value before it is formatted into a string. An
91
+ # options hash is also passed. This can be useful, for example, if a value is stored positive but you
92
+ # want to display it as negative in certain circumstances depending on the view:
93
+ #
94
+ # class LedgerItem < ActiveRecord::Base
95
+ # acts_as_ledger_item
96
+ # acts_as_currency_value :total_amount, :tax_amount, :value_for_formatting => :value_for_formatting
97
+ #
98
+ # def value_for_formatting(value, options={})
99
+ # value *= -1 if options[:debit] == :negative && debit?(options[:self_id])
100
+ # value *= -1 if options[:credit] == :negative && !debit?(options[:self_id])
101
+ # value
102
+ # end
103
+ # end
104
+ #
105
+ # invoice = Invoice.find(1)
106
+ # invoice.total_amount_formatted :debit => :negative, :self_id => invoice.sender_id
107
+ # # => '$25.00'
108
+ # invoice.total_amount_formatted :debit => :negative, :self_id => invoice.recipient_id
109
+ # # => '-$25.00'
110
+ #
111
+ # (The example above is actually a real part of +LedgerItem+.)
87
112
  def acts_as_currency_value(*args)
88
113
  Invoicing::ClassInfo.acts_as(Invoicing::CurrencyValue, self, args)
89
114
 
@@ -94,8 +119,8 @@ module Invoicing
94
119
 
95
120
  # Format a numeric monetary value into a human-readable string, in the currency of the
96
121
  # current model object.
97
- def format_currency_value(value)
98
- currency_value_class_info.format_value(self, value)
122
+ def format_currency_value(value, options={})
123
+ currency_value_class_info.format_value(self, value, options)
99
124
  end
100
125
 
101
126
 
@@ -109,6 +134,73 @@ module Invoicing
109
134
  protected :write_back_currency_values
110
135
 
111
136
 
137
+ # Encapsulates the methods for formatting currency values in a human-friendly way.
138
+ # These methods do not depend on ActiveRecord and can thus also be called externally.
139
+ module Formatter
140
+ class << self
141
+
142
+ # Given the three-letter ISO 4217 code of a currency, returns a hash with useful bits of information:
143
+ # <tt>:code</tt>:: The ISO 4217 code of the currency.
144
+ # <tt>:round</tt>:: Smallest unit of the currency in normal use, to which values are rounded. Default is 0.01.
145
+ # <tt>:symbol</tt>:: Symbol or string usually used to denote the currency. Encoded as UTF-8. Default is ISO 4217 code.
146
+ # <tt>:suffix</tt>:: +true+ if the currency symbol appears after the number, +false+ if it appears before.
147
+ # <tt>:space</tt>:: Whether or not to leave a space between the number and the currency symbol.
148
+ # <tt>:digits</tt>:: Number of digits to display after the decimal point.
149
+ def currency_info(code, options={})
150
+ code = code.to_s.upcase
151
+ valid_options = [:symbol, :round, :suffix, :space, :digits]
152
+ info = {:code => code, :symbol => code, :round => 0.01, :suffix => nil, :space => nil, :digits => nil}
153
+ if ::Invoicing::CurrencyValue::CURRENCIES.has_key? code
154
+ info.update(::Invoicing::CurrencyValue::CURRENCIES[code])
155
+ end
156
+ options.each_pair {|key, value| info[key] = value if valid_options.include? key }
157
+
158
+ info[:suffix] ||= (info[:code] == info[:symbol]) && !info[:code].nil?
159
+ info[:space] ||= info[:suffix]
160
+ info[:digits] = -Math.log10(info[:round]).floor if info[:digits].nil?
161
+ info[:digits] = 0 if info[:digits] < 0
162
+
163
+ info
164
+ end
165
+
166
+ # Given the three-letter ISO 4217 code of a currency and a BigDecimal value, returns the
167
+ # value formatted as an UTF-8 string, ready for human consumption.
168
+ #
169
+ # FIXME: This method currently does not take locale into account -- it always uses the dot
170
+ # as decimal separator and the comma as thousands separator.
171
+ def format_value(currency_code, value, options={})
172
+ info = currency_info(currency_code, options)
173
+
174
+ negative = false
175
+ if value < 0
176
+ negative = true
177
+ value = -value
178
+ end
179
+
180
+ value = "%.#{info[:digits]}f" % value
181
+ while value.sub!(/(\d+)(\d\d\d)/, '\1,\2'); end
182
+ value.sub!(/^\-/, '') # avoid displaying minus zero
183
+
184
+ formatted = if ['', nil].include? info[:symbol]
185
+ value
186
+ elsif info[:space]
187
+ info[:suffix] ? "#{value} #{info[:symbol]}" : "#{info[:symbol]} #{value}"
188
+ else
189
+ info[:suffix] ? "#{value}#{info[:symbol]}" : "#{info[:symbol]}#{value}"
190
+ end
191
+
192
+ if negative
193
+ # default is to use proper unicode minus sign
194
+ formatted = (options[:negative] == :brackets) ? "(#{formatted})" : (
195
+ (options[:negative] == :hyphen) ? "-#{formatted}" : "\xE2\x88\x92#{formatted}"
196
+ )
197
+ end
198
+ formatted
199
+ end
200
+ end
201
+ end
202
+
203
+
112
204
  class ClassInfo < Invoicing::ClassInfo::Base #:nodoc:
113
205
 
114
206
  def initialize(model_class, previous_info, args)
@@ -132,11 +224,18 @@ module Invoicing
132
224
  write_attribute(attr, new_value)
133
225
  end
134
226
 
135
- define_method("#{attr}_formatted") do
136
- begin
137
- format_currency_value(Kernel.Float(send("#{attr}_before_type_cast")))
227
+ define_method("#{attr}_formatted") do |*args|
228
+ options = args.first || {}
229
+ value_as_float = begin
230
+ Kernel.Float(send("#{attr}_before_type_cast"))
138
231
  rescue ArgumentError, TypeError
139
- '' # if <attr>_before_type_cast could not be converted to float
232
+ nil
233
+ end
234
+
235
+ if value_as_float.nil?
236
+ ''
237
+ else
238
+ format_currency_value(value_as_float, options.merge({:method_name => attr}))
140
239
  end
141
240
  end
142
241
  end
@@ -153,43 +252,20 @@ module Invoicing
153
252
  end
154
253
  end
155
254
 
156
- # Returns a hash of information about the currency used by model +object+. Contains the following keys:
157
- # <tt>:code</tt>:: The ISO 4217 code of the currency.
158
- # <tt>:round</tt>:: Smallest unit of the currency in normal use, to which values are rounded. Default is 0.01.
159
- # <tt>:symbol</tt>:: Symbol or string usually used to denote the currency. Encoded as UTF-8. Default is ISO 4217 code.
160
- # <tt>:suffix</tt>:: +true+ if the currency symbol appears after the number, +false+ if it appears before.
161
- # <tt>:space</tt>:: Whether or not to leave a space between the number and the currency symbol.
162
- # <tt>:digits</tt>:: Number of digits to display after the decimal point.
255
+ # Returns a hash of information about the currency used by model +object+.
163
256
  def currency_info_for(object)
164
- valid_options = [:symbol, :round, :suffix, :space, :digits, :format]
165
- code = currency_of(object)
166
- info = {:code => code, :symbol => code, :round => 0.01, :suffix => nil, :space => nil, :digits => nil}
167
- if ::Invoicing::CurrencyValue::CURRENCIES.has_key? code
168
- info.update(::Invoicing::CurrencyValue::CURRENCIES[code])
169
- end
170
- all_options.each_pair {|key, value| info[key] = value if valid_options.include? key }
171
-
172
- info[:suffix] = true if info[:suffix].nil? && (info[:code] == info[:symbol]) && !info[:code].nil?
173
- info[:space] = true if info[:space].nil? && info[:suffix]
174
- info[:digits] = -Math.log10(info[:round]).floor if info[:digits].nil?
175
- info[:digits] = 0 if info[:digits] < 0
176
-
177
- info
257
+ ::Invoicing::CurrencyValue::Formatter.currency_info(currency_of(object), all_options)
178
258
  end
179
259
 
180
260
  # Formats a numeric value as a nice currency string in UTF-8 encoding.
181
261
  # +object+ is the model object carrying the value (used to determine the currency).
182
- def format_value(object, value)
183
- info = currency_info_for(object)
184
-
185
- # FIXME: take locale into account
186
- value = "%.#{info[:digits]}f" % value
187
- while value.sub!(/(\d+)(\d\d\d)/,'\1,\2'); end
188
- if info[:space]
189
- info[:suffix] ? "#{value} #{info[:symbol]}" : "#{info[:symbol]} #{value}"
190
- else
191
- info[:suffix] ? "#{value}#{info[:symbol]}" : "#{info[:symbol]}#{value}"
262
+ def format_value(object, value, options={})
263
+ options = all_options.merge(options).symbolize_keys
264
+ intercept = options[:value_for_formatting]
265
+ if intercept && object.respond_to?(intercept)
266
+ value = object.send(intercept, value, options)
192
267
  end
268
+ ::Invoicing::CurrencyValue::Formatter.format_value(currency_of(object), value, options)
193
269
  end
194
270
 
195
271
  # If other modules have registered callbacks for the event of reading a rounded attribute,
@@ -310,7 +310,7 @@ module Invoicing
310
310
 
311
311
  # Set the 'amount' columns to act as currency values
312
312
  acts_as_currency_value(info.method(:total_amount), info.method(:tax_amount),
313
- :currency => info.method(:currency))
313
+ :currency => info.method(:currency), :value_for_formatting => :value_for_formatting)
314
314
 
315
315
  extend Invoicing::FindSubclasses
316
316
  include Invoicing::LedgerItem::RenderHTML
@@ -416,10 +416,13 @@ module Invoicing
416
416
 
417
417
  # Calculate sum of net_amount and tax_amount across all line items, and assign it to total_amount;
418
418
  # calculate sum of tax_amount across all line items, and assign it to tax_amount.
419
- # Called automatically as a +before_validation+ callback.
419
+ # Called automatically as a +before_validation+ callback. If the LedgerItem subtype is +payment+
420
+ # and there are no line items then the total amount is not touched.
420
421
  def calculate_total_amount
421
- net_total = tax_total = BigDecimal('0')
422
422
  line_items = ledger_item_class_info.get(self, :line_items)
423
+ return if self.class.is_payment && line_items.empty?
424
+
425
+ net_total = tax_total = BigDecimal('0')
423
426
 
424
427
  line_items.each do |line|
425
428
  info = line.send(:line_item_class_info)
@@ -544,6 +547,17 @@ module Invoicing
544
547
  raise ArgumentError, "self_id #{self_id.inspect} is both sender and recipient" if sender_is_self && recipient_is_self
545
548
  self.class.debit_when_sent_by_self ? sender_is_self : recipient_is_self
546
549
  end
550
+
551
+ # Invoked internally when +total_amount_formatted+ or +tax_amount_formatted+ is called. Allows
552
+ # you to specify options like <tt>:debit => :negative, :self_id => 42</tt> meaning that if this
553
+ # ledger item is a debit as regarded from the point of view of +self_id+ then it should be
554
+ # displayed as a negative number. Note this only affects the output formatting, not the actual
555
+ # stored values.
556
+ def value_for_formatting(value, options={})
557
+ value = -value if (options[:debit] == :negative) && debit?(options[:self_id])
558
+ value = -value if (options[:credit] == :negative) && !debit?(options[:self_id])
559
+ value
560
+ end
547
561
 
548
562
 
549
563
  module ClassMethods
@@ -580,29 +594,52 @@ module Invoicing
580
594
 
581
595
  # Returns a summary of the customer or supplier account between two parties identified
582
596
  # by +self_id+ (the party from whose perspective the account is seen, 'you') and +other_id+
583
- # ('them', your supplier/customer). The return value is a hash with the following structure:
597
+ # ('them', your supplier/customer). The return value is a hash with ISO 4217 currency codes
598
+ # as keys (as symbols), and summary objects as values. An account using only one currency
599
+ # will have only one entry in the hash, but more complex accounts may have several.
600
+ #
601
+ # The summary object has the following methods:
584
602
  #
585
- # { :GBP => { # ISO 4217 currency code for the following figures
586
- # :sales => BigDecimal(...), # Sum of sales (invoices sent by self_id)
587
- # :purchases => BigDecimal(...), # Sum of purchases (invoices received by self_id)
588
- # :sale_receipts => BigDecimal(...), # Sum of payments received from customer
589
- # :purchase_payments => BigDecimal(...), # Sum of payments made to supplier
590
- # :balance => BigDecimal(...) # sales - purchases - sale_receipts + purchase_payments
591
- # },
592
- # :USD => { # Another block as above, if the account uses
593
- # ... # multiple currencies
594
- # }}
603
+ # currency => symbol # Same as the key of this hash entry
604
+ # sales => BigDecimal(...) # Sum of sales (invoices sent by self_id)
605
+ # purchases => BigDecimal(...) # Sum of purchases (invoices received by self_id)
606
+ # sale_receipts => BigDecimal(...) # Sum of payments received from customer
607
+ # purchase_payments => BigDecimal(...) # Sum of payments made to supplier
608
+ # balance => BigDecimal(...) # sales - purchases - sale_receipts + purchase_payments
595
609
  #
596
610
  # The <tt>:balance</tt> fields indicate any outstanding money owed on the account: the value is
597
611
  # positive if they owe you money, and negative if you owe them money.
598
- def account_summary(self_id, other_id)
612
+ #
613
+ # In addition, +acts_as_currency_value+ is set on the numeric fields, so you can use its
614
+ # convenience methods such as +summary.sales_formatted+.
615
+ #
616
+ # If +other_id+ is +nil+, this method aggregates the accounts of +self_id+ with *all* other
617
+ # parties.
618
+ def account_summary(self_id, other_id=nil)
599
619
  info = ledger_item_class_info
600
- conditions = {info.method(:sender_id) => [self_id, other_id],
601
- info.method(:recipient_id) => [self_id, other_id]}
602
-
603
- with_scope :find => {:conditions => conditions} do
604
- summaries = account_summaries(self_id)
605
- summaries[other_id] || {}
620
+ self_id = self_id.to_i
621
+ other_id = [nil, ''].include?(other_id) ? nil : other_id.to_i
622
+
623
+ if other_id.nil?
624
+ result = {}
625
+ # Sum over all others, grouped by currency
626
+ account_summaries(self_id).each_pair do |other_id, hash|
627
+ hash.each_pair do |currency, summary|
628
+ if result[currency]
629
+ result[currency] += summary
630
+ else
631
+ result[currency] = summary
632
+ end
633
+ end
634
+ end
635
+ result
636
+
637
+ else
638
+ conditions = {info.method(:sender_id) => [self_id, other_id],
639
+ info.method(:recipient_id) => [self_id, other_id]}
640
+ with_scope(:find => {:conditions => conditions}) do
641
+ account_summaries(self_id)[other_id] || {}
642
+ end
606
643
  end
607
644
  end
608
645
 
@@ -611,11 +648,11 @@ module Invoicing
611
648
  # which have +self_id+ as their +sender_id+ or +recipient_id+. Returns a hash whose keys are the
612
649
  # other party of each account (i.e. the value of +sender_id+ or +recipient_id+ which is not
613
650
  # +self_id+, as an integer), and whose values are again hashes, of the same form as returned by
614
- # +account_summary+:
651
+ # +account_summary+ (+summary+ objects as documented on +account_summary+):
615
652
  #
616
653
  # LedgerItem.account_summaries(1)
617
- # # => { 2 => { :USD => { :sales => ... }, :EUR => { :sales => ... } },
618
- # # 3 => { :EUR => { :sales => ... } } }
654
+ # # => { 2 => { :USD => summary, :EUR => summary },
655
+ # # 3 => { :EUR => summary } }
619
656
  #
620
657
  # If you want to further restrict the ledger items taken into account in this calculation (e.g.
621
658
  # include only data from a particular quarter) you can call this method within an ActiveRecord
@@ -646,8 +683,6 @@ module Invoicing
646
683
  other_id_column = ext.conditional_function(sender_is_self, cols[:recipient_id], cols[:sender_id])
647
684
  filter_conditions = "#{cols[:status]} IN ('closed','cleared') AND (#{sender_is_self} OR #{recipient_is_self})"
648
685
 
649
-
650
-
651
686
  sql = "SELECT #{other_id_column} AS other_id, #{cols[:currency]} AS currency, " +
652
687
  "SUM(#{ext.conditional_function(debit_when_sent, cols[:total_amount], 0)}) AS sales, " +
653
688
  "SUM(#{ext.conditional_function(debit_when_received, cols[:total_amount], 0)}) AS purchase_payments, " +
@@ -672,7 +707,7 @@ module Invoicing
672
707
  row.symbolize_keys!
673
708
  other_id = row[:other_id].to_i
674
709
  currency = row[:currency].to_sym
675
- summary = {:balance => BigDecimal('0')}
710
+ summary = {:balance => BigDecimal('0'), :currency => currency}
676
711
 
677
712
  {:sales => 1, :purchases => -1, :sale_receipts => -1, :purchase_payments => 1}.each_pair do |field, factor|
678
713
  summary[field] = BigDecimal(row[field])
@@ -680,11 +715,95 @@ module Invoicing
680
715
  end
681
716
 
682
717
  results[other_id] ||= {}
683
- results[other_id][currency] = summary
718
+ results[other_id][currency] = AccountSummary.new summary
684
719
  end
685
720
 
686
721
  results
687
722
  end
723
+
724
+ # Takes an array of IDs like those used in +sender_id+ and +recipient_id+, and returns a hash
725
+ # which maps each of these IDs (typecast to integer) to the <tt>:name</tt> field of the
726
+ # hash returned by +sender_details+ or +recipient_details+ for that ID. This is useful as it
727
+ # allows +LedgerItem+ to use human-readable names for people or organisations in its output,
728
+ # without depending on a particular implementation of the model objects used to store those
729
+ # entities.
730
+ #
731
+ # LedgerItem.sender_recipient_name_map [2, 4]
732
+ # => {2 => "Fast Flowers Ltd.", 4 => "Speedy Motors"}
733
+ def sender_recipient_name_map(*sender_recipient_ids)
734
+ sender_recipient_ids = sender_recipient_ids.flatten.map &:to_i
735
+ sender_recipient_to_ledger_item_ids = {}
736
+ result_map = {}
737
+ info = ledger_item_class_info
738
+
739
+ # Find the most recent occurrence of each ID, first in the sender_id column, then in recipient_id
740
+ [:sender_id, :recipient_id].each do |column|
741
+ column = info.method(column)
742
+ quoted_column = connection.quote_column_name(column)
743
+ sql = "SELECT MAX(#{primary_key}) AS id, #{quoted_column} AS ref FROM #{quoted_table_name} WHERE "
744
+ sql << merge_conditions({column => sender_recipient_ids})
745
+ sql << " GROUP BY #{quoted_column}"
746
+
747
+ ActiveRecord::Base.connection.select_all(sql).each do |row|
748
+ sender_recipient_to_ledger_item_ids[row['ref'].to_i] = row['id'].to_i
749
+ end
750
+
751
+ sender_recipient_ids -= sender_recipient_to_ledger_item_ids.keys
752
+ end
753
+
754
+ # Load all the ledger items needed to get one representative of each name
755
+ find(sender_recipient_to_ledger_item_ids.values.uniq).each do |ledger_item|
756
+ sender_id = info.get(ledger_item, :sender_id)
757
+ recipient_id = info.get(ledger_item, :recipient_id)
758
+
759
+ if sender_recipient_to_ledger_item_ids.include? sender_id
760
+ details = info.get(ledger_item, :sender_details)
761
+ result_map[sender_id] = details[:name]
762
+ end
763
+ if sender_recipient_to_ledger_item_ids.include? recipient_id
764
+ details = info.get(ledger_item, :recipient_details)
765
+ result_map[recipient_id] = details[:name]
766
+ end
767
+ end
768
+
769
+ result_map
770
+ end
771
+
772
+ end # module ClassMethods
773
+
774
+
775
+ # Very simple class for representing the sum of all sales, purchases and payments on
776
+ # an account.
777
+ class AccountSummary #:nodoc:
778
+ NUM_FIELDS = [:sales, :purchases, :sale_receipts, :purchase_payments, :balance]
779
+ attr_reader *([:currency] + NUM_FIELDS)
780
+
781
+ def initialize(hash)
782
+ @currency = hash[:currency]; @sales = hash[:sales]; @purchases = hash[:purchases]
783
+ @sale_receipts = hash[:sale_receipts]; @purchase_payments = hash[:purchase_payments]
784
+ @balance = hash[:balance]
785
+ end
786
+
787
+ def method_missing(name, *args)
788
+ if name.to_s =~ /(.*)_formatted$/
789
+ ::Invoicing::CurrencyValue::Formatter.format_value(currency, send($1))
790
+ else
791
+ super
792
+ end
793
+ end
794
+
795
+ def +(other)
796
+ hash = {:currency => currency}
797
+ NUM_FIELDS.each {|field| hash[field] = send(field) + other.send(field) }
798
+ AccountSummary.new hash
799
+ end
800
+
801
+ def to_s
802
+ NUM_FIELDS.map do |field|
803
+ val = send("#{field}_formatted")
804
+ "#{field} = #{val}"
805
+ end.join('; ')
806
+ end
688
807
  end
689
808
 
690
809