FFastero

Connect any database. Ask in plain English.

Try free
Back to blog

Blog article

How to Detect Revenue Leaks with SQL

Revenue leaks are money you earned but never collected — failed charges nobody retried, invoices aging past terms, won deals that were never billed. Here are six SQL queries that find them before your next board meeting.

Fastero Dev TeamFastero Dev Team
2026-08-05
revenuesqlstripesaas-metricsrevenue-leak
How to Detect Revenue Leaks with SQL

Most revenue leaks aren't dramatic. Nobody steals money. No system crashes. A payment fails and the retry window silently expires. An invoice sits unpaid for 60 days and nobody follows up. A deal closes in HubSpot and no subscription ever gets created in Stripe. Each one is small. Added up across a year, they're a headcount you didn't hire.

I've run these queries against production billing data at three different companies. The pattern is the same every time — 3% to 8% of expected revenue leaking through gaps between systems. The six queries below will show you where yours is going.

You'll need your Stripe data and CRM data somewhere queryable. A warehouse works. Fastero's cross-source store works — it lands both into a single DuckDB instance so the joins are trivial. Get subscriptions, invoices, and customers from Stripe, and deals and contacts from your CRM.

1. Failed charges that exhausted retries

Stripe retries failed payments up to 4 times over ~23 days by default. When all retries fail, the subscription cancels. That's involuntary churn — a customer who'd keep paying if someone asked them to update their card.

SELECT
    c.email,
    s.plan_amount / 100.0    AS monthly_amount,
    i.attempt_count,
    i.next_payment_attempt    AS next_retry
FROM invoices i
JOIN subscriptions s ON i.subscription_id = s.id
JOIN customers c     ON s.customer_id = c.id
WHERE i.status = 'open'
  AND i.attempt_count >= 2
  AND s.status = 'past_due'
ORDER BY s.plan_amount DESC;

Sort by amount descending. A $5,000/month customer with a failing card is a five-minute phone call, not a support ticket. Gotcha: next_payment_attempt is null after the final retry. Nulls mean the window already closed — you're in recovery mode, not prevention.

2. Invoices aging past payment terms

Not every SaaS company bills by card-on-file. Enterprise deals often use net-30 or net-60 invoices. Those invoices age. Nobody chases them because the CRM shows the deal as closed-won and everyone assumes finance is handling it.

SELECT
    c.email,
    i.amount_due / 100.0        AS amount_due,
    i.due_date,
    CURRENT_DATE - i.due_date   AS days_overdue
FROM invoices i
JOIN customers c ON i.customer_id = c.id
WHERE i.status IN ('open', 'uncollectible')
  AND i.due_date < CURRENT_DATE - INTERVAL '14 days'
  AND i.amount_due > 0
ORDER BY i.amount_due DESC;

Fourteen days past due is where polite follow-up becomes urgent collection. By 90, you're writing it off.

The same customers show up on this list month after month. They're not refusing to pay — they've learned that nobody chases, so the invoice sits until someone remembers. A single automated reminder at day 7 and day 14 cuts aging by half. See revenue leakage causes and fixes for the full pattern.

Fastero

Connect your database. Ask questions. Get dashboards.

Postgres, BigQuery, Snowflake, and 10+ sources — live-connected, AI-powered, no dashboard builder learning curve.

Try free →

3. Won deals with no corresponding subscription

This is the most expensive leak per occurrence. A deal moves to closed-won in the CRM. The AE celebrates. Nobody creates the subscription. The customer onboards using a trial or courtesy access. Weeks pass. Revenue: zero.

SELECT
    d.deal_name,
    d.amount                      AS deal_value,
    d.close_date,
    CURRENT_DATE - d.close_date   AS days_since_close,
    ct.email                      AS contact_email
FROM deals d
JOIN deal_contacts dc ON d.id = dc.deal_id
JOIN contacts ct      ON dc.contact_id = ct.id
LEFT JOIN customers c ON LOWER(ct.email) = LOWER(c.email)
LEFT JOIN subscriptions s
    ON c.id = s.customer_id
    AND s.created >= d.close_date - INTERVAL '7 days'
    AND s.status IN ('active', 'trialing', 'past_due')
WHERE d.stage = 'closed_won'
  AND d.close_date < CURRENT_DATE - INTERVAL '14 days'
  AND s.id IS NULL
ORDER BY d.amount DESC;

The 7-day window on s.created accounts for deals where billing was set up before the CRM stage changed. Without it, false positives.

At one company, this query returned 11 deals totaling $47,000/year. Eleven customers using the product without paying because the sales-to-billing handoff had no automation. That's not a rounding error. For a deeper walkthrough, see how to reconcile Stripe revenue with your CRM.

4. Pricing drift between CRM and billing

Your CRM says the customer pays $2,400/month. Stripe says $1,800/month. Who's right? Stripe — it's the system that charges the card. The CRM is stale. Maybe the customer downgraded six months ago. Maybe a discount was applied directly in Stripe and never recorded.

