Rex Automaton
All posts
Operations & Admin AutomationJune 25, 20269 min read

How to Automate AppFolio Owner Distributions

We built a production pipeline that schedules AppFolio pulls, applies reserve and waterfall rules, and delivers per-owner packets and bank-ready files with duplicate protection.

By Jacky Lei

We automated owner distributions for a property manager by scheduling month-end AppFolio pulls, running reserve and waterfall math against finalized ledgers, and producing per-owner packet PDFs plus a bank-ready payment file. In production it removed rekeying, prevented duplicates, and made distribution day a checklist instead of a scramble.

Owner distributions automation is the scheduled extraction of period-close data, rule-based allocation of distributable cash to each owner, and one-click packetizing and payment export.

The problem it solves

Owner distributions are repetitive and error-prone when done by hand: export a handful of reports, reconcile accrual vs cash timing, compute reserves and management fees, split per ownership schedule, email statements, and then upload ACH files. If you delay or misallocate, owner trust drops fast.

A system solves this by snapshotting period-close data, applying your rules deterministically, and outputting both documents and payments in one run.

Manual distribution cycleAutomated distribution cycle
Export reports, copy numbers into spreadsheets, carry forward reserve balances, and hand-calc waterfallsScheduled pull at period close. Snapshotted numbers and reserve ledgers feed a rules engine
Build per-owner statements, merge PDFs, and write cover notesPacketizer renders a cover page and merges the owner's statements automatically
Create ACH or check batches by hand, watch for duplicatesBank-ready file is generated with idempotent run IDs and a reconciliation log
Back-and-forth to fix date cutoffs and roundingDeterministic math with period locks and rounding rules

How the automation works

At a high level: the scheduler takes a period-close snapshot, the allocation engine computes distributable cash after reserves and fees, the packetizer assembles per-owner PDF packets, and the payments step emits a bank-ready file and emails remittances. A ledger guard prevents duplicate sends.

  • Scheduler: waits for period close, then takes a one-time snapshot of the required AppFolio reports or exports. We only move forward when inputs pass sanity checks.
  • Allocation engine: applies reserve floors and replenishments, fee calculations, preferred returns, and pro-rata or tiered waterfalls. All rules live in config so finance can change them without code.
  • Packetizer: merges a cover letter with each owner's statements and any supplemental schedules, then names files consistently for audit.
  • Payments and remittances: writes a bank-upload CSV or ACH-format file and emails per-owner remittance details. A ledger of run IDs enforces idempotency and supports partial reruns.

Owner distribution workflow: AppFolio data snapshot flows into an Allocation Engine, then Packetizer, then Payments and Remittances

Step-by-step: how to build it

1) Schedule a period-close snapshot

Answer first: set a time-based trigger to run after books close, pull the necessary AppFolio data in one shot, and store it as an immutable snapshot for that period.

// Apps Script or Node cron invokes this after close
async function runDistributions(periodLabel) {
  // Guard: ensure we have not already run this period
  if (await hasRun(periodLabel)) return log("skip", periodLabel);
  // Pull inputs from AppFolio via API or scheduled exports placed in Drive/S3
  const inputs = await fetchInputs({ periodLabel }); // rent roll, owner statements, ledger balances
  await persistSnapshot(periodLabel, inputs); // write JSON to Drive/S3 + hash for audit
  return inputs;
}

Key gotcha: pick one source of truth for timing. If accrual dates differ from cash disbursement policy, lock the period window explicitly in config so reruns are reproducible.

2) Model owners, reserves, and waterfall rules

Answer first: keep every financial rule in a versioned JSON config so finance can update percentages and floors without code changes.

{
  "period": "2026-05",
  "reservePolicy": {
    "minPerProperty": { "default": 10000, "overrides": { "P-101": 15000 } },
    "replenishFrom": "currentPeriodCash"
  },
  "fees": [
    { "name": "Mgmt", "type": "percent_of_income", "rate": 0.05 },
    { "name": "Asset", "type": "flat_per_property", "amount": 500 }
  ],
  "waterfall": [
    { "tier": 1, "type": "preferred_return", "rate": 0.08, "compounding": false },
    { "tier": 2, "type": "split", "lp": 0.7, "gp": 0.3 }
  ],
  "ownership": {
    "LP A": { "equityPct": 0.6, "email": "lp-a@example.com" },
    "LP B": { "equityPct": 0.3, "email": "lp-b@example.com" },
    "GP":   { "equityPct": 0.1, "email": "gp@example.com" }
  }
}

