Lalabase
Tickets, Doku, Zeiterfassung und Kundengespräche an einem Ort, durchsuchbar per KI. Für Softwarefirmen mit Kundenprojekten. Open Source: auf deinem eigenen Server oder in unserer Cloud in Deutschland.
翻译准备中
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证正在加载完整 README。 以下为已保存的摘录。
A profit tracker for rental properties: log rent payments and repair/expense
costs per property, and see rent collected, expenses paid, and net profit at
a glance. Tenants get their own login to report a problem and watch it get
fixed. Ships with email/password login, so each account only sees its own
data — safe to deploy publicly on Vercel.
• Next.js (App Router) — pages and API routes
• NextAuth (Auth.js) v4 — email/password authentication, session cookies
• Prisma + PostgreSQL — users, properties, tenants, repairs, transactions
• Vercel Blob (optional) — proof photos and receipts查看完整 README ↗A profit tracker for rental properties: log rent payments and repair/expense costs per property, and see rent collected, expenses paid, and net profit at a glance. Tenants get their own login to report a problem and watch it get fixed. Ships with email/password login, so each account only sees its own data — safe to deploy publicly on Vercel.
npm install
.env.example to .env and fill in:
DATABASE_URL — your Postgres connection stringNEXTAUTH_SECRET — generate with openssl rand -base64 32NEXTAUTH_URL — leave as http://localhost:3000 for local devNEXTAUTH_SECRET must be at least 32 characters; a production build refuses to start with a short or placeholder one.SIGNUPS=invite-only; then a SIGNUP_CODE you choose, or a join code from an existing team, is needed.npx prisma migrate deploy
npm run dev
http://localhost:3000 — you'll land on /signup to create the first account.Run the tests with:
npm test
They cover the parts where a quiet mistake costs money: how late rent is counted, what a place rented for in a given month, how amounts are rounded, and how the tax CSV is escaped. No test framework is installed — they run on Node's own test runner in about a second.
Import this repository into Vercel.
Add a Postgres database from the project's Storage tab (or connect an external one like Neon/Supabase) — this sets DATABASE_URL automatically, or you can set it yourself under Settings → Environment Variables.
Add a private Blob store from the same Storage tab if you want receipts,
photos and documents (see below) — the app reads its token as
PRIVATE_BLOB_READ_WRITE_TOKEN.
Add the other environment variables:
NEXTAUTH_SECRET — same as aboveNEXTAUTH_URL — your deployment URL, e.g. https://your-app.vercel.appSIGNUPS=invite-only and SIGNUP_CODE — only if you want to close public signupsLeave a variable out entirely rather than saving it blank — an empty value is not the same as unset and can break the build.
Deploy. The build runs migrations (prisma migrate deploy) before building,
so the tables are created or updated automatically without destroying data.
One account runs the site. [email protected] is the first admin: on a
fresh site the account that signs up with that address starts as admin —
only while the site has no admin at all — and the migration flagged the
existing one. After that, admin comes from another admin and from nowhere
else: signups are open and addresses aren't verified, so an address on its
own must never be enough. Admins get an Admin tab; for everyone else
that page, and every /api/admin route, is a 404.
The panel lists every account and every LLC, with a search box across both, and lets an admin:
Every one of these is written to a log, shown at the bottom of the panel: who did what, to which account or LLC, when. Emails in the log are frozen text, because the account they name may be the one that was deleted.
ADMIN_EMAILS (comma-separated) replaces the built-in owner address for
that first-admin moment. Links the panel makes (a password reset link) are
built on SITE_URL, or the site's own domain when that's unset — never on
the request, whose Host header is whatever the sender says.
Properties belong to the LLC that owns them, not directly to a person. Each LLC has its own team, so a partner in one LLC sees only that LLC's houses, ledger, and profit — nothing from your other LLCs.
K7P2-M9X4) and sends it however they like. The other person signs in,
enters it under "Join with a code" on the dashboard, and lands on that LLC.
Each code works once and expires after 7 days.SIGNUPS=invite-only
closes it again, and a valid join code still gets through then.Each property — or each unit, in a building that has them — can have a tenant on file: name, phone, email, lease start and end, security deposit, and the day of the month their rent is due.
That due day is what makes the dashboard able to say "17 days late" rather than just "unpaid", and the phone number puts Call and Text next to the amount owed, which is the whole point of storing it. A lease ending within 60 days — or one that has already run out — shows up in "Needs attention" alongside the unpaid rent, because a lease nobody noticed expiring is a vacancy waiting to happen.
Someone who moves out is kept as a past tenant rather than deleted, so their history and their old ledger entries still make sense and the next tenant is a new record rather than an overwrite.
An LLC can charge late fees automatically, the same way for every tenant, from the Team page (the LLC's Late fees panel):
Residential: If rent is still owed 5 days after the due day, a late fee of 7% of that month's rent is charged, then $5 a day until it's paid, up to 10% of that month's rent ($100 on $1,000).
Residential or commercial. The top of the panel says what kind of leases the LLC has — This LLC's leases: Residential / Commercial — and every tenant of that LLC follows it; there is no per-tenant lease type. It sets the cap the numbers start from: 10% of the month's rent for residential, 12% for commercial (so "High Acres", commercial, caps a $4,570 unit at $548.40; "Nejad", residential, caps $1,000 rent at $100). Switching the choice fills in that type's cap; the owner can still change it, and nothing changes until Save. A new LLC starts residential: 7% after 5 days' grace, then $5 a day, up to 10%.
The four numbers — grace days, the one-time percentage, the daily amount and the cap — are the LLC's, and the page reads the policy back in a sentence as you type. It is off until an owner switches it on.
Kentucky. Kentucky law (including the landlord-tenant act Lexington has adopted) sets no maximum late fee; a late fee can be charged only if the lease provides for it, and it should be reasonable. Put the fee — grace period, the one-time amount, the daily amount and the cap — in the lease itself, and keep the numbers here the same as the lease. This app charges what you enter; it doesn't check your leases. (Not legal advice.)
Lowering a cap (or switching an LLC from commercial to residential) never adds a fee and never deletes one. A month whose fees are already at or above the new cap simply gets no more, and Run late fees now lists it: "Alan D Ward — $150 in late fees for September 2026 is above the new 10% cap ($100). No more will be added; delete the extra on their statement if you want it gone." Whether to give back the extra is the landlord's decision.
Upgrading (migration a22). Every LLC's saved policy becomes residential and keeps its numbers exactly, with one exception: a policy still at the untouched old defaults — 5 days, 7%, $5/day, cap 12%, on or off — was never chosen by anyone, so its cap becomes the new residential 10%. Any other saved numbers, including a 12% cap saved alongside different numbers, are left alone. An LLC that should be commercial is switched on its panel, which suggests 12%.
Switching it on when rent is already overdue. Saving applies the policy at once to every tenant of the LLC and lists, tenant by tenant, what was charged or exactly why not (paid up, still in the grace period, set to no late fees, on their own rules, at the month's cap, …). Rent already overdue that day — this month's, or an earlier month's that is still unpaid — gets the one-time 7% fee that day. Daily fees count only from the next day; the days before the policy existed are never backfilled. "Still unpaid" means after later payments are set against the oldest rent first, so a tenant who carried a shortfall but has since caught up isn't charged for it. The 12% cap per month holds regardless. Run late fees now does the same thing at any time and never charges twice. (The examples here use the commercial 12% cap; a residential LLC stops at 10%.)
Each tenant's account then says which late fees apply to them, under What bills itself: the LLC's policy (the default), their own late rules with different numbers, or no late fees at all. Rules for every month — a lot fee, pet rent — apply whichever is chosen. A tenant's own late rule can carry the same daily amount and cap as the policy.
How it accrues, on $1,000 rent due on the 1st with the policy above: nothing through the 5th; $70 on the 6th; $5 on each of the 7th, 8th, 9th… until the month's rent is paid or the fees reach $120, whichever comes first (ten daily fees). The daily amount stops the day the rent is paid, even if the fees themselves are still open. A partial payment doesn't reset anything — the daily fee carries on against what's left — and no fee is ever bigger than what's still owed, so a $3 shortfall accrues $3 a day, not $5. A month with no rent (a vacancy, a tenant who has moved out) accrues nothing, and the percentage is of that month's rent, from the rent history, so a raise in September changes September's fee and not August's. No single automatic charge can exceed $2,000, whatever the numbers say.
Every fee is an ordinary charge on the tenant's account — "Late fee", "Late fee (Sep 8)" — that you can see and delete, and the tenant sees it on their portal. Deleting one is final: the app remembers that the day was charged and never charges it again, and a deleted fee still counts toward the month's cap. The fees are applied when the policy is saved, by Run late fees now, by the daily job at 13:00 UTC for every LLC whose policy is on (whether or not its reminders are), and whenever a tenant's statement or portal is opened. Every one of those writes each fee once. The overview's Needs attention shows the month's late fees in what a tenant owes, and Mark paid records the full amount. Rent-late reminders say what was added ("A $70 late fee was added; $5/day more until paid, up to $120.").
Waiving a month's late fee. When you record rent (+ Rent on the property page, or + Record on the overview) or edit a rent entry, tick Waive late fee for this month. The box only shows when the entry belongs to one current tenant, and the month is the entry's date's. Saving deletes every late fee already on that tenant's month (the one-time fee and each daily one, from the policy or their own late rule) and no late fee is ever added to it again — not by the daily job, Run late fees now, opening the statement or portal, or the late-fee status check. It covers that one tenant and that one month only: the LLC's policy and the tenant's late-fee setting don't change, their other unpaid months keep their fees, next month's rent is charged as normal, and other tenants are untouched. Anyone on the LLC's team can do it; who and when is recorded. The statement shows "Late fee waived by …" on that month's row, the tenant's portal shows "Late fee waived", and the overview shows it on the property card and in Needs attention. Rent-late reminders stop mentioning a fee for that month.
To take it back, untick the box on the rent entry or press Remove waiver on the statement. Late fees then start again from that day, as if the policy had been switched on that day: the one-time fee comes back that day only if rent for the month is still owed (and never more than what's owed); $5 a day runs from the next day; the waived days are never billed; and the month's 12% cap counts only fees charged since. Waiving again removes those too.
Backups carry the policy (with the LLC's lease type; a backup from before lease types restores as residential with its numbers as saved), each tenant's choice, every day already charged, and every waiver (with the day it was taken back, if it was), so a restore neither forgets a fee, bills it twice, nor revives a waived one.
Move out on a tenant's card ends the tenancy properly rather than just flipping them to past:
The empty place. A move-out that leaves nobody living there marks the place vacant, from the day rent stops — the later of the day they left and the end of the last month they were charged for, so a vacancy never counts days someone paid for. The dashboard then lists it under Needs attention with how long it's stood empty and the rent it has gone without, prorated by the day at what the place was asking each month. A vacancy is the costliest thing on a rent roll and the only one that never shows up in the ledger, because nothing happens; this is what puts it next to the late rent. Adding the next tenant ends it. Marking a place vacant by hand on its edit form asks when it went empty; places marked vacant before this existed have no date and are shown as vacant without a figure rather than given an invented one.
Undo move-out takes all of it back: the income and charges come out and they're current again. It's refused if someone else now lives there. The income and charges can't be deleted on their own from the ledger or the statement, for the same reason as a mortgage payment's parts.
A deposit deduction is only as good as the evidence for it. "The carpet was ruined" is an argument; "the carpet was Good on the move-in report the tenant signed, and here it is now" isn't. Each tenant's card has + Move-in inspection (and, once there is one or they've moved out, + Move-out inspection), which opens a checklist of rooms and items: entry and living room, kitchen, bathroom, bedroom and the whole place (smoke and CO detectors, heating, water heater, keys). Rename or remove rooms, add your own lines ("Bedroom 2 · Ceiling fan"), and for each line pick Good / Fair / Poor / Damaged / N/A, add a note and photos. Everything saves as you tap, so a walk-through done on a phone survives a dropped connection; rapid taps on the same line always end on the last one. Photos go to private storage and open only for your team and, once shared, that tenant.
Share with tenant puts it in their portal and says so in their Messages thread (which emails and pushes them). They read it room by room, write anything they see differently, and acknowledge it by typing their name. That signature, the moment and their note are kept with the report, their note also lands in Messages — and from then on the report can't be changed by anyone: every edit is refused, and only an owner can delete it. A tenant without a portal login (or one who has already moved out and lost it) signs the Printable report on paper instead.
A move-out inspection starts from the tenant's move-in, line for line, and shows each line's move-in condition, note and photos beside it. Lines that got worse are tinted and listed at the top — "Kitchen · Floors: Good → Damaged" — which is exactly what a deposit deduction rests on. The printable move-out report has Move-in and Now columns side by side.
Move out then offers each of those lines as a deduction, worded for the itemized letter ("Kitchen, floors: damaged (good at move-in)"): tap the ones you're charging for and put in what each cost.
Inspections, their photos and signatures are in backups.
A vacancy is the costliest line on a rent roll, and the app used to stop at saying what one was costing. Now an empty place in Needs attention has List it, and a property with an empty place shows For rent above its tenants.
A listing is the headline, rent, deposit, the date it's free, beds, baths,
square feet, pets, a description, and photos picked from the property's
filing cabinet (anything filed there as a Photo — upload or scan it first).
It gets a link, /rent/<code>, to paste wherever you advertise. The code is
ten random characters, so the page is seen by the people it's sent to; it
asks search engines not to index it.
The page shows the place and an application: name, email, phone, move-in date, how many people, pets, monthly income and its source, where they live now and their current landlord, a note, and a box confirming it's true and that references may be contacted. It asks nothing a fair-housing rule forbids — no age, family status, religion, origin or disability — and no Social Security number or date of birth, which have no business in a web form; screening, if any, is a separate step of your own. An Equal Housing Opportunity line closes the page.
Every application lands in Listings (/dashboard/listings), emails and
pushes the LLC's team once, and shows on the overview as the vacancy's
application count. Each one shows income as a multiple of the rent ("4.2×
rent"), with call and email buttons. Reviewing, Decline and
Approve… move it along. Approving asks for the lease dates (from the
later of today, the date it's free and the move-in they asked for, for a
year), deposit and due day, then makes them the tenant — exactly as adding
one by hand does, so the vacancy ends — and writes the listing's rent into
the rent history from the month the lease starts if it differs from what the
place was asking. The listing closes; its page then says it's taken, and its
photos stop being served. Other applicants are left for you to answer.
The public form is throttled: six applications from one address in a quarter of an hour, then a pause; a hidden field catches bots, which are told it worked and stored nothing. Photos are served only while the listing is open, only if the listing names them, of that property, and only as images.
Applications are not in backups — they are strangers' personal details, and a backup file gets emailed around; an approved applicant is in the backup as a tenant. Owners can delete an application, or a listing with all of its applications, for good.
A tenant can be given a login of their own at /portal. From their card on a
property page, Invite to the portal issues a code (HKTM-9R4P) that you
send them however you already talk to them. They redeem it at
/portal/signup, pick a password, and they're in.
A code names one tenant, is single use, expires in 14 days, and is retired the moment you issue a replacement. There is no form anywhere that lets someone choose which unit they live in — the code decides, which is why self-signup isn't possible.
Signed in, a tenant sees their own place, their lease, their deposit, and nothing else. Not your other properties, not your ledger, not your contact details. Remove takes the login away without touching their lease, deposit or ledger history; Forgot password kills the old login and hands you a fresh code, and everything they ever reported is still there when they sign back in.
Forgot your password? on the sign-in page asks for the email and, when the site can send email, sends a one-time link to set a new one. The answer on screen is the same whether or not the address has an account, so the form can't be used to find out which emails are signed up.
A link lives an hour, works once, and asking for another retires the old. Only its hash is stored, so a copy of the database can't be turned into a way in. Using it signs the account out everywhere — the usual reason for a reset is not being sure who has the old password — and it never gets past two-factor: someone with the link and without the phone still can't sign in. An admin can turn two-factor off from the Admin page if the phone is really gone.
Asking is limited per address as well as per requester, and a link younger than five minutes is left standing, so a stranger who knows the address can't flood it with mail or keep killing the link its owner is about to click. A password change or a sign-out-everywhere kills any outstanding link too.
Email needs RESEND_API_KEY and EMAIL_FROM set (Resend has a free tier;
the sender must be on a domain verified there). Without them the page says
so and points at the admin, who can make a reset link for anyone from the
Admin page and send it however they already talk to that person. Both
routes are the same link; only who hands it over differs.
Ten wrong passwords for one account inside fifteen minutes pauses that account for fifteen minutes; forty wrong passwords from one address inside fifteen minutes, across any accounts, pauses the address. A right password clears the account's count. Both signup forms count a bad code against the address the same way, so a short code can't be walked through.
The pause is enforced inside authorize(), where it can't be skipped. The
login page separately asks /api/auth/lock-status whether a refusal was a
pause, so someone typing their right password sees "Try again in 12
minutes" rather than being told it's wrong. A miss on an email that has no
account is counted too, so the counts can't be used to learn which emails
exist.
Pausing by account is a deliberate trade: someone who knows your email can pause your sign-in on purpose. The pause is short for exactly that reason, and the door stays shut to guessing meanwhile, which is the outcome that matters. A stuck tenant can be handed a fresh code from their card.
User is the landlord side: a User belongs to companies, and every query in
the app reaches data by walking user → company → property. A tenant must never
touch that walk, so they get TenantAccount, their own credentials provider,
and an explicit kind on the session token. getCurrentUserId() fails
closed — anything that isn't literally kind: "user" comes back null — and
proxy.ts blocks each side from the other's routes before a request reaches
any handler.
A role column on a shared User table would put the two one missed if
apart. This way there is no check to miss, because the landlord queries never
join to the tenant table at all.
A property owner or investor who is not on your team — someone who put
money into a house and wants to see how it's doing — can be given a
read-only login of their own at /owners. From Team → Property owners
(/dashboard/owners), an owner of the LLC types their email, ticks the
properties they may see, and sends the invite. The link goes by email when
the site can send it; either way it's shown on screen to copy and send
yourself. It works once, for 14 days, and Resend retires it for a fresh
one. Only the hash of the link's token is stored, like a password reset.
The invitee sets a name and password at /owners/accept and lands on the
portal. Someone who already has an owner login (another LLC invited them,
or you're adding properties) enters that password instead, and the new
properties join what they had. You can Edit which of the LLC's
properties each owner sees at any time, and Remove takes the access
away — and the login with it, if that leaves them nothing anywhere.
Signed in, an owner sees, for their properties only:
lib/pdf-text.ts, base-14 Helvetica, no dependencies).Owners are PropertyOwner rows — a third table, a third credentials
provider ("owner") and a third session kind, for the same reason
tenants are separate: the landlord's queries never join to it. proxy.ts
keeps /owners and /api/owners to owner sessions and everything else
away from them; requireOwnerSession() resolves the session into a list of
property ids and every owner query filters by that list. What an owner may
know about a tenant is one pure function, occupantForOwner in
lib/owners.ts, with a test that pins its shape. Sign-in throttling and
trusted devices work as for the other two doors. Backups carry owners by
email with their properties by position; a restored owner has no password
and is re-invited from the Property owners page.
The owner sign-in has a Forgot your password? link (/owners/forgot)
that emails a reset link through the same Resend setup as the landlord's,
with the same rules: the link works once, lasts an hour, only its hash is
stored, asking is rate-limited per address and per email, a link younger
than five minutes is left standing, and the page answers the same way
whether or not the email has an owner login. The link opens
/owners/reset and, once used, sends them back to the owner sign-in;
setting the new password signs the owner out everywhere else.
Owner links live in their own table (OwnerPasswordReset) rather than the
landlord's PasswordReset, whose rows are tied to landlord accounts: an
owner's link can only ever reset an owner's password, and a landlord's
link does nothing at /owners/reset.
Under Team → Property owners, Send password reset next to an owner emails them a link (a few times an hour at most). When the site can't send email the link is shown on screen to copy and hand over, as invite links are. Only an owner of the LLC can use it, and only for owners of that LLC's properties.
A tenant reports what's wrong, where it is, how urgent, and up to six photos. Categories cover storefronts as well as houses — parking lot, signage, common area — because a laundromat's problems aren't a duplex's.
Each status reads differently on the two sides of the same row. The tenant sees "Your landlord has seen this"; you see "Seen". Replies and status changes land in one timeline rather than a conversation beside a history, because the tenant is asking one question: is anyone doing anything.
The Repairs tab carries a count of what's waiting, on every page. Open repairs also head up "Needs attention" on the Overview, above unpaid rent — a tenant with no hot water outranks a late cheque — and each property page lists its own.
Log what it cost books a finished repair into the ledger against the right property and unit, marks it done, and records the transaction id so the same repair can't be booked twice. Your tenant sees that it was fixed and never what it cost.
A tenant replying to a closed report reopens it, because the alternative is a reply nobody is looking at.
Urgency is two buttons, not a dropdown: how hard it is to claim urgency decides whether the word keeps any meaning. A line that stays on screen whether or not the form is open says fire, a gas smell, or anyone in danger — call 911, and the form itself says to call the LLC's line rather than type for no heat, no water or a lock that won't open. A web form is not an emergency line and shouldn't pretend to be.
Each tenant has one conversation with the company that manages their place — not with a person. On the dashboard it's the Messages tab: an inbox of every tenant you've written with, newest activity first, with a count of what you haven't read, and a thread page per tenant with a composer. Every tenant card on a property page carries a Messages (n unread) link to that thread, so you can write to someone before they have a phone number on file or a portal login. In the portal it's the Messages card under the notices, with a badge in the bar that jumps to it.
A message is text (up to 4,000 characters) plus up to four photos or PDFs.
Files are checked by their bytes, capped at 4 MB each, kept in private
storage, and served only through /api/files/message/<id> after the same
check as every other file: the LLC's team, or the tenant whose thread it's
in. Phone photos are shrunk in the browser before they're sent.
Everything from your side is signed with the company's name in the portal, never a team member's name or email — the same rule as repair replies. On the dashboard you see who on the team wrote each line.
Read means read, not delivered. Each thread keeps two stamps,
tenantReadAt and landlordReadAt; a message from the other side newer than
your stamp is unread. The stamp moves only after the thread has actually been
on screen for a couple of seconds with the tab in front, so a page you open
and leave doesn't count. One stamp serves the whole team: a tenant is talking
to the company, and one of you reading it is the company reading it.
A new message emails and pushes the other side — the tenant, or every member of the team — through the same once-only log the reminders use, with the tenant's own reminder switches respected. It's batched: at most one notification per thread, per side, per half hour, and it says how many messages are waiting. Thirty lines in a row cost the other person one buzz.
Tenants can only ever reach their own thread: every portal route resolves the thread from the session, never from an id in the request. A tenant may send thirty messages an hour. Backups carry each tenant's conversation, with the read stamps and links to the attachments, the way they carry receipts.
Rent goes up. The app records what a place rented for and from when, so raising the rent doesn't re-judge months already on the books.
Without that, putting the rent up from $1,450 to $1,550 would make every month a tenant had paid $1,450 read as $100 short — and the bulk "Mark all paid" would have offered to collect the difference. Instead, each month is measured against whatever the rent was that month.
Nothing is asked of you: edit the rent as usual and the change is recorded from the current month (two edits in one month leave one entry, the later). If the old amount was simply typed wrong (a $5,470 lease entered as $4,570), tick "… was a typo — fix past months too" under the new rent when you save it. Every month that used the wrong figure then uses the right one, so months already paid in full stop reading as overpaid; earlier rents that really were different are left alone. A raise agreed ahead of time goes in through Renew lease, from the month it starts — see Renewing a lease. The property page shows the trail beside the rent ("since Sep 2026 · $1,450 at first") so you can see what the app is using.
A lease coming up for renewal is in Needs attention with a Renew button; any current tenant's card has Renew lease too. The form starts from their lease: another year on the end date, and the new rent from the month after the current lease ends. +3% and +5% fill in a raise rounded to $5, and the form says what it comes to ("+$75 a month · +$900 a year · +5.2%") and the date written notice should go out by — thirty days before the new rent starts, with a warning when that date has passed. (Many leases and local rules ask for at least that; check yours. Not legal advice.)
The point is the rent, which used to have nowhere to go: a raise agreed in October for January could only be entered by editing the rent, which applies it from the current month — so either the tenant was charged three months early or someone had to remember to come back on January 1st. A renewal writes the new rent into the rent history from the month it starts. Every month is read from that history — the overview, late fees, statements, the calendar, bank-import matching — so January is charged the new rent and December the old, whenever anyone next opens the app. The place's current rent (what the property card shows) moves to the new figure when its month arrives: the daily job does it, and so does opening the overview, a property, the calendar or the owner portal, in case the job isn't set up. The property's rent trail shows what's coming ("$1,525 from Jan 2027 · since …").
Each renewal is kept. The card shows it — "Renewed to Nov 30, 2027 · $1,525 from Dec 2026" — with:
Renewing never changes the lease start, so a tenant's statement starts where it always did. A raise from the current month is in force at once, as an edit to the rent would be. Backups carry each renewal and whether a rent change is still waiting for its month.
Typing every rent payment and bill in by hand is most of the work of keeping
books, and the bank already has all of it. Import from bank (on the
overview, or /dashboard/import) takes the CSV that any bank or card
account exports — Chase, Bank of America, Wells Fargo, Capital One and the
rest each write a different file; the importer finds the header (or works
without one, as Wells Fargo's has none), the date, description and amount
columns, signed amounts or separate debit/credit columns, and US date
formats. If it can't tell, it shows the first lines and asks which column is
which. A card statement that prints charges as positive has a Money in is
shown as negative switch.
The file is read in the browser. The server sees each line's date, amount and description only to suggest what it is, and stores only the lines you choose to import — personal spending on the same account stays out of the books.
Every line arrives with a suggestion and the reason for it:
A mortgage payment — the loan's payment to the cent, with or without escrow — is never filed as one expense. It's recorded on its loan exactly as the loan's own form records it: the month it pays (the one whose due date is nearest, so a payment sent August 28th for a loan due on the 1st is September's), split from the balance at the time into interest, escrow for tax and insurance, and principal, with interest and escrow in the ledger under their own Schedule E lines. Two months in one statement split in order. A month the loan already has, or one before its books begin, is skipped; a line with the lender's name but an amount that doesn't fit is left for the loan's form and the lender's own figures.
Two things are suggested as Skip, never import, because each would put a wrong number in the books: a line already in the ledger (the same amount within four days, typed in by hand — each ledger entry answers for one line, so two identical payments are still two); and a security deposit (a tenant's deposit amount near the start of their lease — money held isn't income). Transfers between your own accounts are skipped too. Anything can be overridden.
Worth a look filters to the guesses and anything unmatched. Filing a line offers to file the other lines from the same payee the same way. The bar at the bottom totals what will be imported, and Import writes it all at once.
Each imported entry keeps the bank's words (bankText) — that's what the
next import learns from — and a stable id for its statement line
(bankRef), so uploading the same file twice, or an export that overlaps
last month's, never books a line twice; two imports landing at the same
moment can't either. Imported entries are ordinary ledger entries: edit or
delete them like any other. Backups carry both fields, so a restored LLC
still knows which lines it has. The matching is lib/bank-match.ts, the CSV
reading lib/bank-csv.ts, both pure and covered by tests.
The ledger says what a place made; the Returns tab (and the Investment section on each property page) says what that's worth to you as an investor — the figures a lender, a partner or a buyer asks for.
Each property needs three numbers only you know, entered under Investment → Purchase: what you paid, the day you bought it, and the cash you put in (down payment, closing costs and any work before the first tenant — not what the mortgage paid for). Any of them can be left blank; each figure that needs one says so. From time to time, + Update value records what the place is worth on a day — an appraisal, a broker's opinion, an online estimate, noted as such. The latest one is the value; older ones stay as its history, each with its change from the one before. Until there is one, the purchase price stands in.
| Figure | How it's worked out |
|---|---|
| Equity | Value − principal still owed on the active mortgages entered on the property |
| Net operating income | Rent − operating expenses over the twelve months ending this month (the property page's months). Mortgage interest is financing, so it's left out; property tax and insurance are in |
| Cap rate | NOI ÷ value — what it earns as if bought for cash |
| Cash flow | NOI − mortgage interest − principal: what was actually left |
| Cash-on-cash | Cash flow ÷ cash invested |
| Total return | Every dollar of cash flow since the books began, plus equity over the cash put in — what selling today would come to, before selling costs and tax |
| IRR | The same as a yearly rate that weighs when each dollar moved; shown after a year of ownership |
Twelve-month figures count each month's share of a spread entry, exactly as
the property page does, so the two never disagree. A place owned for less
than a year is scaled up from the months owned (marked *), and shows no
rate at all until it has three. Since-purchase figures go by the date money
moved. When the ledger starts well after the purchase, the page says so: the
earlier cash flow isn't in the books, so the total return and IRR run low.
Across properties, rates are worked out from the totals — never averaged — and only from properties that have both halves, so a house with no value entered can't drag the portfolio's cap rate toward zero.
Members can enter and correct purchase details and values, like any other property detail; viewers see the figures. None of it touches the ledger or the tax export, and all of it is in backups.
The dashboard opens on the current month: the totals, the charts, the per-property figures, and the ledger all cover that period, and the arrows page back through earlier ones. The switcher next to them changes the unit:
Whatever the period, "Needs attention" and the rent-roll meter always describe one month, because chasing rent is a monthly job.
The year ahead. The cash-flow chart switches between Past year and Year ahead. The year ahead is a forecast, drawn hatched so it's never mistaken for the books, built from what the app already knows: each let place's rent from the rent history (so a raise renewed for January is in January), nothing for a place marked vacant; recurring bills in their months, a yearly one — property tax, insurance — in its one month; each mortgage's payment month by month until it's paid off, skipping months already paid ahead, the last one only what's left; and, for everything else, the average of what was spent in the last twelve months outside recurring bills and mortgage payments, labelled as such — a forecast that assumed nothing breaks would be a promise. Under the chart:
It follows the LLC chips like everything else on the overview.
Searching. The ledger's search box deliberately looks across every month, not the period on screen — being told there's nothing in September when the invoice was in March is the opposite of useful. It matches the property, description, note, category, date and amount, and shows what the matches total. Long ledgers render 60 rows at a time with a "Show more".
Units. A property can be split into units on its own page — a duplex, triplex, or any building with more than one tenant. Each unit gets its own rent target and its own progress bar. Leave a property with no units and it's tracked as a single house, exactly as before.
Expense categories. Every expense now picks a category (Repairs, Insurance, Property Tax, Mortgage Interest, HOA, Utilities, Management Fees, Supplies, Legal & Professional, Other) — the same buckets a Schedule E uses.
Who hasn't paid. The dashboard lists any property or unit that's short on rent for the month you're viewing, with how much is owed. Mark a property or unit vacant (on its edit form) to leave it out of that list and hide its rent bar.
Recurring expenses. Set up an insurance, HOA or management payment once on a property's page — amount, category, and a monthly or yearly schedule. Nothing posts itself: when one is due, it shows up on the dashboard for that month with a one-click Log it button that creates the transaction and marks it done for that period.
Mortgages. A mortgage payment is three kinds of money under one number: interest (an expense), escrow for property tax and insurance (also expenses, on their own Schedule E lines), and principal (not an expense — it's equity). Logging the whole payment as one "Mortgage Interest" bill, which is what this app used to suggest, overstates the interest deduction by the principal and escrow every month, understates profit, and gives an interest figure that won't match the lender's Form 1098.
So a property can carry its loans under Mortgages, set up from the figures on the latest statement: the principal balance going into the next payment, the rate, the principal-and-interest payment, the monthly escrow, and the due day. Each payment is then split from the balance at the time — interest into the ledger as Mortgage Interest, escrow as Property Tax and Insurance, and principal only off the balance. The dashboard asks for each payment when it's due, with the split shown before you log it; the property page records one with the lender's own figures when they differ, and shows the balance, the payoff date, the interest still to come, and any month never recorded.
The interest and escrow entries a payment wrote belong to it: the ledger won't change their amount or delete one on its own, because the loan's balance and the books would then disagree. Undo on the payment removes them together. A new rate or escrow figure applies from the next payment; recorded ones keep their split. Removing a loan (owners only) keeps the ledger entries — that interest was really paid. If a property still has a recurring bill filed under Mortgage Interest, the loan panel offers to pause it so nothing is counted twice.
Amounts are worked in whole cents, and a 30-year schedule's rounding residual is folded into the final payment the way lenders do it.
Depreciation. The tax code lets a landlord deduct a rental building's cost over 27.5 years (39 for a commercial building like a storefront), and the same for improvements to it — a roof, a furnace, a remodel. It's usually the largest deduction on Schedule E and, because no money moves, no ledger of payments ever shows it. Each property page has a Depreciation section: add the building (its cost without the land, which never depreciates) and any improvements, with the month each was first ready to rent. The app works out each year's deduction by the IRS method for both classes — straight-line with the mid-month convention — and checks against Publication 946's table in the tests. Each year is the difference of two rounded running totals, so a schedule adds up to exactly its basis. It is deliberately not in the overview's profit, which is money in and out; it is on the tax export.
Mileage. Driving to a rental for its business — a repair, a showing, a
run for supplies — is deductible on Schedule E (line 6, auto and travel) at
the IRS standard mileage rate, and needs a log kept at the time: date,
where, why, how far. The Mileage page is that log. Pick the property
(it remembers how far the last trip there was), the miles there and back,
and what it was for; Again today logs any past trip again in one tap,
and each property page has Log a trip. The rate is the one in force on
the day of the trip — the IRS sometimes changes it mid-year (July 1, 2022,
and July 1, 2026: 72.5¢ to 76¢), so the table in lib/mileage.ts is by
date. A year whose rate isn't in that table yet uses the latest one and says
so. The year's deduction multiplies each rate's total miles once, per
property, and the LLC's is the sum of its properties'. No money moves, so
trips aren't in the ledger or the overview's profit; they're on the tax
export, below. The standard rate can't be used for a car whose actual costs
or depreciation are deducted; parking and tolls go in the ledger as
expenses. Trips are in backups.
Tax-year export. The Export page downloads a CSV for one LLC and one year — every transaction, a summary totalling rental income and each expense category, and, when the LLC owns more than one house, the same breakdown per property. Schedule E is filled in per property, so that last block is the one an accountant actually wants. Mileage adds an auto-and-travel line (Schedule E line 6), per property too, and a final block listing every trip with its rate and deduction — the log itself. A final block lists each mortgage's interest, escrow and principal for the year and its balance at year end — the interest line is the one to check against the lender's Form 1098. When anything is being depreciated, the summary gains a depreciation line (Schedule E line 18) and a net after depreciation, per property too, and a schedule of each asset's basis, class, year's deduction and total taken — the running figure an accountant carries forward and needs again at a sale.
1099-NEC review. Below the tax export, the same LLC and year show who the LLC owes a 1099-NEC: each vendor from the vendor book paid that year, from the expenses that name them, with the total, whether their W-9 is in the filing cabinet, and a status —
Each vendor's tax class is set on their card in the vendor book, from their W-9 (no tax ID is stored — it stays on the W-9). The deadline is January 31st, or the Monday after when that's a weekend, and the page says which. Download 1099 review (CSV) gives the accountant the list. What the app can't know is said rather than guessed: payments made by card or PayPal are reported by the processor on a 1099-K and should be left out, and this isn't tax advice. Backups carry the tax class.
Text going into the CSV is escaped so a note can't become a live formula in whoever's spreadsheet opens it. Amounts are left alone, so the columns still add up.
Every rent payment and repair can carry proof — a photo of a check or Venmo screenshot, a contractor's invoice, a receipt PDF. Attach files when recording the entry, or add them to any existing row later with "+ Attach proof". Thumbnails appear in the ledger and open the full file in a new tab.
The same works on each property page: the "+ Rent" and "+ Expense" forms (and Edit) have an Attach proof area, and every row in "Recent activity" shows a paperclip with the count — tap it for the thumbnails — plus "+ Attach proof". Up to 4 files per entry. On a phone there are two buttons: Take photo (opens the rear camera) and Choose photo or file (photo library, camera or Files — HEIC and PDF included). On a computer it's Choose files or drag the files onto the area.
Photos are shrunk in the browser before upload (long edge 1600px, JPEG), so a phone photo uploads quickly and stays well under the 4 MB per-file limit. PDFs upload as-is, and so does an iPhone HEIC photo on a browser that can't decode it (anything but Safari), as long as it's under 4 MB.
This needs a private Vercel Blob store:
PRIVATE_BLOB_READ_WRITE_TOKEN; if Vercel named it
something else, rename it and redeploy.Until a store is connected, the app works normally and uploads fail with a message saying to set this up — transactions still save either way.
Files never leave through their storage URL. Every receipt, photo and document
is linked as /api/files/<kind>/<id>, which checks the session first: the
landlord team for that property or LLC, the tenant who filed a repair photo,
or the tenant a document was shared with. Anyone else gets a 404.
Upgrading from the public store: files uploaded before the private store existed are still in the old public one, where the exact URL opens them. Go to Account → Stored files → Move old files once: it copies each one into the private store, re-points the app at the copy and deletes the original.
Backups include links to the files rather than the files themselves, so a restored backup re-links to the same files as long as the store still exists. Private links in a backup are signed for the account that exported it: the same account restoring its own backup gets its files back, but anyone else importing a copy of it does not.
Every document across your properties lives in one place — the Files tab —
and each property has its own drawer at /dashboard/properties/<id>/files
(there's a link at the top of the property's Documents section). Filter by
kind (lease, insurance certificate, tax, receipt, statement, notice, photo…)
or by property, search by name, tenant or note, open a file in a new tab,
download it, rename or refile it, and delete it (owners only — it's the only
copy). Documents filed against a tenant can still be shown on their portal.
Scan turns paper into a PDF with a phone. Tap Scan, take a photo of each page (or choose photos already taken), and each one is cleaned up in the browser before anything is uploaded:
Pages can be rotated, reordered and removed before saving. Saving builds the
PDF in the browser (lib/pdf.ts writes the file directly — each page is the
JPEG embedded as-is, no library) and files it against the property or tenant
you choose, under the kind you choose, named "Lease – 12 Oak St – Sep 28, 2026"
unless you type something else. Up to 20 pages a scan; a longer document goes
in as two. The picture arithmetic is in lib/scan.ts and the PDF writer in
lib/pdf.ts, both pure and covered by tests/scan.test.ts and
tests/pdf.test.ts. What the server receives is an ordinary PDF upload, with
the same type sniffing and size limit as any other document.
Rent Roll can do the chasing for you. Under Team → Automatic reminders
(or /dashboard/reminders) each LLC has a master switch and five reminder
types, each with its own on/off, timing and channels (email, phone
notification, or both):
Send test email and Send test notification send you a sample. "What's gone out" lists every attempt with its result.
Everything runs from one daily job, /api/cron/reminders, scheduled in
vercel.json at 13:00 UTC (early morning across the US, when the UTC date
and the local date agree). Vercel calls it with Authorization: Bearer $CRON_SECRET; set CRON_SECRET in the project's environment variables and
Vercel sends it automatically. Every send is claimed first in the
ReminderSent table under a key that names the thing being reminded about
(rent-due:<tenant>:<month>, lease-end:<tenant>:<date>:<60>, …), unique
per channel and recipient, so a rerun — or two runs landing together — never
messages anyone twice. Reminders are off for a company until an owner turns
them on.
Tenants have their own say: the portal's Reminders card lets them switch email or phone notifications off, and add their phone number. You can also switch email reminders off for a tenant from their card.
The site is an installable app, and an installed app can receive
notifications. Set three variables in Vercel (generate the keys once with
npx web-push generate-vapid-keys):
VAPID_PUBLIC_KEY=...
VAPID_PRIVATE_KEY=...
VAPID_SUBJECT=mailto:[email protected]
Without them push is quietly off and email still goes out. Each person
enables notifications per device — Reminders or Account in the
dashboard, the Reminders card in the portal — and can send themselves a
test. Subscriptions are stored per landlord login and per tenant login
(several devices each) in PushSubscription; one the push service reports
gone (404/410) is deleted. The service worker is public/sw.js; it only
shows notifications and opens the right page when one is tapped — no caching.
On an iPhone or iPad notifications need iOS 16.4 or later and only work
once the site is on the home screen: Safari → Share → Add to Home Screen →
open it from there → Enable notifications. On Android Chrome offers a
one-tap Install. A banner explains the right steps on any phone browser that
hasn't installed the app yet (dismissable for 30 days; never shown on a
desktop), and once installed it offers to turn notifications on. The
manifest carries regular and maskable icons at 192 and 512; the portal has
its own manifest (/portal-manifest.webmanifest) so a tenant's installed
app opens on the portal, not the landlord sign-in.
A text-message channel isn't wired up; lib/notify.ts and the per-type
channel columns are shaped so one could be added without touching the rest.
Most of the logging happens standing in a doorway, so the app ships a web manifest and icons: Add to Home Screen gives it a real icon and opens it without browser chrome. The layout is built for a phone first — a bottom tab bar in thumb reach, the record form as a sheet that rises from the bottom, and the ledger as a card per entry rather than a table you scroll sideways.
Dark mode follows the system setting unless you choose otherwise under Settings › Appearance (below).
Each person picks their own look under Settings › Appearance
(/dashboard/settings/appearance, also linked from Account & security).
It's saved to that login only — teammates keep theirs.
Changes apply straight away without a reload. The server writes the choice
onto <html> (data-layout, data-theme, data-accent) before the first
paint, so there's no flash, and app/globals.css keys every colour, font
and radius off those attributes. Tenants and owners get Appearance with
just Light / Dark / Match my device, on the portal page and the owner
Account page. Appearance isn't part of a backup: it's a personal display
preference, not LLC data, so a restore leaves everyone on the defaults.
The Backup page downloads a single JSON file with every LLC, property, unit, tenant, recurring expense, mortgage and its payments, rent change, purchase details and valuation, repair report and ledger entry you can see, and restores one back into the app. Ledger entries a mortgage payment wrote are re-linked to that payment on restore, so they stay protected from being edited on their own. Proof files and repair photos are referenced by link rather than copied into the file.
Repairs carry their reporter as a name rather than an id, because ids from the old database mean nothing in a fresh one — on restore each repair is re-linked to the tenant that came back alongside it.
Tenants' portal logins are deliberately not in the file. It lands in your downloads and gets emailed around, and password hashes have no business in it. After a restore you invite them again from their card, and everything they reported is already there.
Ledger entries that came from a bank statement keep their statement-line id and the bank's description, so a restored LLC won't import those lines again and still recognises the payees.
Restoring only ever adds. Each LLC in the file comes back as a new LLC you
own; if the name is already taken, the restored copy is renamed (e.g.
Birchwood Holdings LLC (imported)) so you can compare the two before
removing either. Nothing is ever overwritten or deleted by an import.
The app runs as many serverless instances, and each opens its own database
connections. A small Postgres allows about a hundred in all; when that runs
out, new connections are refused outright, and the effect is maddening —
pages holding a connection keep working while the overview, which fans out
a dozen queries, fails nearly every time. That was an outage on
2026-09-28, and FATAL: too many connections for role was the cause.
Two things keep it from happening again:
-pooler; Vercel names it POSTGRES_PRISMA_URL, and
the app uses that variable ahead of DATABASE_URL when it's set).
Supabase calls it the transaction pooler, on port 6543, and wants
?pgbouncer=true on the end. A pooler multiplexes thousands of client
connections onto a few real ones, which removes the limit altogether.DATABASE_POSTGRES_URL is the
unpooled connection (the same value as DATABASE_URL on a database with
no pooler); prisma migrate deploy at build time goes through it,
because a pooler in transaction mode can't run a migration safely.connection_limit, the app uses one, and waits up to twenty seconds for
it rather than failing the page. Queries on one instance then run one at
a time — the overview's dozen take about a tenth of a second together./api/health runs SELECT 1 and says whether the database is up, which URL
is in use and whether it's pooled, and on failure the Prisma error code and
a message with hosts, users and quoted values stripped out. It's the first
thing to check when every signed-in page is a 500.
Each deploy briefly doubles the open connections while the old instances drain, so on a direct (unpooled) URL expect a few minutes of errors after each one, and don't deploy twice in five minutes.
Schema changes ship as versioned migration files in prisma/migrations, and
deploys run prisma migrate deploy — which only applies those files and never
drops data to force the schema into shape. (An earlier version used
prisma db push --accept-data-loss, which could silently destroy records on a
schema change; that's gone.)
prisma/baseline.js runs first and handles one specific case: a database
created before migrations existed has no migration history, so it marks the
initial migration as already applied and lets later ones run normally on top.
It no-ops once history exists.
When changing the schema, generate a migration rather than pushing:
npx prisma migrate dev --name describe_the_change
On the Team page, an owner can delete an LLC. This also deletes its properties and every ledger entry under it, for everyone on the team, so the confirmation asks you to type the LLC's name and tells you exactly how much history would go. Download a backup first if you might want those records.
rent-roll/index.html is an earlier, single-file version of this app built
for Claude Artifacts (no login, no separate backend — data is stored by the
Artifacts platform). It's kept for reference; the Next.js app above is the
one meant for a real deployment.