Renewal processing is the heartbeat of any self-storage billing system. Get it wrong and you're dealing with angry tenants, lost revenue, and units that should have been overlocked sitting open for weeks. Over the years building and maintaining billing systems for storage facilities, I've seen every edge case imaginable — and I want to walk through the architecture decisions that actually hold up under real-world load.

Structuring the Renewal Queue

The first thing to understand is that renewals should never run ad hoc. Every facility management platform I've worked with that processes renewals on-the-fly — triggered by a nightly cron that just loops all due leases — eventually develops race conditions, duplicate charges, and timing gaps during DST transitions. The right pattern is a dedicated renewal queue table.

CREATE TABLE renewal_queue (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    lease_id BIGINT UNSIGNED NOT NULL,
    scheduled_date DATE NOT NULL,
    status ENUM('pending','processing','completed','failed','skipped') DEFAULT 'pending',
    attempts TINYINT UNSIGNED DEFAULT 0,
    last_attempt_at DATETIME NULL,
    next_retry_at DATETIME NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_scheduled_status (scheduled_date, status),
    INDEX idx_lease_id (lease_id)
);

Populate this queue 5–7 days out using a separate scheduler process. When a lease is created or modified, your queue builder looks ahead and ensures the next N renewals are staged. This means your actual renewal processor never has to compute "what's due today" — it just pulls from the queue where scheduled_date <= CURDATE() and status = 'pending'.

