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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5ccb3bfdda5d49f50b0902f0bb78996c507d66f5f6847322b4dd68be31615d9f
4
+ data.tar.gz: 4202cc126a5556d17239a9989d7ab11a7dd6c8de19de42e885494a0669776c76
5
+ SHA512:
6
+ metadata.gz: 932d8e285879146e5dcc42b17939ad2f324cbf7fea0eb744681a1572c43ee868cc9a453f1b2be4bfac594ebd5e7d13939af505630f3c4dbd84984232d6005b16
7
+ data.tar.gz: 4df8d76b15d53096521ca7d8835870ad89225b1c15d9d80073bbeab3f6e7db476ba227e225381a8a9495ebd52b46aa88723b9da8e64fdec37fb9b7f9777f91b5
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # CHANGELOG
2
+
3
+ ## 1.0.0 (2026-11-11)
4
+
5
+ The first release of Tallyman. It carries on from [Payday](https://github.com/webtranslateit/payday), which was published as `webtranslateit-payday`, and picks up where that gem's 2.1.2 left off. Everything before this release is in [Payday's changelog](https://github.com/webtranslateit/payday/blob/main/CHANGELOG.md).
6
+
7
+ Invoice amounts and layout are exactly what they were in `webtranslateit-payday` 2.1.2; only the translated labels listed below differ. To move an application over:
8
+
9
+ * In your Gemfile, replace `gem 'webtranslateit-payday', require: 'payday'` with `gem 'tallyman'`.
10
+ * Rename the `Payday` module to `Tallyman` wherever you use it: `Tallyman::Invoice`, `Tallyman::Config`, `Tallyman::Invoiceable`, `Tallyman::LineItemable`, `Tallyman::Appendix` and so on.
11
+ * If you override the invoice labels, move your translations from the `payday:` I18n key to `tallyman:`.
12
+
13
+ A few things change along the way:
14
+
15
+ * The Rails generator, now `rails generate tallyman:setup`, works on current Rails. It used to fail on Rails 7.1 and later, and the migration it wrote could not run. The migration now creates every column an invoice needs to render, and models generated under custom names (`--invoice-name`, `--line-item-name`) are wired so that the invoice finds its lines.
16
+ * An ActiveRecord invoice with a `bill_to` column is billed to whoever that column names. `Invoiceable#bill_to` used to shadow the column with its placeholder, so such an invoice was addressed to "Goofy McGoofison" unless the model redefined the method.
17
+ * The translations are reviewed, so invoices in French, Spanish, German, Dutch and Simplified Chinese print some labels differently. English is untouched.
18
+ * Every locale now translates every label. Dutch, German and Simplified Chinese were missing the receipt number, and Dutch and Simplified Chinese the REFUNDED stamp, so those came out in English.
19
+ * The amount column no longer reads as a second price in French ("Montant"), Spanish ("Importe") or as "total" in Simplified Chinese ("金额").
20
+ * Spanish: the paid date is "Fecha de pago:", the stamps are "VENCIDO" and "REEMBOLSADO", the unit price is "Precio unitario", and the retention line is "Retención:" without "IRPF", which only applies in Spain.
21
+ * German: the shipping address is "Lieferadresse", the retention line "Einbehalt:" and the refunded stamp "ERSTATTET".
22
+ * Dutch: "Faktuur" is spelled "Factuur", and the addresses, due date, shipping line, overdue stamp and quantity use the usual invoice wording.
23
+ * Simplified Chinese: the invoice number, due date, shipping line and the paid and overdue stamps use the usual invoice wording.
24
+
25
+ To keep a label as it was, override it under your locale's `tallyman` key. To name IRPF on a Spanish retention line, set `retention_description` on the invoice.
data/README.md ADDED
@@ -0,0 +1,219 @@
1
+ # Tallyman
2
+
3
+ Tallyman is a Ruby library for rendering invoices to PDF.
4
+
5
+ Here's an [example invoice](https://github.com/Rebase-Capital/tallyman/raw/main/spec/assets/testing_predefined_amount.pdf).
6
+
7
+ ## What is a tallyman?
8
+
9
+ On the docks, the tallyman is the clerk who stands at the ship's side and counts the cargo as it comes over the rail. Every bale, crate and barrel is checked off against the manifest, and the tally that results is the record both the ship and the shipper are held to: what was delivered, in what quantity, and so what is owed.
10
+
11
+ This gem does the same job on paper. You hand it what was sold and to whom, and it draws up the bill.
12
+
13
+ ## Installing
14
+
15
+ ```
16
+ gem install tallyman
17
+ ```
18
+
19
+ Or, using Bundler:
20
+
21
+ ``` ruby
22
+ gem 'tallyman'
23
+ ```
24
+
25
+ Tallyman needs Ruby 3.2 or later.
26
+
27
+ ## Usage
28
+
29
+ Tallyman comes with `Invoice` and `LineItem` classes you can use as they are:
30
+
31
+ ``` ruby
32
+ invoice = Tallyman::Invoice.new(invoice_number: 12)
33
+ # Two lines with a price and a quantity. The amount is price * quantity.
34
+ invoice.add_line_item(price: 20, quantity: 5, description: 'Pants')
35
+ invoice.add_line_item(price: 10, quantity: 3, description: 'Shirts')
36
+ # A line with no price or quantity, only a predefined amount (a flat fee).
37
+ invoice.add_line_item(predefined_amount: 10, description: 'Shipping')
38
+ invoice.render_pdf_to_file('/path/to_file.pdf')
39
+ ```
40
+
41
+ `render_pdf` returns the PDF as a string instead of writing it to disk.
42
+
43
+ API documentation is available at [rubydoc.info](https://www.rubydoc.info/gems/tallyman).
44
+
45
+ ## Customizing your invoice
46
+
47
+ `Tallyman::Config` holds the settings shared by every invoice, such as the logo and the company details:
48
+
49
+ ``` ruby
50
+ Tallyman::Config.default.invoice_logo = "/path/to/company/logo.png"
51
+ Tallyman::Config.default.company_name = "Awesome Corp"
52
+ Tallyman::Config.default.company_details = "10 This Way\nManhattan, NY 10001\n800-111-2222\nawesome@awesomecorp.com"
53
+ ```
54
+
55
+ The logo can be a PNG, a JPEG or an SVG, and is drawn at its natural size. Pass a hash to constrain it:
56
+
57
+ ``` ruby
58
+ Tallyman::Config.default.invoice_logo = {filename: "logo.svg", size: "200x50"}
59
+ ```
60
+
61
+ `page_size` accepts `"LETTER"`, `"A4"` and `"LEGAL"`. `currency` and `date_format` are set the same way.
62
+
63
+ ## QR codes
64
+
65
+ An invoice can carry a QR code, which a growing number of countries expect for tax compliance:
66
+
67
+ ``` ruby
68
+ invoice = Tallyman::Invoice.new(
69
+ invoice_number: 12345,
70
+ qr_code: 'https://verify.example.com/invoice/12345',
71
+ notes: 'Scan the QR code below to verify this invoice online.'
72
+ )
73
+ ```
74
+
75
+ The QR code is rendered below the notes, as vector SVG so it stays sharp in print. The code itself comes straight from [rqrcode](https://github.com/whomwah/rqrcode), so the payload and its error correction level are exactly what that gem produces.
76
+
77
+ ## Using Tallyman with ActiveRecord (or any other objects)
78
+
79
+ Tallyman works with two objects, an invoice and a line item. To use it with your own classes, include `Tallyman::Invoiceable` in the invoice and `Tallyman::LineItemable` in the line item.
80
+
81
+ In a Rails app, a generator writes both models and the migration for their tables:
82
+
83
+ ```
84
+ rails generate tallyman:setup
85
+ ```
86
+
87
+ Run it with `--help` to see how to name the models differently or skip the migration.
88
+
89
+ If you write the classes yourself, an invoice needs to respond to `line_items`, `bill_to`, `invoice_number`, `notes`, `due_at`, `paid_at`, `tax_rate`, `tax_description`, `shipping_rate`, `shipping_description`, `retention_rate` and `retention_description`. The three rates must be numbers, 0 when they don't apply; the rest can be `nil`. A line item needs `description`, `price`, `quantity`, `predefined_amount`, `display_price` and `display_quantity`.
90
+
91
+ ## Rendering PDFs to the web
92
+
93
+ In a Rails controller, send the rendered string straight to the browser:
94
+
95
+ ``` ruby
96
+ respond_to do |format|
97
+ format.html
98
+ format.pdf do
99
+ send_data invoice.render_pdf, filename: 'Invoice #12.pdf', type: 'application/pdf', disposition: 'inline'
100
+ end
101
+ end
102
+ ```
103
+
104
+ ## Appending your own pages
105
+
106
+ Pass a `Tallyman::Appendix` to add pages of your own to the end of an invoice — a per-item breakdown, a delivery note, terms and conditions:
107
+
108
+ ``` ruby
109
+ appendix = Tallyman::Appendix.new(
110
+ source: File.read('appendix.typ'),
111
+ inputs: {'shipments' => shipments.to_json},
112
+ dependencies: {'chart.svg' => File.binread('chart.svg')}
113
+ )
114
+
115
+ invoice.render_pdf(appendix: appendix)
116
+ ```
117
+
118
+ `source` is [Typst](https://typst.app) markup appended to Tallyman's own template, so it is compiled as part of the invoice rather than as a second PDF you would then have to merge in. Two things follow from that: your pages inherit the invoice's page setup and margins, and the page counter runs across the whole document, so the numbering footer counts your pages too. A one-page invoice with one appended page comes out numbered "1 / 2" and "2 / 2".
119
+
120
+ `inputs` reach the template as [sys_inputs](https://typst.app/docs/reference/foundations/sys/), read with `json(bytes(sys.inputs.shipments))`. `dependencies` are files the source can reach by name, with `image("chart.svg")` and the like. Reusing a name the invoice has already taken — the `invoice` input, or the logo and QR code files — raises an `ArgumentError` rather than quietly replacing it.
121
+
122
+ **Pass your data through `inputs`, never by interpolating it into `source`.** Typst treats a string arriving through `sys.inputs` as literal text, which is what stops a customer-supplied field from affecting the layout. `source` is code: interpolating invoice data into it hands anyone who can set a field on an invoice a way to write Typst.
123
+
124
+ An appendix that reads its data and lists it on a page of its own:
125
+
126
+ ``` typst
127
+ #let shipments = json(bytes(sys.inputs.shipments))
128
+
129
+ #pagebreak()
130
+ #text(size: 16pt, weight: "bold")[Shipments]
131
+
132
+ #for shipment in shipments [
133
+ - #shipment.reference
134
+ ]
135
+ ```
136
+
137
+ ## I18n
138
+
139
+ Tallyman uses the i18n gem for its labels, and ships with English, French, German, Spanish, Dutch and Simplified Chinese. To change a label, add a YAML file to the `config/locales` directory of your Rails app. These are the labels you can override:
140
+
141
+ ``` yaml
142
+ en:
143
+ tallyman:
144
+ status:
145
+ paid: PAID
146
+ overdue: OVERDUE
147
+ refunded: REFUNDED
148
+ invoice:
149
+ bill_to: "Bill To"
150
+ ship_to: "Ship To"
151
+ invoice_no: "Invoice number:"
152
+ receipt_no: "Receipt number:"
153
+ due_date: "Due Date:"
154
+ paid_date: "Paid Date:"
155
+ subtotal: "Subtotal:"
156
+ shipping: "Shipping:"
157
+ tax: "Tax:"
158
+ retention: "Retention:"
159
+ total: "Total:"
160
+ notes: "Notes:"
161
+ line_item:
162
+ description: Description
163
+ unit_price: "Unit Price"
164
+ quantity: Quantity
165
+ amount: Amount
166
+ ```
167
+
168
+ If you translate the invoice into another language, a pull request adding your locale file is welcome.
169
+
170
+ ## Contributing
171
+
172
+ Issues and pull requests are welcome on [GitHub](https://github.com/Rebase-Capital/tallyman). For anything larger than a small fix, open an issue first so that two people don't end up doing the same work.
173
+
174
+ Run the specs and the linter with `bundle exec rake`.
175
+
176
+ ## History
177
+
178
+ Tallyman is the continuation of [Payday](https://github.com/webtranslateit/payday), a gem written by Alan Johnson. When the original stopped working on Ruby 2.7 and its source was no longer on GitHub, we recovered the code from [rubygems.org](https://rubygems.org) and maintained it as `webtranslateit-payday`, adding features and fixes along the way. Tallyman 1.0.0 picks up where `webtranslateit-payday` 2.1.2 left off; the earlier history is in [Payday's repository](https://github.com/webtranslateit/payday) and its [changelog](https://github.com/webtranslateit/payday/blob/main/CHANGELOG.md).
179
+
180
+ We use Tallyman at [WebTranslateIt.com](https://webtranslateit.com) and [Trackberry](https://trackberry.com) to generate invoices and receipts.
181
+
182
+ Thanks to everyone who has contributed:
183
+
184
+ * Alan Johnson, the original author
185
+ * Sam Pizzey ([pizzeys](https://github.com/pizzeys))
186
+ * Andrew Nordman ([cadwallion](https://github.com/cadwallion))
187
+ * Pierre Olivier Martel ([pomartel](https://github.com/pomartel))
188
+ * Matt Hoofman ([mhoofman](https://github.com/mhoofman))
189
+ * Édouard Brière ([edouard](https://github.com/edouard))
190
+ * Jim Jones ([aantix](https://github.com/aantix))
191
+ * Hussein Morsy ([husseinmorsy](https://github.com/husseinmorsy))
192
+
193
+ ## Acknowledgements
194
+
195
+ Tallyman renders with [Typst](https://typst.app) and its [Ruby binding](https://github.com/actsasflinn/typst-rb).
196
+
197
+ 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.
198
+
199
+ ## License
200
+
201
+ Copyright (C) 2011 by Alan Johnson
202
+
203
+ Permission is hereby granted, free of charge, to any person obtaining a copy
204
+ of this software and associated documentation files (the "Software"), to deal
205
+ in the Software without restriction, including without limitation the rights
206
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
207
+ copies of the Software, and to permit persons to whom the Software is
208
+ furnished to do so, subject to the following conditions:
209
+
210
+ The above copyright notice and this permission notice shall be included in
211
+ all copies or substantial portions of the Software.
212
+
213
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
214
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
215
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
216
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
217
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
218
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
219
+ THE SOFTWARE.
@@ -0,0 +1,24 @@
1
+ de:
2
+ tallyman:
3
+ status:
4
+ overdue: ÜBERFÄLLIG
5
+ paid: BEZAHLT
6
+ refunded: ERSTATTET
7
+ invoice:
8
+ bill_to: Rechnung an
9
+ ship_to: Lieferadresse
10
+ invoice_no: "Rechnungsnr.:"
11
+ receipt_no: "Quittungsnr.:"
12
+ due_date: "Fälligkeit:"
13
+ paid_date: "Zahlungsdatum:"
14
+ subtotal: "Zwischensumme:"
15
+ shipping: "Versand:"
16
+ tax: "Steuern:"
17
+ retention: "Einbehalt:"
18
+ total: "Gesamt:"
19
+ notes: "Anmerkungen:"
20
+ line_item:
21
+ description: Beschreibung
22
+ unit_price: Stückpreis
23
+ quantity: Menge
24
+ amount: Betrag
@@ -0,0 +1,24 @@
1
+ en:
2
+ tallyman:
3
+ status:
4
+ paid: PAID
5
+ overdue: OVERDUE
6
+ refunded: REFUNDED
7
+ invoice:
8
+ bill_to: "Bill To"
9
+ ship_to: "Ship To"
10
+ invoice_no: "Invoice number:"
11
+ receipt_no: "Receipt number:"
12
+ due_date: "Due Date:"
13
+ paid_date: "Paid Date:"
14
+ subtotal: "Subtotal:"
15
+ shipping: "Shipping:"
16
+ tax: "Tax:"
17
+ retention: "Retention:"
18
+ total: "Total:"
19
+ notes: "Notes:"
20
+ line_item:
21
+ description: Description
22
+ unit_price: "Unit Price"
23
+ quantity: Quantity
24
+ amount: Amount
@@ -0,0 +1,24 @@
1
+ es:
2
+ tallyman:
3
+ status:
4
+ paid: PAGADO
5
+ overdue: VENCIDO
6
+ refunded: REEMBOLSADO
7
+ invoice:
8
+ bill_to: "Facturar a"
9
+ ship_to: "Enviar a"
10
+ invoice_no: "Número de factura proforma:"
11
+ receipt_no: "Número de factura:"
12
+ due_date: "Vencimiento:"
13
+ paid_date: "Fecha de pago:"
14
+ subtotal: "Subtotal:"
15
+ shipping: "Envío:"
16
+ tax: "Impuesto:"
17
+ retention: "Retención:"
18
+ total: "Total:"
19
+ notes: "Notas:"
20
+ line_item:
21
+ description: Descripción
22
+ unit_price: "Precio unitario"
23
+ quantity: Cantidad
24
+ amount: Importe
@@ -0,0 +1,24 @@
1
+ fr:
2
+ tallyman:
3
+ status:
4
+ paid: PAYÉ
5
+ overdue: "EN RETARD"
6
+ refunded: "REMBOURSÉ"
7
+ invoice:
8
+ bill_to: "Adresse de facturation"
9
+ ship_to: "Adresse de livraison"
10
+ invoice_no: "Numéro de facture proforma :"
11
+ receipt_no: "Numéro de facture :"
12
+ due_date: "Date limite de paiement :"
13
+ paid_date: "Date de paiement :"
14
+ subtotal: "Sous-total :"
15
+ shipping: "Livraison :"
16
+ tax: "Taxes :"
17
+ retention: "Retenue :"
18
+ total: "Total :"
19
+ notes: "Notes :"
20
+ line_item:
21
+ description: Description
22
+ unit_price: "Prix unitaire"
23
+ quantity: Quantité
24
+ amount: Montant
@@ -0,0 +1,24 @@
1
+ nl:
2
+ tallyman:
3
+ status:
4
+ paid: BETAALD
5
+ overdue: VERVALLEN
6
+ refunded: TERUGBETAALD
7
+ invoice:
8
+ bill_to: Factuuradres
9
+ ship_to: Afleveradres
10
+ invoice_no: "Factuurnummer:"
11
+ receipt_no: "Kwitantienummer:"
12
+ due_date: "Vervaldatum:"
13
+ paid_date: "Betaald op:"
14
+ subtotal: "Subtotaal:"
15
+ shipping: "Verzendkosten:"
16
+ tax: "Btw:"
17
+ retention: "Inhouding:"
18
+ total: "Totaal:"
19
+ notes: "Opmerkingen:"
20
+ line_item:
21
+ description: Omschrijving
22
+ unit_price: Eenheidsprijs
23
+ quantity: Aantal
24
+ amount: Bedrag
@@ -0,0 +1,24 @@
1
+ zh-CN:
2
+ tallyman:
3
+ status:
4
+ paid: 已付款
5
+ overdue: 已逾期
6
+ refunded: 已退款
7
+ invoice:
8
+ bill_to: 账单地址
9
+ ship_to: 送货地址
10
+ invoice_no: "发票号码:"
11
+ receipt_no: "收据号码:"
12
+ due_date: "到期日期:"
13
+ paid_date: "支付日期:"
14
+ subtotal: "小计:"
15
+ shipping: "运费:"
16
+ tax: "税费:"
17
+ retention: "扣缴:"
18
+ total: "总计:"
19
+ notes: "备注:"
20
+ line_item:
21
+ description: 描述
22
+ unit_price: 单价
23
+ quantity: 数量
24
+ amount: 金额
Binary file
Binary file
@@ -0,0 +1,23 @@
1
+ Description:
2
+ Creates the invoice and line item models Tallyman renders, and a migration
3
+ for their tables with every column Tallyman reads.
4
+
5
+ The models are called Invoice and LineItem unless you name them with
6
+ --invoice-name and --line-item-name. Names can be CamelCase or under_scored.
7
+
8
+ Names are singularized, as `rails generate model` does. Pass --force-plural
9
+ to keep a name such as Media exactly as you typed it.
10
+
11
+ Pass --skip-migration to generate the models only.
12
+
13
+ Examples:
14
+ rails generate tallyman:setup
15
+
16
+ Creates the Invoice and LineItem models and a migration for the
17
+ invoices and line_items tables.
18
+
19
+ rails generate tallyman:setup --invoice-name=Bill --line-item-name=Charge
20
+
21
+ Creates the Bill and Charge models and a migration for the bills and
22
+ charges tables. A bill still reaches its charges through line_items,
23
+ which is the name Tallyman looks for.
@@ -0,0 +1,75 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ # rails generate tallyman:setup [--invoice-name=Invoice] [--line-item-name=LineItem] [--skip-migration]
5
+ #
6
+ # Generates an invoice model, a line item model and the migration for their tables, with every
7
+ # column Tallyman reads when it renders an invoice.
8
+ module Tallyman
9
+ class SetupGenerator < Rails::Generators::Base
10
+ # Brings in next_migration_number, which follows the app's migration numbering settings.
11
+ include ActiveRecord::Generators::Migration
12
+
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ class_option :invoice_name, type: :string, default: "Invoice",
16
+ desc: "Class name of the invoice model"
17
+ class_option :line_item_name, type: :string, default: "LineItem",
18
+ desc: "Class name of the line item model"
19
+ class_option :force_plural, type: :boolean, default: false,
20
+ desc: "Keeps the model names as given instead of singularizing them"
21
+ class_option :skip_migration, type: :boolean, default: false,
22
+ desc: "Does not create the migration file for the tables"
23
+
24
+ def generate_invoice_model
25
+ template "invoice.rb", "app/models/#{invoice_class.underscore}.rb"
26
+ end
27
+
28
+ def generate_line_item_model
29
+ template "line_item.rb", "app/models/#{line_item_class.underscore}.rb"
30
+ end
31
+
32
+ def generate_migration
33
+ return if options.skip_migration?
34
+
35
+ migration_template "migration.rb", "db/migrate/#{migration_name}.rb"
36
+ end
37
+
38
+ private
39
+
40
+ def invoice_class
41
+ model_class(options.invoice_name)
42
+ end
43
+
44
+ def line_item_class
45
+ model_class(options.line_item_name)
46
+ end
47
+
48
+ # Singular, the way `rails generate model` would have it, unless --force-plural says the
49
+ # name is right as it stands: "Media" would otherwise come out as "Medium".
50
+ def model_class(name)
51
+ options.force_plural? ? name.camelize : name.camelize.singularize
52
+ end
53
+
54
+ # Named after the tables rather than after Tallyman, so the generator can be run again
55
+ # for a second pair of models without colliding with its first migration.
56
+ def migration_name
57
+ "create_#{invoice_table}_and_#{line_item_table}"
58
+ end
59
+
60
+ # A namespaced model reads the table named after its last segment unless its namespace sets
61
+ # a table_name_prefix, so Billing::Invoice gets "invoices".
62
+ def invoice_table
63
+ invoice_class.demodulize.tableize
64
+ end
65
+
66
+ def line_item_table
67
+ line_item_class.demodulize.tableize
68
+ end
69
+
70
+ # The name of the association from a line item back to its invoice, and so of its foreign key.
71
+ def invoice_reference
72
+ invoice_class.demodulize.underscore
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,7 @@
1
+ class <%= invoice_class %> < ApplicationRecord
2
+ include Tallyman::Invoiceable
3
+
4
+ # Tallyman reads an invoice's lines through +line_items+, whatever the line item class is called.
5
+ has_many :line_items, class_name: "<%= line_item_class %>", foreign_key: :<%= invoice_reference %>_id,
6
+ inverse_of: :<%= invoice_reference %>, dependent: :destroy
7
+ end
@@ -0,0 +1,5 @@
1
+ class <%= line_item_class %> < ApplicationRecord
2
+ include Tallyman::LineItemable
3
+
4
+ belongs_to :<%= invoice_reference %>, class_name: "<%= invoice_class %>", inverse_of: :line_items
5
+ end
@@ -0,0 +1,43 @@
1
+ class <%= migration_name.camelize %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :<%= invoice_table %> do |t|
4
+ t.string :invoice_number
5
+ t.text :bill_to
6
+ t.text :ship_to
7
+ t.text :notes
8
+
9
+ # tax_rate and retention_rate are percentages of the subtotal. shipping_rate is an amount.
10
+ t.decimal :tax_rate, precision: 8, scale: 4, default: 0, null: false
11
+ t.string :tax_description
12
+ t.decimal :shipping_rate, precision: 12, scale: 2, default: 0, null: false
13
+ t.string :shipping_description
14
+ t.decimal :retention_rate, precision: 8, scale: 4, default: 0, null: false
15
+ t.string :retention_description
16
+
17
+ # A due date in the past stamps the invoice OVERDUE, paid_at stamps it PAID and
18
+ # refunded_at stamps it REFUNDED.
19
+ t.date :due_at
20
+ t.date :paid_at
21
+ t.date :refunded_at
22
+
23
+ t.timestamps
24
+ end
25
+
26
+ create_table :<%= line_item_table %> do |t|
27
+ t.references :<%= invoice_reference %>, null: false, foreign_key: {to_table: :<%= invoice_table %>}
28
+
29
+ t.text :description
30
+ t.decimal :price, precision: 12, scale: 2, default: 0, null: false
31
+ t.decimal :quantity, precision: 12, scale: 3, default: 1, null: false
32
+
33
+ # A line with a predefined_amount is a flat fee: it shows no price or quantity.
34
+ t.decimal :predefined_amount, precision: 12, scale: 2
35
+
36
+ # Shown in place of the formatted price and quantity when set, "2 hours" for example.
37
+ t.string :display_price
38
+ t.string :display_quantity
39
+
40
+ t.timestamps
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tallyman
4
+
5
+ # Extra pages compiled onto the end of an invoice.
6
+ #
7
+ # appendix = Tallyman::Appendix.new(
8
+ # source: File.read('appendix.typ'),
9
+ # inputs: {'shipments' => shipments.to_json},
10
+ # dependencies: {'chart.svg' => File.binread('chart.svg')}
11
+ # )
12
+ # invoice.render_pdf(appendix: appendix)
13
+ #
14
+ # +source+ is Typst markup appended to Tallyman's own template, so it is compiled as part of
15
+ # the invoice rather than as a second document that would then have to be merged in. Two
16
+ # things follow from that. The template's +#set page+ rules stay in force, so the appended
17
+ # pages keep the invoice's margins; and the page counter runs across the whole document, so
18
+ # the numbering footer counts the appended pages. A one-page invoice with one appended page
19
+ # numbers "1 / 2", and a three-page invoice continues into "4 / 4".
20
+ #
21
+ # That is also why the source is concatenated rather than +#include+d: an included file is
22
+ # its own module, and the page rules set inside the template would stop at its edge.
23
+ #
24
+ # **Pass your data through +inputs+, never through +source+.** Inputs reach the template as
25
+ # sys_inputs JSON, where Typst treats every string as literal text, which is what stops a
26
+ # customer-supplied field from affecting the layout. Source is code. Interpolating invoice
27
+ # data into it gives that data back the run of the document, and hands anyone who can set a
28
+ # field on an invoice a way to write Typst.
29
+ #
30
+ # +dependencies+ are files the source can reach by name -- +image("chart.svg")+ and the
31
+ # like. Keys are file names, values are the bytes.
32
+ Appendix = Data.define(:source, :inputs, :dependencies) do
33
+
34
+ # Accepts an Appendix, a Hash of the same attributes, or nil.
35
+ def self.wrap(value)
36
+ return value if value.nil? || value.is_a?(Appendix)
37
+
38
+ new(**value)
39
+ end
40
+
41
+ def initialize(source:, inputs: {}, dependencies: {})
42
+ super(source: source, inputs: inputs.transform_keys(&:to_s), dependencies: dependencies.transform_keys(&:to_s))
43
+ end
44
+
45
+ end
46
+
47
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tallyman
4
+
5
+ # Configuration for Tallyman. This is a singleton, so to set the company_name you would call
6
+ # Tallyman::Config.default.company_name = "Awesome Corp".
7
+ class Config
8
+
9
+ attr_accessor :invoice_logo, :company_name, :company_details, :date_format, :currency
10
+
11
+ # Sets the page size to use. Accepts the names 'LETTER', 'A4'
12
+ # and 'LEGAL', which the template maps onto the equivalent Typst paper. Anything else
13
+ # falls back to A4.
14
+ attr_accessor :page_size
15
+
16
+ # Returns the default configuration instance
17
+ def self.default
18
+ @default ||= new
19
+ end
20
+
21
+ # The invoice's own value for +property+ when it defines one and it is set, otherwise this
22
+ # config's. Every setting an invoice can override is read through here.
23
+ def value_for(invoice, property)
24
+ value = invoice.public_send(property) if invoice.respond_to?(property)
25
+ value || public_send(property)
26
+ end
27
+
28
+ # Internal: Resets a config object back to its default settings.
29
+ #
30
+ # Primarily intended for use in our tests.
31
+ def reset
32
+ # TODO: Move into specs and make minimal configuration required (company name / details)
33
+ self.invoice_logo = File.expand_path('assets/default_logo.png', __dir__)
34
+ self.company_name = 'Awesome Corp'
35
+ self.company_details = 'awesomecorp@commondream.net'
36
+ self.date_format = '%B %e, %Y'
37
+ self.currency = 'USD'
38
+ self.page_size = 'LETTER'
39
+ end
40
+
41
+ # Internal: Contruct a new config object.
42
+ def initialize
43
+ reset
44
+ end
45
+
46
+ end
47
+
48
+ end