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
- Open the booking detail page
- Click Refund (visible only when remaining balance > 0)
- Pick destination (wallet or card)
- Pick mode (full or partial); if partial, enter the dollar amount
- Type a reason (optional but strongly recommended for audit)
- 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
- Insert a
reservation_refundsrow withrefund_type='wallet',wallet_transaction_id=null - Call
creditWalletForRefundpassing the refund row's id - The helper verifies the row exists, checks idempotency (no duplicate
credits within 120s), updates
customers.wallet_balance, and inserts awallet_transactionsrow - 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
- Look up Stripe charges for this customer in the relevant currency
- Filter for charges that match the reservation by metadata
(
reservation_uuid,reservation_number,type='reservation_payment') - Issue refunds against those charges in order until the requested amount is covered
- Insert a
reservation_refundsrow withrefund_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
Cancel-related refund
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:
- Open the cancelled booking
- Click Refund
- Mode: Partial
- Amount: the cancellation fee dollar amount
- Reason: "Waiving cancellation fee — operator decision"
Errors you might see
| Error | Cause | Fix |
|---|---|---|
| "Reservation has no refundable balance remaining" | Already fully refunded or amount_paid_cents = 0 | Check the financial summary |
| "Cannot refund directly to card while customer has negative wallet balance" | Customer owes the wallet | Refund to wallet first or zero the wallet manually |
| "No eligible Stripe charges found" | Customer paid via wallet only | Refund to wallet instead |
| "Insufficient permissions" | Your role lacks ride:refund | Ask an admin |