Ominvo beta launches July 23, 2026 — only 10 spots remain Join the waitlist →
All posts
Field NotesDay 40

Day 40: Annual billing passes end-to-end (after three bugs nearly blocked it)

June 10, 20266 min read

Day 40 was supposed to be a clean end-to-end test of the annual billing flow. Set up a fresh test account, select Chad annual in /settings, complete Stripe checkout, verify the webhook fired, confirm the tier flipped. Simple. By noon, three separate bugs had surfaced — any one would have blocked launch on August 10. By evening, all three were fixed and the test had passed in production. This is what happened.

The signup that wasn't

The morning kicked off with a signup test — email and password, not Google OAuth, because the annual billing flow starts from a fresh account.

Created the account on production. Clicked the confirmation link. Got back: "Error sending confirmation email."

Pulled the Supabase auth logs. The error was a 550 from Resend: "The associated domain with your API key is not verified." Opened the Resend dashboard. The ominvo.com domain showed Pending — had been Pending for 29 days. The DKIM TXT record at resend._domainkey.ominvo.com was missing from Hostinger DNS. At some point it had been removed and nobody noticed.

Why hadn't anyone caught this? Because every Ominvo signup test in the last month had been Google OAuth. OAuth bypasses Resend entirely — no confirmation email, no transactional flow. Email/password signup had been silently returning a 500 the entire time. Any real user who tried to sign up with an email address instead of Google would have hit this error and left.

The fix was straightforward: re-added the DKIM TXT record in Hostinger DNS. Waited for propagation. Verified in Resend. Ran the test again. Confirmation email arrived in 11 seconds.

The lesson is uncomfortable but obvious in hindsight: if you ship multiple authentication paths, you have to test all of them. Not just the one you personally use every day.

The hidden bug above the bug

With email working, the annual billing test continued. Signed in. Navigated to /settings to select an upgrade plan.

The "Your Plan" card showed one button: "Upgrade to Chad — $30/mo." No annual toggle. No GigaChad option. The Day 38 annual billing rollout had updated the /pricing UI but had missed the in-app upgrade flow entirely. Logged-in users who wanted to upgrade had no way to select annual billing or upgrade to GigaChad from inside the product.

The obvious fix was to link the Settings CTA to /pricing. That ran into a second problem: PRE_LAUNCH=true gates all /pricing CTAs to /waitlist for every user, including logged-in ones. That flag was wired in Day 37 for anonymous visitors and it caught authenticated users too. If Settings linked to /pricing, it would route paying customers to the waitlist form.

That meant Settings needed its own authenticated checkout path — a UI that goes directly to Stripe Checkout regardless of what PRE_LAUNCH is doing on the marketing side.

The fix: rebuilt the "Your Plan" card with a Monthly/Annual toggle (defaulting to Annual), tier-aware upgrade cards (Noob sees both Chad and GigaChad side by side; Chad sees GigaChad only; GigaChad sees "You're on the top plan."), and its own fetch to /api/create-checkout-session with { tier, billingInterval }. No PRE_LAUNCH gate anywhere in the settings flow. Authenticated users in /settings always get the real checkout regardless of what the marketing site shows.

See current plan prices at /pricing — the tier breakdown and annual vs monthly comparison are there.

End-to-end, finally

With the rebuilt Settings page deployed, the annual billing E2E test ran for the first time end-to-end in production.

Toggled to Annual in /settings. Selected Chad. Hit "Upgrade to Chad." Stripe Checkout opened with a $288/yr line item. Completed the test payment. Webhook fired. Checked the Supabase businesses row: subscription_tier had flipped from noob to chad, subscription_status was active. Checked the Admin Revenue tab: MRR showed $84 — two existing monthly Chad subs at $30 each, plus the new annual sub contributing $24/mo. The Day 39 MRR fix proved its value on real annual data: the old calculation would have added $288 to MRR and shown a completely wrong total.

Tested cancellation through the Stripe Customer Portal. The subscription moved to cancel_at_period_end — status remained active, billing period running through June 2027. That is the correct behavior. You paid for the year; you get the year. The tier downgrade fires when the period actually ends, not when you initiate the cancellation.

One cosmetic bug surfaced in the Admin Revenue tab: the per-tier breakdown row shows "$30/mo × 3 subs = $90/mo" instead of summing per-sub actual MRR. The total MRR figure ($84) is correct because it uses the Day 39 aggregation. The row label uses an older calculation that does not account for annual subs contributing $24, not $30. Logged for Day 41 — display bug only, not a data integrity issue.

The full chain — Day 37 (toggle UI and Stripe price IDs) + Day 38 (checkout wiring and TIER_MAP fix) + Day 39 (MRR division) + Day 40 (settings rebuild and cancel flow) — is now proven end-to-end in production. First time.

The page task that fit in 30 minutes

/features/smart-filters shipped as the Day 40 page task. Smart Filters is a GigaChad-only feature for triaging the review inbox by star rating, keyword, status, time window, and saved views. The positioning: Chad and Noob can see all their reviews — Smart Filters add the triage layer once review volume climbs above 30-40 per month.

The headline visual in the hero is a static HTML/CSS star-rating filter — five spans in the pill-wrapper pattern from the Monthly/Annual toggle on /pricing, with "★ 1-2" hardcoded active in gold. No JavaScript. The interaction pattern communicated without running it.

Smart Filters pair with the Analytics Dashboard — the same data driving your charts is what you filter from when triaging. High-volume verticals like restaurants getting 80+ reviews a month see the biggest lift: that volume needs triage, not scrolling.

Two Product dropdown items still link to # (Alert Preferences). That is Day 41.

The lesson that keeps repeating

Three bugs almost blocked launch. None showed up in TypeScript checks or unit tests. All three required either a fresh signup attempt, a logged-in user hitting the in-app upgrade path, or a real Stripe webhook firing with production data.

The pattern is consistent: things break silently when you stop using them. The DKIM record sat broken for 29 days because nobody signed up with email. The Settings upgrade card was incomplete for two sprints because everyone who needed to upgrade used a direct link. Test every path. Especially the ones you assume are working.

Day 40 of building in public. Full changelog at /changelog.

Written by

The founder of Ominvo

Building review management for single-location small businesses. Join the waitlist →