Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ GEM
railties (>= 3.2)
jquery-ui-rails (8.0.0)
railties (>= 3.2.16)
json (2.20.0)
json (2.21.2)
json-jwt (1.17.1)
activesupport (>= 4.2)
aes_key_wrap
Expand Down
39 changes: 32 additions & 7 deletions app/admin/billing/invoice_template_playground.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,42 @@
invoice = Billing::Invoice.find(params[:invoice_id])
return render(plain: 'Not authorized to read this invoice', status: 403) unless authorized?(:read, invoice)

# A blank filename template is invalid here (the column is NOT NULL and the
# model requires it), so it is caught locally — previewing a document that
# could never be generated for real would be misleading, and there is
# nothing to ask yeti-pdf about.
if params[:filename_template].blank?
return render(plain: 'Filename template must not be blank', status: :unprocessable_entity)
end

data = BillingInvoice::InvoiceData.call(invoice: invoice)
pdf = YetiPdf::Client.render_pdf(template: params[:template].to_s, data: data)
send_data pdf, type: 'application/pdf', disposition: 'inline'
result = YetiPdf::Client.render_pdf(
template: params[:template].to_s,
filename_template: params[:filename_template].to_s,
data: data
)
# The name the document would be filed under, for the editor to display —
# a header, so it rides along with the inline PDF the iframe is loading.
response.set_header('X-Rendered-Filename', ERB::Util.url_encode(result.filename.to_s))
send_data result.pdf, type: 'application/pdf', disposition: 'inline'
rescue ActiveRecord::RecordNotFound
render plain: 'Invoice not found', status: 404
rescue YetiPdf::Client::Error => e
render plain: e.message, status: :unprocessable_entity
end

# GET ?template_id= -> the saved html_template, for the "Rollback" button.
# GET ?template_id= -> the saved templates, for the "Rollback" button.
page_action :template, method: :get do
template = Billing::InvoiceTemplate.find(params[:template_id])
render json: { html_template: template.html_template.to_s }
render json: { html_template: template.html_template.to_s, filename_template: template.filename_template.to_s }
rescue ActiveRecord::RecordNotFound
render json: { html_template: '' }, status: 404
render json: { html_template: '', filename_template: '' }, status: 404
end

# PATCH -> persist the edited html_template back to the template.
# PATCH -> persist the edited templates back to the template record.
page_action :save, method: :patch do
template = Billing::InvoiceTemplate.find(params[:template_id])
template.update!(html_template: params[:html_template].to_s)
template.update!(html_template: params[:html_template].to_s, filename_template: params[:filename_template].to_s)
render json: { ok: true }
rescue ActiveRecord::RecordNotFound
render json: { error: 'Template not found' }, status: 404
Expand Down Expand Up @@ -66,6 +81,16 @@
end
end

div class: 'tp-filename-row',
style: 'display:flex; gap:8px; align-items:center; margin-bottom:8px;' do
label 'Filename:', for: 'tp-filename'
input type: 'text', id: 'tp-filename', spellcheck: 'false', style: 'flex:1;',
value: template&.filename_template.to_s
# Filled from the preview response with the name yeti-pdf actually
# rendered — the template author sees the result, not just the source.
span '', id: 'tp-filename-preview', style: 'opacity:.75; white-space:nowrap;'
end

div class: 'tp-body' do
div class: 'tp-editor-pane' do
textarea template&.html_template.to_s, id: 'tp-template', class: 'tp-editor', spellcheck: 'false'
Expand Down
7 changes: 6 additions & 1 deletion app/admin/billing/invoice_templates.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
actions :all # :index,:create, :new, :destroy, :delete, :edit, :update
before_action :left_sidebar!

permit_params :name, :html_template
permit_params :name, :html_template, :filename_template

acts_as_export :id, :name, :created_at

