ymlbill 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ba38c5e802b0d79ac1d6a639597f0fe6b1dc0a1c0123e83802a4a8fce9df6acf
4
+ data.tar.gz: 0eb3cb4531f5f166a52b84d41a37af5ccdecd16d0ef05282d207817ed985fdb3
5
+ SHA512:
6
+ metadata.gz: a5eaf2895349aacd7f69263b2a150236aad126fc15d218c3cf0dde5894c319bdb2e238610462fe3e070cfd9c90af9d956cc5d88489ec43b11de5a16eae8b04e7
7
+ data.tar.gz: 9cd1d554cda396bf2f0de792b4b40420abc469e63b5d6b803dc14130c87b42ed7fd5702c6a109f8dcd9a648bdc88c4b1506f6f392c058f8dc73acf4fc52fd54d
data/README.md ADDED
@@ -0,0 +1,363 @@
1
+ # ymlbill
2
+
3
+ A CLI tool to generate PDF invoices and quotes from YAML files.
4
+
5
+ ## Example Output
6
+
7
+ <img src="examples/example_invoice.png" alt="Example Invoice" width="80%">
8
+
9
+ ## Why ymlbill?
10
+
11
+ ymlbill was built with version control and filesystem-based organization in mind. Instead of storing invoices in a proprietary database or SaaS platform, everything lives as plain YAML files in your project directory:
12
+
13
+ - **Versionable**: Track every invoice change with git.
14
+ - **Filesystem-native**: Organize clients, sellers, and invoices in a logical folder structure
15
+ - **Template-driven**: One template, infinite variations. Can be customized per client or project
16
+
17
+ ### Example folder structure
18
+
19
+ ```
20
+ your-project/
21
+ ├── sellers/
22
+ │ └── acme.yml
23
+ ├── clients/
24
+ │ ├── client_a/
25
+ │ │ ├── client.yml
26
+ │ │ ├── invoice_001.yml <-- Refer to client.yml
27
+ │ │ ├── invoice_002.yml
28
+ │ │ └── quote_2026_001.yml
29
+ │ └── client_b/
30
+ │ │ ├── client.yml
31
+ │ └── invoice_001.yml
32
+ └── invoices/
33
+ └── 2026/
34
+ └── INV-2026-001.pdf
35
+ ```
36
+
37
+ Reference sellers and clients from any invoice:
38
+
39
+ ```yaml
40
+ # invoices/2026/INV-2026-001.yml
41
+ document:
42
+ type: invoice
43
+ number: INV-2026-001
44
+
45
+ seller: ../sellers/acme.yml
46
+ client: ../clients/client_a/client.yml # or inline
47
+ ```
48
+
49
+ ## Installation
50
+
51
+ ### With Nix (no installation required)
52
+
53
+ Run directly without installing:
54
+
55
+ ```bash
56
+ nix run github:pcboy/ymlbill -- generate invoice.yml
57
+ ```
58
+
59
+ ### With Ruby
60
+
61
+ ```bash
62
+ gem install ymlbill
63
+ ```
64
+
65
+ ### From source
66
+
67
+ ```bash
68
+ git clone https://github.com/pcboy/ymlbill
69
+ cd ymlbill
70
+ gem build ymlbill.gemspec
71
+ gem install ./ymlbill-*.gem
72
+ ```
73
+
74
+ ## Quick Start
75
+
76
+ 1. Create an invoice YAML file:
77
+
78
+ ```yaml
79
+ # invoice.yml
80
+ document:
81
+ type: invoice
82
+ number: INV-2026-001
83
+ date: 2026-08-13
84
+ due_date: 2026-08-27
85
+ currency: EUR
86
+ tax_perc: 20
87
+
88
+ seller: sellers/acme.yml
89
+ client: clients/client_1.yml
90
+
91
+ items:
92
+ - description: Consulting services
93
+ quantity: 3
94
+ unit_price: 150.00
95
+ ```
96
+
97
+ 2. Generate the PDF:
98
+
99
+ ```bash
100
+ ymlbill generate invoice.yml
101
+ ```
102
+
103
+ ### Custom template
104
+
105
+ ```bash
106
+ ymlbill generate invoice.yml -t custom.html.erb
107
+ ```
108
+
109
+ ### Custom output path
110
+
111
+ ```bash
112
+ ymlbill generate invoice.yml -o output.pdf
113
+ ```
114
+
115
+ ### Options
116
+
117
+ - `-t, --template PATH` - Custom HTML ERB template
118
+ - `-o, --output PATH` - Output PDF path
119
+ - `--version` - Print version
120
+ - `-h, --help` - Show help
121
+
122
+ ## Example YAML
123
+
124
+ ### Inline client and seller
125
+
126
+ ```yaml
127
+ # invoice.yml
128
+ document:
129
+ type: invoice
130
+ number: INV-2026-001
131
+ date: 2026-08-13
132
+ due_date: 2026-08-27
133
+ currency: EUR
134
+ tax_perc: 20
135
+
136
+ seller:
137
+ name: Acme Corp
138
+ email: billing@acme.com
139
+ phone: +33 1 23 45 67 89
140
+ address: |
141
+ 123 Business St
142
+ 75001 Paris
143
+ France
144
+
145
+ client:
146
+ name: Client Corp
147
+ email: contact@client.com
148
+ address: |
149
+ 456 Client Ave
150
+ 69001 Lyon
151
+ France
152
+
153
+ items:
154
+ - description: Consulting services
155
+ quantity: 3
156
+ unit_price: 150.00
157
+ - description: Travel expenses
158
+ quantity: 1
159
+ unit_price: 50.00
160
+ ```
161
+
162
+ ### With file references
163
+
164
+ ```yaml
165
+ # invoice.yml
166
+ document:
167
+ type: invoice
168
+ number: INV-2026-001
169
+ date: 2026-08-13
170
+ currency: EUR
171
+ tax_perc: 20
172
+
173
+ seller: sellers/acme.yml
174
+ client: clients/client_1.yml
175
+
176
+ items:
177
+ - description: Web development
178
+ quantity: 10
179
+ unit_price: 120.00
180
+ ```
181
+
182
+ ### Quote example
183
+
184
+ ```yaml
185
+ # quote.yml
186
+ document:
187
+ type: quote
188
+ number: QUO-2026-001
189
+ date: 2026-08-13
190
+ currency: EUR
191
+ tax_perc: 20
192
+
193
+ seller:
194
+ name: Acme Corp
195
+ email: billing@acme.com
196
+ address: |
197
+ 123 Business St
198
+ 75001 Paris
199
+ France
200
+
201
+ client:
202
+ name: Prospect Ltd
203
+ email: contact@prospect.com
204
+ address: |
205
+ 789 Prospect Blvd
206
+ 13001 Marseille
207
+ France
208
+
209
+ items:
210
+ - description: Website redesign
211
+ quantity: 1
212
+ unit_price: 2500.00
213
+ ```
214
+
215
+ ## Custom Templates
216
+
217
+ A default template is included at [`lib/ymlbill/templates/default.html.erb`](lib/ymlbill/templates/default.html.erb).
218
+
219
+ Create a custom ERB template to customize the invoice/quote appearance:
220
+
221
+ ```erb
222
+ <!-- custom.html.erb -->
223
+ <!DOCTYPE html>
224
+ <html>
225
+ <head>
226
+ <meta charset="utf-8">
227
+ <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
228
+ </head>
229
+ <body class="p-8">
230
+ <h1 class="text-3xl font-bold mb-4"><%= data.document.type.capitalize %></h1>
231
+ <p>Number: <%= data.document.number %></p>
232
+ <p>Date: <%= data.document.date %></p>
233
+
234
+ <div class="mt-8">
235
+ <h2 class="text-xl font-semibold">Seller</h2>
236
+ <p><%= data.seller.name %></p>
237
+ <p><%= data.seller.address %></p>
238
+ </div>
239
+
240
+ <div class="mt-4">
241
+ <h2 class="text-xl font-semibold">Client</h2>
242
+ <p><%= data.client.name %></p>
243
+ </div>
244
+
245
+ <table class="mt-8 w-full">
246
+ <thead>
247
+ <tr>
248
+ <th>Description</th>
249
+ <th>Qty</th>
250
+ <th>Unit Price</th>
251
+ <th>Total</th>
252
+ </tr>
253
+ </thead>
254
+ <tbody>
255
+ <% data.items.each do |item| %>
256
+ <tr>
257
+ <td><%= item.description %></td>
258
+ <td><%= item.quantity %></td>
259
+ <td><%= money(item.unit_price, data.document.currency) %></td>
260
+ <td><%= money(item.quantity * item.unit_price, data.document.currency) %></td>
261
+ </tr>
262
+ <% end %>
263
+ </tbody>
264
+ </table>
265
+ </body>
266
+ </html>
267
+ ```
268
+
269
+ The template has access to:
270
+
271
+ - `data` - All YAML data with dot notation (e.g., `data.document.number`, `data.client.name`)
272
+ - `money(amount, currency)` - Helper to format currency using the Money gem. Currency must be a valid ISO 4217 code (e.g., EUR, USD, GBP)
273
+
274
+ Then use it with:
275
+
276
+ ```bash
277
+ ymlbill generate invoice.yml -t custom.html.erb
278
+ ```
279
+
280
+ ## Flexibility: Add Any Fields You Need
281
+
282
+ The YAML structure is completely flexible. You can add any custom fields to your invoice and they will be passed through to your template:
283
+
284
+ ```yaml
285
+ # invoice.yml
286
+ document:
287
+ type: invoice
288
+ number: INV-2026-001
289
+ date: 2026-08-13
290
+ currency: EUR
291
+ tax_perc: 20
292
+
293
+ seller: sellers/acme.yml
294
+ client: clients/client_1.yml
295
+
296
+ # Custom fields - add whatever you need
297
+ project:
298
+ name: Website Redesign
299
+ code: PRJ-2026-042
300
+ manager: John Doe
301
+
302
+ items:
303
+ - description: Development
304
+ quantity: 10
305
+ unit_price: 150.00
306
+
307
+ # Add notes, terms, or any other metadata
308
+ terms: Payment due within 30 days
309
+ po_number: PO-12345
310
+ ```
311
+
312
+ Then access them in your custom template:
313
+
314
+ ```erb
315
+ <p>Project: <%= data.project.name %> (<%= data.project.code %>)</p>
316
+ <p>Project Manager: <%= data.project.manager %></p>
317
+ <p>PO Number: <%= data.po_number %></p>
318
+ <p>Terms: <%= data.terms %></p>
319
+ ```
320
+
321
+ This lets you adapt invoices to your specific business needs. Purchase orders, project codes, cost centers, custom tax fields, or any other metadata your workflow requires.
322
+
323
+ ## Development
324
+
325
+ ### Prerequisites
326
+
327
+ - Ruby 3.4+
328
+ - Nix (optional, for reproducible environment)
329
+
330
+ ### Setup
331
+
332
+ ```bash
333
+ # With Nix
334
+ nix develop
335
+
336
+ # Without Nix
337
+ bundle install
338
+ ```
339
+
340
+ ### Running tests
341
+
342
+ ```bash
343
+ bundle exec rspec
344
+ ```
345
+
346
+ ### Building the gem
347
+
348
+ ```bash
349
+ gem build ymlbill.gemspec
350
+ gem install ./ymlbill-*.gem
351
+ ```
352
+
353
+ ## Contributing
354
+
355
+ 1. Fork the repository
356
+ 2. Create a feature branch (`git checkout -b feature/my-feature`)
357
+ 3. Commit your changes (`git commit -am 'Add new feature'`)
358
+ 4. Push to the branch (`git push origin feature/my-feature`)
359
+ 5. Create a Pull Request
360
+
361
+ ## License
362
+
363
+ MIT
data/exe/ymlbill ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "ymlbill"
4
+
5
+ Ymlbill::CLI.start
@@ -0,0 +1,82 @@
1
+ require 'thor'
2
+ require 'tempfile'
3
+
4
+ module Ymlbill
5
+ class CLI < Thor
6
+ desc 'generate INPUT_YAML', 'Generate PDF invoice/quote from YAML file'
7
+ option :template, aliases: ['-t'], type: :string, desc: 'Path to custom HTML ERB template'
8
+ option :output, aliases: ['-o'], type: :string,
9
+ desc: 'Output PDF path (default: input basename + .pdf)'
10
+ option :debug_html, aliases: ['-d'], type: :boolean, default: false,
11
+ desc: 'Keep HTML file for debugging'
12
+
13
+ rescue_from InputFileNotFoundError do |e|
14
+ warn "Error: #{e.message}"
15
+ exit 3
16
+ end
17
+
18
+ rescue_from InvalidYamlError do |e|
19
+ warn "Error: #{e.message}"
20
+ exit 3
21
+ end
22
+
23
+ rescue_from TemplateNotFoundError do |e|
24
+ warn "Error: #{e.message}"
25
+ exit 4
26
+ end
27
+
28
+ rescue_from TemplateRenderError do |e|
29
+ warn "Error: #{e.message}"
30
+ exit 4
31
+ end
32
+
33
+ rescue_from PdfGenerationError do |e|
34
+ warn "Error: #{e.message}"
35
+ exit 5
36
+ end
37
+
38
+ def generate(input_file)
39
+ @input_file = input_file
40
+ run_pipeline
41
+ end
42
+
43
+ desc '--version', 'Print version'
44
+ def version
45
+ puts "ymlbill #{Ymlbill::VERSION}"
46
+ end
47
+
48
+ map '--version' => :version
49
+ map ['-h', '--help'] => :help
50
+
51
+ private
52
+
53
+ def run_pipeline
54
+ data = DocumentLoader.load(@input_file)
55
+ template_path = TemplateResolver.resolve(options[:template])
56
+ base_dir = File.dirname(File.expand_path(@input_file))
57
+ html = HtmlRenderer.new(template_path: template_path, base_dir: base_dir).render(data: data)
58
+
59
+ output_path = options[:output] || default_output_path(@input_file)
60
+
61
+ Tempfile.create(['ymlbill', '.html']) do |f|
62
+ f.write(html)
63
+ f.flush
64
+
65
+ if options[:debug_html]
66
+ debug_path = "#{File.basename(@input_file, '.*')}.html"
67
+ File.write(debug_path, html)
68
+ $stdout.puts "HTML saved to: #{debug_path}"
69
+ end
70
+
71
+ PdfEngine.build.render(html_path: f.path, output_path: output_path)
72
+ end
73
+
74
+ $stdout.puts "Generated: #{output_path}"
75
+ end
76
+
77
+ def default_output_path(input)
78
+ basename = File.basename(input, '.*')
79
+ "#{basename}.pdf"
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,22 @@
1
+ module Ymlbill
2
+ class DocumentLoader
3
+ def self.load(path)
4
+ raise InputFileNotFoundError, "Input file not found: #{path}" unless File.exist?(path)
5
+
6
+ yaml_content = File.read(path)
7
+ data = YAML.safe_load(yaml_content, permitted_classes: [Date])
8
+
9
+ raise InvalidYamlError, "YAML must be a hash, got #{data.class}" unless data.is_a?(Hash)
10
+
11
+ base_dir = File.dirname(path)
12
+
13
+ %w[seller client].each do |x|
14
+ data[x] = load(File.join(base_dir, data[x])) if data[x].is_a?(String)
15
+ end
16
+
17
+ data
18
+ rescue ::SyntaxError, Psych::SyntaxError => e
19
+ raise InvalidYamlError, "Invalid YAML syntax: #{e.message}"
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,53 @@
1
+ require 'ostruct'
2
+ require 'json'
3
+ require 'money'
4
+
5
+ module Ymlbill
6
+ class HtmlRenderer
7
+ def initialize(template_path:, base_dir: nil)
8
+ @template_path = template_path
9
+ @base_dir = base_dir
10
+ end
11
+
12
+ def render(data:)
13
+ template_content = File.read(@template_path)
14
+
15
+ data = JSON.parse(data.to_json, object_class: OpenStruct)
16
+
17
+ erb = ERB.new(template_content, trim_mode: '-')
18
+ erb.result(binding)
19
+ rescue SyntaxError => e
20
+ raise TemplateRenderError, "Template syntax error: #{e.message}"
21
+ end
22
+
23
+ def money(amount, currency = 'EUR')
24
+ Money.new(amount * 100, currency.to_s.upcase).format(
25
+ symbol: true, decimal_mark: '.', thousands_separator: ' '
26
+ )
27
+ end
28
+
29
+ def logo_path(path)
30
+ return nil unless path
31
+
32
+ full_path = @base_dir ? File.join(@base_dir, path) : File.expand_path(path)
33
+ full_path = File.expand_path(full_path)
34
+ return nil unless File.exist?(full_path)
35
+
36
+ ext = File.extname(full_path).downcase
37
+ mime_type = case ext
38
+ when '.jpg', '.jpeg' then 'image/jpeg'
39
+ when '.svg' then 'image/svg+xml'
40
+ when '.gif' then 'image/gif'
41
+ when '.webp' then 'image/webp'
42
+ else 'image/png'
43
+ end
44
+
45
+ content = File.read(full_path, mode: 'rb')
46
+ base64 = [content].pack('m0')
47
+ "data:#{mime_type};base64,#{base64}"
48
+ rescue StandardError => e
49
+ warn "Warning: Could not load logo from #{path}: #{e.message}"
50
+ nil
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,9 @@
1
+ require_relative 'pdf_engines/chromium'
2
+
3
+ module Ymlbill
4
+ module PdfEngine
5
+ def self.build
6
+ PdfEngines::Chromium.new
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,27 @@
1
+ require "ferrum"
2
+
3
+ module Ymlbill
4
+ module PdfEngines
5
+ class Chromium
6
+ def render(html_path:, output_path:)
7
+ browser = Ferrum::Browser.new(
8
+ browser_path: ENV["BROWSER_PATH"],
9
+ headless: true,
10
+ browser_options: { "no-sandbox": nil }
11
+ )
12
+
13
+ browser.go_to("file://#{File.expand_path(html_path)}")
14
+ browser.pdf(
15
+ path: output_path,
16
+ paperWidth: 8.27,
17
+ paperHeight: 11.69,
18
+ printBackground: true
19
+ )
20
+ rescue ::Ferrum::Error => e
21
+ raise PdfGenerationError, "Failed to generate PDF: #{e.message}"
22
+ ensure
23
+ browser&.quit
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,17 @@
1
+ module Ymlbill
2
+ class TemplateResolver
3
+ DEFAULT_TEMPLATE_PATH = File.expand_path('templates/default.html.erb', __dir__)
4
+
5
+ class << self
6
+ def resolve(template_path)
7
+ return DEFAULT_TEMPLATE_PATH if template_path.nil?
8
+
9
+ unless File.exist?(template_path)
10
+ raise TemplateNotFoundError, "Template not found: #{template_path}"
11
+ end
12
+
13
+ template_path
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,134 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title><%= data.document.type.capitalize %> <%= data.document.number %></title>
7
+ <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
8
+ <style type="text/tailwindcss">
9
+ @media print {
10
+ body {
11
+ -webkit-print-color-adjust: exact;
12
+ print-color-adjust: exact;
13
+ }
14
+ }
15
+ </style>
16
+ </head>
17
+ <body class="bg-neutral-100 p-4 text-xs text-neutral-800 antialiased">
18
+ <div class="mx-auto w-full max-w-3xl p-1">
19
+
20
+ <!-- Header: seller logo + document title -->
21
+ <div class="flex items-start justify-between mb-8">
22
+ <% if data.seller.logo %>
23
+ <img src="<%= logo_path(data.seller.logo) %>" alt="<%= data.seller.name %>" class="h-12 w-auto" />
24
+ <% else %>
25
+ <h1 class="bg-gradient-to-r from-pink-500 via-yellow-400 to-cyan-400 bg-clip-text text-2xl font-black italic tracking-tight text-transparent drop-shadow-md">
26
+ <%= data.seller.name %>
27
+ </h1>
28
+ <% end %>
29
+ <div class="text-right">
30
+ <h2 class="text-3xl font-light uppercase tracking-wide text-neutral-800"><%= data.document.type %></h2>
31
+ <p class="mt-1 text-sm text-neutral-500"><%= data.document.type.capitalize %> # <%= data.document.number %></p>
32
+ </div>
33
+ </div>
34
+
35
+ <!-- Seller (left) + Date/Client (right) -->
36
+ <div class="mb-6 flex justify-between">
37
+ <!-- Seller + Client side-by-side in a shared container -->
38
+ <div class="flex gap-12">
39
+ <div class="text-xs">
40
+ <p class="font-bold text-neutral-900"><%= data.seller.name %></p>
41
+ <% if data.seller.address %>
42
+ <p class="whitespace-pre-line text-neutral-600"><%= data.seller.address %></p>
43
+ <% end %>
44
+ <% if data.seller.email %><p class="mt-0.5 text-neutral-600"><%= data.seller.email %></p><% end %>
45
+ <% if data.seller.phone %><p class="text-neutral-600"><%= data.seller.phone %></p><% end %>
46
+ </div>
47
+
48
+ <div class="text-xs">
49
+ <p class="mb-0.5 text-neutral-500">Bill To:</p>
50
+ <p class="font-bold text-neutral-900"><%= data.client.name %></p>
51
+ <% if data.client.address %>
52
+ <p class="whitespace-pre-line text-neutral-600"><%= data.client.address %></p>
53
+ <% end %>
54
+ <% if data.client.email %><p class="mt-0.5 text-neutral-600"><%= data.client.email %></p><% end %>
55
+ <% if data.client.phone %><p class="text-neutral-600"><%= data.client.phone %></p><% end %>
56
+ </div>
57
+ </div>
58
+
59
+ <!-- Date block on the far right -->
60
+ <div class="w-56 text-xs">
61
+ <div class="flex justify-between py-0.5">
62
+ <span class="text-neutral-500">Date:</span>
63
+ <span class="text-neutral-900"><%= data.document.date %></span>
64
+ </div>
65
+ <% if data.document.due_date %>
66
+ <div class="flex justify-between py-0.5">
67
+ <span class="text-neutral-500">Due Date:</span>
68
+ <span class="text-neutral-900"><%= data.document.due_date %></span>
69
+ </div>
70
+ <% end %>
71
+ </div>
72
+ </div>
73
+
74
+ <!-- Items table: dark rounded header bar, borderless rows -->
75
+ <table class="mb-4 w-full text-xs">
76
+ <thead>
77
+ <tr class="bg-neutral-700 text-xs font-medium text-white">
78
+ <th class="w-1/2 rounded-l-md border-r border-white/20 py-1.5 pl-3 pr-2 text-left">Item</th>
79
+ <th class="w-1/6 border-r border-white/20 py-1.5 px-2 text-center">Quantity</th>
80
+ <th class="w-1/6 border-r border-white/20 py-1.5 px-2 text-right">Rate</th>
81
+ <th class="w-1/6 rounded-r-md py-1.5 pl-2 pr-3 text-right">Amount</th>
82
+ </tr>
83
+ </thead>
84
+ <tbody class="text-xs">
85
+ <% data.items.each do |item| %>
86
+ <tr>
87
+ <td class="py-1 pl-3 pr-2 font-bold text-neutral-800"><%= item.description %></td>
88
+ <td class="py-1 px-2 text-center text-neutral-600"><%= item.quantity %></td>
89
+ <td class="py-1 px-2 text-right text-neutral-600"><%= money(item.unit_price, data.document.currency) %></td>
90
+ <td class="py-1 pl-2 pr-3 text-right text-neutral-600"><%= money(item.quantity * item.unit_price, data.document.currency) %></td>
91
+ </tr>
92
+ <% end %>
93
+ </tbody>
94
+ </table>
95
+
96
+ <!-- Totals -->
97
+ <% subtotal = data.items.sum { |item| item.quantity * item.unit_price } %>
98
+ <% tax_amount = subtotal * (data.document.tax_perc.to_f / 100) %>
99
+ <div class="mb-6 flex justify-end">
100
+ <div class="w-56 text-xs">
101
+ <div class="flex justify-between border-b px-1 border-neutral-300 py-1 text-neutral-500">
102
+ <span>Subtotal</span>
103
+ <span class="font-semibold text-neutral-800"><%= money(subtotal, data.document.currency) %></span>
104
+ </div>
105
+ <div class="flex justify-between border-b px-1 border-neutral-300 py-1 text-neutral-500">
106
+ <span>Tax (<%= data.document.tax_perc %>%)</span>
107
+ <span class="font-semibold text-neutral-800"><%= money(tax_amount, data.document.currency) %></span>
108
+ </div>
109
+ <div class="flex justify-between bg-neutral-700 px-1 py-2 text-white">
110
+ <span class="font-bold">Total</span>
111
+ <span class="font-bold"><%= money(subtotal + tax_amount, data.document.currency) %></span>
112
+ </div>
113
+ </div>
114
+ </div>
115
+
116
+ <!-- Payment Info + Notes side by side -->
117
+ <div class="mb-4 flex gap-4">
118
+ <% if data.seller.payment_info %>
119
+ <div class="flex-1 rounded-md bg-white p-3 shadow-sm">
120
+ <h3 class="mb-1 text-xs font-bold uppercase tracking-wide text-neutral-500">Payment Information</h3>
121
+ <p class="whitespace-pre-line text-neutral-600"><%= data.seller.payment_info %></p>
122
+ </div>
123
+ <% end %>
124
+
125
+ <% if data.notes %>
126
+ <div class="flex-1 rounded-md bg-white p-3 shadow-sm">
127
+ <h3 class="mb-1 text-xs font-bold uppercase tracking-wide text-neutral-500">Notes</h3>
128
+ <p class="whitespace-pre-line text-neutral-600"><%= data.notes %></p>
129
+ </div>
130
+ <% end %>
131
+ </div>
132
+ </div>
133
+ </body>
134
+ </html>
@@ -0,0 +1,3 @@
1
+ module Ymlbill
2
+ VERSION = '0.1.0'
3
+ end
data/lib/ymlbill.rb ADDED
@@ -0,0 +1,19 @@
1
+ require 'yaml'
2
+ require 'erb'
3
+ require 'tempfile'
4
+
5
+ module Ymlbill
6
+ class Error < StandardError; end
7
+ class InputFileNotFoundError < Error; end
8
+ class InvalidYamlError < Error; end
9
+ class TemplateNotFoundError < Error; end
10
+ class TemplateRenderError < Error; end
11
+ class PdfGenerationError < Error; end
12
+ end
13
+
14
+ require_relative 'ymlbill/version'
15
+ require_relative 'ymlbill/cli'
16
+ require_relative 'ymlbill/document_loader'
17
+ require_relative 'ymlbill/template_resolver'
18
+ require_relative 'ymlbill/html_renderer'
19
+ require_relative 'ymlbill/pdf_engine'
metadata ADDED
@@ -0,0 +1,138 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ymlbill
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - David Hagege
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-01 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ferrum
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.15'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.15'
26
+ - !ruby/object:Gem::Dependency
27
+ name: money
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 7.1.1
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 7.1.1
40
+ - !ruby/object:Gem::Dependency
41
+ name: ostruct
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.6'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.6'
54
+ - !ruby/object:Gem::Dependency
55
+ name: thor
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: 1.5.0
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: 1.5.0
68
+ - !ruby/object:Gem::Dependency
69
+ name: rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '13.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '13.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rspec
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.13'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3.13'
96
+ description: A CLI tool that converts YAML invoice/quote definitions into styled PDF
97
+ documents using ERB and headless Chromium.
98
+ email:
99
+ - david@joynetiks.com
100
+ executables:
101
+ - ymlbill
102
+ extensions: []
103
+ extra_rdoc_files: []
104
+ files:
105
+ - README.md
106
+ - exe/ymlbill
107
+ - lib/ymlbill.rb
108
+ - lib/ymlbill/cli.rb
109
+ - lib/ymlbill/document_loader.rb
110
+ - lib/ymlbill/html_renderer.rb
111
+ - lib/ymlbill/pdf_engine.rb
112
+ - lib/ymlbill/pdf_engines/chromium.rb
113
+ - lib/ymlbill/template_resolver.rb
114
+ - lib/ymlbill/templates/default.html.erb
115
+ - lib/ymlbill/version.rb
116
+ homepage: https://github.com/pcboy/ymlbill
117
+ licenses:
118
+ - MIT
119
+ metadata:
120
+ rubygems_mfa_required: 'true'
121
+ rdoc_options: []
122
+ require_paths:
123
+ - lib
124
+ required_ruby_version: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: '3.4'
129
+ required_rubygems_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ requirements: []
135
+ rubygems_version: 3.7.2
136
+ specification_version: 4
137
+ summary: A CLI tool to generate PDF invoices and quotes from YAML files.
138
+ test_files: []