diff --git a/lib/invoice_pdf.rb b/lib/invoice_pdf.rb new file mode 100644 index 0000000..fc28e44 --- /dev/null +++ b/lib/invoice_pdf.rb @@ -0,0 +1,268 @@ +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