The question I get most often from self-storage operators who've outgrown their facility management software's billing capabilities is whether they should build something custom or buy a billing platform. It's the right question to ask seriously, because the answer isn't always "build." But when the answer is "build," you need to get the architecture right from the beginning — a billing database schema is hard to migrate once it has real financial data in it.
This post covers when to build, how to structure the data model, and the core PHP patterns for a minimum viable billing engine that can handle the real-world complexity of storage billing.
Build vs. Buy: The Honest Decision Framework
Off-the-shelf billing platforms — Stripe Billing, Chargebee, Recurly — handle the general case of subscription billing well. If your billing model is "charge a fixed amount monthly per unit, send a receipt," they're probably sufficient. The economics favor buying when your billing logic is standard and your engineering capacity is limited.
The case for building custom is specific: your billing rules are domain-specific enough that every off-the-shelf platform requires significant workarounds. In self-storage, that means things like: prorated move-in and move-out billing that follows specific period rules, tiered late fee schedules with grace periods that vary by unit type, rate increase programs with advance notice requirements, ancillary billing for insurance and merchandise on a separate cycle, and corporate account billing with multiple units under a single invoice. Generic subscription billing platforms model none of this cleanly. You end up with a billing platform plus a layer of custom logic that could have been the billing platform.
The second signal to build: you need deep integration between billing logic and facility operations. If the billing platform needs to lock units automatically on delinquency, apply credits based on move-out inspections, or generate accounting entries in a specific format for a property management system, the API integration overhead on off-the-shelf tools becomes its own project.
Database Schema Design
A billing platform's schema has a core set of tables that everything else orbits. Here's the structure I start with, described in terms of what each table represents and its key fields:
The Core Tables
tenants — the customer record. Fields: id, first_name, last_name, email, phone, created_at, deleted_at (soft delete). One row per customer regardless of how many units they rent.
units — the inventory. Fields: id, facility_id, unit_number, unit_type, sqft, standard_rate, status (vacant/occupied/reserved). Units exist independently of tenants.
leases — the binding relationship between a tenant and a unit. Fields: id, tenant_id, unit_id, move_in_date, move_out_date, billing_day, contracted_rate, status. The contracted rate on the lease may differ from the unit's standard rate due to promotions. This table is the heart of the billing engine.
ledger — every financial event for every tenant. Fields: id, tenant_id, lease_id, entry_type (charge/payment/credit/refund/adjustment), amount (stored as integer cents — never float), description, due_date, posted_at, created_by. The ledger is append-only. The tenant's balance is the sum of all ledger entries.
invoices — groups of ledger charges presented to a tenant. Fields: id, tenant_id, invoice_number, period_start, period_end, subtotal, total, status, due_date.
payments — records of actual payment transactions. Fields: id, tenant_id, ledger_id, gateway, gateway_transaction_id, amount, status, payment_method_token, processed_at.
CREATE TABLE ledger (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id INT UNSIGNED NOT NULL,
lease_id INT UNSIGNED,
entry_type ENUM('charge','payment','credit','refund','adjustment') NOT NULL,
amount INT NOT NULL, -- cents, negative for credits/refunds
description VARCHAR(255) NOT NULL,
due_date DATE,
posted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT UNSIGNED,
CONSTRAINT fk_ledger_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
INDEX idx_tenant_posted (tenant_id, posted_at)
) ENGINE=InnoDB;
The ER diagram at a high level: Tenants have many Leases. Each Lease belongs to one Unit. Leases drive Charges that post to the Ledger. Payments apply against Ledger entries. Invoices aggregate Ledger charges for presentation. The Ledger is the source of truth for balance; never store a "current balance" field on the tenant record.
The Minimum Viable Billing Engine
The billing engine's core job is generating charges for active leases on their billing cycle date. Here's the PHP structure I use:
class BillingEngine
{
public function __construct(
private PDO $pdo,
private LedgerRepository $ledger,
private InvoiceRepository $invoices
) {}
public function runDailyBillingCycle(DateTimeImmutable $runDate): BillingResult
{
$result = new BillingResult();
$stmt = $this->pdo->prepare(
'SELECT l.*, t.email, u.unit_number
FROM leases l
JOIN tenants t ON t.id = l.tenant_id
JOIN units u ON u.id = l.unit_id
WHERE l.status = :status
AND l.billing_day = :billing_day
AND (l.move_out_date IS NULL OR l.move_out_date > :run_date)'
);
$stmt->execute([
':status' => 'active',
':billing_day' => (int) $runDate->format('j'),
':run_date' => $runDate->format('Y-m-d'),
]);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $lease) {
try {
$this->billLease($lease, $runDate);
$result->addSuccess($lease['id']);
} catch (\Throwable $e) {
$result->addFailure($lease['id'], $e->getMessage());
}
}
return $result;
}
private function billLease(array $lease, DateTimeImmutable $runDate): void
{
$amount = $this->calculateChargeAmount($lease, $runDate);
$this->pdo->beginTransaction();
try {
$this->ledger->postCharge(
tenantId: $lease['tenant_id'],
leaseId: $lease['id'],
amount: $amount,
description: sprintf('Rent — Unit %s — %s',
$lease['unit_number'],
$runDate->format('F Y')),
dueDate: $runDate->format('Y-m-d')
);
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
}
Handling Proration
Move-in proration is where billing engines commonly get wrong. The formula depends on your proration policy — daily rate for partial months is common in storage. Always calculate using the contracted rate on the lease, not the unit's current standard rate:
private function calculateMoveInProration(array $lease, DateTimeImmutable $moveInDate): int
{
$daysInMonth = (int) $moveInDate->format('t');
$dayOfMonth = (int) $moveInDate->format('j');
$remainingDays = $daysInMonth - $dayOfMonth + 1;
$dailyRate = intdiv($lease['contracted_rate'], $daysInMonth); // cents
return $dailyRate * $remainingDays;
}
Store amounts in integer cents throughout. Never use PHP floats for money — 0.1 + 0.2 is not 0.3 in floating point arithmetic, and billing discrepancies from float rounding are the kind of thing that ends up in a dispute spreadsheet.
Late Fees and Credits
Late fees post as ledger entries against specific leases, typically triggered by a separate delinquency job that runs after the payment grace period expires:
public function applyLateFees(DateTimeImmutable $assessmentDate): void
{
$stmt = $this->pdo->prepare(
'SELECT DISTINCT l.tenant_id, l.id as lease_id,
SUM(le.amount) as unpaid_amount
FROM ledger le
JOIN leases l ON l.id = le.lease_id
WHERE le.entry_type = :charge_type
AND le.due_date <= :grace_cutoff
AND le.id NOT IN (SELECT ledger_id FROM payments WHERE status = :paid)
GROUP BY l.tenant_id, l.id'
);
$stmt->execute([
':charge_type' => 'charge',
':grace_cutoff' => $assessmentDate->modify('-5 days')->format('Y-m-d'),
':paid' => 'settled',
]);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$lateFee = $this->calculateLateFee($row['unpaid_amount']);
$this->ledger->postCharge(
tenantId: $row['tenant_id'],
leaseId: $row['lease_id'],
amount: $lateFee,
description: 'Late payment fee',
dueDate: $assessmentDate->format('Y-m-d')
);
}
}
Credits post as negative amounts on the ledger. The tenant's balance calculation sums everything — positive charges, negative credits, negative payments — to arrive at amount owed. Keep the balance calculation as a query rather than a stored field. Stored balances go out of sync; a ledger sum query is always correct.
When This Pays Off
A custom billing platform built on this foundation takes three to six months to reach production readiness for a single-facility operator, and longer for multi-facility. The investment is justified when the alternative is years of workarounds in a platform that doesn't fit your billing model. Once the schema and engine are right, every feature request is an extension of a model you fully understand — not a negotiation with a vendor's API roadmap.