webtranslateit-payday 1.2.7
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 +7 -0
- data/.gitattributes +1 -0
- data/.github/dependabot.yml +11 -0
- data/.github/workflows/ci.yml +53 -0
- data/.gitignore +8 -0
- data/.rubocop.yml +11 -0
- data/.rubocop_todo.yml +22 -0
- data/CHANGELOG.md +76 -0
- data/Gemfile +3 -0
- data/Guardfile +10 -0
- data/README.md +175 -0
- data/Rakefile +12 -0
- data/fonts/NotoSans-Bold.ttf +0 -0
- data/fonts/NotoSans-Regular.ttf +0 -0
- data/lib/generators/payday/setup/USAGE +30 -0
- data/lib/generators/payday/setup/setup_generator.rb +44 -0
- data/lib/generators/payday/setup/templates/invoice.rb +5 -0
- data/lib/generators/payday/setup/templates/line_item.rb +5 -0
- data/lib/generators/payday/setup/templates/migration.rb +26 -0
- data/lib/payday/config.rb +35 -0
- data/lib/payday/i18n.rb +3 -0
- data/lib/payday/invoice.rb +52 -0
- data/lib/payday/invoiceable.rb +87 -0
- data/lib/payday/line_item.rb +46 -0
- data/lib/payday/line_itemable.rb +12 -0
- data/lib/payday/locale/de.yml +23 -0
- data/lib/payday/locale/en.yml +22 -0
- data/lib/payday/locale/es.yml +22 -0
- data/lib/payday/locale/fr.yml +22 -0
- data/lib/payday/locale/nl.yml +22 -0
- data/lib/payday/locale/zh-CN.yml +21 -0
- data/lib/payday/pdf_renderer.rb +322 -0
- data/lib/payday/version.rb +5 -0
- data/lib/payday.rb +18 -0
- data/payday.gemspec +35 -0
- data/spec/assets/default_logo.png +0 -0
- data/spec/assets/svg.pdf +2501 -0
- data/spec/assets/testing.pdf +14178 -0
- data/spec/assets/testing_predefined_amount.pdf +0 -0
- data/spec/assets/tiger.svg +52 -0
- data/spec/invoice_spec.rb +244 -0
- data/spec/line_item_spec.rb +30 -0
- data/spec/pdf_renderer_spec.rb +11 -0
- data/spec/spec_helper.rb +9 -0
- data/spec/support/asset_matchers.rb +26 -0
- metadata +297 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Include {Payday::Invoiceable} in your Invoice class to make it Payday compatible. Payday
|
|
2
|
+
# expects that a +line_items+ method containing an Enumerable of {Payday::LineItem} compatible
|
|
3
|
+
# elements exists. Those LineItem objects primarily need to include quantity, price, and description methods.
|
|
4
|
+
#
|
|
5
|
+
# The +bill_to+ method should always be overwritten by your class. Otherwise, it'll say that your invoice should
|
|
6
|
+
# be billed to Goofy McGoofison. +ship_to+ is also available, but will not be used in rendered invoices if it
|
|
7
|
+
# doesn't exist.
|
|
8
|
+
#
|
|
9
|
+
# Although not required, if a +tax_rate+ method exists, {Payday::Invoiceable} will use it to calculate tax
|
|
10
|
+
# when generating an invoice. We include a simple tax method that calculates tax, but it's probably wiser
|
|
11
|
+
# to override this in your class (our calculated tax won't be stored to a database by default, for example).
|
|
12
|
+
#
|
|
13
|
+
# rubocop:todo Layout/LineLength
|
|
14
|
+
# If the +due_at+, +paid_at+, and +refunded_at+ methods are available, {Payday::Invoiceable} will use them to show due dates,
|
|
15
|
+
# rubocop:enable Layout/LineLength
|
|
16
|
+
# paid dates, and refunded dates, as well as stamps showing if the invoice is paid or due.
|
|
17
|
+
module Payday
|
|
18
|
+
module Invoiceable # rubocop:todo Style/Documentation
|
|
19
|
+
# Who the invoice is being sent to.
|
|
20
|
+
def bill_to
|
|
21
|
+
"Goofy McGoofison\nYour Invoice Doesn't\nHave It's Own BillTo Method"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Calculates the subtotal of this invoice by adding up all of the line items
|
|
25
|
+
def subtotal
|
|
26
|
+
line_items.reduce(BigDecimal('0')) { |result, item| result + item.amount }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The tax for this invoice, as a BigDecimal
|
|
30
|
+
def tax
|
|
31
|
+
if defined?(tax_rate)
|
|
32
|
+
subtotal * tax_rate / 100
|
|
33
|
+
else
|
|
34
|
+
0
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# TODO: Add a per weight unit shipping cost
|
|
39
|
+
# Calculates the shipping
|
|
40
|
+
def shipping
|
|
41
|
+
if defined?(shipping_rate)
|
|
42
|
+
shipping_rate
|
|
43
|
+
else
|
|
44
|
+
0
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Calculates the total for this invoice.
|
|
49
|
+
def total
|
|
50
|
+
subtotal + tax + shipping
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def overdue?
|
|
54
|
+
# rubocop:todo Layout/LineLength
|
|
55
|
+
defined?(due_at) && ((due_at.is_a?(Date) && due_at < Date.today) || (due_at.is_a?(Time) && due_at < Time.now)) && !paid_at
|
|
56
|
+
# rubocop:enable Layout/LineLength
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def refunded?
|
|
60
|
+
defined?(refunded_at) && !!refunded_at
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def paid?
|
|
64
|
+
defined?(paid_at) && !!paid_at
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Renders this invoice to pdf as a string
|
|
68
|
+
def render_pdf
|
|
69
|
+
Payday::PdfRenderer.render(self)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Renders this invoice to pdf
|
|
73
|
+
def render_pdf_to_file(path)
|
|
74
|
+
Payday::PdfRenderer.render_to_file(self, path)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Iterates through the details on this invoiceable. The block given should accept
|
|
78
|
+
# two parameters, the detail name and the actual detail value.
|
|
79
|
+
def each_detail(&block)
|
|
80
|
+
return if defined?(invoice_details).nil?
|
|
81
|
+
|
|
82
|
+
invoice_details.each do |detail|
|
|
83
|
+
block.call(detail[0], detail[1])
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Payday
|
|
2
|
+
# Represents a line item in an invoice.
|
|
3
|
+
#
|
|
4
|
+
# rubocop:todo Layout/LineLength
|
|
5
|
+
# +quantity+ and +price+ are written to be pretty picky, primarily because if we're not picky about what values are set to
|
|
6
|
+
# rubocop:enable Layout/LineLength
|
|
7
|
+
# them your invoice math could get pretty messed up. It's recommended that both values be set to +BigDecimal+ values.
|
|
8
|
+
# Otherwise, we'll do our best to convert the set values to a +BigDecimal+.
|
|
9
|
+
class LineItem
|
|
10
|
+
include LineItemable
|
|
11
|
+
|
|
12
|
+
attr_accessor :description, :display_quantity, :display_price
|
|
13
|
+
attr_reader :quantity, :price, :predefined_amount
|
|
14
|
+
|
|
15
|
+
# Initializes a new LineItem
|
|
16
|
+
def initialize(options = {})
|
|
17
|
+
if options[:predefined_amount]
|
|
18
|
+
self.predefined_amount = options[:predefined_amount]
|
|
19
|
+
else
|
|
20
|
+
self.quantity = options[:quantity] || '1'
|
|
21
|
+
self.display_quantity = options[:display_quantity]
|
|
22
|
+
self.display_price = options[:display_price]
|
|
23
|
+
self.price = options[:price] || '0.00'
|
|
24
|
+
end
|
|
25
|
+
self.description = options[:description] || ''
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Sets the quantity of this {LineItem}
|
|
29
|
+
def quantity=(value)
|
|
30
|
+
value = 0 if value.to_s.blank?
|
|
31
|
+
@quantity = BigDecimal(value.to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Sets the price for this {LineItem}
|
|
35
|
+
def price=(value)
|
|
36
|
+
value = 0 if value.to_s.blank?
|
|
37
|
+
@price = BigDecimal(value.to_s)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Sets the predefined_amount for this {LineItem}
|
|
41
|
+
def predefined_amount=(value)
|
|
42
|
+
value = 0 if value.to_s.blank?
|
|
43
|
+
@predefined_amount = BigDecimal(value.to_s)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Include this module into your line item implementation to make sure that Payday stays happy
|
|
2
|
+
# with it, or just make sure that your line item implements the amount method.
|
|
3
|
+
module Payday
|
|
4
|
+
module LineItemable # rubocop:todo Style/Documentation
|
|
5
|
+
# Returns the total amount for this {LineItemable}, or +price * quantity+
|
|
6
|
+
def amount
|
|
7
|
+
return predefined_amount if predefined_amount
|
|
8
|
+
|
|
9
|
+
price * quantity
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
de:
|
|
2
|
+
payday:
|
|
3
|
+
status:
|
|
4
|
+
overdue: ÜBERFÄLLIG
|
|
5
|
+
paid: BEZAHLT
|
|
6
|
+
refunded: ZURÜCKGEZAHLT
|
|
7
|
+
invoice:
|
|
8
|
+
bill_to: Rechnung an
|
|
9
|
+
ship_to: Versenden an
|
|
10
|
+
invoice_no: "Rechnungsnr.:"
|
|
11
|
+
invoice_date: "Rechnungsdatum:"
|
|
12
|
+
due_date: "Fälligkeit:"
|
|
13
|
+
paid_date: "Zahlungsdatum:"
|
|
14
|
+
subtotal: "Zwischensumme:"
|
|
15
|
+
shipping: "Versand:"
|
|
16
|
+
tax: "Steuern:"
|
|
17
|
+
total: "Gesamt:"
|
|
18
|
+
notes: "Anmerkungen"
|
|
19
|
+
line_item:
|
|
20
|
+
description: Beschreibung
|
|
21
|
+
unit_price: Stückpreis
|
|
22
|
+
quantity: Menge
|
|
23
|
+
amount: Betrag
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
en:
|
|
2
|
+
payday:
|
|
3
|
+
status:
|
|
4
|
+
paid: PAID
|
|
5
|
+
overdue: OVERDUE
|
|
6
|
+
refunded: REFUNDED
|
|
7
|
+
invoice:
|
|
8
|
+
bill_to: "Bill To"
|
|
9
|
+
ship_to: "Ship To"
|
|
10
|
+
invoice_no: "Invoice number:"
|
|
11
|
+
receipt_no: "Receipt number:"
|
|
12
|
+
due_date: "Due Date:"
|
|
13
|
+
paid_date: "Paid Date:"
|
|
14
|
+
subtotal: "Subtotal:"
|
|
15
|
+
shipping: "Shipping:"
|
|
16
|
+
tax: "Tax:"
|
|
17
|
+
total: "Total:"
|
|
18
|
+
line_item:
|
|
19
|
+
description: Description
|
|
20
|
+
unit_price: "Unit Price"
|
|
21
|
+
quantity: Quantity
|
|
22
|
+
amount: Amount
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
es:
|
|
2
|
+
payday:
|
|
3
|
+
status:
|
|
4
|
+
paid: PAGADO
|
|
5
|
+
overdue: ATRASADO
|
|
6
|
+
refunded: REINTEGRADO
|
|
7
|
+
invoice:
|
|
8
|
+
bill_to: "Facturar a"
|
|
9
|
+
ship_to: "Enviar a"
|
|
10
|
+
invoice_no: "Número de Factura Proforma:"
|
|
11
|
+
receipt_no: "Número de Factura:"
|
|
12
|
+
due_date: "Vencimiento:"
|
|
13
|
+
paid_date: "Pagado en:"
|
|
14
|
+
subtotal: "Subtotal:"
|
|
15
|
+
shipping: "Envío:"
|
|
16
|
+
tax: "Impuesto:"
|
|
17
|
+
total: "Total:"
|
|
18
|
+
line_item:
|
|
19
|
+
description: Descripción
|
|
20
|
+
unit_price: "Precio por Unidad"
|
|
21
|
+
quantity: Cantidad
|
|
22
|
+
amount: Precio
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
fr:
|
|
2
|
+
payday:
|
|
3
|
+
status:
|
|
4
|
+
paid: PAYÉ
|
|
5
|
+
overdue: "EN RETARD"
|
|
6
|
+
refunded: "REMBOURSÉ"
|
|
7
|
+
invoice:
|
|
8
|
+
bill_to: "Adresse de facturation"
|
|
9
|
+
ship_to: "Adresse de livraison"
|
|
10
|
+
invoice_no: "Numéro de facture proforma :"
|
|
11
|
+
receipt_no: "Numéro de facture :"
|
|
12
|
+
due_date: "Date limite de paiement :"
|
|
13
|
+
paid_date: "Date de paiement :"
|
|
14
|
+
subtotal: "Sous-total :"
|
|
15
|
+
shipping: "Livraison :"
|
|
16
|
+
tax: "Taxes :"
|
|
17
|
+
total: "Total :"
|
|
18
|
+
line_item:
|
|
19
|
+
description: Description
|
|
20
|
+
unit_price: "Prix Unitaire"
|
|
21
|
+
quantity: Quantité
|
|
22
|
+
amount: Prix
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
nl:
|
|
2
|
+
payday:
|
|
3
|
+
status:
|
|
4
|
+
paid: BETAALD
|
|
5
|
+
overdue: BETAALTERMIJN VERSTREKEN
|
|
6
|
+
refund: FAKTUUR
|
|
7
|
+
invoice:
|
|
8
|
+
bill_to: Te betalen door
|
|
9
|
+
ship_to: Te bezorgen aan
|
|
10
|
+
invoice_no: "Faktuur Nr:"
|
|
11
|
+
due_date: "Te betalen voor:"
|
|
12
|
+
paid_date: "Betaald op:"
|
|
13
|
+
subtotal: "Subtotaal:"
|
|
14
|
+
shipping: "Levering:"
|
|
15
|
+
tax: "Btw:"
|
|
16
|
+
total: "Totaal:"
|
|
17
|
+
notes: "Opmerkingen"
|
|
18
|
+
line_item:
|
|
19
|
+
description: Omschrijving
|
|
20
|
+
unit_price: Eenheidsprijs
|
|
21
|
+
quantity: Hoeveelheid
|
|
22
|
+
amount: Bedrag
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
zh-CN:
|
|
2
|
+
payday:
|
|
3
|
+
status:
|
|
4
|
+
paid: 付款成功
|
|
5
|
+
overdue: 已过期
|
|
6
|
+
invoice:
|
|
7
|
+
bill_to: 账单地址
|
|
8
|
+
ship_to: 送货地址
|
|
9
|
+
invoice_no: "单号 #:"
|
|
10
|
+
due_date: "过期日期:"
|
|
11
|
+
paid_date: "支付日期:"
|
|
12
|
+
subtotal: "小计:"
|
|
13
|
+
shipping: "邮费:"
|
|
14
|
+
tax: "税费:"
|
|
15
|
+
total: "总计:"
|
|
16
|
+
notes: "备注"
|
|
17
|
+
line_item:
|
|
18
|
+
description: 描述
|
|
19
|
+
unit_price: 单价
|
|
20
|
+
quantity: 数量
|
|
21
|
+
amount: 合计
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
module Payday
|
|
2
|
+
# The PDF renderer. We use this internally in Payday to render pdfs, but really you should just need to call
|
|
3
|
+
# {{Payday::Invoiceable#render_pdf}} to render pdfs yourself.
|
|
4
|
+
class PdfRenderer # rubocop:todo Metrics/ClassLength
|
|
5
|
+
# Renders the given invoice as a pdf on disk
|
|
6
|
+
def self.render_to_file(invoice, path)
|
|
7
|
+
pdf(invoice).render_file(path)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Renders the given invoice as a pdf, returning a string
|
|
11
|
+
def self.render(invoice)
|
|
12
|
+
pdf(invoice).render
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.pdf(invoice) # rubocop:todo Metrics/MethodLength, Metrics/AbcSize
|
|
16
|
+
pdf = Prawn::Document.new(page_size: invoice_or_default(invoice, :page_size))
|
|
17
|
+
|
|
18
|
+
font_dir = File.join(File.dirname(__dir__), '..', 'fonts')
|
|
19
|
+
pdf.font_families.update(
|
|
20
|
+
'NotoSans' => { normal: File.join(font_dir, 'NotoSans-Regular.ttf'),
|
|
21
|
+
bold: File.join(font_dir, 'NotoSans-Bold.ttf') }
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# set up some default styling
|
|
25
|
+
pdf.font_size(10)
|
|
26
|
+
pdf.font 'NotoSans'
|
|
27
|
+
|
|
28
|
+
stamp(invoice, pdf)
|
|
29
|
+
company_banner(invoice, pdf)
|
|
30
|
+
bill_to_ship_to(invoice, pdf)
|
|
31
|
+
invoice_details(invoice, pdf)
|
|
32
|
+
line_items_table(invoice, pdf)
|
|
33
|
+
totals_lines(invoice, pdf)
|
|
34
|
+
notes(invoice, pdf)
|
|
35
|
+
|
|
36
|
+
page_numbers(pdf)
|
|
37
|
+
|
|
38
|
+
pdf
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.stamp(invoice, pdf) # rubocop:todo Metrics/MethodLength
|
|
42
|
+
stamp = nil
|
|
43
|
+
if invoice.refunded?
|
|
44
|
+
stamp = I18n.t 'payday.status.refunded', default: 'REFUNDED'
|
|
45
|
+
elsif invoice.paid?
|
|
46
|
+
stamp = I18n.t 'payday.status.paid', default: 'PAID'
|
|
47
|
+
elsif invoice.overdue?
|
|
48
|
+
stamp = I18n.t 'payday.status.overdue', default: 'OVERDUE'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if stamp
|
|
52
|
+
pdf.bounding_box([150, pdf.cursor - 50], width: pdf.bounds.width - 300) do
|
|
53
|
+
pdf.font('NotoSans') do
|
|
54
|
+
pdf.fill_color 'cc0000'
|
|
55
|
+
pdf.text stamp, align: :center, size: 25, rotate: 15, style: :bold
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
pdf.fill_color '000000'
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# rubocop:todo Metrics/MethodLength
|
|
64
|
+
def self.company_banner(invoice, pdf) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
|
|
65
|
+
# render the logo
|
|
66
|
+
image = invoice_or_default(invoice, :invoice_logo)
|
|
67
|
+
height = nil
|
|
68
|
+
width = nil
|
|
69
|
+
|
|
70
|
+
# Handle images defined with a hash of options
|
|
71
|
+
if image.is_a?(Hash)
|
|
72
|
+
data = image
|
|
73
|
+
image = data[:filename]
|
|
74
|
+
width, height = data[:size].split('x').map(&:to_f)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if File.extname(image) == '.svg'
|
|
78
|
+
logo_info = pdf.svg(File.read(image), at: pdf.bounds.top_left, width: width, height: height)
|
|
79
|
+
logo_height = logo_info[:height]
|
|
80
|
+
else
|
|
81
|
+
logo_info = pdf.image(image, at: pdf.bounds.top_left, width: width, height: height)
|
|
82
|
+
logo_height = logo_info.scaled_height
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# render the company details
|
|
86
|
+
table_data = []
|
|
87
|
+
table_data << [bold_cell(pdf, invoice_or_default(invoice, :company_name).strip, size: 12)]
|
|
88
|
+
|
|
89
|
+
invoice_or_default(invoice, :company_details).lines.each { |line| table_data << [line] }
|
|
90
|
+
|
|
91
|
+
table = pdf.make_table(table_data, cell_style: { borders: [], padding: 0 })
|
|
92
|
+
pdf.bounding_box([pdf.bounds.width - table.width, pdf.bounds.top], width: table.width,
|
|
93
|
+
height: table.height + 5) do
|
|
94
|
+
table.draw
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
pdf.move_cursor_to(pdf.bounds.top - logo_height - 20)
|
|
98
|
+
end
|
|
99
|
+
# rubocop:enable Metrics/MethodLength
|
|
100
|
+
|
|
101
|
+
# rubocop:todo Metrics/MethodLength
|
|
102
|
+
def self.bill_to_ship_to(invoice, pdf) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
|
|
103
|
+
bill_to_cell_style = { borders: [], padding: [2, 0] }
|
|
104
|
+
bill_to_ship_to_bottom = 0
|
|
105
|
+
|
|
106
|
+
# render bill to
|
|
107
|
+
pdf.float do
|
|
108
|
+
pdf.table([[bold_cell(pdf, I18n.t('payday.invoice.bill_to', default: 'Bill To'))],
|
|
109
|
+
[invoice.bill_to]], column_widths: [200], cell_style: bill_to_cell_style)
|
|
110
|
+
bill_to_ship_to_bottom = pdf.cursor
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# render ship to
|
|
114
|
+
if defined?(invoice.ship_to) && !invoice.ship_to.nil?
|
|
115
|
+
table = pdf.make_table([[bold_cell(pdf, I18n.t('payday.invoice.ship_to', default: 'Ship To'))],
|
|
116
|
+
[invoice.ship_to]], column_widths: [200], cell_style: bill_to_cell_style)
|
|
117
|
+
|
|
118
|
+
pdf.bounding_box([pdf.bounds.width - table.width, pdf.cursor], width: table.width, height: table.height + 2) do
|
|
119
|
+
table.draw
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# make sure we start at the lower of the bill_to or ship_to details
|
|
124
|
+
bill_to_ship_to_bottom = pdf.cursor if pdf.cursor < bill_to_ship_to_bottom
|
|
125
|
+
pdf.move_cursor_to(bill_to_ship_to_bottom - 20)
|
|
126
|
+
end
|
|
127
|
+
# rubocop:enable Metrics/MethodLength
|
|
128
|
+
|
|
129
|
+
# rubocop:todo Metrics/PerceivedComplexity
|
|
130
|
+
# rubocop:todo Metrics/MethodLength
|
|
131
|
+
# rubocop:todo Metrics/AbcSize
|
|
132
|
+
def self.invoice_details(invoice, pdf) # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
133
|
+
# invoice details
|
|
134
|
+
table_data = []
|
|
135
|
+
|
|
136
|
+
# invoice number
|
|
137
|
+
if defined?(invoice.invoice_number) && invoice.invoice_number
|
|
138
|
+
table_data << if invoice.paid?
|
|
139
|
+
[bold_cell(pdf, I18n.t('payday.invoice.receipt_no', default: 'Receipt #:')),
|
|
140
|
+
bold_cell(pdf, invoice.invoice_number.to_s, align: :right)]
|
|
141
|
+
else
|
|
142
|
+
[bold_cell(pdf, I18n.t('payday.invoice.invoice_no', default: 'Invoice #:')),
|
|
143
|
+
bold_cell(pdf, invoice.invoice_number.to_s, align: :right)]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Due on
|
|
148
|
+
if defined?(invoice.due_at) && invoice.due_at
|
|
149
|
+
due_date = if invoice.due_at.is_a?(Date) || invoice.due_at.is_a?(Time)
|
|
150
|
+
invoice.due_at.strftime(Payday::Config.default.date_format)
|
|
151
|
+
else
|
|
152
|
+
invoice.due_at.to_s
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
table_data << [bold_cell(pdf, I18n.t('payday.invoice.due_date', default: 'Due Date:')),
|
|
156
|
+
bold_cell(pdf, due_date, align: :right)]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Paid on
|
|
160
|
+
if defined?(invoice.paid_at) && invoice.paid_at
|
|
161
|
+
paid_date = if invoice.paid_at.is_a?(Date) || invoice.due_at.is_a?(Time)
|
|
162
|
+
invoice.paid_at.strftime(Payday::Config.default.date_format)
|
|
163
|
+
else
|
|
164
|
+
invoice.paid_at.to_s
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
table_data << [bold_cell(pdf, I18n.t('payday.invoice.paid_date', default: 'Paid Date:')),
|
|
168
|
+
bold_cell(pdf, paid_date, align: :right)]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# loop through invoice_details and include them
|
|
172
|
+
invoice.each_detail do |key, value|
|
|
173
|
+
table_data << [bold_cell(pdf, key),
|
|
174
|
+
bold_cell(pdf, value, align: :right)]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
return unless table_data.length.positive?
|
|
178
|
+
|
|
179
|
+
pdf.table(table_data, cell_style: { borders: [], padding: [1, 10, 1, 1] })
|
|
180
|
+
end
|
|
181
|
+
# rubocop:enable Metrics/AbcSize
|
|
182
|
+
# rubocop:enable Metrics/MethodLength
|
|
183
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
|
184
|
+
|
|
185
|
+
# rubocop:todo Metrics/MethodLength
|
|
186
|
+
def self.line_items_table(invoice, pdf) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
|
|
187
|
+
table_data = []
|
|
188
|
+
table_data << [bold_cell(pdf, I18n.t('payday.line_item.description', default: 'Description'), borders: []),
|
|
189
|
+
bold_cell(pdf, I18n.t('payday.line_item.unit_price', default: 'Unit Price'), align: :center,
|
|
190
|
+
borders: []),
|
|
191
|
+
bold_cell(pdf, I18n.t('payday.line_item.quantity', default: 'Quantity'), align: :center,
|
|
192
|
+
borders: []),
|
|
193
|
+
bold_cell(pdf, I18n.t('payday.line_item.amount', default: 'Amount'), align: :center, borders: [])]
|
|
194
|
+
invoice.line_items.each do |line|
|
|
195
|
+
table_data << if line.predefined_amount
|
|
196
|
+
[line.description, '', '', number_to_currency(line.predefined_amount, invoice)]
|
|
197
|
+
else
|
|
198
|
+
[line.description,
|
|
199
|
+
(line.display_price || number_to_currency(line.price, invoice)),
|
|
200
|
+
(line.display_quantity || BigDecimal(line.quantity.to_s).to_s('F')),
|
|
201
|
+
number_to_currency(line.amount, invoice)]
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
pdf.move_cursor_to(pdf.cursor - 20)
|
|
206
|
+
pdf.table(table_data, width: pdf.bounds.width, header: true,
|
|
207
|
+
cell_style: { border_width: 0.5, border_left_color: 'FFFFFF', border_right_color: 'FFFFFF',
|
|
208
|
+
border_top_color: 'F6F9FC', border_bottom_color: 'BCC6D0', padding: [5, 10],
|
|
209
|
+
inline_format: true },
|
|
210
|
+
row_colors: %w[F6F9FC ffffff]) do
|
|
211
|
+
# left align the number columns
|
|
212
|
+
columns(1..3).rows(1..row_length - 1).style(align: :right)
|
|
213
|
+
|
|
214
|
+
# set the column widths correctly
|
|
215
|
+
natural = natural_column_widths
|
|
216
|
+
natural[0] = width - natural[1] - natural[2] - natural[3]
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
# rubocop:enable Metrics/MethodLength
|
|
220
|
+
|
|
221
|
+
# rubocop:todo Metrics/MethodLength
|
|
222
|
+
def self.totals_lines(invoice, pdf) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
|
|
223
|
+
table_data = []
|
|
224
|
+
table_data << [
|
|
225
|
+
bold_cell(pdf, I18n.t('payday.invoice.subtotal', default: 'Subtotal:')),
|
|
226
|
+
cell(pdf, number_to_currency(invoice.subtotal, invoice), align: :right)
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
tax_description = if invoice.tax_description.nil?
|
|
230
|
+
I18n.t('payday.invoice.tax', default: 'Tax:')
|
|
231
|
+
else
|
|
232
|
+
invoice.tax_description
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
table_data << [
|
|
236
|
+
bold_cell(pdf, tax_description),
|
|
237
|
+
cell(pdf, number_to_currency(invoice.tax, invoice), align: :right)
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
if invoice.shipping_rate.positive?
|
|
241
|
+
shipping_description = if invoice.shipping_description.nil?
|
|
242
|
+
I18n.t('payday.invoice.shipping', default: 'Shipping:')
|
|
243
|
+
else
|
|
244
|
+
invoice.shipping_description
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
table_data << [
|
|
248
|
+
bold_cell(pdf, shipping_description),
|
|
249
|
+
cell(pdf, number_to_currency(invoice.shipping, invoice),
|
|
250
|
+
align: :right)
|
|
251
|
+
]
|
|
252
|
+
end
|
|
253
|
+
table_data << [
|
|
254
|
+
bold_cell(pdf, I18n.t('payday.invoice.total', default: 'Total:'),
|
|
255
|
+
size: 12),
|
|
256
|
+
cell(pdf, number_to_currency(invoice.total, invoice),
|
|
257
|
+
size: 12, align: :right)
|
|
258
|
+
]
|
|
259
|
+
table = pdf.make_table(table_data, cell_style: { borders: [] })
|
|
260
|
+
pdf.bounding_box([pdf.bounds.width - table.width, pdf.cursor],
|
|
261
|
+
width: table.width, height: table.height + 2) do
|
|
262
|
+
table.draw
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
# rubocop:enable Metrics/MethodLength
|
|
266
|
+
|
|
267
|
+
def self.notes(invoice, pdf) # rubocop:todo Metrics/AbcSize
|
|
268
|
+
return unless defined?(invoice.notes) && invoice.notes
|
|
269
|
+
|
|
270
|
+
pdf.move_cursor_to(pdf.cursor - 30)
|
|
271
|
+
pdf.font('NotoSans') do
|
|
272
|
+
pdf.text(I18n.t('payday.invoice.notes', default: 'Notes', style: :bold))
|
|
273
|
+
end
|
|
274
|
+
pdf.line_width = 0.5
|
|
275
|
+
pdf.stroke_color = 'cccccc'
|
|
276
|
+
pdf.stroke_line([0, pdf.cursor - 3, pdf.bounds.width, pdf.cursor - 3])
|
|
277
|
+
pdf.move_cursor_to(pdf.cursor - 10)
|
|
278
|
+
pdf.text(invoice.notes.to_s, inline_format: true)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def self.page_numbers(pdf)
|
|
282
|
+
return unless pdf.page_count > 1
|
|
283
|
+
|
|
284
|
+
pdf.number_pages('<page> / <total>', at: [pdf.bounds.right - 25, -10])
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def self.invoice_or_default(invoice, property)
|
|
288
|
+
if invoice.respond_to?(property) && invoice.send(property)
|
|
289
|
+
invoice.send(property)
|
|
290
|
+
else
|
|
291
|
+
Payday::Config.default.send(property)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def self.cell(pdf, text, options = {})
|
|
296
|
+
Prawn::Table::Cell::Text.make(pdf, text, options)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def self.bold_cell(pdf, text, options = {})
|
|
300
|
+
cell(pdf, "<b>#{text}</b>", options.merge(inline_format: true))
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Converts this number to a formatted currency string
|
|
304
|
+
def self.number_to_currency(number, invoice)
|
|
305
|
+
Money.locale_backend = :currency
|
|
306
|
+
Money.rounding_mode = BigDecimal::ROUND_HALF_UP
|
|
307
|
+
currency = Money::Currency.wrap(invoice_or_default(invoice, :currency))
|
|
308
|
+
number *= currency.subunit_to_unit
|
|
309
|
+
number = number.round unless Money.default_infinite_precision
|
|
310
|
+
Money.new(number, currency).format
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def self.max_cell_width(cell_proxy)
|
|
314
|
+
max = 0
|
|
315
|
+
cell_proxy.each do |cell|
|
|
316
|
+
max = cell.natural_content_width if cell.natural_content_width > max
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
max
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
data/lib/payday.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Not much to see here
|
|
2
|
+
require 'date'
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'bigdecimal'
|
|
5
|
+
require 'prawn'
|
|
6
|
+
require 'prawn/table'
|
|
7
|
+
require 'prawn-svg'
|
|
8
|
+
require 'money'
|
|
9
|
+
require 'active_support/all'
|
|
10
|
+
|
|
11
|
+
require_relative 'payday/version'
|
|
12
|
+
require_relative 'payday/config'
|
|
13
|
+
require_relative 'payday/i18n'
|
|
14
|
+
require_relative 'payday/line_itemable'
|
|
15
|
+
require_relative 'payday/line_item'
|
|
16
|
+
require_relative 'payday/pdf_renderer'
|
|
17
|
+
require_relative 'payday/invoiceable'
|
|
18
|
+
require_relative 'payday/invoice'
|
data/payday.gemspec
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
$LOAD_PATH.push File.expand_path('lib', __dir__)
|
|
2
|
+
require 'payday/version'
|
|
3
|
+
|
|
4
|
+
Gem::Specification.new do |s|
|
|
5
|
+
s.name = 'webtranslateit-payday'
|
|
6
|
+
s.version = Payday::VERSION
|
|
7
|
+
s.required_ruby_version = '>= 2.5'
|
|
8
|
+
s.platform = Gem::Platform::RUBY
|
|
9
|
+
s.authors = ['Alan Johnson', 'Edouard Briere']
|
|
10
|
+
s.email = ['edouard@webtranslateit.com']
|
|
11
|
+
s.homepage = 'https://github.com/webtranslateit/payday'
|
|
12
|
+
s.summary = 'A simple library for rendering invoices.'
|
|
13
|
+
s.description = 'Payday is a library for rendering invoices to pdf.'
|
|
14
|
+
|
|
15
|
+
s.add_dependency 'activesupport'
|
|
16
|
+
s.add_dependency('i18n', '>= 0.7', '< 2.0')
|
|
17
|
+
s.add_dependency('money', '~> 6.5')
|
|
18
|
+
s.add_dependency('prawn', '>= 1.0', '< 2.5')
|
|
19
|
+
s.add_dependency('prawn-svg', '>= 0.15.0', '< 0.32.1')
|
|
20
|
+
s.add_dependency('prawn-table', '>= 0.2.2')
|
|
21
|
+
s.add_dependency 'rexml'
|
|
22
|
+
|
|
23
|
+
s.add_development_dependency('guard')
|
|
24
|
+
s.add_development_dependency('guard-rspec')
|
|
25
|
+
s.add_development_dependency 'guard-rubocop'
|
|
26
|
+
s.add_development_dependency('rspec', '~> 3.10.0')
|
|
27
|
+
s.add_development_dependency('rubocop')
|
|
28
|
+
s.add_development_dependency('rubocop-rspec')
|
|
29
|
+
|
|
30
|
+
s.files = `git ls-files`.split("\n")
|
|
31
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
|
32
|
+
s.executables =
|
|
33
|
+
`git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
|
|
34
|
+
s.require_paths = ['lib']
|
|
35
|
+
end
|
|
Binary file
|