Customer Statement of Accounts Generation
A design reference for batch-generating and emailing periodic customer statements
1. Requirements
1.1 Functional requirements
- Produce a per-customer statement in one of two bodies, selected per batch: a General Ledger body (transaction detail over a from/to date window) or an Accounts Receivable body (an as-of-date outstanding/ageing view), each computed by the reporting engines it shares with the rest of the accounting module. This design covers only recipient assembly, rendering, and delivery — not how those engines compute their rows.
- Assemble recipients one at a time, or in bulk via a Recipient Collection (Customer Group, Territory, Sales Partner, or Sales Person), fetching every matching, non-disabled customer in one action.
- Resolve, per recipient, a Billing Contact address (from the contact record flagged as that customer’s billing contact) and the customer master’s own primary address, both editable afterward.
- Optionally CC a fixed list of internal users, independent of the customers on the batch.
- Filter by legal entity, ledger account, cost centers, projects (General Ledger mode only), finance book, presentation currency, and mode-specific filters (payment-terms template, sales partner/person, territory for the receivable mode; categorize-by-voucher for the ledger mode).
- Optionally append an Ageing Summary — fixed 30/60/90/120-day buckets — to either body.
- Render each statement to PDF via a Jinja print layout: the built-in default, an explicit print format (validated at save to be Jinja, enabled, and matching the chosen mode), or a layout registered by another installed module.
- Deliver via two manual actions (email each recipient individually, or download one combined PDF) and one scheduled path: an Enable Auto Email flag with a frequency (Daily/Weekly/Biweekly/Monthly/Quarterly) and start date, evaluated daily, advancing its own date window forward by that cadence after each automatic send.
1.2 Non-functional requirements
- Fail-open per recipient: a customer with no activity, or no resolvable address, is skipped silently rather than aborting the whole send.
- Scoping integrity: ledger account, cost centers, and projects are validated at save to belong to the batch’s own legal entity.
- Template safety: subject/body/filename templates are Jinja, validated at save with restricted globals.
- Traceability: a successful automatic run leaves a comment noting when it fired.
- No-code configurability: recipients, filters, cadence, and templates are all editable fields, not code.
1.3 Constraints
- One batch is scoped to exactly one legal entity — no cross-company fan-out in a single run.
- Only the two report bodies above plug into this mechanism; there is no generic “any report” option.
- Recipients are always customers — a receivables-facing mechanism only.
- Evaluation is daily-only, with no catch-up if a day is missed (see §4).
2. High-Level Design
2.1 Component diagram
2.2 Recipient email resolution (a short procedure, not a graph)
- Bulk fetch: pull every non-disabled customer under the selected Group/Territory (hierarchy) or Sales Partner/Person (team assignment); if a primary contact is required, customers without one are dropped.
- Resolve two addresses: the customer master’s own contact email (primary), and the email on the most recently created Contact flagged as billing contact (billing).
- Cache both on the recipient row, editable afterward — resolution does not re-run automatically if the underlying Contact/Customer changes.
- At send time, the recipient list is the billing address(es) plus, if required, the primary address. No address on either → the customer is skipped silently.
{ customer: <link to Customer>,
primary_email: <read-only, from the Customer master>,
billing_email: <editable, from the billing Contact> }
3. Deep Dive
3.1 Data model
Statement Run — the batch record: one legal entity, report mode, date window (from_date/to_date for the ledger body, posting_date for the receivable body), filters, print/email templates, and the auto-email cadence.
Statement Recipient — one row per customer: the link plus resolved primary/billing addresses.
CC Recipient — internal users cc’d on every message the batch sends.
Cost Center Filter / Project Filter — multi-select child rows, validated at save to belong to the run’s legal entity. The Ledger Account filter is validated the same way — this mechanism parameterizes the shared chart of accounts rather than introducing its own.
3.2 The two report modes, and the schedule-advance algorithm
The General Ledger body draws on the posting funnel described in the general-ledger design: a date-bounded, per-party listing with a fixed opening/closing/total scaffold. A result of only that three-row scaffold means no activity in the window, and the customer is skipped. Presentation currency falls back explicit currency → party’s own account currency → legal entity’s base currency.
The Accounts Receivable body is the as-of-date outstanding/ageing view built from Party Balance Entries; an empty result skips the customer the same way. The Ageing Summary, when enabled, is computed separately from the same as-of date and appended regardless of mode. Two filters (ignore exchange-rate-revaluation journals, ignore system credit/debit notes) apply uniformly to both engines.
The scheduled path is the one branching flow worth diagramming:
3.3 Rendering and delivery
Each body is wrapped in a base print scaffold, with letterhead and terms-and-conditions merged in if configured, in the chosen orientation. Template order: an explicit print format overrides a layout registered by another installed module, which overrides the module’s own default for that mode.
The download path joins every recipient’s HTML into one document (optional page-break between statements) and renders one PDF. The email path renders each recipient’s HTML to its own PDF and attaches it individually. Subject/body/filename are rendered per recipient from Jinja templates with both the batch record and the recipient’s customer record in context; each message is queued from a configured mailbox or the acting user’s address, tagged back to the originating batch.
3.4 Error handling
- Save-time validation catches most misconfiguration (company mismatches, invalid print format, malformed templates) before any send is attempted.
- Zero-content and zero-address customers are skipped per-recipient; the batch overall still reports success.
- CC resolution failures (a stale user reference) are caught and simply shorten the CC list rather than failing the send.
- The per-recipient generation loop is not itself wrapped in a try/except — an unexpected failure on one customer’s data would surface as a failure for the whole batch rather than being isolated. A gap, not a documented safeguard.
4. Scale and Reliability
- Load shape: low-frequency, bursty — one evaluation per day per batch, fanning into one report computation and PDF render per recipient; cost scales linearly with recipient count.
- Synchronous-then-async: report computation and rendering happen inline before any message is queued, so a large recipient list has real compute cost before delivery starts.
- No catch-up on a missed day: the sweep only matches a window-end date equal to today; a missed run means the batch silently never fires again without manual intervention.
- No de-duplication across reruns: the date-advance step is what makes a normal daily run self-limiting, not an explicit guard.
- Single-legal-entity scope keeps each batch cheap to evaluate, but multi-company operators need one record per company.
5. Trade-off Analysis
| Decision | Trade-off |
|---|---|
| Two hard-coded report modes, not a pluggable registry | Simple and covers the common case, but a third statement type needs a code change, not a registration. |
| Recipient addresses cached at fetch/edit time, not re-resolved at send | Lets an operator hand-correct before sending, but a changed email upstream goes stale until someone re-fetches. |
| Fail-open, skip-silently semantics | The batch always completes, but there is no per-recipient send report to say who was skipped or why. |
| Daily-only scheduler granularity | Matches finance cadences with minimal scheduling machinery, but a missed day has no automatic catch-up. |
| Company-scoped single batch, no cross-entity fan-out | Consistent with independent per-entity books, but one record per company to maintain. |
| Save-time template/format validation over a stricter sandbox | Catches misconfiguration early and allows rich Jinja personalization, trusting “restricted globals” as the boundary. |
6. What to Revisit as the System Grows
- Isolate per-recipient failures so one malformed customer/contact record can’t abort the rest of the batch.
- Add a per-recipient send report (sent / skipped-no-content / skipped-no-address / failed) — today’s only feedback is a boolean and one comment.
- Add catch-up for missed scheduler days by treating a past-due window-end date as still due, not requiring an exact match.
- Re-resolve addresses at send time instead of trusting a cached row value.
- Add an explicit idempotency guard around the send-then-advance sequence.
This is a narrowly-scoped, Low-priority mechanism; the source honestly supports a document at this length rather than a longer one.