webtranslateit-payday 1.7.4 → 2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +47 -3
- data/lib/payday/appendix.rb +47 -0
- data/lib/payday/config.rb +4 -4
- data/lib/payday/invoice_presenter.rb +162 -0
- data/lib/payday/invoiceable.rb +6 -5
- data/lib/payday/markup.rb +61 -0
- data/lib/payday/pdf_renderer.rb +78 -314
- data/lib/payday/qr_code.rb +99 -0
- data/lib/payday/templates/invoice.typ +171 -0
- data/lib/payday.rb +5 -5
- data/payday.gemspec +9 -6
- metadata +19 -83
- data/.gitattributes +0 -1
- data/.github/dependabot.yml +0 -28
- data/.github/release-drafter.yml +0 -36
- data/.github/workflows/ci.yml +0 -47
- data/.github/workflows/release-drafter.yml +0 -24
- data/.gitignore +0 -8
- data/.rubocop.yml +0 -16
- data/.rubocop_todo.yml +0 -0
- data/Gemfile +0 -14
- data/Gemfile.lock +0 -210
- data/Guardfile +0 -12
- data/Rakefile +0 -14
- data/spec/assets/example_invoice_with_qr.pdf +0 -0
- data/spec/assets/svg.pdf +0 -2568
- data/spec/assets/testing.pdf +1 -14241
- data/spec/assets/testing_es.pdf +0 -0
- data/spec/assets/testing_predefined_amount.pdf +0 -0
- data/spec/assets/tiger.svg +0 -52
- data/spec/invoice_spec.rb +0 -403
- data/spec/line_item_spec.rb +0 -34
- data/spec/pdf_renderer_spec.rb +0 -15
- data/spec/spec_helper.rb +0 -11
- data/spec/support/asset_matchers.rb +0 -28
- /data/{spec → lib/payday}/assets/default_logo.png +0 -0
data/lib/payday/pdf_renderer.rb
CHANGED
|
@@ -2,361 +2,125 @@
|
|
|
2
2
|
|
|
3
3
|
module Payday
|
|
4
4
|
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
|
|
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
|
|
8
14
|
|
|
9
15
|
# Renders the given invoice as a pdf on disk
|
|
10
|
-
def self.render_to_file(invoice, path)
|
|
11
|
-
|
|
16
|
+
def self.render_to_file(invoice, path, appendix: nil)
|
|
17
|
+
File.binwrite(path, render(invoice, appendix: appendix))
|
|
12
18
|
end
|
|
13
19
|
|
|
14
|
-
# Renders the given invoice as a pdf, returning a string
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
# Renders the given invoice as a pdf, returning a string. See Payday::Appendix for
|
|
21
|
+
# appending pages of your own to it.
|
|
22
|
+
def self.render(invoice, appendix: nil)
|
|
23
|
+
new(invoice, appendix: appendix).render
|
|
17
24
|
end
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
# set up some default styling
|
|
29
|
-
pdf.font_size(10)
|
|
30
|
-
pdf.font 'NotoSans'
|
|
31
|
-
|
|
32
|
-
stamp(invoice, pdf)
|
|
33
|
-
company_banner(invoice, pdf)
|
|
34
|
-
bill_to_ship_to(invoice, pdf)
|
|
35
|
-
invoice_details(invoice, pdf)
|
|
36
|
-
line_items_table(invoice, pdf)
|
|
37
|
-
totals_lines(invoice, pdf)
|
|
38
|
-
notes(invoice, pdf)
|
|
39
|
-
render_qr_code(invoice, pdf) unless defined?(invoice.notes) && invoice.notes
|
|
40
|
-
|
|
41
|
-
page_numbers(pdf)
|
|
42
|
-
|
|
43
|
-
pdf
|
|
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(currency_for(invoice))
|
|
31
|
+
number *= currency.subunit_to_unit
|
|
32
|
+
number = number.round unless Money.default_infinite_precision
|
|
33
|
+
Money.new(number, currency).format
|
|
44
34
|
end
|
|
45
35
|
|
|
46
|
-
def self.
|
|
47
|
-
|
|
48
|
-
if invoice.refunded?
|
|
49
|
-
stamp = I18n.t 'payday.status.refunded', default: 'REFUNDED'
|
|
50
|
-
elsif invoice.paid?
|
|
51
|
-
stamp = I18n.t 'payday.status.paid', default: 'PAID'
|
|
52
|
-
elsif invoice.overdue?
|
|
53
|
-
stamp = I18n.t 'payday.status.overdue', default: 'OVERDUE'
|
|
54
|
-
end
|
|
36
|
+
def self.currency_for(invoice)
|
|
37
|
+
return invoice.currency if invoice.respond_to?(:currency) && invoice.currency
|
|
55
38
|
|
|
56
|
-
|
|
57
|
-
pdf.bounding_box([150, pdf.cursor - 50], width: pdf.bounds.width - 300) do
|
|
58
|
-
pdf.font('NotoSans') do
|
|
59
|
-
pdf.fill_color 'cc0000'
|
|
60
|
-
pdf.text stamp, align: :center, size: 25, rotate: 15, style: :bold
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
pdf.fill_color '000000'
|
|
39
|
+
Payday::Config.default.currency
|
|
66
40
|
end
|
|
41
|
+
private_class_method :currency_for
|
|
67
42
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
height = nil
|
|
73
|
-
width = nil
|
|
74
|
-
|
|
75
|
-
# Handle images defined with a hash of options
|
|
76
|
-
if image.is_a?(Hash)
|
|
77
|
-
data = image
|
|
78
|
-
image = data[:filename]
|
|
79
|
-
width, height = data[:size].split('x').map(&:to_f)
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
if File.extname(image) == '.svg'
|
|
83
|
-
# Logos are read from local files, so there is nothing to fetch over the network.
|
|
84
|
-
# Passing this explicitly also silences prawn-svg's deprecation warning and keeps
|
|
85
|
-
# behaviour stable when prawn-svg 1.0 flips the default to false.
|
|
86
|
-
logo_info = pdf.svg(File.read(image), at: pdf.bounds.top_left, width: width, height: height,
|
|
87
|
-
enable_web_requests: false)
|
|
88
|
-
logo_height = logo_info[:height]
|
|
89
|
-
else
|
|
90
|
-
logo_info = pdf.image(image, at: pdf.bounds.top_left, width: width, height: height)
|
|
91
|
-
logo_height = logo_info.scaled_height
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# render the company details
|
|
95
|
-
table_data = []
|
|
96
|
-
table_data << [bold_cell(pdf, invoice_or_default(invoice, :company_name).strip, size: 12)]
|
|
97
|
-
|
|
98
|
-
invoice_or_default(invoice, :company_details).lines.each { |line| table_data << [line] }
|
|
99
|
-
|
|
100
|
-
table = pdf.make_table(table_data, cell_style: {borders: [], padding: 0})
|
|
101
|
-
pdf.bounding_box([pdf.bounds.width - table.width, pdf.bounds.top], width: table.width,
|
|
102
|
-
height: table.height + 5) do
|
|
103
|
-
table.draw
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
pdf.move_cursor_to(pdf.bounds.top - logo_height - 20)
|
|
43
|
+
def initialize(invoice, appendix: nil)
|
|
44
|
+
@invoice = invoice
|
|
45
|
+
@appendix = Appendix.wrap(appendix)
|
|
46
|
+
@dependencies = {}
|
|
107
47
|
end
|
|
108
|
-
# rubocop:enable Metrics/MethodLength
|
|
109
48
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
# render bill to
|
|
116
|
-
pdf.float do
|
|
117
|
-
pdf.table([[bold_cell(pdf, I18n.t('payday.invoice.bill_to', default: 'Bill To'))],
|
|
118
|
-
[invoice.bill_to]], column_widths: [200], cell_style: bill_to_cell_style)
|
|
119
|
-
bill_to_ship_to_bottom = pdf.cursor
|
|
120
|
-
end
|
|
49
|
+
def render
|
|
50
|
+
data = InvoicePresenter.new(@invoice).to_h
|
|
51
|
+
register_qr_code(data[:qr_code])
|
|
52
|
+
data[:logo] = logo
|
|
121
53
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
table = pdf.make_table([[bold_cell(pdf, I18n.t('payday.invoice.ship_to', default: 'Ship To'))],
|
|
125
|
-
[invoice.ship_to]], column_widths: [200], cell_style: bill_to_cell_style)
|
|
126
|
-
|
|
127
|
-
pdf.bounding_box([pdf.bounds.width - table.width, pdf.cursor], width: table.width, height: table.height + 2) do
|
|
128
|
-
table.draw
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
# make sure we start at the lower of the bill_to or ship_to details
|
|
133
|
-
bill_to_ship_to_bottom = pdf.cursor if pdf.cursor < bill_to_ship_to_bottom
|
|
134
|
-
pdf.move_cursor_to(bill_to_ship_to_bottom - 20)
|
|
54
|
+
Typst(body: body, dependencies: dependencies, fonts: fonts, sys_inputs: sys_inputs(data))
|
|
55
|
+
.compile(:pdf).bytes.flatten.pack('C*')
|
|
135
56
|
end
|
|
136
|
-
# rubocop:enable Metrics/MethodLength
|
|
137
|
-
|
|
138
|
-
# rubocop:todo Metrics/PerceivedComplexity
|
|
139
|
-
# rubocop:todo Metrics/MethodLength
|
|
140
|
-
# rubocop:todo Metrics/AbcSize
|
|
141
|
-
def self.invoice_details(invoice, pdf) # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
142
|
-
# invoice details
|
|
143
|
-
table_data = []
|
|
144
57
|
|
|
145
|
-
|
|
146
|
-
if defined?(invoice.invoice_number) && invoice.invoice_number
|
|
147
|
-
table_data << if invoice.paid?
|
|
148
|
-
[bold_cell(pdf, I18n.t('payday.invoice.receipt_no', default: 'Receipt #:')),
|
|
149
|
-
bold_cell(pdf, invoice.invoice_number.to_s, align: :right)]
|
|
150
|
-
else
|
|
151
|
-
[bold_cell(pdf, I18n.t('payday.invoice.invoice_no', default: 'Invoice #:')),
|
|
152
|
-
bold_cell(pdf, invoice.invoice_number.to_s, align: :right)]
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
# Due on
|
|
157
|
-
if defined?(invoice.due_at) && invoice.due_at
|
|
158
|
-
due_date = if invoice.due_at.is_a?(Date) || invoice.due_at.is_a?(Time)
|
|
159
|
-
invoice.due_at.strftime(Payday::Config.default.date_format)
|
|
160
|
-
else
|
|
161
|
-
invoice.due_at.to_s
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
table_data << [bold_cell(pdf, I18n.t('payday.invoice.due_date', default: 'Due Date:')),
|
|
165
|
-
bold_cell(pdf, due_date, align: :right)]
|
|
166
|
-
end
|
|
58
|
+
private
|
|
167
59
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
end
|
|
60
|
+
# An appendix is concatenated rather than +#include+d, which would make it a module of its
|
|
61
|
+
# own and leave the template's page rules -- the margins, and the numbering footer --
|
|
62
|
+
# stopping at its edge.
|
|
63
|
+
def body
|
|
64
|
+
template = File.read(TEMPLATE)
|
|
65
|
+
return template if @appendix.nil?
|
|
175
66
|
|
|
176
|
-
|
|
177
|
-
bold_cell(pdf, paid_date, align: :right)]
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
# loop through invoice_details and include them
|
|
181
|
-
invoice.each_detail do |key, value|
|
|
182
|
-
table_data << [bold_cell(pdf, key),
|
|
183
|
-
bold_cell(pdf, value, align: :right)]
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
return unless table_data.length.positive?
|
|
187
|
-
|
|
188
|
-
pdf.table(table_data, cell_style: {borders: [], padding: [1, 10, 1, 1]})
|
|
67
|
+
"#{template}\n#{@appendix.source}"
|
|
189
68
|
end
|
|
190
|
-
# rubocop:enable Metrics/AbcSize
|
|
191
|
-
# rubocop:enable Metrics/MethodLength
|
|
192
|
-
# rubocop:enable Metrics/PerceivedComplexity
|
|
193
69
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
table_data = []
|
|
197
|
-
table_data << [bold_cell(pdf, I18n.t('payday.line_item.description', default: 'Description'), borders: []),
|
|
198
|
-
bold_cell(pdf, I18n.t('payday.line_item.unit_price', default: 'Unit Price'), align: :center,
|
|
199
|
-
borders: []),
|
|
200
|
-
bold_cell(pdf, I18n.t('payday.line_item.quantity', default: 'Quantity'), align: :center,
|
|
201
|
-
borders: []),
|
|
202
|
-
bold_cell(pdf, I18n.t('payday.line_item.amount', default: 'Amount'), align: :center, borders: [])]
|
|
203
|
-
invoice.line_items.each do |line|
|
|
204
|
-
table_data << if line.predefined_amount
|
|
205
|
-
[line.description, '', '', number_to_currency(line.predefined_amount, invoice)]
|
|
206
|
-
else
|
|
207
|
-
[line.description,
|
|
208
|
-
line.display_price || number_to_currency(line.price, invoice),
|
|
209
|
-
line.display_quantity || BigDecimal(line.quantity.to_s).to_s('F'),
|
|
210
|
-
number_to_currency(line.amount, invoice)]
|
|
211
|
-
end
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
pdf.move_cursor_to(pdf.cursor - 20)
|
|
215
|
-
pdf.table(table_data, width: pdf.bounds.width, header: true,
|
|
216
|
-
cell_style: {border_width: 0.5, border_left_color: 'FFFFFF', border_right_color: 'FFFFFF',
|
|
217
|
-
border_top_color: 'F6F9FC', border_bottom_color: 'BCC6D0', padding: [5, 10],
|
|
218
|
-
inline_format: true},
|
|
219
|
-
row_colors: %w[F6F9FC ffffff]) do
|
|
220
|
-
# left align the number columns
|
|
221
|
-
columns(1..3).rows(1..(row_length - 1)).style(align: :right)
|
|
222
|
-
|
|
223
|
-
# set the column widths correctly
|
|
224
|
-
natural = natural_column_widths
|
|
225
|
-
natural[0] = width - natural[1] - natural[2] - natural[3]
|
|
226
|
-
end
|
|
70
|
+
def sys_inputs(data)
|
|
71
|
+
merge({'invoice' => data.to_json}, @appendix&.inputs, 'sys_input')
|
|
227
72
|
end
|
|
228
|
-
# rubocop:enable Metrics/MethodLength
|
|
229
|
-
|
|
230
|
-
# rubocop:todo Metrics/PerceivedComplexity
|
|
231
|
-
# rubocop:todo Metrics/MethodLength
|
|
232
|
-
def self.totals_lines(invoice, pdf) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
233
|
-
table_data = []
|
|
234
|
-
table_data << [
|
|
235
|
-
bold_cell(pdf, I18n.t('payday.invoice.subtotal', default: 'Subtotal:')),
|
|
236
|
-
cell(pdf, number_to_currency(invoice.subtotal, invoice), align: :right)
|
|
237
|
-
]
|
|
238
|
-
|
|
239
|
-
tax_description = if invoice.tax_description.nil?
|
|
240
|
-
I18n.t('payday.invoice.tax', default: 'Tax:')
|
|
241
|
-
else
|
|
242
|
-
invoice.tax_description
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
table_data << [
|
|
246
|
-
bold_cell(pdf, tax_description),
|
|
247
|
-
cell(pdf, number_to_currency(invoice.tax, invoice), align: :right)
|
|
248
|
-
]
|
|
249
|
-
|
|
250
|
-
if invoice.shipping_rate.positive?
|
|
251
|
-
shipping_description = if invoice.shipping_description.nil?
|
|
252
|
-
I18n.t('payday.invoice.shipping', default: 'Shipping:')
|
|
253
|
-
else
|
|
254
|
-
invoice.shipping_description
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
table_data << [
|
|
258
|
-
bold_cell(pdf, shipping_description),
|
|
259
|
-
cell(pdf, number_to_currency(invoice.shipping, invoice),
|
|
260
|
-
align: :right)
|
|
261
|
-
]
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
if invoice.retention_rate.positive?
|
|
265
|
-
retention_description = if invoice.retention_description.nil?
|
|
266
|
-
I18n.t('payday.invoice.retention', default: 'Retention:')
|
|
267
|
-
else
|
|
268
|
-
invoice.retention_description
|
|
269
|
-
end
|
|
270
73
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
]
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
table_data << [
|
|
279
|
-
bold_cell(pdf, I18n.t('payday.invoice.total', default: 'Total:'),
|
|
280
|
-
size: 12),
|
|
281
|
-
cell(pdf, number_to_currency(invoice.total, invoice),
|
|
282
|
-
size: 12, align: :right)
|
|
283
|
-
]
|
|
284
|
-
table = pdf.make_table(table_data, cell_style: {borders: []})
|
|
285
|
-
pdf.bounding_box([pdf.bounds.width - table.width, pdf.cursor],
|
|
286
|
-
width: table.width, height: table.height + 2) do
|
|
287
|
-
table.draw
|
|
288
|
-
end
|
|
74
|
+
# Read after the logo and the QR code have registered theirs, so an appendix reusing one
|
|
75
|
+
# of those names is caught rather than silently replacing the file.
|
|
76
|
+
def dependencies
|
|
77
|
+
merge(@dependencies, @appendix&.dependencies, 'dependency')
|
|
289
78
|
end
|
|
290
|
-
# rubocop:enable Metrics/MethodLength
|
|
291
|
-
# rubocop:enable Metrics/PerceivedComplexity
|
|
292
79
|
|
|
293
|
-
def
|
|
294
|
-
return
|
|
80
|
+
def merge(ours, theirs, what)
|
|
81
|
+
return ours if theirs.nil?
|
|
295
82
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
83
|
+
taken = ours.keys & theirs.keys
|
|
84
|
+
if taken.any?
|
|
85
|
+
raise ArgumentError,
|
|
86
|
+
"appendix #{what} #{taken.map(&:inspect).join(', ')} is reserved by the invoice"
|
|
299
87
|
end
|
|
300
|
-
pdf.line_width = 0.5
|
|
301
|
-
pdf.stroke_color = 'cccccc'
|
|
302
|
-
pdf.stroke_line([0, pdf.cursor - 3, pdf.bounds.width, pdf.cursor - 3])
|
|
303
|
-
pdf.move_cursor_to(pdf.cursor - 10)
|
|
304
|
-
pdf.text(invoice.notes.to_s, inline_format: true)
|
|
305
88
|
|
|
306
|
-
|
|
89
|
+
ours.merge(theirs)
|
|
307
90
|
end
|
|
308
91
|
|
|
309
|
-
def
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
require 'rqrcode'
|
|
313
|
-
|
|
314
|
-
pdf.move_cursor_to(pdf.cursor - 10)
|
|
315
|
-
qr = RQRCode::QRCode.new(invoice.qr_code.to_s)
|
|
316
|
-
png = qr.as_png(size: 200)
|
|
317
|
-
|
|
318
|
-
pdf.image(StringIO.new(png.to_s), width: 100, position: :left)
|
|
92
|
+
def fonts
|
|
93
|
+
FONTS.to_h { |name| [name, File.binread(File.join(FONT_DIR, name))] }
|
|
319
94
|
end
|
|
320
95
|
|
|
321
|
-
|
|
322
|
-
|
|
96
|
+
# Config#invoice_logo is either a path or a {filename:, size: "WxH"} Hash.
|
|
97
|
+
def logo
|
|
98
|
+
setting = invoice_logo
|
|
99
|
+
return nil if setting.nil?
|
|
323
100
|
|
|
324
|
-
|
|
101
|
+
path, width, height = logo_parts(setting)
|
|
102
|
+
name = "logo#{File.extname(path.to_s)}"
|
|
103
|
+
@dependencies[name] = File.binread(path.to_s)
|
|
104
|
+
{name: name, width: width, height: height}
|
|
325
105
|
end
|
|
326
106
|
|
|
327
|
-
def
|
|
328
|
-
if invoice.respond_to?(
|
|
329
|
-
invoice.send(property)
|
|
330
|
-
else
|
|
331
|
-
Payday::Config.default.send(property)
|
|
332
|
-
end
|
|
333
|
-
end
|
|
107
|
+
def invoice_logo
|
|
108
|
+
return @invoice.invoice_logo if @invoice.respond_to?(:invoice_logo) && @invoice.invoice_logo
|
|
334
109
|
|
|
335
|
-
|
|
336
|
-
Prawn::Table::Cell::Text.make(pdf, text, options)
|
|
110
|
+
Payday::Config.default.invoice_logo
|
|
337
111
|
end
|
|
338
112
|
|
|
339
|
-
def
|
|
340
|
-
|
|
341
|
-
end
|
|
113
|
+
def logo_parts(setting)
|
|
114
|
+
return [setting, nil, nil] unless setting.is_a?(Hash)
|
|
342
115
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
Money.locale_backend = :currency
|
|
346
|
-
Money.rounding_mode = BigDecimal::ROUND_HALF_UP
|
|
347
|
-
currency = Money::Currency.wrap(invoice_or_default(invoice, :currency))
|
|
348
|
-
number *= currency.subunit_to_unit
|
|
349
|
-
number = number.round unless Money.default_infinite_precision
|
|
350
|
-
Money.new(number, currency).format
|
|
116
|
+
width, height = setting[:size].to_s.split('x').map(&:to_f)
|
|
117
|
+
[setting[:filename], width, height]
|
|
351
118
|
end
|
|
352
119
|
|
|
353
|
-
def
|
|
354
|
-
|
|
355
|
-
cell_proxy.each do |cell|
|
|
356
|
-
max = cell.natural_content_width if cell.natural_content_width > max
|
|
357
|
-
end
|
|
120
|
+
def register_qr_code(data)
|
|
121
|
+
return if data.nil?
|
|
358
122
|
|
|
359
|
-
|
|
123
|
+
@dependencies['qr.svg'] = QrCode.new(data).to_svg
|
|
360
124
|
end
|
|
361
125
|
|
|
362
126
|
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rqrcode'
|
|
4
|
+
|
|
5
|
+
module Payday
|
|
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
|