Owner Statement automation works by pulling the Owner Statements report from AppFolio, fanning it out per owner, adding a short cover memo, and emailing a finished packet while archiving a copy in Drive. In production it replaces manual month-end assembly for property managers running multi-entity portfolios. This guide shows how we ship it, why we switch away from fund-wide financials, and the gotchas to plan for.
Owner Statement automation: a system that generates, emails, and archives per-owner financial packets without manual exports or copy-paste.
The problem it solves
Manual month-end looks the same everywhere: export statements, filter by owner, merge with notes, save to folders, send emails, and log who got what. One interruption and packets slip a day. The cost is not the thinking. It is the repetitive assembly and routing.
| Task | Manual month-end | Automated with our build | |---|---|---| | Pull statements | Export all, repeat per owner | One scheduled run pulls once and fans out | | Bundle per owner | Copy files into folders | Auto-assemble a packet per owner | | Cover memo | Type a short context note | AI drafts memo with property-level highlights | | Email send | Draft and attach files | Sends with owner-specific subject and body | | Archive | Drag into folders | Writes to YYYY-MM owner folders automatically | | Logging | Update a sheet by hand | Appends a structured log row per send |
McKinsey estimates roughly 60 percent of occupations have at least 30 percent of activities that can be automated. Source: McKinsey Global Institute, A future that works (2017), https://www.mckinsey.com/featured-insights/employment-and-growth/a-future-that-works-automation-employment-and-productivity
How the automation works
We built the system around one fact: fund-wide financial summaries are not reliably filterable to a single owner, so we pivot to Owner Statements which are produced per owner or per the owner's properties. The flow: load your owner to properties mapping, fetch Owner Statements for the period, draft a concise cover memo, build a single packet per owner, send, and archive.
- Data source: AppFolio Owner Statements: we request the Owner Statements report scoped to each owner's properties and the target period. This is the filterable alternative to fund-wide reports when you need per-owner output.
- Orchestration: Google Apps Script: a time trigger runs monthly. It drives fetch, memo drafting, packet assembly, delivery, and logging.
- Configuration and logging: Google Sheets: tabs for Owners, Properties, Settings, and a Log. Non-developers can change recipients and property mappings.
- Memo drafting: AI summarizer: a small model writes a two-paragraph memo highlighting material changes. We keep numbers from the statement, not from the model.
- Delivery and archive: Gmail and Drive: emails send per owner with the packet attached, and an identical copy is written to a YYYY-MM folder.
Step-by-step: how to build it
1) Structure the Sheet and Script properties
Create tabs: Settings, Owners, Properties, Log. Store secrets in Script Properties, not cells. Owners map to property IDs, and Properties carry human names for the memo.
// Apps Script: settings.ts
export function getSettings() {
const props = PropertiesService.getScriptProperties();
return {
baseUrl: props.getProperty('APPFOLIO_BASE_URL'),
apiKey: props.getProperty('APPFOLIO_API_KEY'),
senderName: props.getProperty('SENDER_NAME') || 'Reporting Team',
driveRoot: props.getProperty('DRIVE_ROOT_FOLDER_ID'),
modelKey: props.getProperty('OPENAI_KEY') // optional if you draft memos
};
}
export type Owner = { name: string; email: string; propertyIds: string[] };
export function getOwners(): Owner[] {
const sh = SpreadsheetApp.getActive().getSheetByName('Owners');
const rows = sh.getDataRange().getValues().slice(1);
return rows.filter(r => r[0] && r[1]).map(r => ({
name: String(r[0]).trim(),
email: String(r[1]).trim(),
propertyIds: String(r[2] || '').split(',').map(s => s.trim()).filter(Boolean)
}));
}Key gotcha: keep property IDs in the sheet as text to avoid numeric coercion. We also keep all secrets in Script Properties for safer repos.
2) Fetch Owner Statements for an owner and period
We wrap report calls behind a single helper so the rest of the code never touches URLs or headers directly. The implementation uses your account's documented API access.
// Apps Script: reports.ts
import { getSettings } from './settings';
function apiFetch(kind: 'OWNER_STATEMENTS', payload: Record<string, unknown>): GoogleAppsScript.URL_Fetch.HTTPResponse {
const s = getSettings();
const url = s.baseUrl + '/reports'; // exact path configured in props or code
const options: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = {
method: 'post', // or as documented for your account
contentType: 'application/json',
payload: JSON.stringify({ kind, ...payload }),
headers: { 'Authorization': 'Bearer ' + s.apiKey }
};
return UrlFetchApp.fetch(url, options);
}
export function fetchOwnerStatementPdf(propertyIds: string[], period: { year: number; month: number }): GoogleAppsScript.Drive.File {
const resp = apiFetch('OWNER_STATEMENTS', {
period,
properties: propertyIds
});
const blob = resp.getBlob().setName(`owner-statement-${period.year}-${period.month}.pdf`);
return DriveApp.createFile(blob);
}The shape above keeps platform specifics in one place. In production we add retries and an exponential backoff for transient errors.
3) Draft a concise cover memo from the source numbers
We ask the model to summarize observed changes. Math stays deterministic: totals and deltas are computed in code and provided to the prompt.
// Apps Script: memo.ts
export function buildMemo(ownerName: string, props: { name: string }[], facts: { incomeDelta: number; expenseDelta: number; notable: string[] }) {
const lines = [
`Owner: ${ownerName}`,
`Summary: income ${fmtDelta(facts.incomeDelta)}, expenses ${fmtDelta(facts.expenseDelta)}.`,
`Properties: ${props.map(p => p.name).join(', ')}`
];
if (facts.notable.length) lines.push('Notes: ' + facts.notable.join('; '));
return lines.join('\n');
}
function fmtDelta(n: number) { return (n >= 0 ? '+' : '−') + Math.abs(n).toLocaleString(); }When we do use an AI model, we pass precomputed numbers and short bullet inputs and ask for a 2-paragraph memo, then we paste it into the packet's first page.
4) Assemble the per-owner packet and archive it
We combine the memo with the statement PDF and save the final to a structured Drive path.
// Apps Script: packet.ts
export function assemblePacket(memoText: string, statementPdf: GoogleAppsScript.Drive.File, driveRootId: string, period: { year: number; month: number }, owner: { name: string }) {
const root = DriveApp.getFolderById(driveRootId);
const yy = String(period.year), mm = String(period.month).padStart(2, '0');
const monthFolder = ensureFolder(root, `${yy}-${mm}`);
const ownerFolder = ensureFolder(monthFolder, owner.name);
const memoDoc = DocumentApp.create(`${owner.name} Cover Memo ${yy}-${mm}`);
memoDoc.getBody().appendParagraph(memoText);
memoDoc.saveAndClose();
const memoPdf = DriveApp.getFileById(memoDoc.getId()).getAs('application/pdf');
const memoPdfFile = ownerFolder.createFile(memoPdf).setName(`Cover Memo ${yy}-${mm}.pdf`);
statementPdf.makeCopy(`Owner Statement ${yy}-${mm}.pdf`, ownerFolder);
return { folderId: ownerFolder.getId(), memoPdfId: memoPdfFile.getId() };
}
function ensureFolder(parent: GoogleAppsScript.Drive.Folder, name: string) {
const it = parent.getFoldersByName(name);
return it.hasNext() ? it.next() : parent.createFolder(name);
}We avoid advanced PDF merging to keep it robust. Two PDFs in one folder is simpler and emails fine.
5) Send the email and write the log
One email per owner with a consistent subject, clear body, and both PDFs attached. Then append a log row.
// Apps Script: send.ts
import { getSettings } from './settings';
export function sendOwnerPacket(owner: { name: string; email: string }, period: { year: number; month: number }, folderId: string) {
const s = getSettings();
const yy = String(period.year), mm = String(period.month).padStart(2, '0');
const folder = DriveApp.getFolderById(folderId);
const files = folder.getFiles();
const atts: GoogleAppsScript.Base.Blob[] = [];
while (files.hasNext()) atts.push(files.next().getBlob());
GmailApp.sendEmail(owner.email, `${yy}-${mm} Owner Packet`, `Attached are your ${yy}-${mm} Owner Statement and cover memo.`, {
name: s.senderName,
attachments: atts
});
}
export function logSend(ownerName: string, email: string, period: { year: number; month: number }, status: string, note: string) {
const sh = SpreadsheetApp.getActive().getSheetByName('Log')!;
sh.appendRow([new Date(), ownerName, email, `${period.year}-${String(period.month).padStart(2,'0')}`, status, note]);
}Gmail's attachment limit is about 25 MB. For very large portfolios we split sends or include a Drive link with permissions scoped to the recipient.
6) Orchestrate the monthly run with backfill safety
We schedule one trigger for the first business day after statements finalize and add a backfill runner with visible scope warnings.
// Apps Script: main.ts
import { getOwners } from './settings';
import { fetchOwnerStatementPdf } from './reports';
import { buildMemo } from './memo';
import { assemblePacket } from './packet';
import { sendOwnerPacket, logSend } from './send';
export function runMonthly() {
const today = new Date();
const period = { year: today.getFullYear(), month: today.getMonth() }; // run for prior month
const owners = getOwners();
owners.forEach(o => {
try {
const pdf = fetchOwnerStatementPdf(o.propertyIds, period);
const memo = buildMemo(o.name, o.propertyIds.map(id => ({ name: id })), { incomeDelta: 0, expenseDelta: 0, notable: [] });
const pkt = assemblePacket(memo, pdf, getSettings().driveRoot, period, { name: o.name });
sendOwnerPacket({ name: o.name, email: o.email }, period, pkt.folderId);
logSend(o.name, o.email, period, 'OK', '');
} catch(e) {
logSend(o.name, o.email, period, 'ERROR', String(e));
}
});
}
export function runBackfill(year: number, monthStart: number, monthEnd: number) {
if (monthEnd - monthStart > 2) throw new Error('Narrow your backfill window to avoid mass emails');
for (let m = monthStart; m <= monthEnd; m++) {
const period = { year, month: m };
getOwners().forEach(o => {
// same body as runMonthly, but consider sending drafts first
});
}
}Backfills are where people accidentally blast old packets. We fence it to a small range and recommend draft-only first.
Where it gets complicated
- Fund-wide vs per-owner data: fund-level financial summaries are not reliably filterable to a single owner. We fetch Owner Statements to get true per-owner breakdowns.
- Statement timing and retries: statements finalize at different times. Add retries and a status page so you do not send half-complete packets.
- Attachment size and delivery: some owners span many properties. Split attachments, compress, or send a Drive link when approaching email size limits.
- Property mapping drift: owners buy and sell. Keep an Owners tab that is the single source of truth and add a monthly reconcile step.
- Trigger ownership: the Google account that installs the trigger owns the sends. Decide sender display name and send-as before handoff.
- Month boundaries and timezone: run for the prior month on the first business day in the client's timezone. Do not rely on the server default.
What this actually changes
For a property management operator, this moved packet assembly from a calendar reminder to a logged, idempotent job. In one Iron Age Investments build we dropped fund-wide financial pulls and standardized on Owner Statements so each investor received only their properties. The value is structural: less time on assembly, fewer mis-routed emails, and consistent archives you can audit.
A broader point: automation reliably reclaims the repetitive 30 percent of a role. McKinsey reports roughly 60 percent of occupations have at least 30 percent of activities that can be automated. Source: McKinsey Global Institute, A future that works (2017), https://www.mckinsey.com/featured-insights/employment-and-growth/a-future-that-works-automation-employment-and-productivity
Frequently asked questions
Does AppFolio let you generate Owner Statements per owner?
Yes. The Owner Statements report is the practical way to deliver per-owner financial packets. In our builds we scope the request to the properties owned by that recipient and assemble a single packet for that owner.
What AppFolio plan or setup do I need for this?
Access to programmatic report pulls is gated and must be enabled on your account. Your AppFolio admin or support contact can confirm eligibility and turn on access. Our implementation keeps all account-specific details in one configuration layer.
Can it send one combined PDF instead of two attachments?
Yes. We often ship a simple two-file packet because it is robust. If you prefer one file, we can merge the cover memo and statement PDFs during assembly, with safeguards for size limits.
How do you prevent sending the wrong packet to the wrong owner?
We map owners to property IDs in a controlled Sheet, derive the packet from that mapping, and log every send. Dry-runs and a limited backfill window reduce risk during the first month.
Can this include additional docs like maintenance summaries or receipts?
Yes. The packet step accepts any list of Drive files for the period. We commonly include maintenance digests and a brief leasing note when relevant.
How long does this take to implement?
Most teams go live in one to two weeks once AppFolio access is confirmed. The time goes into mapping owners to properties, setting the send schedule, and validating one full month in draft before turning it on.
If you want per-owner packets to land every month without manual assembly, we have built and shipped this pattern already. See our related post on automating AppFolio investor reporting, browse our services, and when you are ready book a 15-minute call.
Want us to build this for you?
15-minute discovery call. No pitch. We tell you what to automate first.
Book a Discovery Call