webtranslateit-payday 1.7.3 → 2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 79e897436dac18ec0146ca7c556d2a1fd9a614fdc78b3b2fe4da6cff769184a5
4
- data.tar.gz: 77f740456d34e88037867ca6890110bb39014073d476fedffed124fd698a844f
3
+ metadata.gz: c69e90db47eb429202583e59002641017e200efa98a05989a8112db2504ca8d6
4
+ data.tar.gz: 0e1a0a649e12c88225f5eafd95bc7b2eb2f1a1b3825570ca54611ea072d16a6a
5
5
  SHA512:
6
- metadata.gz: 3253e3a0d51b8125df8fb0ee6341c18c320af4dbec9361376437082fc101b7552f8d25619f6d5ab79f914a2745c9a25a8952acce2a7cdeddbc71feeb7032afdf
7
- data.tar.gz: 6af84ba49bb0171f4c969a13707b743e400694e897bdbf27ce494b2e1d18b7570ccee60da7c90789941c5e1669e82bde3d40b44b6088c772b3064aa30dd1bd91
6
+ metadata.gz: 3bfaddfd9cbd922672a519e1309e723a8abfa5e64d035e1d3e355c9437ce93d0734fdfbfb7b624b1389f2ff66dc65685327a9cd802826055468b6353351df606
7
+ data.tar.gz: beac41f39e7cc6013e6eb7f94057e55042daad14c73a17b33a2e99bbcbc8b23d0b63e4c67618c5338298131e02c3f1614349d89983a42805ac693d76a0df1625
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 2.0.0 (2026-08-26)
4
+
5
+ **Breaking:** Payday now renders with [Typst](https://typst.app) instead of Prawn. The `prawn`, `prawn-table` and `prawn-svg` dependencies are gone, replaced by the single Apache-2.0 licensed `typst` gem, which ships precompiled native builds for macOS and Linux on both x86_64 and arm64.
6
+
7
+ The motivation was `prawn-table`, which has had no release since 2015 while the renderer depended on it for every table.
8
+
9
+ * The public API is unchanged. `Invoiceable#render_pdf`, `#render_pdf_to_file`, `PdfRenderer.render`, `PdfRenderer.render_to_file` and every `Payday::Config` accessor keep their signatures, and rendered invoices keep their existing layout.
10
+ * Prawn is no longer loaded for you. If your application relied on Payday requiring Prawn, require it yourself.
11
+ * Layout now lives in `lib/payday/templates/invoice.typ`, and invoice data is turned into a plain Hash by the new `Payday::InvoicePresenter`.
12
+ * The `inline_format` markup Payday accepted in notes and line item descriptions still works. `Payday::Markup` converts `<b>`, `<i>`, `<u>`, `<strikethrough>`, `<sub>`, `<sup>`, `<font size>`, `<color rgb>`, `<link href>` and `<br>` into styled runs.
13
+ * Invoice data reaches the template only as JSON, where Typst treats every string as literal text, so customer-supplied fields can no longer affect the layout.
14
+ * Rendered PDFs are now byte-for-byte reproducible for the same input.
15
+ * QR codes are drawn as vector SVG rather than a scaled 200px PNG, so they stay sharp in print, and the three finder patterns are drawn as rounded frames. The matrix still comes straight from RQRCode: the payload and its error correction level are untouched, and the rendered code is module-for-module identical to what the encoder produces.
16
+ * `Config#company_details` has any per-line indentation stripped. Prawn rendered each line as its own table cell, which trimmed leading whitespace, so a value written as an indented heredoc used to look fine and would otherwise have started rendering with its indentation.
17
+ * Logos still accept PNG, JPEG and SVG, and a logo given as a bare path keeps its natural size. Only the `{filename:, size:}` form constrains the dimensions.
18
+
19
+ Two packaging bugs are fixed along the way, both of which predate this release:
20
+
21
+ * Payday's own translations now load. The load path was resolved against the working directory, so unless a process happened to run from the gem's root the locales never loaded at all, and `I18n.t` raised `InvalidLocale` rather than falling back to the built-in English defaults. The German, Spanish, French, Dutch and Chinese translations that ship with the gem have effectively never been used.
22
+ * The default `invoice_logo` points inside the gem. It used to reference a file under `spec/`, which only resolved because the gemspec shipped the whole test suite.
23
+
24
+ The packaged gem now carries only `lib`, `config/locales`, `fonts` and the docs, which takes it from 563K to 277K.
25
+
26
+ Invoices keep the layout the prawn renderer produced. Page margins, table row heights, block spacing and the position of the status stamp were all measured against the previous output, and four of the five reference invoices in the test suite now match it within a single point at every word.
27
+
28
+ ## 1.7.4 (2026-08-13)
29
+
30
+ * Pass `enable_web_requests: false` when rendering SVG logos. Logos are read from local files, so nothing needs fetching over the network. This silences prawn-svg's deprecation warning and keeps behaviour stable when prawn-svg 1.0 flips the default to `false`.
31
+
3
32
  ## 1.7.3 (2026-02-16)
4
33
 
5
34
  * Add retention support (e.g. Spanish IRPF). Set `retention_rate` on an invoice to apply a post-tax deduction calculated as a percentage of the subtotal. The retention amount is displayed as a negative line in the PDF and subtracted from the total. Customize the label with `retention_description` or via the `payday.invoice.retention` i18n key.
data/README.md CHANGED
@@ -2,7 +2,9 @@ Payday!
2
2
  ===
3
3
  Payday is a library for rendering invoices to pdfs.
4
4
 
5
- We use payday intensively at [WebTranslateIt.com](https://webtranslateit.com) to generate customer’s invoices and receipts. When we upgraded our app to ruby 2.7 this gem failed in many ways and the original source code by Alan Johnson was nowhere to be found on GitHub. So we took the source code out of [rubygems.org](https://rubygems.org) and created this repo. We’ve since added a few new features and fixes, which can be seen [in the changelog](https://github.com/webtranslateit/payday/blob/main/CHANGELOG.md).
5
+ We use payday intensively at [WebTranslateIt.com](https://webtranslateit.com) and [Trackberry](https://trackberry.com) to generate customer’s invoices and receipts. When we upgraded our app to ruby 2.7 this gem failed in many ways and the original source code by Alan Johnson was nowhere to be found on GitHub. So we took the source code out of [rubygems.org](https://rubygems.org) and created this repo. We’ve since added a few new features and fixes, which can be seen [in the changelog](https://github.com/webtranslateit/payday/blob/main/CHANGELOG.md).
6
+
7
+ Invoices can now carry a QR code, which a growing number of countries expect for tax compliance, and as of 2.0 the PDFs are rendered with [Typst](https://typst.app) rather than Prawn.
6
8
 
7
9
  Here’s an [example PDF Invoice](https://github.com/webtranslateit/payday/raw/main/spec/assets/testing_predefined_amount.pdf) generated with our fork of payday.
8
10
 
@@ -53,6 +55,15 @@ Payday::Config.default.company_name = "Awesome Corp"
53
55
  Payday::Config.default.company_details = "10 This Way\nManhattan, NY 10001\n800-111-2222\nawesome@awesomecorp.com"
54
56
  ```
55
57
 
58
+ The logo can be a PNG, a JPEG or an SVG, and is drawn at its natural size. Pass a hash to
59
+ constrain it:
60
+
61
+ ``` ruby
62
+ Payday::Config.default.invoice_logo = {filename: "logo.svg", size: "200x50"}
63
+ ```
64
+
65
+ `page_size` accepts `"LETTER"`, `"A4"` and `"LEGAL"`.
66
+
56
67
  QR Code Support
57
68
  ===
58
69
  Invoices can include QR codes for verification purposes, useful for tax compliance requirements in various countries.
@@ -67,7 +78,9 @@ invoice = Payday::Invoice.new(
67
78
  )
68
79
  ```
69
80
 
70
- QR codes are automatically rendered below the notes section.
81
+ QR codes are automatically rendered below the notes section, as vector SVG so they stay
82
+ sharp in print. The code itself comes straight from [rqrcode](https://github.com/whomwah/rqrcode),
83
+ so the payload and its error correction level are exactly what that gem produces.
71
84
 
72
85
  Using Payday with ActiveRecord Objects (or any other objects, for that matter)
73
86
  ===
@@ -166,7 +179,9 @@ Here's what we're planning on working on with Payday in the near future:
166
179
 
167
180
  Acknowledgements
168
181
  ===
169
- This wouldn't be possible without the amazing [Prawn](http://prawn.majesticseacreature.com) gem and the team behind it.
182
+ Payday renders with [Typst](https://typst.app) and its [Ruby binding](https://github.com/actsasflinn/typst-rb).
183
+
184
+ For its first decade this gem was built on [Prawn](https://prawnpdf.org), and it would not exist without that project and the team behind it.
170
185
 
171
186
  License
172
187
  ===
data/lib/payday/config.rb CHANGED
@@ -8,9 +8,9 @@ module Payday
8
8
 
9
9
  attr_accessor :invoice_logo, :company_name, :company_details, :date_format, :currency
10
10
 
11
- # Sets the page size to use. See the
12
- # {http://prawn.majesticseacreature.com/docs/0.10.2/Prawn/Document/PageGeometry.html Prawn documentation} for valid
13
- # page_size values.
11
+ # Sets the page size to use. Accepts the names Payday has always taken, 'LETTER', 'A4'
12
+ # and 'LEGAL', which the template maps onto the equivalent Typst paper. Anything else
13
+ # falls back to A4.
14
14
  attr_accessor :page_size
15
15
 
16
16
  # Returns the default configuration instance
@@ -23,7 +23,7 @@ module Payday
23
23
  # Primarily intended for use in our tests.
24
24
  def reset
25
25
  # TODO: Move into specs and make minimal configuration required (company name / details)
26
- self.invoice_logo = File.join(File.dirname(__FILE__), '..', '..', 'spec', 'assets', 'default_logo.png')
26
+ self.invoice_logo = File.expand_path('assets/default_logo.png', __dir__)
27
27
  self.company_name = 'Awesome Corp'
28
28
  self.company_details = 'awesomecorp@commondream.net'
29
29
  self.date_format = '%B %e, %Y'
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Payday
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(Payday::Config.default.date_format)
148
+ end
149
+
150
+ def t(key, default)
151
+ I18n.t("payday.#{key}", default: default)
152
+ end
153
+
154
+ def setting(property)
155
+ return @invoice.send(property) if @invoice.respond_to?(property) && @invoice.send(property)
156
+
157
+ Payday::Config.default.send(property)
158
+ end
159
+
160
+ end
161
+
162
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Payday
4
+
5
+ # Converts the Prawn +inline_format+ markup Payday 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