Skip to content

Web UX conversion audit: wire the pricing→trial funnel (marketing + chat) #1902

Description

@sweetmantech

Tracking issue for the 2026-07-29 web UX conversion audit (marketing + chat). Live funnel context, out of scope beyond this line: /pricing is the #2 marketing page (79 visitors in the 30 days to 2026-07-29) yet Stripe shows the last real trial signup and last new card were both 2026-06-06 — pricing traffic is producing zero trials. Five PRs shipped 2026-07-29 (the docs#280 → api#797 → marketing#60 card-on-file chain, marketing#56 direct checkout, marketing#61 the P0 contrast fix) — see Done; the rest are open.

Decision 2026-07-29 (Sweets): the pricing page's Pro CTA now starts Stripe checkout directly on the marketing site instead of deep-linking into chat. Marketing mounts the same Privy app as chat, POST /api/subscriptions/sessions is CORS-open (Access-Control-Allow-Origin: * + OPTIONS handler), and getOrCreateAccountIdByAuthToken auto-provisions accounts for first-time signups, so the cross-domain relay (marketing to chat homepage to login to checkout) was avoidable complexity. marketing#56 is reworked to a useProCheckout hook; chat#1904's ?intent=pro-trial param keeps its outreach-email / shared-deep-link role. Fewer redirects, no cross-origin session loss before the card.

Decision 2026-07-29 (Sweets) — the credit/monetization-mechanics items moved here from chat#1889 (its rows 34–36, now retired there): card-on-file 333→555, scheduled credit refill, and tagged-CTA signup attribution. They are revenue work, not onboarding, and this tracker already owns the card-on-file surface (marketing#60 / chat#1910).

Decision 2026-07-30 (Sweets) — the three defects from Ben Hanchett's 2026-07-29 report moved to chat#1912. The catalog-report dead end (was P0), the contradictory side-menu auth state (was P0), and the unlinked tooling in the side menu (was P1) were one live customer's continuous path through the product, not three unrelated papercuts — splitting them across two priority buckets here obscured that and left all three unowned. This tracker keeps the pricing→trial funnel; #1912 owns the post-signup path for account 148e1644. marketing#59 stays here and is the marketing-side sibling of #1912's row 1; land the two with consistent progress copy.

Goal

A visitor who clicks "Start 30-day trial" on recoupable.dev/pricing reaches the existing Stripe checkout (POST /api/subscriptions/sessionstrial_period_days: 30) without losing intent at login; every in-app upsell surface (sidebar trial card, composer, valuation report) either converts or captures an email; and the funnel emits custom analytics events so conversion is measurable without manual archaeology. Files involved: chat/hooks/useSubscribeClick.ts, chat/lib/stripe/createClientCheckoutSession.ts, chat/components/Sidebar/UnlockProCard.tsx, api/lib/stripe/config.ts, marketing pricing/valuation pages.

PRs (updated 2026-07-30)

