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 +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +18 -3
- data/lib/payday/config.rb +4 -4
- data/lib/payday/invoice_presenter.rb +162 -0
- data/lib/payday/markup.rb +61 -0
- data/lib/payday/pdf_renderer.rb +54 -319
- 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 +10 -7
- metadata +18 -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 -29
- data/.gitignore +0 -8
- data/.rubocop.yml +0 -16
- data/.rubocop_todo.yml +0 -0
- data/Gemfile +0 -14
- data/Gemfile.lock +0 -191
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c69e90db47eb429202583e59002641017e200efa98a05989a8112db2504ca8d6
|
|
4
|
+
data.tar.gz: 0e1a0a649e12c88225f5eafd95bc7b2eb2f1a1b3825570ca54611ea072d16a6a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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.
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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.
|
|
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
|