Expand Down Expand Up @@ -39,6 +39,10 @@ def find_resource
f.semantic_errors *f.object.errors.attribute_names
f.inputs form_title do
f.input :name
f.input :filename_template,
hint: 'pongo2 template naming the generated file, rendered against the same data as the ' \
'HTML template. No extension — ".pdf" is added when the document is served. ' \
'Example: {{ invoice.reference }}_{{ invoice.start_date|strfdate:"%Y-%m" }}'
f.input :html_template, as: :text, input_html: { rows: 24 }
end
f.actions
Expand All @@ -48,6 +52,7 @@ def find_resource
attributes_table do
row :id
row :name
row :filename_template
row :created_at
end

Expand Down
31 changes: 28 additions & 3 deletions app/assets/javascripts/template_playground.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ $(document).ready(function () {
var csrf = csrfEl ? csrfEl.content : '';
var invoiceSel = document.getElementById('tp-invoice');
var editor = document.getElementById('tp-template');
var filenameInput = document.getElementById('tp-filename');
var filenamePreview = document.getElementById('tp-filename-preview');
var iframe = document.getElementById('tp-pdf');
var errorBox = document.getElementById('tp-error');
var body = container.querySelector('.tp-body');
Expand Down Expand Up @@ -84,6 +86,14 @@ $(document).ready(function () {
errorBox.style.display = 'none';
}

// The name the document would be stored under, as rendered by yeti-pdf
// (URL-encoded by the preview action so it survives the header).
function showFilename(encoded) {
if (!filenamePreview) return;
var name = encoded ? decodeURIComponent(encoded) : '';
filenamePreview.textContent = name ? '→ ' + name + '.pdf' : '';
}

// Render the current template against the selected invoice. A spinner is
// shown over the previous PDF until the response arrives.
var spinner = container.querySelector('.tp-spinner');
Expand All @@ -103,13 +113,19 @@ $(document).ready(function () {
'X-CSRF-Token': csrf,
'Accept': 'application/pdf'
},
body: JSON.stringify({ invoice_id: invoiceSel.value, template: templateValue() })
body: JSON.stringify({
invoice_id: invoiceSel.value,
template: templateValue(),
filename_template: filenameInput ? filenameInput.value : ''
})
}).then(function (resp) {
if (resp.ok) {
showFilename(resp.headers.get('X-Rendered-Filename'));
return resp.blob().then(function (blob) {
iframe.src = URL.createObjectURL(blob);
});
}
showFilename('');
return resp.text().then(function (t) { showError('Render failed: ' + t); });
}).catch(function (e) {
showError('Request failed: ' + e.message);
Expand All @@ -125,6 +141,7 @@ $(document).ready(function () {
}

invoiceSel.addEventListener('change', render); // immediate on invoice change
if (filenameInput) { filenameInput.addEventListener('input', scheduleRender); }
if (cm) {
cm.on('change', scheduleRender);
} else {
Expand All @@ -146,7 +163,11 @@ $(document).ready(function () {
hideError();
fetch(templateUrl + '?template_id=' + encodeURIComponent(templateId), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (j) { setTemplate(j.html_template || ''); render(); })
.then(function (j) {
setTemplate(j.html_template || '');
if (filenameInput) { filenameInput.value = j.filename_template || ''; }
render();
})
.catch(function (e) { showError('Reload failed: ' + e.message); });
});

Expand All @@ -157,7 +178,11 @@ $(document).ready(function () {
fetch(saveUrl, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf, 'Accept': 'application/json' },
body: JSON.stringify({ template_id: templateId, html_template: templateValue() })
body: JSON.stringify({
template_id: templateId,
html_template: templateValue(),
filename_template: filenameInput ? filenameInput.value : ''
})
}).then(function (r) {
if (r.ok) {
if (saveStatus) {
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/rest/admin/invoices_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ def pdf
doc = ::Billing::InvoiceDocument.find_by(invoice_id: params[:id])
return head 404 if doc.nil? || doc.pdf_data.blank?

send_data doc.pdf_data, filename: "invoice-#{params[:id]}.pdf"
send_data doc.pdf_data, filename: "#{doc.filename}.pdf"
end
end
4 changes: 0 additions & 4 deletions app/models/billing/invoice.rb
Original file line number Diff line number Diff line change
Expand Up @@ -210,10 +210,6 @@ def regenerate_document
end
end

def file_name
"#{id}_#{start_date}_#{end_date}"
end

delegate :contacts_for_invoices, :invoice_period, to: :account

def subject
Expand Down
12 changes: 8 additions & 4 deletions app/models/billing/invoice_template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
# Table name: billing.invoice_templates
# Database name: primary
#
# id :integer(4) not null, primary key
# html_template :text
# name :string not null
# created_at :timestamptz
# id :integer(4) not null, primary key
# filename_template :string default("invoice-{{invoice.reference}}"), not null
# html_template :text
# name :string not null
# created_at :timestamptz
#
# Indexes
#
Expand All @@ -19,6 +20,9 @@ class Billing::InvoiceTemplate < ApplicationRecord
self.table_name = 'billing.invoice_templates'
validates :name, presence: true, uniqueness: true
validates :html_template, presence: true
# NOT NULL alone would still allow '', which yeti-pdf treats as "no filename
# template" and would leave the document with no name to store.
validates :filename_template, presence: true, length: { maximum: 255 }

def display_name
name
Expand Down
2 changes: 1 addition & 1 deletion app/resources/api/rest/admin/invoice_template_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
class Api::Rest::Admin::InvoiceTemplateResource < ::BaseResource
model_name 'Billing::InvoiceTemplate'

attributes :name, :html_template
attributes :name, :html_template, :filename_template

paginator :paged

Expand Down
25 changes: 20 additions & 5 deletions app/services/billing_invoice/generate_document.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# frozen_string_literal: true

# Generates the invoice PDF document: builds the raw invoice data payload, sends
# it with the account's html_template to the external yeti-pdf service, and
# stores the returned PDF on the InvoiceDocument.
# it with the account's html_template and filename_template to the external
# yeti-pdf service, and stores the returned PDF and rendered name on the
# InvoiceDocument.
#
# Any generation failure (no template, yeti-pdf not configured, yeti-pdf error)
# is recorded on invoice.pdf_error and swallowed, so a rendering problem never
Expand All @@ -22,6 +23,15 @@ def initialize(invoice_id)
end
end

# yeti-pdf rejects a filename template that renders to something unusable,
# so reaching this means it answered without the header at all — a version
# that predates filename_template support.
class FilenameMissing < Error
def initialize(invoice_id)
super("yeti-pdf returned no filename for invoice #{invoice_id}")
end
end

parameter :invoice, required: true

def call
Expand All @@ -39,12 +49,17 @@ def generate_document!
raise PdfApiNotConfigured, invoice.id unless YetiPdf::Client.configured?

data = InvoiceData.call(invoice: invoice)
pdf_data = YetiPdf::Client.render_pdf(template: template.html_template, data: data)
result = YetiPdf::Client.render_pdf(
template: template.html_template,
filename_template: template.filename_template,
data: data
)
raise FilenameMissing, invoice.id if result.filename.blank?

Billing::InvoiceDocument.create!(
invoice: invoice,
filename: invoice.file_name.to_s,
pdf_data: pdf_data
filename: result.filename,
pdf_data: result.pdf
)
end

Expand Down
51 changes: 42 additions & 9 deletions app/services/yeti_pdf/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@
require 'httpx'

# Client for the external yeti-pdf render service: POSTs a pongo2 template plus
# a JSON data payload and returns either the rendered PDF bytes (#render_pdf)
# or the merged HTML (#render_html — a cheap pongo2 merge with no PDF step).
# a JSON data payload and returns either the rendered PDF and the name to file
# it under (#render_pdf) or the merged HTML (#render_html — a cheap pongo2
# merge with no PDF step).
module YetiPdf
class Client
class Error < StandardError; end

RENDER_PATH = '/v1/render'
RENDER_HTML_PATH = '/v1/render/html'
DEFAULT_TIMEOUT = 30
PDF_EXTENSION = '.pdf'

# What #render_pdf hands back: the document and the name to file it under,
# both produced by a single render request. #filename is nil unless a
# filename_template was sent.
Result = Struct.new(:pdf, :filename)

class << self
def render_pdf(...)
Expand All @@ -29,33 +36,59 @@ def configured?
end
end

# @return [String] binary PDF bytes
def render_pdf(template:, data:, options: {})
post(RENDER_PATH, template: template, data: data, options: options)
# Renders the document and the name to store it under — one request, one
# data payload, both templates merged by yeti-pdf. A filename template that
# renders to something unusable (empty, a path separator, a control
# character) fails the whole request there, so a bad name never reaches us.
#
# filename_template is required: yeti-web always names its documents, so
# every Content-Disposition on the response is a name we asked for. The
# endpoint itself allows the field to be omitted, but no caller here does.
#
# @return [Result] the PDF bytes and the rendered filename (nil only if
# yeti-pdf answered without one)
def render_pdf(template:, data:, filename_template:, options: {})
response = post(RENDER_PATH, template: template, data: data, options: options,
filename_template: filename_template)
Result.new(response.body.to_s, rendered_filename(response))
end

# @return [String] the merged HTML (pre-PDF); useful for debugging/preview
def render_html(template:, data:, options: {})
post(RENDER_HTML_PATH, template: template, data: data, options: options)
post(RENDER_HTML_PATH, template: template, data: data, options: options).body.to_s
end

private

def post(path, template:, data:, options:)
# Every keyword becomes a field of the JSON body, so each endpoint sends
# exactly the keys it was given — render_html has no filename_template to
# pass and therefore never sends one.
def post(path, **payload)
cfg = YetiConfig.invoice&.pdf_api
raise Error, 'invoice.pdf_api.base_url is not configured' if cfg&.base_url.blank?

proxy = proxy_for(cfg)
http = proxy.apply(client(cfg))
response = proxy.run { http.post(url(cfg, path), json: { template: template, data: data, options: options }) }
response = proxy.run { http.post(url(cfg, path), json: payload) }
response.raise_for_status
response.body.to_s
response
rescue HTTPX::HTTPError => e
raise Error, "yeti-pdf returned HTTP #{e.status}: #{safe_body(e.response)}"
rescue HTTPX::Error => e
raise Error, "yeti-pdf request failed: #{e.message}"
end

# yeti-pdf returns the rendered name in Content-Disposition with a ".pdf"
# extension (so the response is directly saveable); we store the base name
# and re-append the extension when serving. httpx parses both the quoted
# and the RFC 2231 filename*=utf-8'' forms.
def rendered_filename(response)
name = response.body.filename
return if name.blank?

name.delete_suffix(PDF_EXTENSION)
end

def client(cfg)
t = (cfg.timeout || DEFAULT_TIMEOUT).to_i
# Rendering a large invoice can take yeti-pdf minutes, so the client must
Expand Down
3 changes: 3 additions & 0 deletions db/custom_seeds/invoice_template_example.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,7 @@
# Created once; an existing row with the same name is left untouched.
Billing::InvoiceTemplate.find_or_create_by!(name: 'Example (HTML)') do |template|
template.html_template = File.read(Rails.root.join('db/custom_seeds/invoice_template_example.html'))
# Rendered by yeti-pdf alongside the document itself; no extension, ".pdf" is
# appended when the document is served.
template.filename_template = 'invoice-{{invoice.reference}}-{{ invoice.end_date|strfdate:"%Y-%m" }}'
end
Loading
Loading