PR Item State
marketing#61 P0 legibility: /pricing + /advisory body text was invisible (--muted surface token used as a text color); 1.08:1 to 7.57:1 dark, 1.07:1 to 5.33:1 light, plus a regression guard ✅ merged 2026-07-29 — see Done
chat#1904 Honor checkout intent param for outreach emails + shared deep links (pricing-CTA role moved to direct checkout, decision 2026-07-29) 🔄 open — full suite 284 tests green, preview verification pending
marketing#56 Pro CTA starts Stripe checkout directly on marketing via useProCheckout (Privy login in place + sessions POST); free CTAs keep UTM links ✅ merged 2026-07-29 — see Done
chat#1903 useSubscribeClick: login prompt when signed out + same-tab redirect (popup-blocker fix) 🔄 open — 13 unit tests green, preview verification pending
marketing#58 Resolve the $19 Plus plan (remove or reframe pricing page to match the one real price) 🔄 open — Free tier bullets evidence-backed (DEFAULT_CREDITS 333), build green, preview verification pending
chat#1908 Valuation/catalog report email capture ("email me this report") 🔄 open — anonymous-capable route via chat Resend path, 20 tests green, preview verification pending
chat#1905 Anonymous composer: re-prompt sign-up on send attempt instead of dead-end disabled button 🔄 open — 282 tests green, draft persistence verified, preview verification pending
chat#1907 Custom analytics events (signup, checkout opened, trial started, valuation run) — chat side 🔄 open — 4 events wired through one trackEvent door, 279 tests green, preview verification pending
marketing#57 Custom analytics events — marketing side (pricing CTA clicks, valuation runs) ⚠️ open — CONFLICTING after #56 merged (both touch the pricing CTA); needs a rebase before it can merge
marketing#55 Pricing + Valuation links in the footer (header already had inline desktop nav, see PR scope note) 🔄 open — build green, preview verification pending
marketing#59 Valuation run progress UI (currently ~60s frozen form) + drop the a–z Spotify prefetch 🔄 open — progress + retry UI, alphabet prefetch deleted, 23 tests + build green, preview verification pending
marketing#60 Free CTA opens a $0 Stripe card-setup screen (card on file for top-ups) + enterprise CTA books a call (Calendly) instead of a bare mailto: ✅ merged 2026-07-29 — see Done
docs#280 OpenAPI contract + reference nav for POST /api/subscriptions/card-on-file (contract-first, leads the api PR) ✅ merged 2026-07-29 — see Done
api#797 POST /api/subscriptions/card-on-file route exposing the existing createCardOnFileSession ($0 setup mode) ✅ merged 2026-07-29 — see Done
chat#1906 Model picker: dedupe list, badge premium models, wire lock → upgrade prompt 🔄 open — 285 tests green, premium derived from gateway pricing metadata, preview verification pending
database#TBD · api#TBD · chat#TBD Card-on-file upsell at the end of onboarding: 333 → 555 monthly credits, $0 today (from chat#1889; pairs with chat#1910 + marketing#60) ⏳ not started
database#TBD · api#TBD Refill credits on a schedule, not on read (from chat#1889; 2026-07-28 finding: 91% of the fleet overdue) ⏳ not started
marketing#TBD · chat#TBD · api#TBD · database#TBD Tagged CTA links → first-touch signup attribution (from chat#1889) ⏳ not started

Merge sequencing: no hard pairs remain after the 2026-07-29 direct-checkout decision; marketing#56 checks out against the api directly and chat#1904 serves outreach/deep links independently. marketing#56 and marketing#57 both touch the Pro CTA, whichever merges second resolves the conflict. Otherwise pick from the top. The card-on-file work is a hard chain: docs#280 then api#797 then marketing#60#60's free CTA calls that route, so merging it first ships a CTA that only shows an error.

