intermediate
shop-rentals
late-fees
cron

Late Returns and Applying Late Fees

How the late-return cron flags overdue rentals, computes hourly fees, and lets operators trigger the charge with a single click

Levy Fleets TeamMay 7, 20265 min read

When a customer keeps a bike past their scheduled return time, the late-fee system flags the booking, computes a fee, and lets you apply it. We deliberately don't auto-charge — chargebacks come from surprised customers, and a human-in-the-loop step prevents them.

How detection works

A cron job runs every 15 minutes at /api/cron/reservation-late-returns. It looks for reservations where:

  • status is active or checked_in
  • return_at < now() (scheduled return is in the past)
  • actual_return_at IS NULL (bike is still out)

For each match, it reads the pricing snapshot for that booking and calculates:

grace_minutes  = pricing_snapshot.late_return_grace_minutes (default 60)
late_rate      = pricing_snapshot.late_return_hourly_rate_cents (default 0)
late_by_ms     = now() - return_at
grace_ms       = grace_minutes * 60 * 1000

if late_by_ms < grace_ms:
  skip
else:
  late_hours = ceil((late_by_ms - grace_ms) / 1 hour)
  fee_cents  = late_hours * late_rate
  set is_late=true, late_fee_cents=fee_cents

So a booking that returned 10 minutes late doesn't get flagged. A booking 2 hours late with a 60-minute grace and $15/hr rate gets flagged with $15 fee (1 rounded-up hour past grace).

What the operator sees

The booking detail page shows an amber Late return banner above the financial summary:

⚠ Late return
This rental is past the scheduled return time.
Computed late fee: $30.00
[Apply late fee]

The banner only appears when both is_late = true and late_fee_cents > 0.

Applying the late fee

Click Apply late fee on the banner. The endpoint:

  1. Adds late_fee_cents to adjustment_cents (line item on financial summary)
  2. Adds it to total_cents and balance_due_cents
  3. Sets adjustment_reason = "Late return fee" (or with a custom suffix)
  4. Resets late_fee_cents = 0 and is_late = false

The banner disappears. The booking's financial section now shows the adjustment line and an updated balance due.

What the late fee doesn't do

  • Doesn't charge the card automatically. The fee is recorded as a balance the customer owes.
  • Doesn't email the customer. No "you've been charged $30 in late fees" email is sent. Operator should follow up if needed.
  • Doesn't release the deposit. The deposit hold remains until return is completed; it can absorb the late fee or be captured then released minus the fee.

Collecting the fee

Three paths:

  1. Capture from the deposit at return time — the deposit hold can absorb the fee. Use the existing capture flow.
  2. Charge the saved card off-session via the admin charge-card endpoint or wallet-debit pattern.
  3. Wait for the customer to pay manually — they see the balance due on their manage-link page.

For most shops, option 1 (capture from deposit) is cleanest.

Editing the fee before applying

If the auto-computed fee feels wrong (customer had a legitimate reason to be late), you can:

  • Apply the fee then partial-refund it back as a goodwill credit
  • Override the late_fee_cents value via SQL before clicking Apply (rarely needed)
  • Skip applying entirely — the late_fee just sits unflagged once return is completed

The endpoint accepts an optional amountCents parameter to override the computed fee:

POST /api/reservations/[id]/charge-late-fee
{ "amountCents": 1500, "reason": "Customer late due to weather" }

Recurring late behavior

The cron re-evaluates every 15 minutes, so if you ignore the banner the late fee continues to accrue. If the customer is 5 hours late, you'll see the fee climb each cron tick.

If you don't want this — the customer is just dropping the bike off soon and you'll handle it personally — you can clear is_late manually or wait for the operator to mark the return complete.

Reporting

SELECT
  date_trunc('week', applied_at) AS week,
  count(*) AS late_returns_charged,
  sum(amount_cents) / 100.0 AS late_fee_revenue
FROM (
  SELECT
    cancelled_at AS applied_at,
    adjustment_cents AS amount_cents
  FROM reservations
  WHERE adjustment_reason LIKE 'Late return fee%'
    AND adjustment_cents > 0
) t
GROUP BY 1
ORDER BY 1 DESC;

(This is approximate — for clean reporting, consider a separate audit table for fee applications. Currently the adjustment is folded into adjustment_cents.)

Common scenarios

Customer claims they returned on time

Check actual_return_at on the booking. If it's stamped before return_at, no late fee applies (the cron wouldn't have flagged it).

If they say "I dropped the bike off but no one was at the counter," they're technically still active until you press Complete return — the cron sees the bike as "still out." Investigate via security camera, then either back-date actual_return_at or stand by the fee.

Customer is 90 minutes late but well-meaning

Two options:

  • Apply the fee then immediately partial-refund the same amount as goodwill (preserves audit trail)
  • Don't apply the fee at all (just let it sit)

Recurring bad actor

Pull a report of all customers with multiple late returns:

SELECT customer_uuid, count(*)
FROM reservations
WHERE adjustment_reason LIKE 'Late return fee%'
GROUP BY 1
HAVING count(*) > 2;

Consider tightening the deposit amount or refusing future bookings.