A deposit deduction is only as good as the two inspections behind it. The system is a move-in baseline recorded room by room with photos, a move-out inspection walked against the same checklist, a query that joins the two and surfaces every condition drop, a classification pass that separates wear and tear from deductible damage, and an itemized statement where every charge points at the inspection line and photos that prove it. Build it on a schema and the statement writes itself; skip the baseline and every deduction is your word against the tenant's memory. With an AI coding agent and the reference schema linked below, a working version is a weekend, not a software project.
Most deposit disputes are not really disagreements about money. They are disagreements about evidence: whether the carpet stain predates the tenancy, whether the wall was already scuffed, whether "cleaning" meant anything specific. The landlord who loses in small claims court usually loses because the paper trail starts at move-out, when it needed to start at move-in. This guide builds the workflow that fixes that, on top of our rental property reference schema, the same build-on-the-schema format as our hotel housekeeping board guide. Our security deposit research covers how often deposits are withheld and disputed; this page is the operational answer.
One scoping rule up front: this is the condition-and-money layer, not an accounting system. Rent balances stay in rent_ledger_entry, work orders stay in maintenance_request, and the turn itself is tracked in unit_turn. What you are building here owns exactly four tables: inspection, inspection_item, security_deposit, and deposit_deduction, plus the seeded state_deposit_rule reference table the schema ships for the statutory deadlines.
The whole system is a diff
Think of the deposit workflow as version control for a unit's condition. The move-in inspection is the initial commit. The move-out inspection is the working state. The deduction statement is the diff, annotated with prices. That framing tells you what matters at each step: the baseline must be granular enough to diff against (room by room, item by item, with photos), the move-out walk must cover the same items so the join has something to match, and every charge must trace to a specific changed line. This is what the delta view looks like once both inspections are in the database:
The delta ledger: every move-out item paired with its move-in twin on the schema's six-word scale (new, good, fair, worn, damaged, missing). Two items fall to damaged and become charges, one slides from good to worn and is waived as wear and tear, and the pre-existing chip charges nothing because the baseline already recorded it.
Notice what the fourth row does. The countertop chip is the kind of thing that triggers a dispute when there is no baseline: the landlord sees a chip at move-out and bills for it, the tenant swears it was always there, and nobody can prove anything. With a baseline row that says "small chip near sink, pre-existing" and two photos behind it, the chip never even becomes a conversation.
The build, in seven steps
Capture the move-in baseline
Before keys change hands, walk the unit and write one inspection_item row per room and item: walls, flooring, ceiling, fixtures, appliances, windows, doors. Each row carries a condition rating on the schema's six-word scale (new, good, fair, worn, damaged, missing), free-text notes for anything pre-existing, and a photo count. The photos themselves live in your storage; the schema only needs to know they exist and how many cover each item, and it holds you to that with a CHECK constraint: a row rated damaged or missing with zero photos is rejected outright, because an undocumented damage claim is worthless.
This is no longer just good practice. Under California Civil Code section 1950.5 (leginfo.legislature.ca.gov), for tenancies beginning July 1, 2025 or later, the landlord must photograph the unit at the inception of the tenancy, and must later deliver move-out photographs with the itemized statement. The statute is effectively mandating the baseline this step builds.
-- Move-in baseline (unit 12 under lease 340 in the worked example)
INSERT INTO inspection (unit_id, lease_id, inspection_type, inspected_at, tenant_present)
VALUES (:unit_id, :lease_id, 'move_in', '2024-06-01', true)
RETURNING id; -- keep this id; the move-out will point back at it
INSERT INTO inspection_item
(inspection_id, room_label, item, condition_rating, notes, photo_count)
VALUES
(:move_in_id, 'living_room', 'walls', 'new', 'Fresh paint, no marks', 4),
(:move_in_id, 'living_room', 'carpet', 'good', 'Light wear at entry', 3),
(:move_in_id, 'bedroom_2', 'carpet', 'new', 'Installed June 2023', 3),
(:move_in_id, 'bedroom_2', 'walls', 'new', NULL, 4),
(:move_in_id, 'kitchen', 'countertop', 'fair', 'Small chip near sink, pre-existing', 2);
Field note: the notes column earns its keep on pre-existing flaws, and the install date on anything with a useful life. "Installed June 2023" on the bedroom carpet is six words at move-in that become the entire depreciation calculation at move-out.
Trigger the move-out inspection from the notice
The day a move-out notice lands, two things should happen mechanically: the notice is recorded as a dated legal fact in the notice table (served date, effective date, delivery method), and the move_out inspection is created from it, dated to the notice's effective date and pointing at the baseline through baseline_inspection_id. Do not wait until the tenant is gone to think about the inspection; the clock that matters (the statutory return deadline in step 6) starts at vacancy, and the walk needs to happen at the front of that window, not the end. Clone the baseline's room and item list into the new inspection's checklist so the inspector walks exactly what was walked at move-in; a diff only works when both sides cover the same ground.
-- Record the notice as a dated legal fact (lease 340 in the example)
INSERT INTO notice (lease_id, notice_type, served_on, effective_on, delivery_method)
VALUES (:lease_id, 'tenant_notice_to_vacate',
'2026-05-28', '2026-06-30', 'email');
-- Open the move-out inspection from it, once, pointed at the baseline
INSERT INTO inspection
(unit_id, lease_id, inspection_type, baseline_inspection_id, inspected_at)
SELECT l.unit_id, l.id, 'move_out', b.id, n.effective_on
FROM lease l
JOIN inspection b ON b.lease_id = l.id
AND b.inspection_type = 'move_in'
JOIN notice n ON n.lease_id = l.id
AND n.notice_type = 'tenant_notice_to_vacate'
WHERE l.id = :lease_id
AND NOT EXISTS (
SELECT 1 FROM inspection i
WHERE i.lease_id = l.id
AND i.inspection_type = 'move_out'
);
In California, one more inspection sits in this window: the tenant can request an initial inspection before move-out, and section 1950.5 also requires post-possession photographs taken before any repairs or cleaning begin. Model that as a routine inspection tied to the same lease; the schema's inspection_type enum already accommodates it.
Run the comparison query
This query is the heart of the system. It joins every move-out item to its move-in twin for the same unit, room, and item, and returns only the rows where condition dropped. The output is the delta ledger shown above: the complete, and only, set of candidates for deduction. Anything not on this list either did not change or was already accounted for in the baseline, and charging for it is how deposit disputes start.
-- Condition deltas: move-out vs move-in, same room and item.
-- condition_rating is text, so ordering comes from its position on
-- the scale: new, good, fair, worn, damaged, missing.
WITH scale AS (
SELECT ARRAY['new','good','fair','worn','damaged','missing'] AS r
)
SELECT
mo.room_label,
mo.item,
mi.condition_rating AS move_in_rating,
mo.condition_rating AS move_out_rating,
array_position(s.r, mo.condition_rating)
- array_position(s.r, mi.condition_rating) AS severity_steps,
mo.notes AS move_out_notes,
mi.notes AS baseline_notes,
mi.photo_count AS baseline_photos,
mo.photo_count AS move_out_photos
FROM inspection_item mo
JOIN inspection io ON io.id = mo.inspection_id
JOIN inspection_item mi ON mi.inspection_id = io.baseline_inspection_id
AND mi.room_label = mo.room_label
AND mi.item = mo.item
CROSS JOIN scale s
WHERE io.id = :move_out_inspection_id
AND array_position(s.r, mo.condition_rating)
> array_position(s.r, mi.condition_rating)
ORDER BY severity_steps DESC;
Three details worth copying exactly. The join runs through baseline_inspection_id, the pointer every move-out inspection carries back to its own move-in, so a unit's previous tenancies never bleed into the current diff. The pairing is sound because the schema enforces UNIQUE (inspection_id, room_label, item): each move-out line has exactly one baseline twin to match. And the query returns both photo counts, because a delta with zero move-out photos is a charge you cannot defend; the schema already refuses damaged and missing ratings without photos, and your review screen should hold every other worsened row to the same bar.
Classify each delta: wear and tear or damage
Every delta now needs a call, and the call is a legal one before it is a financial one. Section 1950.5 bars California landlords from deducting for "ordinary wear and tear or the effects thereof," and Washington's RCW 59.18.280 (leg.wa.gov) draws the same line while also refusing deductions for damage the landlord cannot document. The working test: would this condition exist if a careful tenant had lived here the same number of years? If yes, it is wear and tear and the deposit does not pay for it. If the condition required an incident (an impact, a pet, a burn, neglect), it is damage.
- Nail and thumbtack holes from hanging pictures
- Traffic paths worn into carpet in walkways
- Paint faded by sunlight; minor scuffs at furniture height
- Loose door handles and hinges from normal use
- Worn enamel in a tub that saw daily use
- Gouges, dents, or fist-sized holes in walls
- Pet urine stains or odors in carpet and pad
- Cigarette burns in flooring or countertops
- Crayon, marker, or unapproved paint on walls
- Broken blinds, cracked panes, torn screens
The pairs matter more than the lists. Nail holes are wear; a wall gouge is damage. A carpet's traffic path is wear; the same carpet's pet stain is damage. Faded paint is wear; crayon murals are damage. Encoding the classification as data (a category on each delta, with the waived rows kept, not deleted) means the statement can later show not just what you charged but what you chose not to charge, which reads very differently to a tenant weighing whether to dispute. Our tenant damage research compiles how often each of these categories actually shows up at move-out.
Price it, with depreciation, and write the deduction rows
Repairs are charged at cost: the drywall patch is the contractor invoice or your materials plus documented labor time (Washington's RCW 59.18.280 spells out exactly that: bill, invoice, or receipt for materials, plus time spent and a reasonable hourly rate for the landlord's own work). Replacements are different. If the tenant destroyed a carpet that was already three years into its life, charging full replacement price bills them for value that was already gone, and courts routinely cut such charges down. The defensible method is remaining useful life. According to IRS Publication 527 (irs.gov), appliances, carpeting, and furniture used in a residential rental activity are 5-year property, which gives you a standard, citable life to prorate against.
The worked example: lease 340 ends with an $1,800 deposit held and three charges surviving classification. The bedroom carpet ($1,200 to replace the room) was installed 36 months before move-out; with a 60-month life, 24 months of value remain, so the charge is $1,200 × 24/60 = $480. The wall gouge is a $180 repair at cost. Cleaning back to the move-in standard is $150, and the schema's category enum names it precisely: cleaning_beyond_normal, because routine turnover cleaning is not the tenant's bill. Note the foreign key on every row: each deduction points at the inspection_item that documents it, and the schema makes that column NOT NULL.
-- Deposit 77 in the worked example holds $1,800 for lease 340.
-- Carpet: $1,200 replacement, 36 of 60 months used, 24/60 remain
-- 1200.00 * 24 / 60 = 480.00
INSERT INTO deposit_deduction
(security_deposit_id, inspection_item_id, category, description, amount)
VALUES
(:deposit_id, :carpet_item_id, 'damage',
'Bedroom 2 carpet: pet stains, replacement prorated to remaining life',
480.00),
(:deposit_id, :wall_item_id, 'damage',
'Living room wall: gouge behind door, patch and repaint',
180.00),
(:deposit_id, :cleaning_item_id, 'cleaning_beyond_normal',
'Cleaning to documented move-in standard',
150.00);
Field note: the FK to inspection_item is the discipline, not a nicety. A deduction that cannot name its documenting inspection line should fail a NOT NULL constraint, because the same charge would fail in front of a judge.
Encode state rules as data, not code
How long you have, what you must attach, and how notice travels all vary by state, and they change. Hardcode "21 days" into your workflow and the system silently breaks the day you buy a unit across a state line, or the day the legislature amends the statute. Keep the rules in a reference table keyed by state and join through property:
| State | Deadline | What the statute requires | Provision |
|---|---|---|---|
| California | 21 days | Itemized statement plus refund within 21 calendar days of vacancy; copies of receipts and invoices when total deductions exceed $125; move-in, pre-repair, and post-repair photographs delivered with the statement. | Civ. Code 1950.5(g), (h) |
| Florida | 15 / 30 days | Full refund within 15 days if no claim; otherwise written notice of intent to claim by certified mail within 30 days, with a 15-day objection window. Miss the notice and the right to claim the deposit is forfeited. | Fla. Stat. 83.49(3) |
| Washington | 30 days | Full and specific statement within 30 days, with copies of estimates or invoices substantiating damage charges; landlord's own repairs documented with receipts, time spent, and hourly rate. | RCW 59.18.280 |
Three rows shown; your table carries one per state you operate in, verified against the state legislature's own published code, not a blog's summary. Florida Statute 83.49 (leg.state.fl.us) is a good illustration of why the shape of the rule matters, not just the number: it is not a "return within N days" rule but a notice regime, where failing to send the certified-mail notice within 30 days forfeits the claim entirely. The reference table needs columns for the mechanics, not just the day count.
You do not have to build this table. The reference schema ships it as state_deposit_rule, seeded with eight states whose rows were verified against the statute text and stamped with a verified_on date, because a compliance rule you cannot date is a compliance rule you cannot trust. Add rows for the states you operate in, re-verify yearly, and join through the property:
-- state_deposit_rule ships seeded in the reference schema.
-- Adding a state you operate in follows the seeded shape:
INSERT INTO state_deposit_rule
(state_code, return_deadline_days, claim_notice_deadline_days,
deadline_note, deposit_cap, documentation_note, statute, verified_on)
VALUES
('FL', 15, 30,
'Full refund within 15 days if no claim; written notice of intent to claim by certified mail within 30 days, or the claim is forfeited',
'No statutory cap.',
'15-day tenant objection window after the claim notice',
'Fla. Stat. 83.49(3)', DATE '2026-08-01')
ON CONFLICT (state_code) DO NOTHING;
-- When is this deposit's statement due, and under which statute?
SELECT sd.id,
l.moved_out_on + r.return_deadline_days AS statement_due,
r.documentation_note,
r.statute,
r.verified_on
FROM security_deposit sd
JOIN lease l ON l.id = sd.lease_id
JOIN unit u ON u.id = l.unit_id
JOIN property p ON p.id = u.property_id
JOIN state_deposit_rule r ON r.state_code = p.state_code
WHERE sd.id = :deposit_id; -- deposit 77 in the worked example
Produce the tenant-facing statement
Everything so far exists to make this last step a query instead of an argument. The statement lists each deduction with its room, item, description, and amount, shows the math from deposit held to refund due, names the evidence behind every line, and goes out before the due date computed in step 6. For the worked example, with the unit vacated June 30 in California, the statement is due July 21 and looks like this:
The statement the system generates: every charge traceable to an inspection line and its photos, the depreciation math shown, the waived wear-and-tear item named, and the statutory requirements satisfied on their face.
-- Statement lines with their evidence
SELECT dd.description,
dd.amount,
ii.room_label,
ii.item,
ii.photo_count,
ii.notes
FROM deposit_deduction dd
JOIN inspection_item ii ON ii.id = dd.inspection_item_id
WHERE dd.security_deposit_id = :deposit_id -- deposit 77 in the example
ORDER BY dd.amount DESC;
-- The totals box
SELECT sd.amount AS deposit_held,
COALESCE(SUM(dd.amount), 0) AS total_deductions,
sd.amount - COALESCE(SUM(dd.amount),0) AS refund_due
FROM security_deposit sd
LEFT JOIN deposit_deduction dd ON dd.security_deposit_id = sd.id
WHERE sd.id = :deposit_id
GROUP BY sd.id, sd.amount;
Render it however your stack renders documents (HTML to PDF is fine), send it by whatever method your state's rule row requires, and record the send date. If the refund is short or the statement is late, the reference table row tells the next person exactly which statute they are now explaining.
The AI-agent build prompt
If you are handing this build to a coding agent, do not let it invent a schema on the fly; it will model whatever your first message happened to mention and you will discover the missing foreign key at statement time. Our rental property data model publishes the full schema as downloadable SQL. The prompt below starts by fetching it, then scopes phase one to exactly this workflow:
Build a move-out inspection and deposit deduction system for my
rental units.
First, fetch and read the reference data model:
https://rapideyeinspections.com/blog/rental-property-data-model/schema.sql
Use it as the database schema, unchanged. Keep at minimum the
property, unit, tenant, lease, lease_tenant, notice,
security_deposit, deposit_deduction, inspection, and inspection_item
tables; drop what we do not need yet. The schema already ships
state_deposit_rule seeded with eight statute-verified states
(columns: state_code, return_deadline_days,
claim_notice_deadline_days, deadline_note, deposit_cap,
documentation_note, statute, verified_on); add rows for my other
operating states in the same shape.
Constraints:
- Every lease gets a move_in inspection with one inspection_item per
room and item (condition_rating on the schema's scale: new, good,
fair, worn, damaged, missing; plus notes and photo_count).
Recording a tenant_notice_to_vacate in the notice table
auto-creates the move_out inspection, dated to the notice's
effective_on, with baseline_inspection_id pointing at the lease's
move_in inspection and the same room and item list.
- The core screen is the delta view: move-out items joined to
move-in items through baseline_inspection_id on room_label and
item, showing every item whose rating worsened along the scale
(compare ordinal position on the scale, not text), with both
photo counts.
- Each delta gets a classification: wear and tear (never chargeable)
or chargeable. Keep waived deltas on the record. Chargeable rows
become deposit_deduction rows using the schema's categories
(damage, cleaning_beyond_normal, missing_item, unreturned_keys,
other).
- Every deposit_deduction row requires a non-null FK to the
inspection_item that documents it (the schema enforces this).
Replacements are prorated by remaining useful life (ask me for
item lives; default carpet, appliances, furniture to 60 months).
Repairs are charged at cost with a receipt_uri reference.
- Statement due dates and documentation requirements come from
state_deposit_rule joined through property.state_code. Never
hardcode a state's deadline.
- Warn me when a deduction has zero move-out photos, when the state
rule row's documentation_note requires attachments I have not
provided, and when today is within 5 days of the statement due
date.
Phase 1 only: baseline entry, the delta view, deduction entry with
the depreciation calculator, and an itemized statement rendered as
a printable page showing deposit held, each deduction with its room,
item, photos, and math, waived wear-and-tear items, refund due, and
the governing statute. No payments integration yet. Ask me for my
states, room and item checklist, and deposit amounts before writing
code.
Two practices carry over from the hotel board guide unchanged: phase the build and live with each phase before extending it, and put the tool behind real authentication from day one, because this one holds tenant names, addresses, and money. Add a third specific to deposits: never delete or edit a sent statement's rows. If a charge was wrong, reverse it with a new row and reissue; the statement is a legal document, and its history should read like one.
Quick FAQ
How long does a landlord have to return a security deposit?
It depends entirely on the state, which is why the deadline belongs in a reference table rather than in code. California Civil Code section 1950.5 requires the itemized statement and any refund no later than 21 calendar days after the tenant vacates. Washington's RCW 59.18.280 allows 30 days. Florida Statute 83.49 requires the deposit back within 15 days if no claim is made, or written notice of a claim by certified mail within 30 days.
What is the difference between normal wear and tear and deductible damage?
Wear and tear is the decline that happens from ordinary living: nail holes from hanging pictures, traffic paths worn into carpet, paint faded by sunlight, loose door handles. Damage is decline caused by abuse, accident, or neglect: gouged walls, pet urine stains, cigarette burns, broken blinds. California Civil Code section 1950.5 bars deductions for ordinary wear and tear or its effects, and Washington's RCW 59.18.280 draws the same line. The practical test is whether the condition would exist after a careful tenant lived the same number of years in the unit.
Do I have to depreciate when charging a tenant for replacement?
If you charge full replacement cost for an item that was already partway through its useful life, you are billing the tenant for value that was already gone, and courts routinely reject it. The defensible method is to prorate by remaining useful life. IRS Publication 527 classifies appliances, carpeting, and furniture in residential rental activity as 5-year property, which is a widely used baseline: a carpet three years into a five-year life supports at most 40 percent of replacement cost.
What photos does California require for security deposit deductions?
California Civil Code section 1950.5, as amended by AB 2801, requires photographs at three points: at the inception of the tenancy for tenancies beginning July 1, 2025 or later, within a reasonable time after possession is returned but before any repairs or cleaning, and after repairs or cleaning are completed. The photographs must be delivered with the itemized statement. A schema that stores a photo count on every inspection item makes this a query instead of a scramble.
Sources
Sources are named at the publisher level with their root domain, rather than linked or titled; every figure is verifiable at the named source.
- Civil Code section 1950.5, California Legislative Informationleginfo.legislature.ca.gov
- Florida Statutes section 83.49, Florida Legislatureleg.state.fl.us
- RCW 59.18.280, Washington State Legislatureleg.wa.gov
- Publication 527, residential rental property, Internal Revenue Serviceirs.gov

