Invoice render orchestration — builds a single RenderContext and
exposes format-helper functions shared by the email + PDF pipelines.
Contract
build_assigns/2 is the ONE point of entry. It:
- Loads the invoice (+ preloads items + customer) if given an id.
- Freezes
Accrue.Config.branding/0into the struct ONCE (Pitfall 8). - Resolves locale and timezone via the configured precedence ladder:
opts > customer column > "en" / "Etc/UTC". - Pre-computes every
formatted_*string viaformat_money/3andformat_datetime/3so templates never call CLDR directly.
Fail-safe locale + timezone (Pitfall 5)
Both format_money/3 and format_datetime/3 wrap their underlying
calls in try/rescue. On failure they emit a telemetry event and
retry with the safe fallback ("en" / "Etc/UTC"); they NEVER raise.
This keeps a bad preferred_locale or preferred_timezone string on
a single customer from breaking the entire email pipeline (one poison
row stays isolated).
Events emitted:
[:accrue, :email, :locale_fallback]— metadata%{requested: locale, currency: currency}(no PII)[:accrue, :email, :timezone_fallback]— metadata%{requested: timezone}[:accrue, :email, :format_money_failed]— metadata%{requested: locale, currency: currency}(second-attempt failure)
Summary
Types
@type opts() :: [ locale: String.t() | nil, timezone: String.t() | nil, customer: term() | nil, now: DateTime.t() | nil ]
Functions
@spec build_assigns(Accrue.Billing.Invoice.t() | String.t(), opts()) :: Accrue.Invoices.RenderContext.t()
Builds a RenderContext from an Invoice struct or an invoice id.
opts:
:locale— overrides customer.preferred_locale:timezone— overrides customer.preferred_timezone:customer— pre-loaded customer, skipping the Repo fetch:now— pin the "issued at" timestamp (defaults toDateTime.utc_now/0)
@spec format_datetime(DateTime.t() | nil, String.t(), String.t()) :: String.t() | nil
Formats a DateTime into a human-readable string in timezone.
On timezone shift failure (unknown TZ or missing tzdata), emits
[:accrue, :email, :timezone_fallback] and retries with "Etc/UTC".
Locale is accepted for forward-compatibility but v1.0 uses
Calendar.strftime/2 (en-only) — hosts override via a custom
formatter in a future release.
Formats an integer minor-unit amount into a human-readable currency string using the given locale.
NEVER raises — on unknown locale/currency it emits a telemetry
fallback event and retries with "en"; on second failure it emits a
hard-failure event and returns a raw fallback like "1000 usd".