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.
@@ -2,361 +2,125 @@
2
2
 
3
3
  module Payday
4
4
 
5
- # The PDF renderer. We use this internally in Payday to render pdfs, but really you should just need to call
6
- # {{Payday::Invoiceable#render_pdf}} to render pdfs yourself.
7
- class PdfRenderer # rubocop:todo Metrics/ClassLength
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
- pdf(invoice).render_file(path)
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
- def self.render(invoice)
16
- pdf(invoice).render
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
- def self.pdf(invoice) # rubocop:todo Metrics/MethodLength, Metrics/AbcSize
20
- pdf = Prawn::Document.new(page_size: invoice_or_default(invoice, :page_size))
21
-
22
- font_dir = File.join(File.dirname(__dir__), '..', 'fonts')
23
- pdf.font_families.update(
24
- 'NotoSans' => {normal: File.join(font_dir, 'NotoSans-Regular.ttf'),
25
- bold: File.join(font_dir, 'NotoSans-Bold.ttf')}
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.stamp(invoice, pdf) # rubocop:todo Metrics/MethodLength
47
- stamp = nil
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
- if stamp
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
- # rubocop:todo Metrics/MethodLength
69
- def self.company_banner(invoice, pdf) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
70
- # render the logo
71
- image = invoice_or_default(invoice, :invoice_logo)
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
- # rubocop:todo Metrics/MethodLength
111
- def self.bill_to_ship_to(invoice, pdf) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
112
- bill_to_cell_style = {borders: [], padding: [2, 0]}
113
- bill_to_ship_to_bottom = 0
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
- # render ship to
123
- if defined?(invoice.ship_to) && !invoice.ship_to.nil?
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
- # invoice number
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
- # Paid on
169
- if defined?(invoice.paid_at) && invoice.paid_at
170
- paid_date = if invoice.paid_at.is_a?(Date) || invoice.due_at.is_a?(Time)
171
- invoice.paid_at.strftime(Payday::Config.default.date_format)
172
- else
173
- invoice.paid_at.to_s
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
- table_data << [bold_cell(pdf, I18n.t('payday.invoice.paid_date', default: 'Paid Date:')),
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
- # rubocop:todo Metrics/MethodLength
195
- def self.line_items_table(invoice, pdf) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
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
- table_data << [
272
- bold_cell(pdf, retention_description),
273
- cell(pdf, number_to_currency(-invoice.retention, invoice),
274
- align: :right)
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 self.notes(invoice, pdf) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
294
- return unless defined?(invoice.notes) && invoice.notes
80
+ def merge(ours, theirs, what)
81
+ return ours if theirs.nil?
295
82
 
296
- pdf.move_cursor_to(pdf.cursor - 30)
297
- pdf.font('NotoSans') do
298
- pdf.text(I18n.t('payday.invoice.notes', default: 'Notes'), style: :bold)
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
- render_qr_code(invoice, pdf)
89
+ ours.merge(theirs)
307
90
  end
308
91
 
309
- def self.render_qr_code(invoice, pdf)
310
- return unless defined?(invoice.qr_code) && invoice.qr_code.to_s.strip.present?
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
- def self.page_numbers(pdf)
322
- return unless pdf.page_count > 1
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
- pdf.number_pages('<page> / <total>', at: [pdf.bounds.right - 25, -10])
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 self.invoice_or_default(invoice, property)
328
- if invoice.respond_to?(property) && invoice.send(property)
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
- def self.cell(pdf, text, options = {})
336
- Prawn::Table::Cell::Text.make(pdf, text, options)
110
+ Payday::Config.default.invoice_logo
337
111
  end
338
112
 
339
- def self.bold_cell(pdf, text, options = {})
340
- cell(pdf, "<b>#{text}</b>", options.merge(inline_format: true))
341
- end
113
+ def logo_parts(setting)
114
+ return [setting, nil, nil] unless setting.is_a?(Hash)
342
115
 
343
- # Converts this number to a formatted currency string
344
- def self.number_to_currency(number, invoice)
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 self.max_cell_width(cell_proxy)
354
- max = 0
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
- max
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