Key gotcha: ownership often changes mid-period. Model effective dates and use the schedule in effect at the period close to keep totals traceable.

3) Compute distributable cash and allocations

Answer first: start with operating cash, subtract fees and reserve top-ups, then allocate the remainder by your waterfall.

function allocate(period, inputs, rules) {
  const cash = inputs.operatingCash; // from snapshot
  const fees = calcFees(cash, rules.fees, inputs.properties);
  const reserveTopUp = calcReserveTopUp(inputs.reserves, rules.reservePolicy);
  const distributable = Math.max(0, cash - fees.total - reserveTopUp.total);
 
  // Preferred return accrual for LPs
  const prefOwed = Object.fromEntries(Object.entries(rules.ownership)
    .filter(([k]) => k !== "GP")
    .map(([k, v]) => [k, v.equityPct * rules.waterfall[0].rate * inputs.periodBasis]));
 
  const tier1Paid = payProRata(distributable, prefOwed);
  const remaining = distributable - sum(Object.values(tier1Paid));
  const split = rules.waterfall.find(t => t.type === "split");
  const tier2Paid = {
    lp: remaining * split.lp,
    gp: remaining * split.gp
  };
 
  // Final per-owner amounts
  const owners = {};
  for (const [name, o] of Object.entries(rules.ownership)) {
    const base = tier1Paid[name] || 0;
    const share = name === "GP" ? tier2Paid.gp : tier2Paid.lp * o.equityPct / (1 - rules.ownership.GP.equityPct);
    owners[name] = round2(base + share);
  }
 
  return { fees, reserveTopUp, distributable, owners };
}

Key gotcha: rounding. Pick a rule once and stick to it. We round at the last possible step and adjust the final penny to the largest LP to avoid drift.

4) Generate per-owner packets

Answer first: produce a cover letter with period highlights and merge it with the owner's statements and any supporting schedules.

import { PDFDocument } from "pdf-lib";
 
async function buildPacket(owner, period, files) {
  const packet = await PDFDocument.create();
  const cover = await renderCoverPdf({ owner, period, summary: files.summaryJson });
  for (const src of [cover, files.ownerStatementPdf, files.supplementalPdf]) {
    if (!src) continue;
    const srcDoc = await PDFDocument.load(await fetchArrayBuffer(src));
    const pages = await packet.copyPages(srcDoc, srcDoc.getPageIndices());
    pages.forEach(p => packet.addPage(p));
  }
  return await packet.save();
}

Key gotcha: statement availability. If some statements are monthly and others are quarterly, include a contents page and note the date range on the cover so owners do not confuse mismatched periods.

5) Create a bank-ready payment file and remittances

Answer first: write a deterministic payment file and email remittance details. Keep a ledger of run IDs and hashes for reconciliation.

import { stringify } from "csv-stringify/sync";
 
function buildBankCsv(period, allocations, banking) {
  const rows = Object.entries(allocations.owners)
    .filter(([_, amt]) => amt > 0)
    .map(([owner, amt]) => ({
      date: period.label,
      owner,
      amount: amt.toFixed(2),
      routing: banking[owner].routing,
      account: banking[owner].accountLast4,
      memo: `Distribution ${period.label}`
    }));
  return stringify(rows, { header: true });
}
 
async function sendRemittance(owner, email, amount, attachments) {
  await mailer.send({
    to: email,
    subject: `Owner Distribution ${attachments.period}`,
    text: `We processed your distribution of $${amount.toFixed(2)} for ${attachments.period}. See attached packet.`,
    attachments: [{ filename: attachments.filename, content: attachments.buffer }]
  });
}

Key gotcha: idempotency. Tag every distribution batch with a period label and a content hash. If a run with the same label and hash is attempted again, abort the payment file and only allow packet regeneration.

