Rivvl · committed PR audit

Do not ship this PR yet

The screenshot is a genuine state-machine regression. The PR also leaves billing, refund, validation, and distributed-state gaps that can create duplicate charges, refund paid work incorrectly, or expose purchase history.

Confirmed from the supplied logs

Why regeneration ends at 0% / timeout

The terminal line POST /api/homes/compare 429 means no new generation was created. The API found another queued/running generation and returned its ID. The progress page then treats any 429 carrying generationId as a valid run, stores that old ID, and polls it.

If that row was accepted by Inngest but never claimed, it remains queued at zero progress. The status endpoint only converts it to GENERATION_STALE after 20 minutes, producing the exact message in the screenshot: “Report generation took longer than expected.” The green credit message is consistent with the row never consuming a credit.

PUT /api/inngest 200 only proves that the function registration endpoint answered. It does not prove that the worker received or claimed the event. The PR did not change the home generation trigger/processor, so the missing worker claim is an Inngest delivery/consumer or environment problem; the PR’s client/API state handling makes that problem sticky and unrecoverable.

Concrete code path

  1. src/app/homes/report/progress/page.tsx:498 accepts 429 + generation ID.
  2. src/app/api/homes/compare/route.ts:641 returns the conflict row.
  3. src/app/api/homes/report-status/[generationId]/route.ts:94 waits 20 minutes before failing it.
  4. Rerun only returns a reserved bundle credit when the generation is already failed; it does not recover an active orphaned queue row.

Priority findings

The PR is not safe as a billing or generation release

PriorityFindingImpact / location
P0Refunded upgrades remain reclaimableupgradeRefunded=true is only a side flag. Both upgrade runners reclaim upgrade_failed, and verify-session trusts Stripe payment_status=paid, which remains true after a refund. A replay can generate paid content after the charge was returned. report-upgrade-status.ts:5, upgradeFailure.ts:225, verify-session/route.ts:62.
P0Refund state is written after the Stripe side effectThe row stays retryable while refunds.create runs. A concurrent retry can claim it; the later CAS then fails, leaving Stripe refunded and local state non-refunded. upgradeFailure.ts:225-256.
P1Checkout matching ignores the requested plan roleMatching uses only user + report metadata. A paid/open home_standard session can block or satisfy a home_premium request. upgradeCheckout.ts:16-23.
P1Duplicate-charge prevention scans only 30 sessionsThe comment says every session, but Stripe listing reads one page. Older paid/open sessions can be missed when the stored ID is stale, so a new checkout can be created. api/stripe/checkout/route.ts:455.
P1Purchase history trusts mutable billing identityAuthenticated users have broad profile/report update grants, including stripe_customer_id and report JSON. The new history code does not verify Stripe customer ownership or filter session metadata by user. This exposes sensitive purchase/report IDs if a customer ID is changed. dashboard/page.tsx:107, paymentHistory.ts:106, RLS migrations.
P2Cross-vertical validation is client-onlyHomes and vehicles compare APIs accept broad HTTP(S) URLs. Direct/stale requests can queue the wrong vertical before the worker rejects them, consuming capacity and creating generation state. compare/homes/page.tsx:151, both compare routes.
P2Email cooldown is process-local and fail-openA module-level Map resets on cold starts/instances, never cleans successful keys, and releases the reservation on downstream failure. dashboard/payment-history/email/route.ts:9.
P2Dashboard synchronously traverses all Stripe historyEvery dashboard render can paginate the complete customer history and expand charges before rendering. Latency and availability now depend on unbounded external work. dashboard/page.tsx:103-115, paymentHistory.ts:113.
P2Short-link resolver can reject valid providersIt tries HEAD and only falls back to GET for 405/501/403. A shortener that returns a non-redirect HEAD response or blocks HEAD differently is rejected; tests do not cover this or timeout/body cleanup. listingUrl.ts:133-157.

The P0/P1 findings are release blockers. Some underlying permissive RLS and inline Inngest fallbacks predate this PR, but the new refund/history features make those existing weaknesses operationally dangerous.

Requested feature checklist

Implemented, but not uniformly production-safe

ItemAssessmentReason
Free→Pro home upgradePartial / unsafeCheckout reuse and async upgrade exist, but refund/replay races and refunded-state reclaim remain.
Short linksPartialKnown short hosts and redirect allowlisting exist; provider behavior and server-boundary coverage are incomplete.
Vehicle progress = Real EstatePresentFailure-copy/layout parity was added; no equivalent new queue recovery was added.
Vehicle web report parityPresentPaid/unlock UI parity changes are present, but share the refund/replay lifecycle risk.
Report ID on support ticketsPresentServer resolves the owned generation’s readable ID into ticket metadata.
Remove “include report ID” copyPresentProgress pages no longer display the manual-copy instruction.
Bigger error messagePresentBoth progress pages use the larger error treatment.
Wrong links on wrong compare pagePartialNormal browser flow is blocked; direct API callers are not blocked before generation/credit work.
User purchase historyPresent / unsafeDashboard/email feature exists, but billing ownership, pagination, and distributed throttling need hardening.

Recommended order

Fix the invariants before polishing the UI

  1. Make generation recovery explicit: stale queued rows must be re-dispatched or failed/recreated; 429 must not be treated as a successful new run; add an integration test for the supplied screenshot path.
  2. Make refund lifecycle durable: reserve upgrade_refund_pending before Stripe, use idempotent refund/reconciliation, and make refund-pending/refunded terminal in every enqueue and verify path.
  3. Move billing-owned fields out of user-writable JSON/profile columns; verify Stripe customer/session metadata against the authenticated user; filter history by user.
  4. Paginate or ledger checkout discovery, include role in the report/payment identity, and add concurrency tests for stale report JSON and duplicate checkout.
  5. Move cross-vertical validation to both API boundaries, use GET fallback for short links deliberately, and replace process-local email throttling with shared durable state.

Existing local checks pass (tests, TypeScript, build), but they do not cover these boundary/concurrency scenarios. That is why the green build is not evidence that this PR is safe.