Done

  • recoupable/marketing#61 — pricing and advisory body text is legible again. Fixes chat#1909.
    ✅ Shipped 2026-07-29 (merged to main as fec197d by @sweetmantech).
    --muted is a surface token (#f7f7f7 light, #151515 dark) and four pages used it as a text color, rendering text the same shade as the card behind it. All 24 occurrences of text-[var(--muted)] became text-[var(--muted-foreground)], the token already used correctly in 65 other places: app/pricing/page.tsx (10), app/advisory/page.tsx (10), app/advisory/book/BookingForm.tsx (3), app/advisory/book/page.tsx (1). --muted itself is unchanged and still used as a surface in 3 places. Shipped with lib/__tests__/mutedTokenUsage.test.ts, which walks app/, components/ and lib/ and fails if the surface token appears in a text-* utility; it runs under the existing pnpm test since this repo has no .github workflows.
    Verified on the preview built from head 5316ed87, 262 element measurements across /pricing and /advisory in both themes with zero elements below the AA 4.5:1 floor (worst 5.33:1, median 7.57:1 on pricing dark), against 1.08:1 dark and 1.07:1 light before. Screenshots of both themes on the assets/pr61 branch. The guard was verified in both directions: with the token fix stashed it fails and names all four files.
    Measurement note worth keeping: a first pass wrongly reported the Pro card failing at 1.05:1, because Tailwind v4 emits text-white/90 as oklab(...) and parsing the computed color string as RGB yields garbage. Correct method is to paint each color onto a 1x1 canvas and read the pixel back, then composite alpha over the resolved background.

  • recoupable/marketing#60 — free CTA opens a $0 card-setup screen; enterprise CTA books a call.
    ✅ Shipped 2026-07-29 (merged to main as daec044 by @sweetmantech). Completes the card-on-file chain: docs#280 to api#797 to this.
    The free plan CTA is now a client button backed by hooks/useCardOnFile.ts, which posts to POST /api/subscriptions/card-on-file and redirects same-tab to a Stripe setup-mode session, saving a card for later credit top-ups without charging. The Privy sign-in gate shared with the Pro checkout was extracted into hooks/useGatedStripeRedirect.ts rather than duplicated, keeping useProCheckout's public API unchanged. The Partner CTA points at siteConfig.bookingUrl (Calendly) in a new tab, replacing a bare mailto:. Two copy corrections shipped with it: the FAQ no longer claims the Pro trial needs no credit card (it does collect one), and "No credit card required" was dropped from the bottom CTA.
    Verified on the preview built from head 164e8ee1, 17/17 across two comments: 13 on the free flow (click to Privy in place to auto-resume after the emailed code to Stripe "Save payment information" with no amount and no subscription language, metadata.accountId correct, existing Stripe customer reused rather than duplicated, 0 payment methods / 0 charges / 0 subscriptions) and 4 on the enterprise CTA (clicking genuinely opens a new tab, lands on the renamed event, is404: false). This run required the api test branch to be synced with main first, since lib/config.ts resolves previews to test-recoup-api.
    Two things deliberately left open, both product calls rather than code: #58 still adds a "No credit card required" bullet that is now false, and the page has two free entry points with different friction (this CTA collects a card, the bottom "Get started free" does not).

  • recoupable/api#797POST /api/subscriptions/card-on-file route.
    ✅ Shipped 2026-07-29 (merged to main as 18a1aa5 by @sweetmantech).
    Exposes the pre-existing createCardOnFileSession helper over HTTP: route.ts to createCardOnFileSessionHandler to validateCreateCardOnFileSessionRequest, reusing the sibling's strict body schema and error mapper. Auth via x-api-key or Privy bearer through validateAuthContext; accountId is never read from the body (a strict schema 400s on it). Returns flat { id, url }; OPTIONS returns CORS headers so recoupable.dev can call it cross-origin. One deliberate divergence from the sibling, documented in the spec: a URL-less Stripe session is a 500, not the sessions route's 400, since the caller cannot correct it post-auth. A review round replaced a hand-rolled request.json() try/catch with the shared lib/networking/safeParseJson (33 other validators use it); the observable consequence is that an unparseable body now returns the same missing-field 400 as an empty one instead of Invalid JSON body, still a 400 either way.
    Verified against the preview for the final commit c11e4c79 (deployment confirmed built from that sha), using a key minted on the preview itself via POST /api/agents/signup — the trick that unblocked the authenticated path, since prod keys 401 on previews due to a different key-hash salt. First run 9/9 covering the 200 plus every documented 4xx with a valid key; re-run 7/7 after the refactor including a new empty-body probe. The returned session renders Stripe's card-setup screen ("Save payment information", Save button, no amount, no subscription language, asserted programmatically), and the Stripe customer carried the correct metadata.accountId with 0 payment methods, 0 charges and 0 subscriptions. Suite 231/231, tsc and lint clean, CI green.
    Note: previews carry live Stripe credentials, so the runs created real cs_live_ setup sessions and one Stripe customer (no card, no charge).

  • recoupable/docs#280 — OpenAPI contract for POST /api/subscriptions/card-on-file.
    ✅ Shipped 2026-07-29 (merged to main as 5255df6 by @sweetmantech).
    Documents the $0 setup-mode session that saves a card without charging: POST /api/subscriptions/card-on-file, auth by x-api-key or Privy bearer, body { successUrl }, 200 { id, url }, plus 400/401/500. Reuses the sibling's request/response schemas rather than duplicating them, and adds the page to the Accounts to Billing nav in docs.json so it reaches llms.txt. Contract-first: this is what api#797 implements against and what marketing#60 calls.
    Verified by running npx mintlify@latest dev against the branch and walking the rendered page, 11 checks with screenshots: nav placement and active state, request/response schemas, all four status tabs, and code-sample generation across cURL, JS and PHP. Two fixes came out of that run: the description was cut from 5 sentences to 3 (review note from @sweetmantech), and per-status error examples were added because the shared error schema's single field-level example made the 401 tab display a 400 message. Each documented error string was captured from the live api preview rather than invented. Confirmed live post-merge: the page returns 200 at docs.recoupable.dev/api-reference/subscriptions/card-on-file-create and card-on-file appears in llms.txt.
    Follow-up noted on the PR: POST /api/subscriptions/sessions has the same shared-example defect and renders a 400 message under its 401 tab.

  • recoupable/marketing#56 — pricing Pro CTA starts Stripe checkout directly on the marketing site.
    ✅ Shipped 2026-07-29 (squash-merged to main as 4976c30 by @sweetmantech).
    The Pro card's CTA is now a client button backed by hooks/useProCheckout.ts: it opens the Privy modal in place on recoupable.dev when the visitor is signed out, auto-resumes after auth via a pending flag, requests a session from POST /api/subscriptions/sessions with the Privy bearer (lib/checkout/createCheckoutSession.ts), and navigates the same tab to the returned Stripe URL. PricingPlan.ctaHref became optional because Pro no longer links out. Free CTAs keep UTM-tagged links to chat. The ?intent=pro-trial deep link was dropped from marketing entirely and survives only for outreach emails (chat#1904).
    Verified on the preview built from the PR head (022e4fd), 13/13 checks: signed-out click opened Privy over /pricing without leaving the domain; entering the emailed code advanced straight to Stripe with no second click, proving the auto-resume; the redirect stayed in the same tab (popup-blocker safe); checkout rendered "Try Recoupable Pro / 30 days free / then $99.00 per month starting August 28, 2026" for price_1RyDFD00JObOnOb53PcVOeBz; cross-origin curl from the preview host confirmed OPTIONS 200 with Access-Control-Allow-Origin: * and 401 for both missing and invalid bearers; a Stripe customer search confirmed the test created no customer or subscription (payment was deliberately not completed). Screenshots on the assets/pr56 branch.
    Known side effect of merging: marketing#57 and marketing#60 touch the same CTA and conflicted; Tech322/setting mobile #60 has been rebased and is mergeable, Tech322/side menu #57 still needs a rebase.

Open — P0: broken or intent-losing revenue paths

  • Pricing Pro CTA checks out directly on marketing; intent deep link serves outreach. (recoupable/marketing#56 direct checkout + chat#1904 outreach deep link, independent)

    • Why: "Start 30-day trial" ($99 Pro) and "Start free" on /pricing both link to bare https://chat.recoupable.dev/ — no plan param, no UTM. The buyer lands on a generic chat homepage behind a login modal and must rediscover the trial in a hamburger sidebar. Verified live 2026-07-29 by crawling the pricing page's anchor hrefs. The same intent link is also needed by sales outreach emails: today they have no durable upsell URL — the 2026-07-29 rescue email (Casey / Silo) had to plan around minting a raw Stripe checkout session that expires in ~24h. A permanent ?intent=pro-trial link makes every outreach email upsell-capable.
    • Fix (revised 2026-07-29, see decision callout): (1) marketing: useProCheckout hook on the pricing page — Privy login in place if needed (same Privy app as chat), then POST /api/subscriptions/sessions with the bearer and a chat success URL, then window.location.href to the returned Stripe URL; free CTAs keep UTM-tagged links to chat. (2) chat: ?intent=pro-trial handler ships as the durable upsell link for outreach emails and shared deep links — immediately to Stripe when a session exists, after Privy auth otherwise. (3) outreach emails use chat.recoupable.dev/?intent=pro-trial&utm_source=outreach&utm_medium=email (convention only).
    • Done when: clicking "Start 30-day trial" on /pricing as a signed-out visitor opens the Privy modal on recoupable.dev and lands on Stripe checkout for price_1RyDFD00JObOnOb53PcVOeBz with the 30-day trial, never passing through the chat homepage (signed-in visitors go straight to Stripe); clicking the intent link from an email while already signed in lands on Stripe checkout directly, no rediscovery; the two entry paths are distinguishable by UTM in analytics (pairs with the P1 analytics item).
  • useSubscribeClick silently no-ops for signed-out users and is popup-blocker-fragile for signed-in users. (chat#1903)

    • Why: hooks/useSubscribeClick.ts:13 returns early when !userData?.account_id — the sidebar "Start Free Trial" button does nothing for signed-out users (reproduced live 2026-07-29). lib/stripe/createClientCheckoutSession.ts calls window.open after an async fetch, which real-browser popup blockers kill; errors are also swallowed (the function returns { error } but the hook ignores it).
    • Fix: when signed out, open the Privy login modal (and continue to checkout after auth); replace window.open(url, "_blank") with window.location.href = url; surface fetch errors as a toast.
    • Done when: signed-out click → login prompt → checkout; signed-in click → same-tab Stripe checkout; a forced 500 from /api/subscriptions/sessions shows a visible error.
  • Resolve the $19 "Plus" plan fiction on the pricing page. (recoupable/marketing#58)

    • Why: the API has exactly one purchasable price (api/lib/stripe/config.ts:1-2: price_1RyDFD00JObOnOb53PcVOeBz, $99/mo, 30-day trial). There is no $19 checkout path anywhere; the Plus card's "Start free" CTA is a decoy that also contradicts its own $19 price tag.
    • Fix: decision needed — either (a) reframe the page around Free tier + Pro $99 + Partner, or (b) create the $19 price in Stripe and a plan-aware checkout. Default to (a) unless we actually want to sell Plus.
    • Done when: every plan card on /pricing has a working purchase (or signup) path that matches its copy and price.
  • Valuation report captures an email at the moment of peak interest. (chat#1908; no api endpoint needed, ships on the chat Resend path)

    • Why: the valuation result (verified 2026-07-29 with a live Mac DeMarco run: $27.8M band, 238 tracks) is delivered anonymously, the full catalog report lives at a public URL (chat.recoupable.dev/catalogs/{id} renders without auth), and the only next step offered is weekly-report setup. Our best lead magnet — and the source of the Attio Valuation Leads pipeline — captures nothing.
    • Fix: add an "Email me this report" capture on the valuation result (marketing panel and/or the catalog report page), feeding the existing valuation-email path (api#773 rework) and the Attio lead route. Keep the number visible (don't hard-gate; the ungated number is the hook).
    • Done when: an anonymous valuation run offers an email field; submitting it sends the report email and creates/updates the corresponding Valuation Leads entry.
  • Anonymous composer re-prompts sign-up instead of dead-ending. (chat#1905)

    • Why: a signed-out visitor who dismisses the login modal can type a message, but the send button stays permanently disabled on a never-resolving "Loading user profile" and Enter does nothing (reproduced 2026-07-29 in a cookie-less context). Every curious visitor who closes the modal is lost silently.
    • Fix: on send attempt (click or Enter) while unauthenticated, reopen the login modal with the draft preserved.
    • Done when: signed-out → dismiss modal → type → Enter reopens login; after auth the drafted message is still in the composer.

Open — P1: measurement + discoverability

  • Custom analytics events on both apps. (chat#1907 + recoupable/marketing#57)

    • Why: both Vercel Web Analytics projects report zero custom events (verified via the analytics API 2026-07-29) — signup, checkout, trial, and valuation activity are invisible; this audit required Stripe archaeology to answer "when did someone last start a trial".
    • Fix: track() events: signup_started, signup_completed, checkout_opened, trial_started, valuation_run, pricing_cta_clicked (with plan + page props; checkout_opened also carries the utm source/medium so email-driven checkouts from outreach are distinguishable from pricing-page ones).
    • Done when: the events appear in the Web Analytics dashboards for chat and marketing with non-zero counts from a manual walkthrough.
  • Pricing and Valuation reachable from the visible desktop header and the footer. (recoupable/marketing#55)

    • Why: neither /pricing (Tech322/desktop #2 page) nor /valuation (I add loading UI. #3) appears in the footer's Product/Resources/Company columns (verified 2026-07-29). Correction 2026-07-29: the desktop header already shows Valuation/Pricing/Docs/Blog inline at >=768px (shipped in marketing#6); the original hamburger-only observation came from a narrow audit viewport, so only the footer gap is real.
    • Fix: expose Valuation + Pricing as top-level header links on desktop; add both to the footer Product column.
    • Done when: both links are visible without opening the hamburger at ≥1024px, and present in the footer.
  • Valuation run shows progress and stops hammering Spotify search. (recoupable/marketing#59)

    • Why: the run is a single synchronous POST /api/valuation taking ~60s (measured 2026-07-29, Mac DeMarco, 238 tracks) while every control is disabled with no indicator — users will assume it broke, and big catalogs risk function timeout. Separately, /valuation page load fires 26 GET /api/spotify/search?q=<letter> requests, one per letter a–z.
    • Fix: add a progress state (even an indeterminate "Measuring 238 tracks…" with elapsed feedback); remove or lazy-load the alphabet prefetch. Async/workflow conversion of the endpoint can be a follow-up if timeouts show up in practice.
    • Done when: clicking "Value my catalog" shows visible progress within 1s, and a fresh /valuation load fires zero alphabet-prefetch search requests.
  • Model picker supports the premium-models upsell. (chat#1906)

    • Why: the picker shows ~200 models to anonymous/free users with no locks or Pro badges (verified 2026-07-29), including duplicates ("GPT 5.2" twice, "Qwen3 VL 235B A22B Thinking" twice) — the sidebar sells "premium AI models" but nothing in the picker is visibly premium, so there is no upgrade trigger at the point of model choice.
    • Fix: dedupe the list; badge premium models; selecting a locked model while unsubscribed opens the trial/checkout prompt instead of silently selecting.
    • Done when: the list has no duplicate entries, premium models are visually badged, and selecting one while free/anonymous triggers the upgrade prompt.

Open — credit mechanics + attribution (moved from chat#1889, 2026-07-29)

  • Card-on-file upsell at the end of onboarding: 333 → 555 monthly credits, $0 today. (database#TBD · api#TBD · chat#TBD)

    • Why: onboarding currently ends with no ask. Web traffic is rising and the funnel converts a valuation into a signup but never into a payment relationship, so every cold-start account sits on the free tier indefinitely. A card on file is the smallest possible commitment (no charge, no plan) and is the step that makes every later monetization path — auto top-up, Pro, metered Songstats — a one-click upgrade instead of a new decision. Deliberately a small upsell moment placed at the point of maximum goodwill: right after the account has just seen its catalog valued (row 7).
    • Ground truth (already built): DEFAULT_CREDITS = 333 and PRO_CREDITS = 9999 live in api/lib/credits/const.ts, mirrored in chat/lib/consts.ts (the docstring requires the two agree). createCardOnFileSession already creates exactly the screen this needs — Stripe Checkout mode: "setup", $0, no product, no subscription — and is already used by ensureSongstatsPaymentMethod. accountHasPaymentMethod and findDefaultPaymentMethodForCustomer exist. So the Stripe half is largely done; the new work is the credit tier, the grant trigger, and the CTA.
    • ⚠️ Blocker on the copy, not the code — "no charge" must stay true. Auto-recharge is opt-out, not opt-in: getAutoRechargeOptOut treats the absence of a Stripe metadata key as consent, so a card saved through this CTA becomes chargeable off-session by default via autoRechargeOrFail. Advertising "$0 today" while silently enabling off-session charging is the Eric top-up incident shape, and Credits: reprice POST /api/research/web 5→1 credit + self-serve auto top-up opt-out #1861 shipped the opt-out because of it. This CTA must write auto_recharge_opt_out at save time (default opted-out) and say plainly that we will not charge without asking. Landing the upsell without this is a trust regression, not a conversion win.
    • Fix: documentation-driven, docs → database → api → chat.
      1. Contract: write the three-tier grant down — 333 no card · 555 card on file, no subscription · 9999 Pro — plus the endpoint that starts the card session and the one that reports tier + card state.
      2. api: add CARD_ON_FILE_CREDITS = 555; teach checkAndResetCredits to pick the total from (isPro, hasPaymentMethod) instead of (isPro) alone; mirror the constant into chat/lib/consts.ts. Expose a route wrapping createCardOnFileSession with a successUrl back into /setup.
      3. Grant on save, not on next refill. checkAndResetCredits only refills after a month, so a user who adds a card would see nothing change. Handle checkout.session.completed with mode: "setup" and raise remaining_credits to the new tier immediately, idempotently (the credits webhook already ignores payment intents whose purpose it doesn't own; mirror that guard).
      4. chat: the CTA on the completion surface row 7 builds, showing the real numbers and the $0. Hide it when accountHasPaymentMethod is already true or the account is Pro.
    • Done when: a free-tier account finishing setup sees the offer; clicking it opens Stripe's card screen with no amount due; saving a card raises the visible monthly allotment from 333 to 555 without waiting for a refill cycle; the account is opted out of auto-recharge by default and a subsequent request that would exceed credits asks rather than charges; and an account that already has a card, or is Pro, never sees the CTA.
    • Note: scope is the upsell moment only. Pricing-page changes, trial mechanics and the Pro conversion path are out of scope; if this converts, the same pattern is worth repeating at other high-goodwill moments (first report delivered, first valuation refresh).
  • Refill credits on a schedule, not on read. (database#TBD · api#TBD)

    • Why: 🔁 Refill credits on a schedule, not on read. checkAndResetCredits is called from exactly one place - the GET /api/accounts/{id}/credits handler (getAccountCreditsHandler.ts) - so an account nobody opens never refills. The spend path never calls it: autoRechargeOrFail reads remaining_credits straight from selectCreditsUsage (autoRechargeOrFail.ts) and on a shortfall mints a Checkout session instead. Replace with a Vercel cron that sweeps accounts whose credits_usage.timestamp is over a month old and tops them up. Must resolve isPro correctly or it cements the bug - see the callout.
    • Ground truth (measured on prod, carried from chat#1889):

Finding 2026-07-28 (Sweets + sales sweep) — credit refills are lazy, and 91% of the fleet is carrying one that never fired. checkAndResetCredits refills a stale credits_usage row to the plan total (DEFAULT_CREDITS 333 / PRO_CREDITS 9999, const.ts), but it is only reachable from the GET credits handler. Nothing on the spend path calls it. So an account that is never opened never refills, and every credit-gated call it makes returns 402 and mints another Checkout session with no idempotency and no cap.
Measured on prod 2026-07-28: of 1,646 accounts holding a credits_usage row, 1,499 (91%) have a refill overdue by more than a month and unapplied; 133 of those are at or below zero, i.e. blocked right now against credits they are already entitled to; 60 are negative.
Three live cases, all fixed by hand today, none by code: ivo@disruptiverecords.nl sat at 2 credits stamped 2026-06-24 and generated 534 expired Checkout sessions in 30 days (134/200/200 in ~2-minute bursts at 06:00 UTC on three consecutive Mondays) - a single GET restored him to 333. derekgtaylor@me.com is a $20/mo subscriber since 2024-12-19 (21/21 charges paid, $405.46 lifetime) whose balance read 456 stamped 2024-12-19 - never refilled once in 19 months, against a Pro entitlement of 9,999/mo; he has paid for roughly 2% of a single month's allowance and used the product on exactly one day. stephanie.guerrero@onerpm.com is at -882 stamped 2026-07-04 with 14 artists and 3 live schedules.
A cron alone is not sufficient. getAccountSubscriptionState derives isPro by looking a Stripe subscription up from the account id (getAccountSubscriptionState.ts). evan@seekermusic.com is the only paying subscriber whose Stripe customer carries no accountId in metadata, so that lookup returns nothing, isPro is false, and he holds 333 credits while billing $5,000/mo. A sweep that inherits the same resolution would top him up to the free-tier number every month forever. Fixing the linkage (or resolving Pro by customer email as a fallback) is in scope for row 28.
Also note the refill SETS rather than increments - updateCreditsUsage({ remaining_credits: isPro ? PRO_CREDITS : DEFAULT_CREDITS }). A read against a free-tier account sitting above 333 silently reduces its balance, so the sweep must not regress balances it touches.

  • Fix: a Vercel cron (workflow in api, per the tasks-repo deprecation) sweeps accounts whose credits_usage.timestamp is over a month old and tops them up to the tier total — never downward (the current refill SETS rather than increments, so the sweep must not regress balances above tier). Resolve isPro robustly (customer-email fallback where Stripe metadata lacks accountId) or the sweep cements the evan@seekermusic.com bug at scale.

  • Done when: the sweep leaves zero accounts with a refill overdue by more than a month; a Pro account whose Stripe customer lacks accountId metadata still refills to the Pro total; an account sitting above its tier total is untouched; and the three 2026-07-28 live cases stay healthy on the next cycle without manual GETs.

  • 🔗 Tagged CTA links so a signup can be attributed to the social post that caused it. (marketing#TBD · chat#TBD · api#TBD · database#TBD)

    • Perspective (out of scope for this item): the marketing run publishes to 4 platforms daily and cannot currently tell which post, or which platform, produced a signup — so acquisition spend and creative decisions are unfalsifiable. Codified as step 6 of the recoup-internal-marketing skill; this item is only the capture chain.
    • Why: every CTA we publish is a bare recoupable.dev or an Instagram "link in bio" with no query tag. Verified across the 2026-07-28 slate — IG, X, YouTube, LinkedIn — none carries a tag, so zero of four posts is attributable. The click also crosses hosts (recoupable.devchat.recoupable.dev), so a tag on the landing URL alone does not survive to signup.
    • Fix:
      1. Link convention (no code). https://recoupable.dev/?utm_source=<ig|x|yt|li>&utm_medium=social&utm_campaign=<project-slug>. utm_campaign is the content project slug, already present as cfg.slug in the marketing runner's post.config.mjs. Standard UTMs so any analytics tool parses them. On X this is free — X weights any URL at 23 chars regardless of literal length, so a full UTM string costs exactly what a bare domain costs.
      2. marketing — first-touch capture. On first load, read utm_* and write cookie rcp_attr = {source, medium, campaign, landing, ts}, Domain=.recoupable.dev, Max-Age 90d, SameSite=Lax. First-touch only, never overwritten — last-touch would erase the post that earned the visit.
      3. marketing — forward across the hop. The "Open app" CTA from marketing#52 appends the attribution to its outbound URL as one opaque param, so the chain survives even if cookie scoping fails across the subdomain.
      4. chat — hand off once. On the auth-completion path, read cookie-or-param and POST to api exactly once, on account creation.
      5. database + api — persist. New table account_attribution: account_id (FK, unique — one row per account), utm_source, utm_medium, utm_campaign, landing_path, first_seen_at, created_at. Insert-if-absent so a returning visitor cannot overwrite first touch. A separate table rather than a column on accounts because attribution is optional metadata and accounts is hot.
      6. Instagram caveat — do not skip. IG captions do not render clickable links, so a per-post tag is impossible there; only the bio link is clickable. Ship as: set the bio link to the current campaign's tagged URL at publish time (campaign-level granularity). IG is our best-performing video platform, so treating an unmeasured IG zero as "IG does not convert" would be a measurement error, not a finding.
    • Check first: Vercel Analytics may already capture utm_* on recoupable.dev. If it does, steps 1-3 may reduce to reading an existing dashboard and only the signup join (4-5) is new work. Verify before building — it changes the size of the job.
    • Done when:
      1. Visit recoupable.dev/?utm_source=x&utm_medium=social&utm_campaign=verify-test, click Open app, sign up with a fresh email → account_attribution has exactly one row for the new account_id with utm_source = "x" and utm_campaign = "verify-test".
      2. Re-visit with utm_source=li&utm_campaign=other on the same account → the row is unchanged (first-touch holds).
      3. A visit with no utm_* that signs up → no account_attribution row (absence, not a row of nulls).
      4. SELECT utm_source, count(*) FROM account_attribution GROUP BY 1 returns a per-platform signup count — the number the marketing run reads at its 48h re-pull.

Open — P2: polish found during the audit

  • Login modal email field a11y label. The Privy modal's email textbox has the accessible name "Submit" (should be "Email address"). Done when the field's accessible name is correct in an axe/a11y-tree check.
  • Partner plan "Talk to us" is a bare mailto:. (recoupable/marketing#60) Now points at the Calendly booking link in a new tab, labelled "Book a call", with the URL held once in siteConfig.bookingUrl. Done when the CTA opens the booking flow and the click is trackable. Note: two more enterprise-intent mailtos remain on /audit (lib/copy/audit.ts) and are unclaimed.

Spawned issues

  • chat#1909 (recoupable/marketing#61) — P0: /pricing body text renders at 1.08:1 contrast in both themes (--muted surface token used as a text color). Found 2026-07-29 while reviewing marketing#56 preview screenshots. Pre-existing on prod, not caused by this fleet, but it defeats the purpose of every pricing-page change here, so it should land first. Overlaps marketing#58 in the same file.
  • chat#1910 — free CTA leads to a $0 card-on-file registration screen (Stripe mode: "setup"). Marketing side folded into marketing#60 (decision 2026-07-29); the api route + docs are in sibling PRs. Remaining copy decision: Tech322/side mobile #58 adds a "No credit card required" bullet that this makes false.
  • chat#1912Ben Hanchett's first-session blockers (account 148e1644): catalog report page dead-ends while measuring and cross-account, side menu renders Sign In alongside the signed-in user chip, and the side menu doesn't link Catalogs. Migrated out of this tracker 2026-07-30 (see the decision callout) because the three are one customer's continuous path. marketing#59 remains here as that issue's marketing-side sibling.

Architecture decisions

  • Checkout happens where the buyer already is. The pricing page authenticates and creates the Stripe session itself against api.recoupable.dev (same Privy app, CORS-open endpoint, auto-provisioning auth); we never bounce a buyer across subdomains before payment. Cross-domain deep links (?intent=pro-trial) exist for outreach emails and sharing, not for the primary CTA. Supersedes the marketing-to-chat relay design from earlier the same day.

  • The single Stripe price is the source of truth for what's sellable. api/lib/stripe/config.ts defines one subscription price ($99 Pro, 30-day trial). Any plan shown on the pricing page must map to a real price ID or a free tier — marketing copy never invents plans (the $19 Plus card is the standing violation this tracker removes).

  • Don't hard-gate the valuation number. The ungated dollar figure is the acquisition hook (per the valuation-first homepage direction); capture email alongside it, not in front of it.

Source references

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions