invoicing 0.1.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.
Files changed (62) hide show
  1. data/CHANGELOG +3 -0
  2. data/LICENSE +20 -0
  3. data/Manifest +60 -0
  4. data/README +48 -0
  5. data/Rakefile +75 -0
  6. data/invoicing.gemspec +41 -0
  7. data/lib/invoicing.rb +9 -0
  8. data/lib/invoicing/cached_record.rb +107 -0
  9. data/lib/invoicing/class_info.rb +187 -0
  10. data/lib/invoicing/connection_adapter_ext.rb +44 -0
  11. data/lib/invoicing/countries/uk.rb +24 -0
  12. data/lib/invoicing/currency_value.rb +212 -0
  13. data/lib/invoicing/find_subclasses.rb +193 -0
  14. data/lib/invoicing/ledger_item.rb +718 -0
  15. data/lib/invoicing/ledger_item/render_html.rb +515 -0
  16. data/lib/invoicing/ledger_item/render_ubl.rb +268 -0
  17. data/lib/invoicing/line_item.rb +246 -0
  18. data/lib/invoicing/price.rb +9 -0
  19. data/lib/invoicing/tax_rate.rb +9 -0
  20. data/lib/invoicing/taxable.rb +355 -0
  21. data/lib/invoicing/time_dependent.rb +388 -0
  22. data/lib/invoicing/version.rb +21 -0
  23. data/test/cached_record_test.rb +100 -0
  24. data/test/class_info_test.rb +253 -0
  25. data/test/connection_adapter_ext_test.rb +71 -0
  26. data/test/currency_value_test.rb +184 -0
  27. data/test/find_subclasses_test.rb +120 -0
  28. data/test/fixtures/README +7 -0
  29. data/test/fixtures/cached_record.sql +22 -0
  30. data/test/fixtures/class_info.sql +28 -0
  31. data/test/fixtures/currency_value.sql +29 -0
  32. data/test/fixtures/find_subclasses.sql +43 -0
  33. data/test/fixtures/ledger_item.sql +39 -0
  34. data/test/fixtures/line_item.sql +33 -0
  35. data/test/fixtures/price.sql +4 -0
  36. data/test/fixtures/tax_rate.sql +4 -0
  37. data/test/fixtures/taxable.sql +14 -0
  38. data/test/fixtures/time_dependent.sql +35 -0
  39. data/test/ledger_item_test.rb +352 -0
  40. data/test/line_item_test.rb +139 -0
  41. data/test/models/README +4 -0
  42. data/test/models/test_subclass_in_another_file.rb +3 -0
  43. data/test/models/test_subclass_not_in_database.rb +6 -0
  44. data/test/price_test.rb +9 -0
  45. data/test/ref-output/creditnote3.html +82 -0
  46. data/test/ref-output/creditnote3.xml +89 -0
  47. data/test/ref-output/invoice1.html +93 -0
  48. data/test/ref-output/invoice1.xml +111 -0
  49. data/test/ref-output/invoice2.html +86 -0
  50. data/test/ref-output/invoice2.xml +98 -0
  51. data/test/ref-output/invoice_null.html +36 -0
  52. data/test/render_html_test.rb +69 -0
  53. data/test/render_ubl_test.rb +32 -0
  54. data/test/setup.rb +37 -0
  55. data/test/tax_rate_test.rb +9 -0
  56. data/test/taxable_test.rb +180 -0
  57. data/test/test_helper.rb +48 -0
  58. data/test/time_dependent_test.rb +180 -0
  59. data/website/curvycorners.js +1 -0
  60. data/website/screen.css +149 -0
  61. data/website/template.html.erb +43 -0
  62. metadata +180 -0
@@ -0,0 +1,515 @@
1
+ # encoding: utf-8
2
+
3
+ require 'builder'
4
+
5
+ module Invoicing
6
+ module LedgerItem
7
+ # Included into ActiveRecord model object when +acts_as_ledger_item+ is invoked.
8
+ module RenderHTML
9
+ # Shortcut for rendering an invoice or a credit note into a human-readable HTML format.
10
+ # Can be called without any arguments, in which case a general-purpose representation is
11
+ # produced. Can also be given options and a block for customising the output:
12
+ #
13
+ # @invoice = Invoice.find(params[:id])
14
+ # @invoice.render_html :quantity_column => false do |i|
15
+ # i.date_format "%d %B %Y" # overwrites default "%Y-%m-%d"
16
+ # i.recipient_label "Customer" # overwrites default "Recipient"
17
+ # i.sender_label "Supplier" # overwrites default "Sender"
18
+ # i.description_tag do |params|
19
+ # "<p>Thank you for your order. Here is our invoice for your records.</p>\n" +
20
+ # "<p>Description: #{params[:description]}</p>\n"
21
+ # end
22
+ # end
23
+ def render_html(options={}, &block)
24
+ output_builder = HTMLOutputBuilder.new(self, options)
25
+ yield output_builder if block_given?
26
+ output_builder.build
27
+ end
28
+
29
+
30
+ class HTMLOutputBuilder #:nodoc:
31
+
32
+ HTML_ESCAPE = { '&' => '&amp;', '>' => '&gt;', '<' => '&lt;', '"' => '&quot;' }
33
+
34
+ attr_reader :ledger_item, :current_line_item, :options, :custom_fragments, :factor
35
+
36
+ def initialize(ledger_item, options)
37
+ @ledger_item = ledger_item
38
+ @options = default_options
39
+ @options.update(options)
40
+ @custom_fragments = {}
41
+ total_amount = get(:total_amount)
42
+ @factor = (total_amount && total_amount < BigDecimal('0')) ? BigDecimal('-1') : BigDecimal('1')
43
+ end
44
+
45
+ def default_options
46
+ line_items = get(:line_items)
47
+ {
48
+ :tax_point_column => line_items.map{|i| line_get(:tax_point, i) }.compact != [],
49
+ :quantity_column => line_items.map{|i| line_get(:quantity, i) }.compact != [],
50
+ :description_column => true,
51
+ :net_amount_column => true,
52
+ :tax_amount_column => line_items.map{|i| line_get(:tax_amount, i) }.compact != [],
53
+ :gross_amount_column => false,
54
+ :subtotal => true
55
+ }
56
+ end
57
+
58
+ def h(s)
59
+ s.to_s.gsub(/[#{HTML_ESCAPE.keys.join}]/) { |char| HTML_ESCAPE[char] }
60
+ end
61
+
62
+ # foo { block } => call block when invoking fragment foo
63
+ # foo "string" => return string when invoking fragment foo
64
+ # invoke_foo => invoke fragment foo; if none set, delegate to default_foo
65
+ def method_missing(method_id, *args, &block)
66
+ method_id = method_id.to_sym
67
+ if method_id.to_s =~ /^invoke_(.*)$/
68
+ method_id = $1.to_sym
69
+ if custom_fragments[method_id]
70
+ return custom_fragments[method_id].call(*args, &block)
71
+ else
72
+ return send("default_#{method_id}", *args, &block)
73
+ end
74
+ end
75
+
76
+ return super unless respond_to? "default_#{method_id}"
77
+
78
+ if block_given? && args.empty?
79
+ custom_fragments[method_id] = proc &block
80
+ elsif args.length == 1
81
+ custom_fragments[method_id] = proc{ args[0].to_s }
82
+ else
83
+ raise ArgumentError, "#{method_id} expects exactly one value or block argument"
84
+ end
85
+ end
86
+
87
+ # Returns the value of a (potentially renamed) method on the ledger item
88
+ def get(method_id)
89
+ ledger_item.send(:ledger_item_class_info).get(ledger_item, method_id)
90
+ end
91
+
92
+ # Returns the value of a (potentially renamed) method on a line item
93
+ def line_get(method_id, line_item = current_line_item)
94
+ line_item.send(:line_item_class_info).get(line_item, method_id)
95
+ end
96
+
97
+ # String for one level of indentation
98
+ def indent
99
+ ' '
100
+ end
101
+
102
+ # This is quite meta. :)
103
+ #
104
+ # params_hash(:sender_label, :sender_tax_number => :tax_number_label)
105
+ # # => {:sender_label => invoke_sender_label,
106
+ # # :sender_tax_number => invoke_sender_tax_number(params_hash(:tax_number_label))}
107
+ def params_hash(*param_names)
108
+ result = {}
109
+ param_names.flatten!
110
+ options = param_names.extract_options!
111
+
112
+ param_names.each{|param| result[param.to_sym] = send("invoke_#{param}") }
113
+
114
+ options.each_pair do |key, value|
115
+ result[key.to_sym] = send("invoke_#{key}", params_hash(value))
116
+ end
117
+
118
+ result
119
+ end
120
+
121
+ # Renders an invoice or credit note to HTML
122
+ def build
123
+ addresses_table_deps = [:sender_label, :recipient_label, :sender_address, :recipient_address, {
124
+ :sender_tax_number => :tax_number_label,
125
+ :recipient_tax_number => :tax_number_label
126
+ }]
127
+
128
+ metadata_table_deps = [{
129
+ :identifier => :identifier_label,
130
+ :issue_date => [:date_format, :issue_date_label],
131
+ :period_start => [:date_format, :period_start_label],
132
+ :period_end => [:date_format, :period_end_label],
133
+ :due_date => [:date_format, :due_date_label]
134
+ }]
135
+
136
+ line_items_header_deps = [:line_tax_point_label, :line_quantity_label, :line_description_label,
137
+ :line_net_amount_label, :line_tax_amount_label, :line_gross_amount_label]
138
+
139
+ line_items_subtotal_deps = [:subtotal_label, :net_amount_label, :tax_amount_label,
140
+ :gross_amount_label, {
141
+ :net_amount => :net_amount_label,
142
+ :tax_amount => :tax_amount_label,
143
+ :total_amount => :gross_amount_label
144
+ }]
145
+
146
+ line_items_total_deps = [:total_label, :net_amount_label, :tax_amount_label,
147
+ :gross_amount_label, {
148
+ :net_amount => :net_amount_label,
149
+ :tax_amount => :tax_amount_label,
150
+ :total_amount => :gross_amount_label
151
+ }]
152
+
153
+ page_layout_deps = {
154
+ :title_tag => :title,
155
+ :addresses_table => addresses_table_deps,
156
+ :metadata_table => metadata_table_deps,
157
+ :description_tag => :description,
158
+ :line_items_table => [:line_items_list, {
159
+ :line_items_header => line_items_header_deps,
160
+ :line_items_subtotal => line_items_subtotal_deps,
161
+ :line_items_total => line_items_total_deps
162
+ }]
163
+ }
164
+
165
+ invoke_page_layout(params_hash(page_layout_deps))
166
+ end
167
+
168
+ def default_date_format
169
+ "%Y-%m-%d"
170
+ end
171
+
172
+ def default_invoice_label
173
+ "Invoice"
174
+ end
175
+
176
+ def default_credit_note_label
177
+ "Credit Note"
178
+ end
179
+
180
+ def default_recipient_label
181
+ "Recipient"
182
+ end
183
+
184
+ def default_sender_label
185
+ "Sender"
186
+ end
187
+
188
+ def default_tax_number_label
189
+ "VAT number:<br />"
190
+ end
191
+
192
+ def default_identifier_label
193
+ "Invoice no.:"
194
+ end
195
+
196
+ def default_issue_date_label
197
+ "Issue date:"
198
+ end
199
+
200
+ def default_period_start_label
201
+ "Period from:"
202
+ end
203
+
204
+ def default_period_end_label
205
+ "Period until:"
206
+ end
207
+
208
+ def default_due_date_label
209
+ "Payment due:"
210
+ end
211
+
212
+ def default_line_tax_point_label
213
+ "Tax point"
214
+ end
215
+
216
+ def default_line_quantity_label
217
+ "Quantity"
218
+ end
219
+
220
+ def default_line_description_label
221
+ "Description"
222
+ end
223
+
224
+ def default_line_net_amount_label
225
+ "Net price"
226
+ end
227
+
228
+ def default_line_tax_amount_label
229
+ "VAT"
230
+ end
231
+
232
+ def default_line_gross_amount_label
233
+ "Gross price"
234
+ end
235
+
236
+ def default_subtotal_label
237
+ "Subtotal"
238
+ end
239
+
240
+ def default_total_label
241
+ "Total"
242
+ end
243
+
244
+ def default_net_amount_label
245
+ "Net: "
246
+ end
247
+
248
+ def default_tax_amount_label
249
+ "VAT: "
250
+ end
251
+
252
+ def default_gross_amount_label
253
+ ""
254
+ end
255
+
256
+ def default_title
257
+ (factor == BigDecimal('-1')) ? invoke_credit_note_label : invoke_invoice_label
258
+ end
259
+
260
+ def default_title_tag(params)
261
+ "<h1 class=\"invoice\">#{params[:title]}</h1>\n"
262
+ end
263
+
264
+ def default_address(details)
265
+ html = "#{indent*3}<div class=\"fn org\">#{ h(details[:name]) }</div>\n"
266
+ html << "#{indent*3}<div class=\"contact\">#{ h(details[:contact_name])}</div>\n" unless details[:contact_name].blank?
267
+ html << "#{indent*3}<div class=\"adr\">\n"
268
+ html << "#{indent*4}<span class=\"street-address\">#{h(details[:address]).strip.gsub(/\n/, '<br />')}</span><br />\n"
269
+ html << "#{indent*4}<span class=\"locality\">#{ h(details[:city]) }</span><br />\n" unless details[:city].blank?
270
+ html << "#{indent*4}<span class=\"region\">#{ h(details[:state]) }</span><br />\n" unless details[:state].blank?
271
+ html << "#{indent*4}<span class=\"postal-code\">#{ h(details[:postal_code]) }</span><br />\n" unless details[:postal_code].blank?
272
+ html << "#{indent*4}<span class=\"country-name\">#{h(details[:country]) }</span>\n" unless details[:country].blank?
273
+ html << "#{indent*3}</div>\n"
274
+ end
275
+
276
+ def default_sender_address
277
+ invoke_address(get(:sender_details))
278
+ end
279
+
280
+ def default_recipient_address
281
+ invoke_address(get(:recipient_details))
282
+ end
283
+
284
+ def default_sender_tax_number(params)
285
+ sender_tax_number = get(:sender_details)[:tax_number]
286
+ "#{params[:tax_number_label]}<span class=\"tax-number\">#{h(sender_tax_number)}</span>"
287
+ end
288
+
289
+ def default_recipient_tax_number(params)
290
+ recipient_tax_number = get(:recipient_details)[:tax_number]
291
+ "#{params[:tax_number_label]}<span class=\"tax-number\">#{h(recipient_tax_number)}</span>"
292
+ end
293
+
294
+ def default_addresses_table(params)
295
+ html = "#{indent*0}<table class=\"invoice addresses\">\n"
296
+ html << "#{indent*1}<tr>\n"
297
+ html << "#{indent*2}<th class=\"recipient\">#{params[:recipient_label]}</th>\n"
298
+ html << "#{indent*2}<th class=\"sender\">#{params[:sender_label]}</th>\n"
299
+ html << "#{indent*1}</tr>\n"
300
+ html << "#{indent*1}<tr>\n"
301
+ html << "#{indent*2}<td class=\"recipient vcard\">\n#{params[:recipient_address]}"
302
+ html << "#{indent*2}</td>\n"
303
+ html << "#{indent*2}<td class=\"sender vcard\">\n#{params[:sender_address]}"
304
+ html << "#{indent*2}</td>\n"
305
+ html << "#{indent*1}</tr>\n"
306
+ html << "#{indent*1}<tr>\n"
307
+ html << "#{indent*2}<td class=\"recipient\">\n"
308
+ html << "#{indent*3}#{params[:recipient_tax_number]}\n"
309
+ html << "#{indent*2}</td>\n"
310
+ html << "#{indent*2}<td class=\"sender\">\n"
311
+ html << "#{indent*3}#{params[:sender_tax_number]}\n"
312
+ html << "#{indent*2}</td>\n"
313
+ html << "#{indent*1}</tr>\n"
314
+ html << "#{indent*0}</table>\n"
315
+ end
316
+
317
+ def default_metadata_item(params, key, value)
318
+ label = params["#{key}_label".to_sym]
319
+ html = "#{indent*1}<tr class=\"#{key.to_s.gsub(/_/, '-')}\">\n"
320
+ html << "#{indent*2}<th>#{label}</th>\n"
321
+ html << "#{indent*2}<td>#{h(value)}</td>\n"
322
+ html << "#{indent*1}</tr>\n"
323
+ end
324
+
325
+ def default_identifier(params)
326
+ invoke_metadata_item(params, :identifier, get(:identifier))
327
+ end
328
+
329
+ def default_issue_date(params)
330
+ if issue_date = get(:issue_date)
331
+ invoke_metadata_item(params, :issue_date, issue_date.strftime(params[:date_format]))
332
+ else
333
+ ""
334
+ end
335
+ end
336
+
337
+ def default_period_start(params)
338
+ if period_start = get(:period_start)
339
+ invoke_metadata_item(params, :period_start, period_start.strftime(params[:date_format]))
340
+ else
341
+ ""
342
+ end
343
+ end
344
+
345
+ def default_period_end(params)
346
+ if period_end = get(:period_end)
347
+ invoke_metadata_item(params, :period_end, period_end.strftime(params[:date_format]))
348
+ else
349
+ ""
350
+ end
351
+ end
352
+
353
+ def default_due_date(params)
354
+ if due_date = get(:due_date)
355
+ invoke_metadata_item(params, :due_date, due_date.strftime(params[:date_format]))
356
+ else
357
+ ""
358
+ end
359
+ end
360
+
361
+ def default_metadata_table(params)
362
+ "<table class=\"invoice metadata\">\n" + params[:identifier] + params[:issue_date] +
363
+ params[:period_start] + params[:period_end] + params[:due_date] + "#{indent*0}</table>\n"
364
+ end
365
+
366
+ def default_description
367
+ h(get(:description))
368
+ end
369
+
370
+ def default_description_tag(params)
371
+ "<p class=\"invoice description\">#{params[:description]}</p>\n"
372
+ end
373
+
374
+ def default_line_items_header(params)
375
+ html = "#{indent*1}<tr>\n"
376
+ html << "#{indent*2}<th class=\"tax-point\">#{ params[:line_tax_point_label] }</th>\n" if options[:tax_point_column]
377
+ html << "#{indent*2}<th class=\"quantity\">#{ params[:line_quantity_label] }</th>\n" if options[:quantity_column]
378
+ html << "#{indent*2}<th class=\"description\">#{ params[:line_description_label] }</th>\n" if options[:description_column]
379
+ html << "#{indent*2}<th class=\"net-amount\">#{ params[:line_net_amount_label] }</th>\n" if options[:net_amount_column]
380
+ html << "#{indent*2}<th class=\"tax-amount\">#{ params[:line_tax_amount_label] }</th>\n" if options[:tax_amount_column]
381
+ html << "#{indent*2}<th class=\"gross-amount\">#{params[:line_gross_amount_label]}</th>\n" if options[:gross_amount_column]
382
+ html << "#{indent*1}</tr>\n"
383
+ end
384
+
385
+ def default_line_tax_point(params)
386
+ if tax_point = line_get(:tax_point)
387
+ h(tax_point.strftime(params[:date_format]))
388
+ else
389
+ ""
390
+ end
391
+ end
392
+
393
+ def default_line_quantity(params)
394
+ h(line_get(:quantity).to_s)
395
+ end
396
+
397
+ def default_line_description(params)
398
+ h(line_get(:description))
399
+ end
400
+
401
+ def default_line_net_amount(params)
402
+ if net_amount = line_get(:net_amount)
403
+ h(current_line_item.format_currency_value(net_amount*factor))
404
+ else
405
+ "—"
406
+ end
407
+ end
408
+
409
+ def default_line_tax_amount(params)
410
+ if tax_amount = line_get(:tax_amount)
411
+ h(current_line_item.format_currency_value(tax_amount*factor))
412
+ else
413
+ "—"
414
+ end
415
+ end
416
+
417
+ def default_line_gross_amount(params)
418
+ if gross_amount = line_get(:gross_amount)
419
+ h(current_line_item.format_currency_value(gross_amount*factor))
420
+ else
421
+ "—"
422
+ end
423
+ end
424
+
425
+ def default_net_amount(params)
426
+ if net_amount = get(:net_amount)
427
+ h(ledger_item.format_currency_value(net_amount*factor))
428
+ else
429
+ "—"
430
+ end
431
+ end
432
+
433
+ def default_tax_amount(params)
434
+ if tax_amount = get(:tax_amount)
435
+ h(ledger_item.format_currency_value(tax_amount*factor))
436
+ else
437
+ "—"
438
+ end
439
+ end
440
+
441
+ def default_total_amount(params)
442
+ if total_amount = get(:total_amount)
443
+ h(ledger_item.format_currency_value(total_amount*factor))
444
+ else
445
+ "—"
446
+ end
447
+ end
448
+
449
+ def default_line_item(params)
450
+ html = "#{indent*1}<tr>\n"
451
+ html << "#{indent*2}<td class=\"tax-point\">#{ params[:line_tax_point] }</td>\n" if options[:tax_point_column]
452
+ html << "#{indent*2}<td class=\"quantity\">#{ params[:line_quantity] }</td>\n" if options[:quantity_column]
453
+ html << "#{indent*2}<td class=\"description\">#{ params[:line_description] }</td>\n" if options[:description_column]
454
+ html << "#{indent*2}<td class=\"net-amount\">#{ params[:line_net_amount] }</td>\n" if options[:net_amount_column]
455
+ html << "#{indent*2}<td class=\"tax-amount\">#{ params[:line_tax_amount] }</td>\n" if options[:tax_amount_column]
456
+ html << "#{indent*2}<td class=\"gross-amount\">#{params[:line_gross_amount]}</td>\n" if options[:gross_amount_column]
457
+ html << "#{indent*1}</tr>\n"
458
+ end
459
+
460
+ def default_line_items_list
461
+ get(:line_items).sorted(:tax_point).map do |line_item|
462
+ @current_line_item = line_item
463
+ invoke_line_item(params_hash(
464
+ :line_tax_point => [:line_tax_point_label, :date_format],
465
+ :line_quantity => [:line_quantity_label],
466
+ :line_description => [:line_description_label],
467
+ :line_net_amount => [:line_net_amount_label],
468
+ :line_tax_amount => [:line_tax_amount_label],
469
+ :line_gross_amount => [:line_gross_amount_label]
470
+ ))
471
+ end.join
472
+ end
473
+
474
+ def default_line_items_subtotal(params)
475
+ colspan = 0
476
+ colspan += 1 if options[:tax_point_column]
477
+ colspan += 1 if options[:quantity_column]
478
+ colspan += 1 if options[:description_column]
479
+ html = "#{indent*1}<tr class=\"subtotal\">\n"
480
+ html << "#{indent*2}<th colspan=\"#{colspan}\">#{params[:subtotal_label]}</th>\n"
481
+ html << "#{indent*2}<td class=\"net-amount\">#{params[:net_amount_label]}#{params[:net_amount]}</td>\n" if options[:net_amount_column]
482
+ html << "#{indent*2}<td class=\"tax-amount\">#{params[:tax_amount_label]}#{params[:tax_amount]}</td>\n" if options[:tax_amount_column]
483
+ html << "#{indent*2}<td class=\"gross-amount\"></td>\n" if options[:gross_amount_column]
484
+ html << "#{indent*1}</tr>\n"
485
+ end
486
+
487
+ def default_line_items_total(params)
488
+ colspan = -1
489
+ colspan += 1 if options[:tax_point_column]
490
+ colspan += 1 if options[:quantity_column]
491
+ colspan += 1 if options[:description_column]
492
+ colspan += 1 if options[:net_amount_column]
493
+ colspan += 1 if options[:tax_amount_column]
494
+ colspan += 1 if options[:gross_amount_column]
495
+ html = "#{indent*1}<tr class=\"total\">\n"
496
+ html << "#{indent*2}<th colspan=\"#{colspan}\">#{params[:total_label]}</th>\n"
497
+ html << "#{indent*2}<td class=\"total-amount\">#{params[:gross_amount_label]}#{params[:total_amount]}</td>\n"
498
+ html << "#{indent*1}</tr>\n"
499
+ end
500
+
501
+ def default_line_items_table(params)
502
+ html = "<table class=\"invoice line-items\">\n"
503
+ html << params[:line_items_header] + params[:line_items_list]
504
+ html << params[:line_items_subtotal] if options[:subtotal]
505
+ html << params[:line_items_total] + "</table>\n"
506
+ end
507
+
508
+ def default_page_layout(params)
509
+ params[:title_tag] + params[:addresses_table] + params[:metadata_table] +
510
+ params[:description_tag] + params[:line_items_table]
511
+ end
512
+ end
513
+ end
514
+ end
515
+ end