6) Log, reconcile, and re-run safely

Answer first: write a concise reconciliation log and allow scoped reruns without double-paying.

async function finalize(period, snapshotHash, allocations) {
  await db.insert("dist_ledger", {
    period: period.label,
    inputs_hash: snapshotHash,
    owners_json: JSON.stringify(allocations.owners),
    total: Object.values(allocations.owners).reduce((a,b) => a+b, 0)
  });
}
 
function canRerun(period, snapshotHash) {
  const prior = db.selectOne("select * from dist_ledger where period = ?", [period.label]);
  if (!prior) return true; // allowed: first run
  return prior.inputs_hash !== snapshotHash; // disallow duplicate payments on identical inputs
}

Key gotcha: partial reruns. Allow regenerating packets for a subset of owners without touching the payment file. Your ledger should track per-owner sent state and the batch payment reference.

Where it gets complicated

  • Cash vs accrual and close timing: distributions paid on a cash basis but sourced from accrual statements cause confusion. We lock the period window explicitly and note the basis on the cover letter.
  • Fund-wide vs property-filtered data: some summary reports are not filterable by property in AppFolio. We rely on owner statements and ledger-level snapshots for investor-specific math, then attach any fund-wide statements as supporting docs.
  • Mid-period ownership changes: apply effective-dated schedules. The allocation engine selects the schedule in effect at period close and records the schedule version in the log.
  • Reserve floors and replenishments: reserves often live per property, not per owner. We compute replenishments before the waterfall across the owning portfolio so reserves stay healthy without surprising LP line items.
  • Duplicate protection: backfills and reruns can double-pay if you do not tag batch IDs and hash inputs. We gate payment generation behind a unique period label plus snapshot hash and allow packet-only reruns.

What this actually changes

For a multifamily operator, distribution day shifted from a spreadsheet marathon to a one-hour review. Finance set rules once, then used the dashboard to run a locked period. We observed clean per-owner packets, a bank file that reconciled to the penny, and no duplicate payments across reruns.

One reason the payments side is practical: the ACH Network is the de facto standard for business payouts. In 2023 it processed 31.5 billion payments, a figure published by Nacha, which underscores why a bank-ready output is a durable pattern for operator workflows. Source: https://www.nacha.org/news/ach-network-closes-2023-with-31-5-billion-payments/

Frequently asked questions

Does AppFolio expose everything needed for owner distributions?

AppFolio exposes much of what you need, but some financial summaries are not filterable by property. We bridge this by snapshotting the right mix of statements and ledgers, then doing investor-level math outside AppFolio. Where an export is not available via API, we schedule a report export and treat it as an input.

Can this handle preferred returns and tiered waterfalls?

Yes. We model preferred returns as a first tier, then split remaining distributable cash by configured percentages. All rules live in versioned config so you can change rates over time without code edits, and the engine logs which rule version applied each period.

What about reserve floors and property-level cash constraints?

Reserve policies run before the waterfall. We compute top-ups to meet per-property floors from current-period cash, then allocate the remainder. If the portfolio is cash-constrained, the system records an unmet reserve delta and carries it forward for visibility.

How do you prevent duplicate payments if we need to rerun?

We treat payment generation as a one-way gate. Each batch carries a period label and an inputs hash. Identical inputs cannot produce a second payment file. Packet regeneration is allowed, and partial owner reruns are scoped to documents only.

Can a non-developer on the finance team operate it?

Day-to-day, yes. The rules live in JSON or a structured admin form. Finance triggers the run after close, reviews a reconciliation page, and clicks Send. Engineering maintains integrations and makes sure source data stays healthy across AppFolio updates.

What does this cost monthly to operate?

There is no heavy infrastructure. Hosting and email are inexpensive at this scale. The main cost is the initial build and occasional maintenance to reflect changes in rules or report formats. Most teams see time savings immediately on the first live period.

If you are already running AppFolio and want owner distributions to be a one-click job, we have shipped this exact pattern. See how we approach statements in our related post on Automate AppFolio Owner Statements, or explore our document automation services. 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

Related reading