Shop Rentals uses ledger-style tables for refunds and gift card transactions. This article covers the queries you'll run for accounting, tax, and partner-payout reconciliation.
Two ledgers
| Ledger | Tracks | Source-of-truth for |
|---|---|---|
reservation_refunds | Every refund issued — wallet or card, full or partial, by booking | Refund accounting |
gift_card_transactions | Every gift card transaction — issuance, redemption, refund-back | Gift card liability |
Both follow the insert-first discipline: a row is written before any wallet/card credit happens. This means you can audit them independently and the totals always match.
Revenue reporting
Gross revenue (per month)
SELECT
date_trunc('month', completed_at) AS month,
count(*) AS rentals,
sum(total_cents) / 100.0 AS gross_revenue,
sum(tax_cents) / 100.0 AS tax_collected,
sum(amount_paid_cents) / 100.0 AS amount_paid
FROM reservations
WHERE subaccount_id = '<your-subaccount-id>'
AND status NOT IN ('cancelled', 'no_show', 'expired')
AND completed_at >= '2026-01-01'
GROUP BY 1
ORDER BY 1;
Net revenue (after refunds)
WITH r AS (
SELECT
date_trunc('month', completed_at) AS month,
sum(amount_paid_cents) AS paid
FROM reservations
WHERE subaccount_id = '<your-subaccount-id>'
AND status NOT IN ('cancelled', 'no_show', 'expired')
AND completed_at >= '2026-01-01'
GROUP BY 1
),
rf AS (
SELECT
date_trunc('month', processed_at) AS month,
sum(amount * 100) AS refunded
FROM reservation_refunds rr
JOIN reservations re ON re.id = rr.reservation_uuid
WHERE re.subaccount_id = '<your-subaccount-id>'
AND rr.status = 'succeeded'
AND processed_at >= '2026-01-01'
GROUP BY 1
)
SELECT
r.month,
r.paid / 100.0 AS paid,
COALESCE(rf.refunded, 0) / 100.0 AS refunded,
(r.paid - COALESCE(rf.refunded, 0)) / 100.0 AS net
FROM r
LEFT JOIN rf USING (month)
ORDER BY r.month;
Refund analysis
Refunds by destination
SELECT
refund_type,
refund_mode,
count(*) AS refunds,
sum(amount) AS total_refunded
FROM reservation_refunds rr
JOIN reservations r ON r.id = rr.reservation_uuid
WHERE r.subaccount_id = '<your-subaccount-id>'
AND rr.status = 'succeeded'
AND processed_at >= '2026-01-01'
GROUP BY 1, 2
ORDER BY 1, 2;
Tells you the wallet vs card split, full vs partial split. High card-refund ratios suggest customer dissatisfaction (they want the cash back instead of a wallet credit).
Refunds by reason
SELECT
COALESCE(reason, '<no reason>') AS reason,
count(*) AS refunds,
sum(amount) AS total
FROM reservation_refunds
WHERE subaccount_id = '<your-subaccount-id>' -- via reservations join
AND processed_at >= '2026-01-01'
GROUP BY 1
ORDER BY count DESC
LIMIT 20;
Patterns surface common dissatisfaction sources.
Gift card liability
SELECT
count(*) AS active_cards,
sum(initial_balance_cents) / 100.0 AS total_loaded,
sum(initial_balance_cents - current_balance_cents) / 100.0 AS total_redeemed,
sum(current_balance_cents) / 100.0 AS outstanding_balance
FROM gift_cards
WHERE subaccount_id = '<your-subaccount-id>'
AND is_active = true;
outstanding_balance is your gift card liability — money collected,
service not yet delivered. Should match your books' deferred revenue
account.
Partner payouts
If your shop is part of a partner-revenue-share program:
partner_payout = (net_deposited - customer_sales_tax) × partner_share%
where net_deposited = card_funded - stripe_fees - refunds.
This is the formula that makes the refund discipline so important —
refunds NEED to flow through reservation_refunds so net_deposited
is correct. If you bypass and credit a wallet manually, partner gets
overpaid.
Tax remittance
Sales tax flows from reservations.tax_cents:
SELECT
date_trunc('month', completed_at) AS month,
sum(tax_cents) / 100.0 AS tax_collected,
sum(tax_cents) FILTER (WHERE rr.refund_mode = 'full') AS tax_refunded_via_full_refunds
FROM reservations r
LEFT JOIN reservation_refunds rr ON rr.reservation_uuid = r.id
WHERE r.subaccount_id = '<your-subaccount-id>'
AND r.status NOT IN ('cancelled', 'no_show', 'expired')
AND r.completed_at >= '2026-01-01'
GROUP BY 1
ORDER BY 1;
For tax filings, the metric you typically need is collected minus
refunded (you don't owe tax on what you refunded). This is also where
the card_source_amount on wallet transactions matters — it tells you
how much of a wallet refund originally came from a card charge.
Damage / late fee revenue
SELECT
date_trunc('month', updated_at) AS month,
sum(adjustment_cents) FILTER (WHERE adjustment_reason LIKE 'Late return fee%') / 100.0 AS late_fees,
sum(adjustment_cents) FILTER (WHERE adjustment_reason LIKE 'Damage at return%') / 100.0 AS damage_charges,
sum(adjustment_cents) / 100.0 AS total_adjustments
FROM reservations
WHERE subaccount_id = '<your-subaccount-id>'
AND adjustment_cents > 0
AND updated_at >= '2026-01-01'
GROUP BY 1
ORDER BY 1;
These are typically your highest-margin revenue lines — virtually pure profit beyond the labor of capturing the events.
Reconciliation tips
When totals don't match between Stripe and your reports
Likely culprits:
- Stripe fees aren't captured (you're seeing gross, not net)
- Refunds went to wallet (no Stripe charge to reconcile against)
- Gift card redemptions (no Stripe involvement)
Reconcile by ledger type, not against Stripe directly. The
reservation_refunds.stripe_refund_ids array IS your Stripe-paired
audit trail for card refunds; wallet refunds and gift card
redemptions are independent.
When refunded_cents on a reservation looks wrong
It's denormalized — recomputed via syncReservationRefundedTotal after
each refund. If it looks stale, run the helper manually for that
reservation. The source of truth is always the sum of
reservation_refunds.amount rows.
When current_balance_cents on a gift card looks wrong
Run:
SELECT
gc.code,
gc.initial_balance_cents,
gc.current_balance_cents,
gc.initial_balance_cents + COALESCE(SUM(gct.amount_cents), 0) AS computed_balance
FROM gift_cards gc
LEFT JOIN gift_card_transactions gct ON gct.gift_card_id = gc.id
WHERE gc.id = '<gift-card-id>'
GROUP BY gc.id;
If computed_balance != current_balance_cents, the denormalized field
drifted. Update it from the computed value.
Auditing the discipline
Quick check that no wallet credit happened without a refund row:
SELECT count(*)
FROM wallet_transactions wt
WHERE wt.reference_type = 'reservation_refund'
AND wt.reference_id NOT IN (
SELECT reservation_uuid FROM reservation_refunds
);
Should return 0. If non-zero, someone bypassed the refund discipline — investigate immediately.
Running a fleet on this hardware?
Levy Fleets runs scooters, e-bikes, mopeds and golf carts on the IoT hardware you already have, with integration pages for 29 vendors including Segway, Okai, Omni, Queclink, Invers and Comodule. Keyless unlock, geofencing, rider payments and rider support are included, and the Managed plan is a share of revenue with no per-vehicle software fee.