asxpio/lib/invoice_pdf.rb
Sergei Poljanski 33ba7650c3
invoices: self-contained payout addresses
Crypto options were limited to a hard-coded asset list, with one
address per coin in a side table. A payout address now carries
everything an invoice needs: ticker, name, QR URI scheme, amount
parameter, CoinGecko id, address and an active flag. Any coin can be
added, several addresses per ticker are fine, and known tickers fill the
QR and rate fields from the built-in templates.

The new-invoice form picks a payout address. The invoice snapshots the
ticker, address, name, scheme and amount parameter, and the PDF and QR
render from that copy, so editing, retiring or deleting a payout entry
never changes an issued invoice. "Fetch live" looks up the rate by the
payout address's CoinGecko id.

Migration 012 keeps the existing rows and backfills both the payout
metadata and the snapshots on existing crypto invoices.

Assisted-by: Claude:opus-5
2026-10-10 09:17:09 +04:00

391 lines
12 KiB
Ruby

require 'prawn'
require 'prawn/table'
require 'bigdecimal'
require 'stringio'
require_relative 'fmt'
require_relative 'crypto_asset'
require_relative 'crypto_qr'
class InvoicePdf
FONTS_DIR = File.join($root, 'public', 'fonts')
LOGO_PATH = File.join($root, 'public', 'logo.png')
LOGO_SIZE = 52
ISSUER = {
name_latin: 'IE Sergei Poljanski',
name_alt: 'SERGEI POLJANSKI',
name_georgian: 'ინდ. მეწარმე სერგეი პოლჯანსკი',
tax_id: '304813343',
address: [
'Ilia and Nino Nakashidze St, N 1, Building N3, Apt N3',
'Krtsanisi, Tbilisi, Georgia'
],
email: 'ie@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
COLOR_VOID = 'C0392B'.freeze
# Uniform line spacing for the FROM / BILL TO party blocks: every line gets the
# same leading, no extra gaps between groups, so both columns read as one even
# rhythm and line up. NAME_GAP is the one exception — a little extra air after
# the bold company name to set it off from the details below.
PARTY_LEADING = 2
NAME_GAP = 3
# `status` overrides the status shown in the PDF (e.g. to pre-render a "paid"
# copy before any payment date exists). Defaults to the invoice's live status.
def self.render(invoice, status: nil)
new(invoice, status: status).render
end
def initialize(invoice, status: nil)
@invoice = invoice
@status = status || invoice.status
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)
draw_cancelled_stamp(pdf) if @status == 'cancelled'
pdf.render
end
private
# Diagonal stamp on every page plus the date/reason under it, so a
# cancelled invoice can't be mistaken for a payable one.
def draw_cancelled_stamp(pdf)
when_text = (@invoice.cancelled_at || Time.now.utc).strftime('%Y-%m-%d')
reason = @invoice.cancel_reason.to_s.strip
pdf.repeat(:all) do
pdf.fill_color COLOR_VOID
pdf.transparent(0.18) do
pdf.rotate(35, origin: [pdf.bounds.width / 2, pdf.bounds.height / 2]) do
pdf.font_size(96) do
pdf.draw_text 'CANCELLED', style: :bold,
at: [pdf.bounds.width / 2 - pdf.width_of('CANCELLED', style: :bold) / 2, pdf.bounds.height / 2 - 30]
end
end
end
pdf.transparent(0.9) do
pdf.font_size(9) do
line = "Cancelled #{when_text}#{reason.empty? ? '' : ": #{reason}"}"
pdf.draw_text line, at: [0, pdf.bounds.height - 4]
end
end
pdf.fill_color COLOR_TEXT
end
end
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)
if File.exist?(LOGO_PATH)
pdf.image LOGO_PATH,
at: [pdf.bounds.right - LOGO_SIZE, pdf.cursor],
width: LOGO_SIZE,
height: LOGO_SIZE
end
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: PARTY_LEADING }
pdf.move_down NAME_GAP
pdf.font_size(9) do
lines = [
ISSUER[:name_georgian],
*ISSUER[:address],
"Tax ID: #{ISSUER[:tax_id]}",
ISSUER[:email],
ISSUER[:phone]
]
pdf.text lines.join("\n"), leading: PARTY_LEADING
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: PARTY_LEADING }
pdf.move_down NAME_GAP
pdf.font_size(9) do
lines = [@invoice.client_email]
lines << @invoice.client_address unless @invoice.client_address.to_s.strip.empty?
pdf.text lines.join("\n"), leading: PARTY_LEADING
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 8
cells = [
['ISSUED', @invoice.issued_on.strftime('%Y-%m-%d')],
['DUE', @invoice.due_on.strftime('%Y-%m-%d')],
['CURRENCY', @invoice.currency],
['STATUS', @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 2
pdf.font_size(10) { pdf.text value, style: :bold }
end
end
# The cells are absolutely-positioned boxes that don't advance the outer
# cursor, so reserve the row height manually. Tuned (measured from the
# rendered PNG) so the gap below the values matches the ~10pt gap above the
# labels rather than ballooning to ~29pt.
pdf.move_down 9
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_rate(@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
draw_crypto(pdf) if @invoice.respond_to?(:crypto?) && @invoice.crypto?
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_crypto(pdf)
coin = @invoice.crypto_coin
name = @invoice.crypto_name || CryptoAsset.name(coin) # snapshot; legacy fallback
amount = @invoice.crypto_amount_due
qr_size = 78
pdf.move_down 12
pdf.fill_color COLOR_MUTED
pdf.font_size(8) { pdf.text "PAY IN #{name.upcase} (#{coin})", character_spacing: 1.5 }
pdf.fill_color COLOR_TEXT
pdf.move_down 4
# Fixed-height row: QR on the right (drawn with float so it doesn't advance
# the cursor), text column on the left. The outer bounding_box of height
# qr_size leaves the cursor exactly below the row when it ends.
qr_bytes = CryptoQr.png(scheme: @invoice.crypto_scheme, amount_param: @invoice.crypto_amount_param,
address: @invoice.crypto_address, amount: amount)
pdf.bounding_box([0, pdf.cursor], width: pdf.bounds.width, height: qr_size) do
pdf.float do
pdf.bounding_box([pdf.bounds.right - qr_size, pdf.bounds.top], width: qr_size, height: qr_size) do
pdf.image StringIO.new(qr_bytes), width: qr_size, height: qr_size
end
end
pdf.bounding_box([0, pdf.bounds.top], width: pdf.bounds.width - qr_size - 16) do
pdf.font_size(9) do
if amount
pdf.text "Amount: #{fmt_crypto(amount)} #{coin}", style: :bold
if @invoice.crypto_rate
pdf.fill_color COLOR_MUTED
pdf.text "(rate 1 #{coin} = #{fmt_rate(@invoice.crypto_rate)} #{@invoice.currency} at issue)", size: 8
pdf.fill_color COLOR_TEXT
end
pdf.move_down 3
end
pdf.text 'Address:'
pdf.font_size(8) { pdf.text @invoice.crypto_address }
pdf.move_down 3
pdf.fill_color COLOR_MUTED
hint = if @invoice.crypto_scheme && @invoice.crypto_amount_param
"Scan the QR with any #{name} wallet to prefill the payment."
else
"QR encodes the address. Send #{coin} only. Verify the network before sending."
end
pdf.font_size(7) { pdf.text hint }
pdf.fill_color COLOR_TEXT
end
end
end
end
def draw_footer(pdf)
# Static left-side line repeats on every page.
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]
)
end
pdf.fill_color COLOR_TEXT
end
end
# Page number must be stamped per page: inside repeat(:all), pdf.page_number
# resolves to the final page count for every page. number_pages substitutes
# <page>/<total> at finalization, once per actual page.
pdf.number_pages(
'Page <page> of <total>',
at: [pdf.bounds.right - 90, -25],
width: 90,
align: :right,
size: 8,
color: COLOR_MUTED
)
end
def fmt_money(value)
BigDecimal(value.to_s).round(2).to_s('F').then { |s| with_thousands(s) }
end
def fmt_crypto(value)
Fmt.crypto(value)
end
def fmt_rate(value, min_dp: 4)
Fmt.rate(value, min_dp: min_dp)
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 = Fmt.group(int)
frac ? "#{int}.#{frac.ljust(2, '0')[0, 2]}" : "#{int}.00"
end
end