tallyman 1.0.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/README.md +219 -0
- data/config/locales/tallyman.de.yml +24 -0
- data/config/locales/tallyman.en.yml +24 -0
- data/config/locales/tallyman.es.yml +24 -0
- data/config/locales/tallyman.fr.yml +24 -0
- data/config/locales/tallyman.nl.yml +24 -0
- data/config/locales/tallyman.zh-CN.yml +24 -0
- data/fonts/NotoSans-Bold.ttf +0 -0
- data/fonts/NotoSans-Regular.ttf +0 -0
- data/lib/generators/tallyman/setup/USAGE +23 -0
- data/lib/generators/tallyman/setup/setup_generator.rb +75 -0
- data/lib/generators/tallyman/setup/templates/invoice.rb +7 -0
- data/lib/generators/tallyman/setup/templates/line_item.rb +5 -0
- data/lib/generators/tallyman/setup/templates/migration.rb +43 -0
- data/lib/tallyman/appendix.rb +47 -0
- data/lib/tallyman/assets/default_logo.png +0 -0
- data/lib/tallyman/config.rb +48 -0
- data/lib/tallyman/invoice.rb +49 -0
- data/lib/tallyman/invoice_presenter.rb +160 -0
- data/lib/tallyman/invoiceable.rb +100 -0
- data/lib/tallyman/line_item.rb +47 -0
- data/lib/tallyman/line_itemable.rb +18 -0
- data/lib/tallyman/markup.rb +61 -0
- data/lib/tallyman/pdf_renderer.rb +115 -0
- data/lib/tallyman/qr_code.rb +99 -0
- data/lib/tallyman/templates/invoice.typ +171 -0
- data/lib/tallyman.rb +28 -0
- data/tallyman.gemspec +30 -0
- metadata +185 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tallyman
|
|
4
|
+
|
|
5
|
+
# Basically just an invoice. Stick a ton of line items in it, add some details, and then render it out!
|
|
6
|
+
class Invoice
|
|
7
|
+
|
|
8
|
+
include Tallyman::Invoiceable
|
|
9
|
+
|
|
10
|
+
attr_accessor :invoice_number, :bill_to, :ship_to, :notes, :line_items, :shipping_description,
|
|
11
|
+
:tax_description, :retention_description, :due_at, :paid_at, :refunded_at, :currency,
|
|
12
|
+
:invoice_details, :invoice_date, :qr_code
|
|
13
|
+
|
|
14
|
+
attr_reader :tax_rate, :shipping_rate, :retention_rate
|
|
15
|
+
|
|
16
|
+
# Options assigned as given; a missing one is nil, which the rate setters turn into 0.
|
|
17
|
+
OPTIONS = %i[invoice_number bill_to ship_to notes shipping_rate shipping_description tax_rate
|
|
18
|
+
tax_description retention_rate retention_description due_at paid_at refunded_at
|
|
19
|
+
currency invoice_date qr_code].freeze
|
|
20
|
+
|
|
21
|
+
def initialize(options = {})
|
|
22
|
+
OPTIONS.each { |name| public_send(:"#{name}=", options[name]) }
|
|
23
|
+
self.line_items = options[:line_items] || []
|
|
24
|
+
self.invoice_details = options[:invoice_details] || []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The tax rate that we're applying, as a BigDecimal
|
|
28
|
+
def tax_rate=(value)
|
|
29
|
+
@tax_rate = Tallyman.to_decimal(value)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Shipping rate
|
|
33
|
+
def shipping_rate=(value)
|
|
34
|
+
@shipping_rate = Tallyman.to_decimal(value)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Retention rate (e.g. IRPF). Applied as a percentage of the subtotal and deducted from the total.
|
|
38
|
+
def retention_rate=(value)
|
|
39
|
+
@retention_rate = Tallyman.to_decimal(value)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Adds a line item
|
|
43
|
+
def add_line_item(options = {})
|
|
44
|
+
line_items << Tallyman::LineItem.new(options)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tallyman
|
|
4
|
+
|
|
5
|
+
# Turns an Invoiceable into the plain Hash the Typst template consumes.
|
|
6
|
+
#
|
|
7
|
+
# Every currency, date and translation decision happens here, so the template stays a pure
|
|
8
|
+
# layout concern and receives nothing but literal strings.
|
|
9
|
+
class InvoicePresenter
|
|
10
|
+
|
|
11
|
+
def initialize(invoice)
|
|
12
|
+
@invoice = invoice
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_h
|
|
16
|
+
{
|
|
17
|
+
page_size: setting(:page_size),
|
|
18
|
+
company_name: setting(:company_name).strip,
|
|
19
|
+
company_details: company_details,
|
|
20
|
+
stamp: stamp,
|
|
21
|
+
bill_to: @invoice.bill_to,
|
|
22
|
+
ship_to: ship_to,
|
|
23
|
+
labels: labels,
|
|
24
|
+
details: details,
|
|
25
|
+
line_items: line_items,
|
|
26
|
+
totals: totals,
|
|
27
|
+
notes: Markup.to_runs(@invoice.notes),
|
|
28
|
+
qr_code: qr_code
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def line_items
|
|
33
|
+
@invoice.line_items.map { |line| line_item(line) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def totals
|
|
37
|
+
[[t('invoice.subtotal', 'Subtotal:'), money(@invoice.subtotal), false],
|
|
38
|
+
[tax_label, money(@invoice.tax), false],
|
|
39
|
+
*shipping_row,
|
|
40
|
+
*retention_row,
|
|
41
|
+
[t('invoice.total', 'Total:'), money(@invoice.total), true]]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def stamp
|
|
45
|
+
return t('status.refunded', 'REFUNDED') if @invoice.refunded?
|
|
46
|
+
return t('status.paid', 'PAID') if @invoice.paid?
|
|
47
|
+
|
|
48
|
+
t('status.overdue', 'OVERDUE') if @invoice.overdue?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def details
|
|
52
|
+
rows = [*number_row, *due_row, *paid_row]
|
|
53
|
+
@invoice.each_detail { |key, value| rows << [key.to_s, value.to_s] }
|
|
54
|
+
rows
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def labels
|
|
58
|
+
{
|
|
59
|
+
bill_to: t('invoice.bill_to', 'Bill To'),
|
|
60
|
+
ship_to: t('invoice.ship_to', 'Ship To'),
|
|
61
|
+
notes: t('invoice.notes', 'Notes'),
|
|
62
|
+
description: t('line_item.description', 'Description'),
|
|
63
|
+
unit_price: t('line_item.unit_price', 'Unit Price'),
|
|
64
|
+
quantity: t('line_item.quantity', 'Quantity'),
|
|
65
|
+
amount: t('line_item.amount', 'Amount')
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def shipping_row
|
|
72
|
+
[[shipping_label, money(@invoice.shipping), false]] if @invoice.shipping_rate.positive?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def retention_row
|
|
76
|
+
[[retention_label, money(-@invoice.retention), false]] if @invoice.retention_rate.positive?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def number_row
|
|
80
|
+
[[number_label, @invoice.invoice_number.to_s]] if @invoice.invoice_number
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def due_row
|
|
84
|
+
[[t('invoice.due_date', 'Due Date:'), date(@invoice.due_at)]] if @invoice.due_at
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def paid_row
|
|
88
|
+
[[t('invoice.paid_date', 'Paid Date:'), date(@invoice.paid_at)]] if @invoice.paid_at
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def qr_code
|
|
92
|
+
return nil unless @invoice.respond_to?(:qr_code) && @invoice.qr_code.to_s.strip.present?
|
|
93
|
+
|
|
94
|
+
@invoice.qr_code.to_s
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def line_item(line)
|
|
98
|
+
return predefined_line_item(line) if line.predefined_amount
|
|
99
|
+
|
|
100
|
+
{description: Markup.to_runs(line.description),
|
|
101
|
+
price: line.display_price || money(line.price),
|
|
102
|
+
quantity: line.display_quantity || BigDecimal(line.quantity.to_s).to_s('F'),
|
|
103
|
+
amount: money(line.amount)}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def predefined_line_item(line)
|
|
107
|
+
{description: Markup.to_runs(line.description), price: '', quantity: '',
|
|
108
|
+
amount: money(line.predefined_amount)}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def tax_label
|
|
112
|
+
@invoice.tax_description || t('invoice.tax', 'Tax:')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def shipping_label
|
|
116
|
+
@invoice.shipping_description || t('invoice.shipping', 'Shipping:')
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def retention_label
|
|
120
|
+
@invoice.retention_description || t('invoice.retention', 'Retention:')
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def money(number)
|
|
124
|
+
PdfRenderer.number_to_currency(number, @invoice)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Prawn rendered each line of company_details as its own table cell, which collapsed any
|
|
128
|
+
# leading indentation. Config is commonly set from an indented heredoc, so strip it here
|
|
129
|
+
# rather than let it show up in the PDF.
|
|
130
|
+
def company_details
|
|
131
|
+
setting(:company_details).to_s.lines.map(&:strip).join("\n").strip
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def ship_to
|
|
135
|
+
@invoice.ship_to if @invoice.respond_to?(:ship_to)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def number_label
|
|
139
|
+
return t('invoice.receipt_no', 'Receipt #:') if @invoice.paid?
|
|
140
|
+
|
|
141
|
+
t('invoice.invoice_no', 'Invoice #:')
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def date(value)
|
|
145
|
+
return value.to_s unless value.is_a?(Date) || value.is_a?(Time)
|
|
146
|
+
|
|
147
|
+
value.strftime(setting(:date_format))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def t(key, default)
|
|
151
|
+
I18n.t("tallyman.#{key}", default: default)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def setting(property)
|
|
155
|
+
Tallyman::Config.default.value_for(@invoice, property)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Include {Tallyman::Invoiceable} in your Invoice class to make it Tallyman compatible. Tallyman
|
|
4
|
+
# expects that a +line_items+ method containing an Enumerable of {Tallyman::LineItem} compatible
|
|
5
|
+
# elements exists. Those LineItem objects primarily need to include quantity, price, and description methods.
|
|
6
|
+
#
|
|
7
|
+
# The +bill_to+ method should always be overwritten by your class. Otherwise, it'll say that your invoice should
|
|
8
|
+
# be billed to Goofy McGoofison. +ship_to+ is also available, but will not be used in rendered invoices if it
|
|
9
|
+
# doesn't exist.
|
|
10
|
+
#
|
|
11
|
+
# Although not required, if a +tax_rate+ method exists, {Tallyman::Invoiceable} will use it to calculate tax
|
|
12
|
+
# when generating an invoice. We include a simple tax method that calculates tax, but it's probably wiser
|
|
13
|
+
# to override this in your class (our calculated tax won't be stored to a database by default, for example).
|
|
14
|
+
#
|
|
15
|
+
# If the +due_at+, +paid_at+, and +refunded_at+ methods are available, {Tallyman::Invoiceable} will use them
|
|
16
|
+
# to show due dates, paid dates, and refunded dates, as well as stamps showing if the invoice is paid or due.
|
|
17
|
+
module Tallyman
|
|
18
|
+
|
|
19
|
+
module Invoiceable
|
|
20
|
+
|
|
21
|
+
# Who the invoice is being sent to. A +bill_to+ the class already has wins over this
|
|
22
|
+
# placeholder: an ActiveRecord column reader sits behind this module in the ancestors, so
|
|
23
|
+
# without the +super+ a saved +bill_to+ would never be read.
|
|
24
|
+
def bill_to
|
|
25
|
+
return super if defined?(super)
|
|
26
|
+
|
|
27
|
+
"Goofy McGoofison\nYour Invoice Doesn't\nHave It's Own BillTo Method"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Calculates the subtotal of this invoice by adding up all of the line items
|
|
31
|
+
def subtotal
|
|
32
|
+
line_items.reduce(BigDecimal(0)) { |result, item| result + item.amount }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The tax for this invoice, as a BigDecimal
|
|
36
|
+
def tax
|
|
37
|
+
defined?(tax_rate) ? subtotal * tax_rate / 100 : 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# TODO: Add a per weight unit shipping cost
|
|
41
|
+
# Calculates the shipping
|
|
42
|
+
def shipping
|
|
43
|
+
defined?(shipping_rate) ? shipping_rate : 0
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Calculates the retention (e.g. IRPF) as a percentage of the subtotal.
|
|
47
|
+
# This is a post-tax deduction subtracted from the total.
|
|
48
|
+
def retention
|
|
49
|
+
defined?(retention_rate) ? subtotal * retention_rate / 100 : 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Calculates the total for this invoice.
|
|
53
|
+
def total
|
|
54
|
+
subtotal + tax + shipping - retention
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def overdue?
|
|
58
|
+
defined?(due_at) && Invoiceable.past?(due_at) && !paid_at
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Whether a Date is before today or a Time before now. A module function rather than a
|
|
62
|
+
# private method, so it does not land in the classes that include Invoiceable.
|
|
63
|
+
def self.past?(moment)
|
|
64
|
+
case moment
|
|
65
|
+
when Date then moment < Date.today
|
|
66
|
+
when Time then moment < Time.now
|
|
67
|
+
else false
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def refunded?
|
|
72
|
+
defined?(refunded_at) && !!refunded_at
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def paid?
|
|
76
|
+
defined?(paid_at) && !!paid_at
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Renders this invoice to pdf as a string. Pass a Tallyman::Appendix to compile pages of
|
|
80
|
+
# your own onto the end of it.
|
|
81
|
+
def render_pdf(appendix: nil)
|
|
82
|
+
Tallyman::PdfRenderer.render(self, appendix: appendix)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Renders this invoice to pdf
|
|
86
|
+
def render_pdf_to_file(path, appendix: nil)
|
|
87
|
+
Tallyman::PdfRenderer.render_to_file(self, path, appendix: appendix)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Iterates through the details on this invoiceable. The block given should accept
|
|
91
|
+
# two parameters, the detail name and the actual detail value.
|
|
92
|
+
def each_detail
|
|
93
|
+
return unless defined?(invoice_details)
|
|
94
|
+
|
|
95
|
+
invoice_details.each { |detail| yield detail[0], detail[1] }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tallyman
|
|
4
|
+
|
|
5
|
+
# Represents a line item in an invoice.
|
|
6
|
+
#
|
|
7
|
+
# +quantity+ and +price+ are written to be pretty picky, primarily because if we're not picky about what
|
|
8
|
+
# values are set to them your invoice math could get pretty messed up. It's recommended that both values be
|
|
9
|
+
# set to +BigDecimal+ values. Otherwise, we'll do our best to convert the set values to a +BigDecimal+.
|
|
10
|
+
class LineItem
|
|
11
|
+
|
|
12
|
+
include LineItemable
|
|
13
|
+
|
|
14
|
+
attr_accessor :description, :display_quantity, :display_price
|
|
15
|
+
attr_reader :quantity, :price, :predefined_amount
|
|
16
|
+
|
|
17
|
+
# Initializes a new LineItem
|
|
18
|
+
def initialize(options = {})
|
|
19
|
+
if options[:predefined_amount]
|
|
20
|
+
self.predefined_amount = options[:predefined_amount]
|
|
21
|
+
else
|
|
22
|
+
self.quantity = options[:quantity] || '1'
|
|
23
|
+
self.display_quantity = options[:display_quantity]
|
|
24
|
+
self.display_price = options[:display_price]
|
|
25
|
+
self.price = options[:price] || '0.00'
|
|
26
|
+
end
|
|
27
|
+
self.description = options[:description] || ''
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Sets the quantity of this {LineItem}
|
|
31
|
+
def quantity=(value)
|
|
32
|
+
@quantity = Tallyman.to_decimal(value)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Sets the price for this {LineItem}
|
|
36
|
+
def price=(value)
|
|
37
|
+
@price = Tallyman.to_decimal(value)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Sets the predefined_amount for this {LineItem}
|
|
41
|
+
def predefined_amount=(value)
|
|
42
|
+
@predefined_amount = Tallyman.to_decimal(value)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Include this module into your line item implementation to make sure that Tallyman stays happy
|
|
4
|
+
# with it, or just make sure that your line item implements the amount method.
|
|
5
|
+
module Tallyman
|
|
6
|
+
|
|
7
|
+
module LineItemable
|
|
8
|
+
|
|
9
|
+
# Returns the total amount for this {LineItemable}, or +price * quantity+
|
|
10
|
+
def amount
|
|
11
|
+
return predefined_amount if predefined_amount
|
|
12
|
+
|
|
13
|
+
price * quantity
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tallyman
|
|
4
|
+
|
|
5
|
+
# Converts the Prawn +inline_format+ markup this gem used to accept in notes and line item
|
|
6
|
+
# descriptions into structured runs that the Typst template can style.
|
|
7
|
+
#
|
|
8
|
+
# We deliberately produce data rather than Typst source. The template renders each run's
|
|
9
|
+
# +text+ as a literal string, so a customer-supplied description can never become Typst
|
|
10
|
+
# code. Never change this to emit markup.
|
|
11
|
+
class Markup
|
|
12
|
+
|
|
13
|
+
# Tags that simply switch a style on, mapped to the key they set on a run.
|
|
14
|
+
FLAGS = {'b' => :bold, 'i' => :italic, 'u' => :underline, 'strikethrough' => :strike,
|
|
15
|
+
'sub' => :sub, 'sup' => :sup}.freeze
|
|
16
|
+
|
|
17
|
+
TAG = %r{<(?<name>b|i|u|strikethrough|sub|sup|font|color|link)(?<attrs>[^>]*)>(?<body>.*?)</\k<name>>}m
|
|
18
|
+
LINE_BREAK = %r{<br\s*/?>}
|
|
19
|
+
|
|
20
|
+
# Returns an Array of Hashes, each with a :text key and any of :bold, :italic, :underline,
|
|
21
|
+
# :strike, :sub, :sup, :size, :color and :link. Returns nil when there is nothing to render.
|
|
22
|
+
def self.to_runs(text)
|
|
23
|
+
return nil if text.nil?
|
|
24
|
+
|
|
25
|
+
runs(text.to_s.gsub(LINE_BREAK, "\n"), {})
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.runs(text, style)
|
|
29
|
+
result = []
|
|
30
|
+
position = 0
|
|
31
|
+
|
|
32
|
+
while (match = TAG.match(text, position))
|
|
33
|
+
append(result, style, text[position...match.begin(0)])
|
|
34
|
+
result.concat(runs(match[:body], style.merge(style_for(match[:name], match[:attrs]))))
|
|
35
|
+
position = match.end(0)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
append(result, style, text[position..])
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
private_class_method :runs
|
|
42
|
+
|
|
43
|
+
def self.append(result, style, text)
|
|
44
|
+
result << style.merge(text: text) unless text.nil? || text.empty?
|
|
45
|
+
end
|
|
46
|
+
private_class_method :append
|
|
47
|
+
|
|
48
|
+
def self.style_for(name, attrs)
|
|
49
|
+
return {FLAGS[name] => true} if FLAGS.key?(name)
|
|
50
|
+
|
|
51
|
+
case name
|
|
52
|
+
when 'font' then {size: attrs[/size=['"](\d+)['"]/, 1].to_i}
|
|
53
|
+
when 'color' then {color: attrs[/rgb=['"]#?(\h{6})['"]/, 1]}
|
|
54
|
+
when 'link' then {link: attrs[/href=['"](.*?)['"]/, 1]}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
private_class_method :style_for
|
|
58
|
+
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tallyman
|
|
4
|
+
|
|
5
|
+
# Renders an invoice to PDF by feeding a presented Hash to the Typst template.
|
|
6
|
+
#
|
|
7
|
+
# Invoice data crosses into the template only as sys_inputs JSON, where Typst treats every
|
|
8
|
+
# string as literal text. Never interpolate invoice data into the template source.
|
|
9
|
+
class PdfRenderer
|
|
10
|
+
|
|
11
|
+
TEMPLATE = File.expand_path('templates/invoice.typ', __dir__)
|
|
12
|
+
FONT_DIR = File.expand_path('../../fonts', __dir__)
|
|
13
|
+
FONTS = %w[NotoSans-Regular.ttf NotoSans-Bold.ttf].freeze
|
|
14
|
+
|
|
15
|
+
# Renders the given invoice as a pdf on disk
|
|
16
|
+
def self.render_to_file(invoice, path, appendix: nil)
|
|
17
|
+
File.binwrite(path, render(invoice, appendix: appendix))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Renders the given invoice as a pdf, returning a string. See Tallyman::Appendix for
|
|
21
|
+
# appending pages of your own to it.
|
|
22
|
+
def self.render(invoice, appendix: nil)
|
|
23
|
+
new(invoice, appendix: appendix).render
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Converts this number to a formatted currency string
|
|
27
|
+
def self.number_to_currency(number, invoice)
|
|
28
|
+
Money.locale_backend = :currency
|
|
29
|
+
Money.rounding_mode = BigDecimal::ROUND_HALF_UP
|
|
30
|
+
currency = Money::Currency.wrap(Tallyman::Config.default.value_for(invoice, :currency))
|
|
31
|
+
number *= currency.subunit_to_unit
|
|
32
|
+
number = number.round unless Money.default_infinite_precision
|
|
33
|
+
Money.new(number, currency).format
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(invoice, appendix: nil)
|
|
37
|
+
@invoice = invoice
|
|
38
|
+
@appendix = Appendix.wrap(appendix)
|
|
39
|
+
@dependencies = {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render
|
|
43
|
+
data = InvoicePresenter.new(@invoice).to_h
|
|
44
|
+
register_qr_code(data[:qr_code])
|
|
45
|
+
data[:logo] = logo
|
|
46
|
+
|
|
47
|
+
Typst(body: body, dependencies: dependencies, fonts: fonts, sys_inputs: sys_inputs(data))
|
|
48
|
+
.compile(:pdf).bytes.flatten.pack('C*')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# An appendix is concatenated rather than +#include+d, which would make it a module of its
|
|
54
|
+
# own and leave the template's page rules -- the margins, and the numbering footer --
|
|
55
|
+
# stopping at its edge.
|
|
56
|
+
def body
|
|
57
|
+
template = File.read(TEMPLATE)
|
|
58
|
+
return template if @appendix.nil?
|
|
59
|
+
|
|
60
|
+
"#{template}\n#{@appendix.source}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def sys_inputs(data)
|
|
64
|
+
merge({'invoice' => data.to_json}, @appendix&.inputs, 'sys_input')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Read after the logo and the QR code have registered theirs, so an appendix reusing one
|
|
68
|
+
# of those names is caught rather than silently replacing the file.
|
|
69
|
+
def dependencies
|
|
70
|
+
merge(@dependencies, @appendix&.dependencies, 'dependency')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def merge(ours, theirs, what)
|
|
74
|
+
return ours if theirs.nil?
|
|
75
|
+
|
|
76
|
+
taken = ours.keys & theirs.keys
|
|
77
|
+
if taken.any?
|
|
78
|
+
raise ArgumentError,
|
|
79
|
+
"appendix #{what} #{taken.map(&:inspect).join(', ')} is reserved by the invoice"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
ours.merge(theirs)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def fonts
|
|
86
|
+
FONTS.to_h { |name| [name, File.binread(File.join(FONT_DIR, name))] }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Config#invoice_logo is either a path or a {filename:, size: "WxH"} Hash.
|
|
90
|
+
def logo
|
|
91
|
+
setting = Tallyman::Config.default.value_for(@invoice, :invoice_logo)
|
|
92
|
+
return nil if setting.nil?
|
|
93
|
+
|
|
94
|
+
path, width, height = logo_parts(setting)
|
|
95
|
+
name = "logo#{File.extname(path.to_s)}"
|
|
96
|
+
@dependencies[name] = File.binread(path.to_s)
|
|
97
|
+
{name: name, width: width, height: height}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def logo_parts(setting)
|
|
101
|
+
return [setting, nil, nil] unless setting.is_a?(Hash)
|
|
102
|
+
|
|
103
|
+
width, height = setting[:size].to_s.split('x').map(&:to_f)
|
|
104
|
+
[setting[:filename], width, height]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def register_qr_code(data)
|
|
108
|
+
return if data.nil?
|
|
109
|
+
|
|
110
|
+
@dependencies['qr.svg'] = QrCode.new(data).to_svg
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rqrcode'
|
|
4
|
+
|
|
5
|
+
module Tallyman
|
|
6
|
+
|
|
7
|
+
# Draws a QR code as an SVG.
|
|
8
|
+
#
|
|
9
|
+
# The matrix comes straight from RQRCode, so the payload and its error correction level are
|
|
10
|
+
# untouched and the code carries exactly the same data it always did. Only the drawing
|
|
11
|
+
# changes: vector instead of a 200px raster, lightly rounded modules, and the three finder
|
|
12
|
+
# patterns drawn as rounded frames.
|
|
13
|
+
#
|
|
14
|
+
# Rounding is kept deliberately slight. These codes are scanned off paper, sometimes off a
|
|
15
|
+
# bad photocopy, and on a Verifactu invoice a code that will not scan is a compliance
|
|
16
|
+
# problem rather than a cosmetic one. Modules keep their full square footprint apart from a
|
|
17
|
+
# softened corner, so a scanner sees the same geometry the old raster produced.
|
|
18
|
+
class QrCode
|
|
19
|
+
|
|
20
|
+
QUIET_ZONE = 4 # modules of clear space; the QR spec's minimum
|
|
21
|
+
UNIT = 10 # user units per module
|
|
22
|
+
RADIUS = 1.5 # corner radius of a data module, out of UNIT
|
|
23
|
+
FINDER = 7 # finder patterns are 7 modules square
|
|
24
|
+
# Corner radius of a finder frame. Above roughly 1.2 modules the curve starts cutting the
|
|
25
|
+
# corner module out of the ring, which changes what a scanner reads.
|
|
26
|
+
EYE_RADIUS = 10
|
|
27
|
+
DARK = '#111111'
|
|
28
|
+
LIGHT = '#ffffff'
|
|
29
|
+
|
|
30
|
+
def initialize(data)
|
|
31
|
+
@modules = RQRCode::QRCode.new(data.to_s).modules
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_svg
|
|
35
|
+
<<~SVG
|
|
36
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{side} #{side}" shape-rendering="geometricPrecision">
|
|
37
|
+
<rect width="#{side}" height="#{side}" fill="#{LIGHT}"/>
|
|
38
|
+
#{(data_modules + finders).join("\n")}
|
|
39
|
+
</svg>
|
|
40
|
+
SVG
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def size
|
|
46
|
+
@modules.length
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def side
|
|
50
|
+
(size + (QUIET_ZONE * 2)) * UNIT
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def data_modules
|
|
54
|
+
@modules.each_with_index.flat_map do |cells, row|
|
|
55
|
+
cells.each_with_index.filter_map do |dark, col|
|
|
56
|
+
module_rect(row, col) if dark && !finder?(row, col)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def module_rect(row, col)
|
|
62
|
+
%(<rect class="m" x="#{x(col)}" y="#{x(row)}" width="#{UNIT}" height="#{UNIT}" ) +
|
|
63
|
+
%(rx="#{RADIUS}" fill="#{DARK}"/>)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A finder is a 7x7 ring with a 3x3 centre. Drawing it as two rounded rects rather than 33
|
|
67
|
+
# separate modules is what gives the code its softer corners.
|
|
68
|
+
def finders
|
|
69
|
+
finder_origins.flat_map { |row, col| [ring(row, col), pupil(row, col)] }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def ring(row, col)
|
|
73
|
+
%(<rect class="f" x="#{x(col) + (UNIT / 2)}" y="#{x(row) + (UNIT / 2)}" ) +
|
|
74
|
+
%(width="#{UNIT * 6}" height="#{UNIT * 6}" rx="#{EYE_RADIUS}" ) +
|
|
75
|
+
%(fill="none" stroke="#{DARK}" stroke-width="#{UNIT}"/>)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def pupil(row, col)
|
|
79
|
+
%(<rect class="p" x="#{x(col + 2)}" y="#{x(row + 2)}" ) +
|
|
80
|
+
%(width="#{UNIT * 3}" height="#{UNIT * 3}" rx="#{UNIT}" fill="#{DARK}"/>)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def finder_origins
|
|
84
|
+
[[0, 0], [0, size - FINDER], [size - FINDER, 0]]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def finder?(row, col)
|
|
88
|
+
finder_origins.any? do |r, c|
|
|
89
|
+
row.between?(r, r + FINDER - 1) && col.between?(c, c + FINDER - 1)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def x(index)
|
|
94
|
+
(index + QUIET_ZONE) * UNIT
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
end
|