asxpio/lib/invoice_pdf.rb
Sergei Poljanski a605ccdc52
invoicing: Prawn PDF renderer with Noto fonts
InvoicePdf.render(invoice) returns A4 PDF bytes. Layout: title + number,
horizontal rule, two-column FROM/BILL TO, meta strip (issued/due/currency/
status) bracketed by rules, line items table, right-aligned totals with
optional GEL equivalent, payment block (Bank of Georgia IBAN), optional
notes, repeating footer with issuer ID and page number.

Both party columns capture pdf.y after rendering and the global cursor jumps
to whichever ran longer — avoids the bbox cursor-restore footgun that was
collapsing the meta strip into the FROM column.

Noto Sans Georgian is registered as a fallback family so the Georgian legal
name renders without needing a separate text run.

Issuer details (legal name, tax ID, registration, address, bank) are
hardcoded as a constant — these are public on the rendered site already and
change rarely enough that a config file would be overkill.
2026-05-26 17:43:38 +03:00

268 lines
7.7 KiB
Ruby

require 'prawn'
require 'prawn/table'
require 'bigdecimal'
class InvoicePdf
FONTS_DIR = File.join($root, 'public', 'fonts')
ISSUER = {
name_latin: 'IE Sergei Poljanski',
name_alt: 'SERGEI POLJANSKI',
name_georgian: 'ინდ. მეწარმე სერგეი პოლჯანსკი',
tax_id: '304813343',
reg_number: 'B26353511',
registered: '2026-05-04',
address: [
'Ilia and Nino Nakashidze St, N 1, Building N3, Apt N3',
'Krtsanisi, Tbilisi, Georgia'
],
email: 'me@asxp.io',
phone: '+995 595 026 471',
bank_name: 'Bank of Georgia',
iban: 'GE53BG0000000612343299',
swift: 'BAGAGE22'
}.freeze
COLOR_TEXT = '111111'.freeze
COLOR_MUTED = '6F6F6F'.freeze
COLOR_RULE = 'DADADA'.freeze
COLOR_ACCENT = '1A1A1A'.freeze
def self.render(invoice)
new(invoice).render
end
def initialize(invoice)
@invoice = invoice
end
def render
pdf = Prawn::Document.new(
page_size: 'A4',
margin: [40, 50, 50, 50],
info: {
Title: "Invoice #{@invoice.number}",
Author: ISSUER[:name_latin],
Creator: 'asxp.io'
}
)
register_fonts(pdf)
pdf.font('NotoSans')
pdf.fill_color COLOR_TEXT
draw_header(pdf)
draw_parties(pdf)
draw_meta(pdf)
draw_items(pdf)
draw_totals(pdf)
draw_payment(pdf)
draw_footer(pdf)
pdf.render
end
private
def register_fonts(pdf)
pdf.font_families.update(
'NotoSans' => {
normal: File.join(FONTS_DIR, 'NotoSans-Regular.ttf'),
bold: File.join(FONTS_DIR, 'NotoSans-Bold.ttf'),
italic: File.join(FONTS_DIR, 'NotoSans-Italic.ttf')
},
'NotoSansGeorgian' => {
normal: File.join(FONTS_DIR, 'NotoSansGeorgian-Regular.ttf'),
bold: File.join(FONTS_DIR, 'NotoSansGeorgian-Bold.ttf')
}
)
pdf.fallback_fonts = ['NotoSansGeorgian']
end
def draw_header(pdf)
pdf.font_size(20) { pdf.text 'INVOICE', style: :bold, character_spacing: 2 }
pdf.move_down 2
pdf.fill_color COLOR_MUTED
pdf.font_size(9) { pdf.text @invoice.number }
pdf.fill_color COLOR_TEXT
pdf.move_down 16
pdf.stroke_color COLOR_RULE
pdf.stroke_horizontal_rule
pdf.move_down 16
end
def draw_parties(pdf)
top_y = pdf.y
right = 285
col_w = 250
pdf.bounding_box([0, pdf.cursor], width: col_w) do
draw_party_label(pdf, 'FROM')
pdf.font_size(11) { pdf.text ISSUER[:name_latin], style: :bold, leading: 2 }
pdf.font_size(9) do
pdf.text ISSUER[:name_georgian], leading: 4
pdf.move_down 8
pdf.text ISSUER[:address].join("\n"), leading: 3
pdf.move_down 8
pdf.text "Tax ID: #{ISSUER[:tax_id]}", leading: 3
pdf.text "Reg. №: #{ISSUER[:reg_number]} (#{ISSUER[:registered]})", leading: 3
pdf.move_down 8
pdf.text ISSUER[:email], leading: 3
pdf.text ISSUER[:phone]
end
end
from_y = pdf.y
pdf.y = top_y
pdf.bounding_box([right, pdf.cursor], width: pdf.bounds.right - right) do
draw_party_label(pdf, 'BILL TO')
pdf.font_size(11) { pdf.text @invoice.client_name, style: :bold, leading: 2 }
pdf.font_size(9) do
pdf.text @invoice.client_email, leading: 3
unless @invoice.client_address.to_s.strip.empty?
pdf.move_down 8
pdf.text @invoice.client_address, leading: 3
end
end
end
bill_y = pdf.y
pdf.y = [from_y, bill_y].min
pdf.move_down 24
end
def draw_party_label(pdf, label)
pdf.fill_color COLOR_MUTED
pdf.font_size(8) { pdf.text label, character_spacing: 1.5 }
pdf.fill_color COLOR_TEXT
pdf.move_down 8
end
def draw_meta(pdf)
pdf.stroke_color COLOR_RULE
pdf.stroke_horizontal_rule
pdf.move_down 10
cells = [
['ISSUED', @invoice.issued_on.strftime('%Y-%m-%d')],
['DUE', @invoice.due_on.strftime('%Y-%m-%d')],
['CURRENCY', @invoice.currency],
['STATUS', @invoice.status.upcase]
]
col_w = pdf.bounds.width / cells.size.to_f
top = pdf.cursor
cells.each_with_index do |(label, value), i|
pdf.bounding_box([i * col_w, top], width: col_w) do
pdf.fill_color COLOR_MUTED
pdf.font_size(7) { pdf.text label, character_spacing: 1.2 }
pdf.fill_color COLOR_TEXT
pdf.move_down 3
pdf.font_size(10) { pdf.text value, style: :bold }
end
end
# Reserve the height the row actually used.
pdf.move_down 30
pdf.stroke_horizontal_rule
pdf.move_down 16
end
def draw_items(pdf)
header = ['Description', 'Qty', "Unit (#{@invoice.currency})", "Amount (#{@invoice.currency})"]
rows = @invoice.items_array.map do |i|
qty = BigDecimal(i['qty'].to_s)
unit = BigDecimal(i['unit_price'].to_s)
[i['description'], fmt_qty(qty), fmt_money(unit), fmt_money(qty * unit)]
end
table_data = [header] + rows
pdf.table(table_data, header: true, width: pdf.bounds.width,
column_widths: { 0 => pdf.bounds.width - 270, 1 => 60, 2 => 100, 3 => 110 }) do
cells.borders = [:bottom]
cells.border_color = COLOR_RULE
cells.padding = [8, 8, 8, 8]
cells.size = 9
row(0).font_style = :bold
row(0).text_color = COLOR_MUTED
row(0).size = 8
row(0).background_color = 'FAFAFA'
columns(1..3).align = :right
end
pdf.move_down 8
end
def draw_totals(pdf)
total = @invoice.total
rows = [
['Subtotal', "#{@invoice.currency} #{fmt_money(total)}"],
['Total', "#{@invoice.currency} #{fmt_money(total)}"]
]
if @invoice.currency != 'GEL'
rows << ["In GEL (rate #{fmt_money(@invoice.gel_rate)})", "GEL #{fmt_money(@invoice.total_gel)}"]
end
pdf.bounding_box([pdf.bounds.right - 270, pdf.cursor], width: 270) do
pdf.table(rows, cell_style: { borders: [], padding: [4, 0, 4, 0], size: 10 },
column_widths: { 0 => 130, 1 => 140 }) do
columns(0).text_color = COLOR_MUTED
columns(1).align = :right
row(1).font_style = :bold
row(1).text_color = COLOR_ACCENT
row(1).size = 12
end
end
pdf.move_down 30
end
def draw_payment(pdf)
pdf.fill_color COLOR_MUTED
pdf.font_size(8) { pdf.text 'PAYMENT', character_spacing: 1.5 }
pdf.fill_color COLOR_TEXT
pdf.move_down 4
pdf.font_size(9) do
pdf.text "Bank: #{ISSUER[:bank_name]}"
pdf.text "IBAN: #{ISSUER[:iban]}"
pdf.text "SWIFT: #{ISSUER[:swift]}"
pdf.text "Beneficiary: #{ISSUER[:name_latin]}"
end
unless @invoice.notes.to_s.strip.empty?
pdf.move_down 14
pdf.fill_color COLOR_MUTED
pdf.font_size(8) { pdf.text 'NOTES', character_spacing: 1.5 }
pdf.fill_color COLOR_TEXT
pdf.move_down 4
pdf.font_size(9) { pdf.text @invoice.notes }
end
end
def draw_footer(pdf)
pdf.repeat(:all) do
pdf.canvas do
pdf.fill_color COLOR_MUTED
pdf.font_size(8) do
pdf.draw_text(
"#{ISSUER[:name_latin]} · Tax ID #{ISSUER[:tax_id]} · Small Business (1% turnover tax)",
at: [50, 25]
)
pdf.draw_text("Page #{pdf.page_number}", at: [pdf.bounds.right - 50, 25])
end
pdf.fill_color COLOR_TEXT
end
end
end
def fmt_money(value)
BigDecimal(value.to_s).round(2).to_s('F').then { |s| with_thousands(s) }
end
def fmt_qty(value)
bd = BigDecimal(value.to_s)
bd.frac.zero? ? bd.to_i.to_s : bd.to_s('F')
end
def with_thousands(numstr)
int, frac = numstr.split('.')
int = int.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
frac ? "#{int}.#{frac.ljust(2, '0')[0, 2]}" : "#{int}.00"
end
end