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
src/app/homes/report/progress/page.tsx:498accepts 429 + generation ID.src/app/api/homes/compare/route.ts:641returns the conflict row.src/app/api/homes/report-status/[generationId]/route.ts:94waits 20 minutes before failing it.- 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
| Priority | Finding | Impact / location |
|---|---|---|
| P0 | Refunded upgrades remain reclaimable | upgradeRefunded=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. |
| P0 | Refund state is written after the Stripe side effect | The 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. |
| P1 | Checkout matching ignores the requested plan role | Matching uses only user + report metadata. A paid/open home_standard session can block or satisfy a home_premium request. upgradeCheckout.ts:16-23. |
| P1 | Duplicate-charge prevention scans only 30 sessions | The 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. |
| P1 | Purchase history trusts mutable billing identity | Authenticated 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. |
| P2 | Cross-vertical validation is client-only | Homes 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. |
| P2 | Email cooldown is process-local and fail-open | A 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. |
| P2 | Dashboard synchronously traverses all Stripe history | Every 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. |
| P2 | Short-link resolver can reject valid providers | It 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
| Item | Assessment | Reason |
|---|---|---|
| Free→Pro home upgrade | Partial / unsafe | Checkout reuse and async upgrade exist, but refund/replay races and refunded-state reclaim remain. |
| Short links | Partial | Known short hosts and redirect allowlisting exist; provider behavior and server-boundary coverage are incomplete. |
| Vehicle progress = Real Estate | Present | Failure-copy/layout parity was added; no equivalent new queue recovery was added. |
| Vehicle web report parity | Present | Paid/unlock UI parity changes are present, but share the refund/replay lifecycle risk. |
| Report ID on support tickets | Present | Server resolves the owned generation’s readable ID into ticket metadata. |
| Remove “include report ID” copy | Present | Progress pages no longer display the manual-copy instruction. |
| Bigger error message | Present | Both progress pages use the larger error treatment. |
| Wrong links on wrong compare page | Partial | Normal browser flow is blocked; direct API callers are not blocked before generation/credit work. |
| User purchase history | Present / unsafe | Dashboard/email feature exists, but billing ownership, pagination, and distributed throttling need hardening. |
Recommended order
Fix the invariants before polishing the UI
- 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.
- Make refund lifecycle durable: reserve
upgrade_refund_pendingbefore Stripe, use idempotent refund/reconciliation, and make refund-pending/refunded terminal in every enqueue and verify path. - Move billing-owned fields out of user-writable JSON/profile columns; verify Stripe customer/session metadata against the authenticated user; filter history by user.
- Paginate or ledger checkout discovery, include role in the report/payment identity, and add concurrency tests for stale report JSON and duplicate checkout.
- 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.