intermediate
shop-rentals
refunds
wallet

Refunding a Reservation

Wallet vs card refunds, full vs partial, with the source-of-truth discipline that protects your accounting integrity

Levy Fleets TeamMay 7, 20268 min read

Refunds are the most consequential financial operation in Shop Rentals. They affect customer trust, your books, partner payouts, and tax remittance. This guide covers when to use which refund type and what happens behind the scenes.

The cardinal rule

Never credit a customer's wallet directly. Always go through the refund modal or the cancel button — both flow through the reservation_refunds source-of-truth ledger. If you bypass this and manually edit customers.wallet_balance, you corrupt accounting and partner payouts.

This is the same discipline as ride refunds. The reservation is the single source of truth for every dollar that moves.

Wallet vs Card — when to use which

Refund to wallet

  • Default for most cases
  • Customer keeps the funds in their account for future rentals
  • Avoids Stripe processing fees on the refund
  • Instant — no banking delay
  • Use when: customer might rent again, dispute is small, you're crediting goodwill

Refund to card

  • Sends money back to the original card via Stripe
  • Stripe takes 5-10 business days to surface in the customer's bank
  • Recommended when: customer cancelled and won't return, large dollar amount, customer specifically requested it
  • Cannot run if customer's wallet balance is negative — the system blocks card refunds in that case to prevent abuse

Full vs Partial

Full refund

  • Refunds the entire remaining refundable balance
  • = amount_paid_cents - already_refunded_cents
  • Use when: customer's complaint is about the whole rental experience, bike never delivered, etc.

Partial refund

  • You enter an explicit dollar amount, must be ≤ remaining
  • Use when: damage credit, partial bike issue, goodwill reduction

The pricing modal won't let you enter more than the remaining refundable amount.

How to issue a refund

  1. Open the booking detail page
  2. Click Refund (visible only when remaining balance > 0)
  3. Pick destination (wallet or card)
  4. Pick mode (full or partial); if partial, enter the dollar amount
  5. Type a reason (optional but strongly recommended for audit)
  6. Click Confirm Refund

You'll see a green success toast with the refunded amount. The page refreshes; the booking's refunded_cents updates and the Refund button disappears if fully refunded.

What happens behind the scenes

Wallet path

  1. Insert a reservation_refunds row with refund_type='wallet', wallet_transaction_id=null
  2. Call creditWalletForRefund passing the refund row's id
  3. The helper verifies the row exists, checks idempotency (no duplicate credits within 120s), updates customers.wallet_balance, and inserts a wallet_transactions row
  4. Helper links the wallet transaction back to the refund row

If step 2 or 3 fails, the refund row is rolled back to keep the ledger consistent.

Card path

  1. Look up Stripe charges for this customer in the relevant currency
  2. Filter for charges that match the reservation by metadata (reservation_uuid, reservation_number, type='reservation_payment')
  3. Issue refunds against those charges in order until the requested amount is covered
  4. Insert a reservation_refunds row with refund_type='card' and the captured Stripe refund IDs

If no eligible charges exist (e.g., customer paid via wallet only), the card path returns a 400 — switch to wallet refund.

Permissions

Refunds require the ride:refund permission (the name predates the shop-rentals module but applies). Operator roles have it; analyst and service-tech roles do not.

Idempotency

The wallet helper has a 120-second idempotency window — calling the same refund twice in quick succession is rejected with a DuplicateWalletRefundError. This protects against double-clicks and network retries.

The card refund path has no such guard at the app level, but Stripe itself rejects duplicate refund requests on the same charge for the same amount within a short window.

Refund summary on the booking detail

After the refund, the financial summary section updates:

Total:           $50.00
Amount paid:     $50.00
Refunded:        $25.00     ← updated
Balance due:     $0.00

The "Refund" button stays visible if there's still a balance to refund.

Reporting

Pull all refunds for a date range:

SELECT
  rr.processed_at,
  r.reservation_number,
  c.full_name AS customer,
  rr.refund_type,
  rr.refund_mode,
  rr.amount,
  rr.reason
FROM reservation_refunds rr
JOIN reservations r ON r.id = rr.reservation_uuid
JOIN customers c ON c.id = rr.customer_uuid
WHERE rr.processed_at BETWEEN '2026-05-01' AND '2026-05-31'
  AND rr.status = 'succeeded'
ORDER BY rr.processed_at DESC;

For tax-remittance impact, the refund and revenue reporting article covers how refunds flow into net_deposited and partner payouts.

Common scenarios

Don't issue a refund manually. Use the Cancel Booking button — it auto-refunds the deposit minus the cancellation fee. See Cancellation handling.

Customer paid by wallet, asks for card refund

Card refund requires Stripe charges in their history matching the reservation. If they only paid by wallet, there's no card charge to refund against. Either:

  • Refund to wallet (instant, easy)
  • Manually transfer them money via Stripe Connect Payouts (out-of-band)

Damage charge dispute

Customer claims the damage charge is wrong. Issue a partial refund matching the disputed amount, with reason "Damage charge dispute — agreed $X reduction." This keeps the audit trail clean.

Multiple refunds on the same booking

Each refund is a separate reservation_refunds row. The remaining refundable balance updates after each one. You can refund $5 today and $3 next week — they're independent rows in the ledger.

Refund the cancellation fee

The system doesn't have a dedicated "refund the cancellation fee" button. To do this:

  1. Open the cancelled booking
  2. Click Refund
  3. Mode: Partial
  4. Amount: the cancellation fee dollar amount
  5. Reason: "Waiving cancellation fee — operator decision"

Errors you might see

ErrorCauseFix
"Reservation has no refundable balance remaining"Already fully refunded or amount_paid_cents = 0Check the financial summary
"Cannot refund directly to card while customer has negative wallet balance"Customer owes the walletRefund to wallet first or zero the wallet manually
"No eligible Stripe charges found"Customer paid via wallet onlyRefund to wallet instead
"Insufficient permissions"Your role lacks ride:refundAsk an admin