SELECT
    d.deal_name,
    ct.email,
    d.amount                                    AS crm_annual_value,
    s.plan_amount / 100.0 * 12                  AS stripe_annual_value,
    d.amount - (s.plan_amount / 100.0 * 12)     AS annual_drift,
    ROUND(
        ABS(d.amount - (s.plan_amount / 100.0 * 12))
        / NULLIF(d.amount, 0) * 100, 1
    )                                           AS drift_pct
FROM deals d
JOIN deal_contacts dc ON d.id = dc.deal_id
JOIN contacts ct      ON dc.contact_id = ct.id
JOIN customers c      ON LOWER(ct.email) = LOWER(c.email)
JOIN subscriptions s  ON c.id = s.customer_id
WHERE d.stage = 'closed_won'
  AND s.status = 'active'
  AND ABS(d.amount - (s.plan_amount / 100.0 * 12)) > 100
ORDER BY ABS(annual_drift) DESC;

The > 100 threshold filters proration noise. Adjust based on your average deal size. Drift in both directions matters — Stripe lower than CRM means you're under-collecting, Stripe higher means your CRM forecasts and net revenue retention numbers are wrong.

5. Subscriptions downgraded without a save attempt

Customers downgrade. That's normal. What's not normal is finding out about it three weeks later in a monthly report. A downgrade is a signal — the customer decided your higher tier isn't worth it. If nobody reaches out within the first 48 hours, the save rate drops to near zero.

SELECT
    c.email,
    s.previous_plan_amount / 100.0   AS old_amount,
    s.plan_amount / 100.0            AS new_amount,
    (s.previous_plan_amount - s.plan_amount) / 100.0 AS monthly_loss,
    s.plan_changed_at
FROM subscriptions s
JOIN customers c ON s.customer_id = c.id
WHERE s.previous_plan_amount > s.plan_amount
  AND s.plan_changed_at > CURRENT_DATE - INTERVAL '30 days'
  AND s.status = 'active'
ORDER BY monthly_loss DESC;

This assumes your data includes previous_plan_amount — if you're syncing subscription events, it comes from previous_attributes. Otherwise reconstruct from subscription.updated webhooks or Stripe subscription analytics. The monthly_loss column gets people's attention. A single $500/month downgrade that could have been saved is $6,000 in recovered ARR.

6. Coupons and discounts that never expired

A sales rep promises "3 months at 50% off" to close a deal. The coupon gets created in Stripe with duration: repeating and duration_in_months: 3. Except sometimes it gets created with duration: forever. Now you're giving a permanent 50% discount that nobody intended and nobody is monitoring.

SELECT
    c.email,
    s.plan_amount / 100.0   AS list_price,
    d.coupon_id,
    d.percent_off,
    d.duration,
    CURRENT_DATE - d.start  AS days_active,
    CASE WHEN d.percent_off IS NOT NULL
         THEN s.plan_amount / 100.0 * d.percent_off / 100.0
         ELSE d.amount_off / 100.0
    END                     AS monthly_leak
FROM discounts d
JOIN subscriptions s ON d.subscription_id = s.id
JOIN customers c     ON s.customer_id = c.id
WHERE d.duration = 'forever'
   OR (d.duration = 'repeating'
       AND d.start + (d.duration_in_months || ' months')::INTERVAL < CURRENT_DATE)
ORDER BY monthly_leak DESC;

The second condition catches repeating discounts past their intended end. Stripe ends repeating coupons automatically, but if someone manually re-applies the coupon after expiry, it resets the clock. I've seen support reps do this without realizing the customer already had one.

Putting it together

Run these six queries weekly. Each one targets a different crack in the revenue pipeline:

Query Leak type Typical recovery
Failed charges Involuntary churn 30-50% recoverable with outreach
Aging invoices Collection gap 70%+ collectible within 60 days
Won, never billed Process failure 90%+ recoverable once detected
Pricing drift CRM/billing mismatch Varies — often reveals under-billing
Downgrades Voluntary churn 10-20% saveable within 48 hours
Perpetual discounts Coupon mismanagement 100% recoverable going forward

The hard part isn't the SQL. It's the join. Your CRM data and your Stripe data live in different systems with different schemas and no shared key. Email matching gets you 70% of the way. Domain matching adds another 15%. The rest requires manual mapping or a system that handles identity resolution for you.

Fastero's revenue leak detection handles this — it connects to Stripe and your CRM, lands both into a cross-source DuckDB store, resolves identities automatically, and runs these patterns continuously. When it finds a leak, you get a Slack alert with the customer, the amount, and the leak type. No warehouse required, no query maintenance. But if you have a warehouse and 30 minutes, start with query #3 (won deals, never billed). It's the highest-value leak and the easiest to fix. You'll probably find money.

Related:


Try Fastero free — connect Stripe and your CRM, get automated leak detection with zero SQL maintenance. No credit card required.

Ready to try it yourself?

Connect your database, ask questions in plain English, and get live dashboards — in under 2 minutes. No credit card required.