How to Generate Monthly Owner Statements as CSV and Email Them to Landlords
Build a Spojit workflow that runs on the first of every month, pulls each managed property's rent, fees, and expenses from your property-management ledger, nets every owner's balance, builds a per-owner CSV statement, and emails each landlord their statement as an attachment.
What This Integration Does
Property managers owe every landlord a clear monthly accounting: rent collected, management and maintenance fees deducted, repair and supplier expenses charged, and the net amount remitted. Doing this by hand across dozens of properties means exporting spreadsheets, filtering rows per owner, recalculating totals, and sending dozens of near-identical emails. This Spojit workflow removes the manual cycle: it reads straight from your ledger database, computes each owner's net balance, formats a tidy CSV statement, and delivers it to the right landlord with a short templated summary in the email body.
The workflow is started by a Schedule trigger on a monthly cron. When it fires, a Connector node on the mysql connector queries the ledger for the previous month's transactions grouped per property and per owner. A Loop node then iterates over each landlord; inside the loop the math connector nets that owner's balance, the csv connector turns their line items into a CSV statement, and a Send Email node sends it. The run leaves no state in Spojit beyond its execution-history entry, so each month is independent and re-running the workflow simply re-reads the same ledger rows and re-sends the statements.
Prerequisites
- A mysql connection to your property-management ledger database, configured under Connections. See Adding a New Connection if you have not added it yet.
- A ledger schema you can query: tables (or views) that hold rent received, management/maintenance fees, and expenses, each row tagged with a property id, an owner id, an owner name, and the owner's email address.
- Each landlord's email address must be on your organization allowlist under Settings -> General -> Email recipients so the Send Email node can deliver to them. See Configuring the Email Allowlist.
- The IANA timezone you bill in (for example
Australia/Sydney) so the schedule and the reporting month line up.
Step 1: Start the workflow with a monthly Schedule trigger
On a new workflow canvas, add a Trigger node and set Trigger Type to Schedule. The Schedule trigger uses a 5-field Unix cron expression plus a timezone. To run at 7am on the first day of every month, set the cron to:
0 7 1 * *
Set the timezone to your billing region, for example Australia/Sydney. The trigger output is { scheduledAt }, which you can reference downstream as {{ trigger.scheduledAt }} if you want to stamp the statement with the run date. For more on cron fields, see Setting Up a Schedule Trigger.
Step 2: Pull last month's ledger rows with mysql execute-query
Add a Connector node, choose the mysql connector, and set it to Direct mode with the execute-query tool. Direct mode is deterministic and costs no AI credits, which is what you want for a predictable read. Put a parameterized SQL statement in the query field that returns one row per owner line item for the previous calendar month. Use a query shaped like this:
SELECT o.owner_id,
o.owner_name,
o.owner_email,
p.property_ref,
l.category, -- 'rent', 'fee', or 'expense'
l.description,
l.amount,
l.txn_date
FROM ledger_entries l
JOIN properties p ON p.property_id = l.property_id
JOIN owners o ON o.owner_id = p.owner_id
WHERE l.txn_date >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-01')
AND l.txn_date < DATE_FORMAT(CURDATE(), '%Y-%m-01')
ORDER BY o.owner_id, l.txn_date;
Map this node's result to a variable such as ledger. Adjust table and column names to match your schema. If you prefer to pass the month range as bound values rather than computing it in SQL, supply them through the params field and use ? placeholders in query.
Step 3: Group the rows by owner
The query returns a flat list of line items, but you want to email each landlord once. Add a Transform node to reshape {{ ledger }} into one entry per owner, each carrying that owner's name, email, and the array of their line items. The shape you are aiming for is:
[
{
"ownerId": "1042",
"ownerName": "J. Patel",
"ownerEmail": "j.patel@example.com",
"lines": [
{ "property_ref": "12 Oak St", "category": "rent", "description": "March rent", "amount": 2400 },
{ "property_ref": "12 Oak St", "category": "fee", "description": "Mgmt fee 8%", "amount": -192 },
{ "property_ref": "12 Oak St", "category": "expense", "description": "Plumber", "amount": -150 }
]
}
]
Store the grouped result in a variable such as owners. Keep expense and fee amounts as negative numbers so the netting in the next step is a simple sum. For help building the expression, see Using Transform Nodes (Structured Mode), or ask Miraxa, the intelligent layer across your automation, to draft the reshape for you.
Step 4: Loop over each landlord
Add a Loop node in ForEach mode and point it at {{ owners }}. Every iteration exposes the current landlord as the loop item (for example {{ owner }}), so the remaining steps run once per owner. All three nodes below live inside the loop body so each statement is calculated, formatted, and sent for one landlord at a time before moving to the next. For loop mechanics, see Using Loop Nodes.
Step 5: Net the owner's balance with the math connector
Inside the loop, add a Connector node on the math connector in Direct mode using the sum tool. Pass the owner's amounts into the numbers field by plucking the amount from each line item, for example {{ owner.lines.*.amount }} (a list of numbers such as [2400, -192, -150]). The tool returns { result }, which is the net balance owed to that landlord. Map it to a variable such as netBalance.
If you need a derived figure rather than a straight total, use the calculate tool instead: put an expression in expression and supply named values through variables, for example:
expression: "rent - fees - expenses"
variables: { "rent": 2400, "fees": 192, "expenses": 150 }
That also returns { result }. Use whichever fits how your ledger categorizes amounts.
Step 6: Build the CSV statement with csv from-json
Still inside the loop, add a Connector node on the csv connector in Direct mode with the from-json tool. Feed the owner's line items into the data field (an array of objects, for example {{ owner.lines }}). Use the optional columns field to fix the column order and labels in the statement:
columns: ["property_ref", "category", "description", "amount"]
header: true
The tool returns { csv, rows, encoding }; map it to a variable such as statement. The statement.csv value is the CSV text you will attach to the email. If you want the net balance to appear as a final row in the file, append a totals object to data in your Transform step before this node so it flows through into the CSV.
Step 7: Email the statement to the landlord
As the last step inside the loop, add a Send Email node, which sends from Spojit's built-in mail service with no connection required. Configure it like this:
- Recipients:
{{ owner.ownerEmail }} - Subject:
Owner statement for {{ owner.ownerName }} - {{ trigger.scheduledAt }} - Body (templated plain text):
Hi {{ owner.ownerName }}, Your statement for the past month is attached as a CSV. Net amount due to you: {{ netBalance.result }} Thank you, Property Management - Attach the CSV by passing
{{ statement.csv }}as the attachment content with a filename such asowner-statement-{{ owner.ownerId }}.csv. - If sending fails: choose Continue anyway so one bad address does not stop the rest of the landlords from receiving their statements.
Each landlord receives only their own statement because the loop runs this node once per owner. For more on this node, see Using Send Email Nodes. To send from your own domain instead of Spojit's mail service, swap in the resend or smtp connector.
Tips
- Keep fee and expense amounts negative in the ledger query so the
sumtool nets the balance in one call; this avoids a separate subtraction step. - Run the
execute-querynode once on its own first and inspect the result in the execution log so you can confirm the column names line up with thecolumnsyou pass tofrom-json. - The Send Email node counts toward your monthly email allowance, and each loop iteration sends one email; for a large portfolio, confirm the allowance covers your number of landlords.
- Use Miraxa to scaffold the canvas quickly with a prompt such as "Add a Loop over
{{ owners }}, then inside it a math sum on{{ owner.lines.*.amount }}, a csv from-json, and a Send Email node," then fine-tune fields in the properties panel.
Common Pitfalls
- Timezone drift on the month boundary. If the Schedule trigger timezone and your ledger's transaction dates disagree, you can include or exclude a day's transactions. Match the trigger timezone to the timezone your ledger records dates in.
- Recipients not on the allowlist. External landlord addresses must be on the org email allowlist or delivery is blocked. Add them under Settings -> General -> Email recipients before going live.
- Empty owners produce empty CSVs. An owner with no transactions last month still loops; add a Condition node inside the loop to skip owners whose
{{ owner.lines }}is empty so they do not receive a blank statement. - Schema drift. If a column is renamed in your ledger, the query silently returns different keys and the CSV columns shift. Pin column names explicitly in both the SQL
SELECTand thefrom-jsoncolumnslist.
Testing
Before scheduling it for the whole portfolio, validate on a small scope. Temporarily narrow the execute-query SQL to a single owner (add AND o.owner_id = '1042'), and set the Recipients in the Send Email node to your own address. Trigger a run manually from the Run button, then open the execution log to confirm the netted balance from the sum tool, the CSV produced by from-json, and the email body all look right. Once the single-owner run is correct, remove the owner filter and point recipients back to {{ owner.ownerEmail }}, then enable the schedule.