NtiReceiptBuilder
A drag-and-drop receipt template designer and millimetre-accurate renderer, packaged as a Rails engine.
The gem ships the whole vertical — model, layout validation, renderer, print views, and a
Stimulus builder UI — but carries no domain vocabulary of its own. Your application
declares a Variables subclass mapping receipt placeholders to methods on whatever object it
prints receipts for, and the gem drives the designer, the validator and the renderer from it.
Installation
# Gemfile
gem 'nti_receipt_builder'
bundle install
bin/rails generate nti_receipt_builder:install
bin/rails db:migrate
The generator creates:
config/initializers/nti_receipt_builder.rb- a migration creating
nti_receipt_templates config/initializers/turbo_stream_tags.rb— skipped if your app already definesopen_modalonTurbo::Streams::TagBuilder
Declaring your variables
MAPPINGS supplies the placeholder keys and the method that produces each value. Everything
else is derived, and overridden per key with variable only where the derived default is
wrong.
class OrderReceiptVariables < NtiReceiptBuilder::Variables
MAPPINGS = {
'CUSTOMER_NAME' => 'customer_name',
'ORDER_REFERENCE' => 'order_reference',
'ORDER_SUBTOTAL' => 'order_subtotal',
'ORDER_DATETIME' => 'order_datetime',
'ORDER_LINES' => 'order_lines'
}.freeze
variable 'CUSTOMER_NAME', sample: 'Juan Dela Cruz'
variable 'ORDER_SUBTOTAL', sample: BigDecimal('275.00')
variable 'ORDER_DATETIME', label: 'Order Date/Time',
sample: -> { Time.zone.local(2026, 7, 31, 14, 32) }
variable 'ORDER_LINES', columns: {
'description' => { label: 'Description', width_mm: 24 },
'quantity' => { label: 'Qty', width_mm: 8, align: 'center' },
'unit_price' => { label: 'Unit Price', width_mm: 12, align: 'right' },
'line_total' => { label: 'Total', width_mm: 12, align: 'right' }
}
def customer_name = receipt_object.customer.name
def order_reference = receipt_object.reference
def order_subtotal = receipt_object.subtotal
def order_datetime = receipt_object.created_at
def order_lines
receipt_object.order_lines.order(:order_line_number).map do |line|
{ 'description' => line.product_name, 'quantity' => line.quantity,
'unit_price' => line.retail_price, 'line_total' => line.subtotal }
end
end
end
receipt_object is whatever you pass to the renderer — an order, an invoice, a booking.
What is derived, and what you can override
| Aspect | Derived default | Override |
|---|---|---|
| Label | key.titleize — 'STORE_NAME' → 'Store Name' |
label: |
| Formatting type | the value's class at render time: BigDecimal → currency, responds to strftime → datetime, Array → collection, other Numeric → plain, else string |
type: |
| Preview sample | the label as a string; a collection gets three filler rows | sample: — a value or a lambda |
| Collection | the one key declaring columns: is the collection |
— |
Type is decided at format time, not declaration time, so real data and preview samples travel
the same path. type: exists for the cases inference cannot get right — a pre-formatted
String that should be currency, say.
Column options are label:, type:, width_mm: and align:, all optional. They whitelist
the column keys a saved layout may use, seed the builder when a designer drops a fresh table,
and decide cell formatting.
A malformed class fails when its schema is first read — missing MAPPINGS, a mapped method
that does not exist, a variable call for a key absent from MAPPINGS, or two keys declaring
columns: all raise NtiReceiptBuilder::InvalidVariablesError. Typos surface in development
rather than at print time.
An error raised inside a mapping method propagates. That is deliberate: a receipt printing silently without its total is worse than a visible failure.
Configuration
NtiReceiptBuilder.configure do |config|
config.variables_class = 'OrderReceiptVariables' # required
config.template_class = 'ReceiptTemplate' # default 'NtiReceiptBuilder::Template'
config.currency_unit = '₱'
config.datetime_format = '%m/%d/%Y %I:%M %p'
config.print_stylesheet = 'nti_receipt_builder' # nil to load none
end
Class references may be given as a Class or a String; either way the gem retains only the
name and resolves it per access. Holding a reloadable application class in configuration
would pin that class and its constant tree past every development reload, and would serve a
stale class after your code reloads.
Wiring a controller
The gem ships its actions as a concern. Your controller keeps routing, authorization and tenant scoping.
class Partner::ReceiptTemplatesController < PartnerController
include NtiReceiptBuilder::TemplatesController
def index
# yours — datatable, decorators, whatever you already use
end
private
def receipt_owner = current_account
end
# config/routes.rb
resources :receipt_templates do
member do
get :preview
post :render_preview
get :print
patch :set_default
end
end
receipt_owner is mandatory: every query the concern makes is scoped through it, so the
tenant boundary stays visible in your code rather than in a configured lambda. A controller
that omits it raises NotImplementedError instead of running an unscoped query.
The concern provides new, create, edit, update, destroy, preview,
render_preview, print and set_default. Define any of them in your controller to take
over — a method on the including class wins. SalesWiz overrides destroy, for instance, to
archive rather than delete. The navigation hooks after_create_path, after_destroy_path and
after_set_default_path are overridable too, and worth overriding if you have named routes.
Subclassing the model
Point template_class at a subclass to attach your own concerns while sharing the table:
class ReceiptTemplate < NtiReceiptBuilder::Template
# See the note below — these two lines go together.
def self.base_class = self
self.table_name = 'nti_receipt_templates'
include Auditable
has_assignable_status
validates :is_default, uniqueness: { scope: %i[owner_type owner_id],
conditions: -> { where(is_default: true) } }
end
The parent sets inheritance_column = nil, so the subclass shares nti_receipt_templates
with no STI type condition and no type column to maintain. Host-specific columns go in your
own migration against the same table.
If you subclass, override base_class and re-declare the table. Rails resolves
base_class up the superclass chain, so without the override it returns
NtiReceiptBuilder::Template. Anything that records a class name from base_class —
PaperTrail's item_type, polymorphic *_type columns — would then store the gem's class name
rather than yours, and your own queries would not find those rows. Returning self is safe
precisely because there is no STI to disturb.
The two lines are a pair: once the subclass is its own base_class, Rails derives the table
name from it, which would give receipt_templates. Naming the engine's table explicitly
keeps them on the same rows.
Overriding a view
Host view prefixes are searched before the gem's, so dropping
app/views/partner/receipt_templates/edit.html.erb into your app shadows the gem's copy with
no configuration. The same trick works for _print_assets.html.erb if you want exact control
over what the standalone print document loads.
Rendering a receipt
@render_result = NtiReceiptBuilder::Renderer.new(
template: template, receipt_object: order
).call
render template: 'nti_receipt_builder/templates/print', layout: false
Omit receipt_object: and the renderer uses your declared samples without calling a single
mapping method — that is what the designer's preview does.
RenderResult exposes elements (presenters in paint order), overflow? and
content_height_mm. Preload whatever associations your scalar mappings traverse; the gem
cannot know them.
JavaScript and CSS
// app/javascript/controllers/index.js
import { registerNtiReceiptBuilder } from 'nti_receipt_builder'
registerNtiReceiptBuilder(application)
/* app/assets/stylesheets/application.css */
@import 'nti_receipt_builder';
Identifiers are nrb-builder, nrb-canvas, nrb-draggable, nrb-print and nrb-properties.
Set --nrb-accent and --nrb-accent-rgb to tint the builder's selection and palette
affordances; both fall back to Tabler's primary.
Host requirements
- Tabler 1.x with Bootstrap JS in the layout — the builder chrome uses Tabler's card, button and form classes, and the preview modal uses Bootstrap's dismiss behaviour.
Turbo::Streams::TagBuilderresponding toopen_modal(size:, &block),close_modal,toast(message, type:)andreload_datatable(id), with matchingturbo_streamaction handlers in your JavaScript. The install generator supplies these if you lack them.receipt_owneron the controller including the concern.- Optional: a global
window.Toastwith.warning(message)for the element-limit notice. The builder degrades silently without it. - Optional: the
material-symbols-outlinedicon font, used for palette and toolbar icons.
Limits
Enforced by LayoutValidator before anything persists, which is what bounds the memory a
single template can consume:
| Limit | Value |
|---|---|
| Elements per template | 60 |
| Minimum element dimension | 1.0mm |
| Text length | 500 |
| Fallback / prefix / suffix | 200 / 50 / 50 |
| Order-line columns | 1–6 |
| Font size | 6–72pt |
z-index |
0–1000 |
Development
bin/setup
createdb nti_receipt_builder_test # specs need PostgreSQL — layout is jsonb
bundle exec rspec
bundle exec rubocop
Specs run against a minimal dummy Rails app in spec/dummy.
License
MIT.