I run a small aviation SaaS ($45 MRR, 3 paying customers, 75% churn). This morning I decided to actually look at my data instead of write more code. In 45 minutes I found three silent revenue-leaking bugs. Sharing each one because they're the kind of stuff every indie SaaS has and never notices.
Bug 1: cron silently returned 0 candidates for 6 weeks
I had a winback cron (/api/cron/winback) that emails canceled subscribers at day 7, 21, 45 after they cancel. The Supabase query:
const { data: subs, error } = await supabase
.from("subscriptions")
.select(`user_id, status, tier, current_period_end, updated_at, created_at, ...`)
.eq("status", "canceled")
.gte("updated_at", new Date(Date.now() - 60 * 86400000).toISOString());
Problem: the column is canceled_at, not updated_at. The Supabase JS client returned { error: 'column does not exist' }, the cron caught it and quietly returned an empty array. Every day for weeks, "0 candidates, 0 emails sent, exit 0". No alarm.
I caught it because I ran SELECT COUNT(*) WHERE winback_pulses_sent IS NOT NULL and got 0 across 51 premium-tagged profiles. Then I read the cron source.
Lesson: anywhere your code does if (error) return [], log the error first. Silent failure is the worst failure mode in marketing automation because users don't bounce — they just never hear from you.
Bug 2: webhook never wrote the timestamp it was supposed to
Same subscriptions table. 14 rows with status='canceled' and canceled_at = NULL. Stripe knew when each cancellation happened, my webhook just never stored it.
Looking at the webhook:
case "customer.subscription.deleted": {
if (sub) {
await supabaseAdmin
.from("subscriptions")
.update({ status: "canceled" }) // ← no canceled_at here
.eq("user_id", sub.user_id);
// ... later, inside a try block ...
try {
// ... other side effects ...
await supabaseAdmin
.from("subscriptions")
.update({
cancellation_reason: cancelReason,
cancellation_feedback: cancelFeedback,
canceled_at: new Date().toISOString(),
})
.eq("user_id", sub.user_id);
} catch (err) {
// swallow
}
}
}
canceled_at was only set inside a try block alongside other side effects. If any of the side effects failed (which it apparently did, since 14/15 rows had NULL), the whole try aborted and canceled_at never got written. The first .update() was the only one guaranteed to run, and it didn't include canceled_at.
Fix: move the timestamp to the unconditional update.
.update({ status: "canceled", canceled_at: new Date().toISOString() })
Then backfilled 15 historical rows from Stripe's canceled_at via the Stripe API + Supabase REST PATCH.
This bug compounds the first one: even with the cron fixed, it would've found 0 candidates because the column it filtered on was NULL across all historical cancels.
Bug 3: filter that worked on day 1 broke as data accumulated
Recovery cron for abandoned checkouts. Pulls Stripe sessions in status='expired' from last 48h, then filters out anyone who "already paid". The filter:
const { data: activeProfiles } = await supabaseAdmin
.from("profiles")
.select("email, subscription_tier")
.neq("subscription_tier", "free");
Intent: "don't bother people who already converted". Implementation: filter by subscription_tier column in profiles.
Reality: 51 profiles in my DB had subscription_tier = 'premium' from past subscriptions that had since canceled. The webhook bug above meant their tier wasn't downgraded to free when they canceled. So when a new abandoned-checkout user shared an email with one of those 51 zombie profiles, the cron silently skipped them.
Result: cron output said {"total_abandoned": 11, "sent": 0, "skipped": 11} every day. Looks like the system is working ("no abandoned checkouts to recover today"), is actually broken.
Fix: derive the "already paid" set from subscriptions.status='active', not from profiles.subscription_tier. The subscriptions table is what Stripe writes, the profiles tier was a denormalized convenience that drifted.
What I shipped after finding all three
- Winback cron fix → 8 emails to real canceled customers immediately (1 of them at pulse 3 = 60% off final offer)
- Webhook fix → future cancels populate
canceled_atcorrectly - Recovery cron fix → next run will pulse 11 abandoned-checkout emails in pipeline
- Manual final-shot to 5 maxed-out abandoned-checkout leads (60-cent customs in Resend)
- Backfilled 15 historical timestamps from Stripe API
Total emails out from those 45 minutes: 14, all with WINBACK60 coupon (60% off 3 months). Worst case: nobody converts and I learned my data flow. Best case: even 2 conversions = +$30 MRR which is 66% growth on a $45 baseline.
The meta-lesson
I had been writing more features all week. Adding more cron jobs. More email templates. More variants. None of it would have helped because the actual pipes were leaking. Sometimes the highest-leverage 30 minutes you can spend is just SQL-querying your own database.
If you run a small SaaS: try SELECT status, COUNT(*) FROM subscriptions GROUP BY status and reconcile it against your Stripe dashboard. If those don't match, you have at least one of the three bugs I had.
I also build aitells.vercel.app (free AI text detector + paid humanizer) and supabase-security (open source RLS auditor). Both built after I got bitten by the problem they solve. Same pattern.
Top comments (0)