asxpio/lib/invoice.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

166 lines
5.1 KiB
Ruby

require 'sequel'
require 'securerandom'
require 'json'
require 'bigdecimal'
require_relative 'crypto_asset'
class Invoice < Sequel::Model(:invoices)
CURRENCIES = %w[USD EUR GEL].freeze
plugin :json_serializer
def items_array
raw = self[:items]
raw.is_a?(String) ? JSON.parse(raw) : raw
end
def items_array=(arr)
self[:items] = Sequel.pg_jsonb_wrap(arr)
rescue NoMethodError
self[:items] = arr.to_json
end
def paid?
!paid_at.nil?
end
def cancelled?
!cancelled_at.nil?
end
# Cancellation wins over payment: a cancelled invoice is no longer owed.
def status
return 'cancelled' if cancelled?
paid? ? 'paid' : 'pending'
end
# Both status variants are pre-rendered at creation; `pdf_key` is the base
# path (no suffix). The download route picks a variant by current status.
def pdf_key_for(status)
base = pdf_key.sub(/\.pdf\z/, '')
"#{base}-#{status}.pdf"
end
def current_pdf_key
pdf_key_for(status)
end
def total
BigDecimal(subtotal.to_s)
end
def total_gel
(total * BigDecimal(gel_rate.to_s)).round(2)
end
def crypto?
!crypto_address.to_s.strip.empty?
end
# The crypto amount to display / encode in the QR. Returns nil when no
# amount is known (address-only invoice).
def crypto_amount_due
return nil unless crypto?
return BigDecimal(crypto_amount.to_s) unless crypto_amount.nil?
return nil if crypto_rate.nil? || BigDecimal(crypto_rate.to_s).zero?
(total / BigDecimal(crypto_rate.to_s)).round(8)
end
class << self
# Max is taken over the numeric suffix, not the string: past 9999 the
# 5-digit numbers would sort below INV-YYYY-9999 lexically and the
# allocator would hand out duplicates.
def allocate_number(year = Date.today.year)
prefix = "INV-#{year}-"
last = where(Sequel.like(:number, "#{prefix}%"))
.max(Sequel.cast(Sequel.function(:split_part, :number, '-', 3), Integer))
format("#{prefix}%04d", (last || 0) + 1)
end
def build(params)
items = normalize_items(params.fetch(:items))
subtotal = items.sum { |i| BigDecimal(i['qty'].to_s) * BigDecimal(i['unit_price'].to_s) }
invoice = Invoice.new(
number: allocate_number,
client_name: params.fetch(:client_name).to_s.strip,
client_email: params.fetch(:client_email).to_s.strip,
client_address: params[:client_address].to_s.strip,
currency: normalize_currency(params.fetch(:currency)),
gel_rate: BigDecimal(params.fetch(:gel_rate).to_s),
subtotal: subtotal,
items: items.to_json,
issued_on: parse_date(params[:issued_on]) || Date.today,
due_on: parse_date(params[:due_on]) || (Date.today + 14),
notes: params[:notes].to_s.strip,
pdf_key: '',
created_at: Time.now.utc
)
apply_crypto(invoice, params, subtotal)
invoice.uuid = SecureRandom.uuid
invoice
end
private
# Crypto is opt-in: only populated when an address is given (the route
# fills these from the chosen PayoutAddress#snapshot). Name/scheme/amount
# param are copied so later payout edits don't change this invoice; a
# known ticker without them falls back to the built-in template. Rate and
# amount are both optional; if amount is blank but rate is present,
# derive it.
def apply_crypto(invoice, params, subtotal)
address = params[:crypto_address].to_s.strip
return if address.empty?
coin = params[:crypto_coin].to_s.upcase
coin.match?(CryptoAsset::TICKER_RE) or raise ArgumentError, "Bad ticker: #{coin}"
tmpl = CryptoAsset::DEFAULTS[coin] || {}
pick = ->(k, t) { params.key?(k) ? params[k].to_s.strip.then { |v| v.empty? ? nil : v } : tmpl[t] }
invoice.crypto_coin = coin
invoice.crypto_address = address
invoice.crypto_name = pick.(:crypto_name, :name) || coin
invoice.crypto_scheme = pick.(:crypto_scheme, :scheme)
invoice.crypto_amount_param = pick.(:crypto_amount_param, :amount_param)
rate = decimal_or_nil(params[:crypto_rate])
invoice.crypto_rate = rate
amount = decimal_or_nil(params[:crypto_amount])
amount ||= (subtotal / rate).round(8) if rate && !rate.zero?
invoice.crypto_amount = amount
end
def decimal_or_nil(raw)
s = raw.to_s.strip
return nil if s.empty?
BigDecimal(s)
rescue ArgumentError
nil
end
def normalize_currency(c)
c = c.to_s.upcase
CURRENCIES.include?(c) or raise ArgumentError, "Unsupported currency: #{c}"
c
end
def normalize_items(raw)
Array(raw).filter_map do |row|
desc = row['description'].to_s.strip
next if desc.empty?
qty = BigDecimal(row['qty'].to_s.empty? ? '0' : row['qty'].to_s)
unit = BigDecimal(row['unit_price'].to_s.empty? ? '0' : row['unit_price'].to_s)
{ 'description' => desc, 'qty' => qty.to_s('F'), 'unit_price' => unit.to_s('F') }
end
end
def parse_date(s)
return nil if s.nil? || s.to_s.strip.empty?
Date.parse(s.to_s)
rescue ArgumentError
nil
end
end
end