Current section
Files
Jump to
Current section
Files
lib/rendro/adapters/accrue.ex
if Code.ensure_loaded?(Accrue) do
defmodule Rendro.Adapters.Accrue do
@moduledoc """
Canonical, deterministic billing-document recipe tailored for the `accrue`
financial library.
This optional adapter transforms an `Accrue.Invoice` struct into a
`Rendro.Document` ready to be rendered into a deterministic artifact.
It guarantees a stable layout for invoices, statements, and financial
documents, ensuring exact, repeatable pagination and formatting across runs.
This module is only compiled when `Accrue` is available at compile
time (via `Code.ensure_loaded?/1`). If `:accrue` is not in your
project's dependencies, this module is absent and core Rendro is
unaffected.
## Usage
invoice = MyApp.Billing.fetch_invoice!(invoice_id)
# 1. Build the canonical Document using this Accrue recipe
{:ok, doc} = Rendro.Adapters.Accrue.recipe(invoice)
# 2. Render deterministically to an Artifact
{:ok, artifact} = Rendro.render_to_artifact(doc, deterministic: true)
# 3. Store the immutable financial artifact
{:ok, _path} = Rendro.Storage.Local.put(artifact, path: "/tmp/invoice.pdf")
## Contract
Accepts an `%Accrue.Invoice{}` with the following fields read by the
recipe:
* `:id` — invoice identifier rendered as the document title
* `:customer` — map or struct with at least a `:name` (used in header)
* `:line_items` — list of `%Accrue.LineItem{}` with `:description`,
`:quantity`, `:unit_amount`, `:subtotal`
* `:total` — integer or Decimal-like value rendered in the totals row
* `:issued_at` — Date/DateTime rendered next to the title
Unrecognized inputs return `{:error, {:invalid_invoice, value}}`.
## Errors
Returns `{:error, {:invalid_invoice, term()}}` for non-`%Accrue.Invoice{}`
inputs. The recipe itself does not call `Rendro.render/1`; callers
compose the recipe result with `Rendro.render/1` so render-time
policies (max pages/bytes/timeout) and error tuples flow through the
normal pipeline.
"""
@moduledoc tags: [:adapter]
@spec recipe(term()) ::
{:ok, Rendro.Document.t()} | {:error, {:invalid_invoice, term()}}
def recipe(%Accrue.Invoice{} = invoice) do
template = build_page_template()
doc =
Rendro.Document.new()
|> Rendro.Document.add_template(template)
|> Rendro.Document.set_template(template.name)
|> Rendro.Document.add_section(build_header_section(invoice))
|> Rendro.Document.add_section(build_body_section(invoice))
|> Rendro.Document.add_section(build_footer_section(invoice))
{:ok, doc}
end
def recipe(other), do: {:error, {:invalid_invoice, other}}
defp build_page_template do
Rendro.page_template(name: :accrue_invoice)
end
defp build_header_section(%Accrue.Invoice{id: id, issued_at: issued_at, customer: customer}) do
Rendro.section(
name: :accrue_header,
region: :header,
content: [
Rendro.block(Rendro.text("INVOICE ##{id}", size: 18)),
Rendro.block(Rendro.text("Issued: #{inspect(issued_at)}", size: 10)),
Rendro.block(Rendro.text("Bill to: #{customer_name(customer)}", size: 10))
]
)
end
defp build_body_section(%Accrue.Invoice{line_items: line_items, total: total}) do
rows =
Enum.map(line_items || [], fn %Accrue.LineItem{} = item ->
[
to_string(item.description),
to_string(item.quantity),
format_amount(item.unit_amount),
format_amount(item.subtotal)
]
end)
table =
Rendro.table(rows,
header: ["Description", "Qty", "Unit", "Subtotal"],
columns: [{:share, 1}, {:fixed, 40}, {:fixed, 60}, {:fixed, 60}]
)
Rendro.section(
name: :accrue_body,
region: :body,
content: [
Rendro.block(table),
Rendro.block(Rendro.text("Total: #{format_amount(total)}", size: 12))
]
)
end
defp build_footer_section(%Accrue.Invoice{}) do
Rendro.section(
name: :accrue_footer,
region: :footer,
content: [Rendro.block(Rendro.text("Generated by Rendro + Accrue", size: 8))]
)
end
defp customer_name(%{name: name}) when is_binary(name), do: name
defp customer_name(_), do: "(unknown)"
defp format_amount(nil), do: ""
defp format_amount(value) when is_integer(value), do: "$#{value}"
defp format_amount(value), do: to_string(value)
end
end