function processRenewalBatch(PDO $db, int $batchSize = 50): array
{
    $db->beginTransaction();

    // Lock rows for this worker process to avoid concurrent processing
    $stmt = $db->prepare("
        SELECT id, lease_id FROM renewal_queue
        WHERE scheduled_date <= CURDATE()
          AND status = 'pending'
          AND (next_retry_at IS NULL OR next_retry_at <= NOW())
        ORDER BY scheduled_date ASC
        LIMIT :batch
        FOR UPDATE SKIP LOCKED
    ");
    $stmt->bindValue(':batch', $batchSize, PDO::PARAM_INT);
    $stmt->execute();
    $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

    $ids = array_column($rows, 'id');
    if (empty($ids)) {
        $db->rollBack();
        return [];
    }

    // Mark as processing atomically
    $placeholders = implode(',', array_fill(0, count($ids), '?'));
    $db->prepare("UPDATE renewal_queue SET status='processing', last_attempt_at=NOW() WHERE id IN ($placeholders)")
       ->execute($ids);

    $db->commit();
    return $rows;
}

The SKIP LOCKED hint is critical if you're running multiple worker processes. Without it, every worker will block on the same locked rows and you'll serialize your throughput down to a single thread effectively.

Grace Periods and Late Fee Logic

Most storage facilities offer a grace period — typically 3 to 5 days — before a late fee is assessed. The tricky part is that "grace period" means different things in different states. In Florida, for example, the late fee cannot exceed $20 or 20% of the monthly rent, whichever is greater, and you cannot assess it until after the grace period expires. Your billing engine needs to know the facility's jurisdiction and encode those rules explicitly.

function calculateLateFee(Lease $lease, float $balance, \DateTimeInterface $paymentDate): float
{
    $gracePeriodDays = $lease->facility->grace_period_days ?? 5;
    $dueDate = $lease->current_period_due_date;
    $daysPastDue = (int) $dueDate->diff($paymentDate)->days;

    if ($daysPastDue <= $gracePeriodDays) {
        return 0.0;
    }

    $feeConfig = $lease->facility->late_fee_config;

    return match($feeConfig->type) {
        'flat'       => (float) $feeConfig->amount,
        'percentage' => round($balance * ($feeConfig->rate / 100), 2),
        'greater_of' => max($feeConfig->flat_floor, round($balance * ($feeConfig->rate / 100), 2)),
        default      => 0.0,
    };
}

One edge case that bites facilities constantly: late fees applied to prepaid leases. If a tenant paid three months in advance, they are not late — but a naive system that checks "balance > 0 after renewal" will still fire the late fee logic if the prepaid credit wasn't applied correctly before the check runs. Always apply credits before calculating balance, and guard against negative fee amounts explicitly.

Failed Payment Retry Sequences

Card declines are a fact of life. A solid retry sequence for self-storage billing typically looks like this: immediate retry after 24 hours (catches temporary bank holds), then retry at day 3, day 5, and day 7. After day 7, the unit moves to overlock candidate status and dunning escalates to a final notice.

const RETRY_SCHEDULE = [1, 3, 5, 7]; // days after initial failure

function scheduleRetry(PDO $db, int $queueId, int $attemptNumber): void
{
    if (!isset(RETRY_SCHEDULE[$attemptNumber])) {
        // Exhausted retries — mark failed and flag for overlock
        $db->prepare("UPDATE renewal_queue SET status='failed' WHERE id=?")
           ->execute([$queueId]);
        flagForOverlock($db, $queueId);
        return;
    }

    $daysUntilRetry = RETRY_SCHEDULE[$attemptNumber];
    $nextRetry = (new \DateTime())->modify("+{$daysUntilRetry} days")->format('Y-m-d H:i:s');

    $db->prepare("
        UPDATE renewal_queue
        SET status='pending', next_retry_at=?, attempts=attempts+1
        WHERE id=?
    ")->execute([$nextRetry, $queueId]);
}

Dunning Emails

Each retry attempt should trigger a contextually appropriate email. Day 1 is a soft "payment didn't go through, we'll try again." Day 5 gets firmer. Day 7 includes the overlock warning with the specific state statute cited — this matters legally and reduces disputes downstream. I implement dunning as a simple state machine: the renewal queue's attempts count maps directly to a template key.

function getDunningTemplate(int $attempt, bool $overlockWarning): string
{
    return match(true) {
        $attempt === 1                     => 'dunning_soft',
        $attempt === 2                     => 'dunning_reminder',
        $attempt >= 3 && !$overlockWarning => 'dunning_firm',
        $attempt >= 3 && $overlockWarning  => 'dunning_overlock_warning',
        default                            => 'dunning_soft',
    };
}

Build your dunning templates to include the tenant's name, unit number, balance owed, and a direct payment link. Facilities that add a one-click payment URL to their dunning emails consistently recover 15–25% more failed renewals than facilities that just send a generic "your payment failed" message.

Handling Partial Payments and Overlocks

Partial payments are where most billing systems get sloppy. A tenant pays $60 of a $120 renewal. Do you keep the lease active? Apply a partial credit? The right behavior depends on facility policy, but your data model needs to support it regardless. I use a ledger pattern: every payment, charge, and credit gets its own row. The current balance is always a sum query, never a stored field — stored balance fields go stale and cause reconciliation headaches.

// Always calculate balance from ledger, never from a cached column
function getLeaseBalance(PDO $db, int $leaseId): float
{
    $stmt = $db->prepare("
        SELECT
            SUM(CASE WHEN entry_type = 'charge' THEN amount ELSE 0 END) -
            SUM(CASE WHEN entry_type IN ('payment','credit') THEN amount ELSE 0 END) AS balance
        FROM lease_ledger
        WHERE lease_id = ?
    ");
    $stmt->execute([$leaseId]);
    return (float) ($stmt->fetchColumn() ?? 0.0);
}

For overlocks, the state transition needs to be reversible and auditable. When a unit gets an overlock flag set, the corresponding gate access credential should be deactivated. When payment clears, the flag drops and access restores — automatically if you've integrated with the facility's access control API, or via a work order queue if the facility uses manual overlocks. Never automate the physical overlock removal without a confirmation step in the workflow.

Prepaid Leases

Prepaid leases — tenants who pay 6 or 12 months upfront — require a different renewal trigger. The renewal isn't due until the prepaid balance is exhausted. I track prepaid credit in the same ledger as everything else, with a credit_type column distinguishing prepaid from standard credits. The renewal queue builder checks remaining prepaid credit against the monthly rate and calculates the actual next-due date before staging the renewal entry. Never assume a prepaid tenant's next due date is simply last_paid_date + 30 days. Always derive it from the ledger balance divided by the monthly rate.

Getting renewals right means treating them as a deterministic, auditable pipeline — not a cron job that "runs billing." Every state transition should be logged, every failure should be recoverable, and edge cases should be handled by explicit code paths rather than falling through to whatever the default behavior happens to be. The facilities that run the smoothest are the ones whose billing systems can answer "exactly what happened with this tenant's renewal on this date" in under five seconds.