Skip to content

[Refactor][Feat] Implement Plan Limits for Hard-and-Soft Item Caps#1215

Merged
BilalG1 merged 44 commits intodevfrom
payment-subscription-handling-rework
May 5, 2026
Merged

[Refactor][Feat] Implement Plan Limits for Hard-and-Soft Item Caps#1215
BilalG1 merged 44 commits intodevfrom
payment-subscription-handling-rework

Conversation

@nams1570
Copy link
Copy Markdown
Collaborator

@nams1570 nams1570 commented Feb 19, 2026

Suggested Review Areas

Please see plans.ts and seed.ts to verify whether the item caps are where they should be. Outside of that, each commit should be atomic so stepping through the commits should give you an idea of how I implemented each limit.

Discussion

Something to discuss: when a user cancels team/growth we regrant free fine, but any extra-seats they had just keeps billing. So they end up paying ~$29/mo per extra-seat on top of free's 1 seat, which is strictly worse than just staying on team. This surfaced while manually testing this PR, we only enforce the add-on base requirement at purchase time, nothing cascades on cancel. Should we cascade cancel add ons?

Context

Now that we have a stable suite of products for stack-auth, we want to limit the items under each product a customer has access to based on their plan. So for example, a free plan user has a certain amount of emails they can send out each month, and so on. We try to implement limits in this PR.

Summary of Changes

Implemented hard limits for dashboard admins, analytics per-query timeouts, sent email monthly capacity, events, and session replays. Implemented a soft cap for auth users (where if there's a signup beyond the limit, we log it to sentry so we can manually choose to email that user/team).

For auth users, we do not block new user sign ups once plan limit has been hit. We also don't degrade or impact the customer experience. It logs to sentry and it is up to us to take manual action to email the user to upgrade the plan. Also, implementation wise, we count all the users across all the projects for this team and compare it to their plan item limit, rather than debiting items like we do for other approaches. As a soft cap, this should be fine plus this is a better source of truth.

For email capacity, we operate a monthly limit of emails. Once this is hit, no more emails can be sent until the next month/ a plan upgrade. These emails will be treated as a send error, so they can be manually resent once the capacity is reset. With respect to the email-queue state engine, they go from SENDING->SERVER_ERROR, hooking into the existing state engine flow, with an external error that shows it's because of the rate limit. This is cleaner than inventing a new state that is identical for all intents and purposes to SERVER_ERROR. We check in processSingleEmail since that maps to the sending state.

For analytics query timeouts, the backend route accepts a timeout parameter with the request. The way we implement the timeout for each query is by taking the min(request_timeout,plan_timeout) and using that. This determines how long a query can run for.

For analytics events, there are server-side events (like refresh token refreshes or sign up rule triggers) and client side events (like page views or clicks). When these events occur, they are written to the events table in clickhouse. We choose to implement a hard cap for the total events, not just server side or client side. Once the cap is hit, we stop storing the events and display a banner on the analytics page. A different banner renders when we are at >=80% of total plan capacity.

For session replays, we stop creating new session replays when the limit is hit. Old replays can still have chunks appended to them. The source of truth here is the session replay table- a new replay corresponds to a new row in the table. We have similar banners as to the events.

Dashboard admins should be 4 for both team and unlimited.

Implementation Caveats

For debiting items across these limits, we now use tryDecreaseQuantity at the beginning. This means we debit first if possible before conducting the action (like writing events to clickhouse). In practice, this means that if clickhouse fails, then the user is debited for something that doesn't happen. However trying to build a refund workaround would be very clunky, and also, clickhouse is reliable. For debits that are very small in the order of things (say, 200 items on a 100k plan), it doesn't mean much.

For emails, we don't debit items if it's a retry. This prevents the user for being charged multiple times for effectively one email.

UI Changes

The only UI changes in this PR are having certain banners render in analytics when a customer is approaching/ is at their monthly limit of session replays or events.

Out of Scope for this PR

We do not have metered pricing yet, so events/session replays/ email use beyond the limits cannot be charged yet. This is why for this implementation, we rely on hard and soft caps.
We do not implement payment per-transaction pricing yet. That is deferred to a followup PR.
The UI for the onboarding call will be set up as part of the overall onboarding flow which doesn't exist yet, so it has been deferred.
Since the UI for the dashboard home page and project/account settings is currently being reworked, finding a better spot for plan upgrades is not handled in this PR.

Summary by CodeRabbit

  • New Features

    • Session replays added as a monthly included entitlement; onboarding calls added to Team/Growth plans. Dashboard banners warn about analytics-event and session-replay limits. Projects page adds extra-seat flow and improved invitation error handling.
  • Behavior Changes

    • Monthly renewal semantics for emails-per-month and analytics-events; analytics query timeouts now respect plan limits and are clamped. Email sends, analytics events, and new session creation are blocked when quotas are exhausted. Growth plan seats set to 4.
  • Tests

    • E2E and unit tests added to verify quota enforcement and free-plan regranting.

…ipe for subscription

stripe mock gives us invalifd subscription dates which means we dont get the item. so we sanitize it to make it a valid range. In prod, this is unlikely to happen but it will serve as a guard.
Stripe metadata has a character limit of 500.
We used to pass product info (including num of items) into the metadata of the stripe object.
So when we tried to invoke stripe with this metadata, if it was over 500 chars, it would cause stripe to return an error.
This was done because when the stripe webhook event fired, it would send the metadata along with it so our handler could pick it up.
We rework this to only passing an id for use in a new table lookup in the handler.
This decouples the product info from the webhook event.
We keep it backwards compatible because there are existing subscriptions that have the product in the metadata, the same way we kept the offer parsing code for the subscriptions that had offer in the metadata.
The productVersionId is hashed on the productJson to dedup it.
One bug where if a subscription was cancelled but its end period hadn't passed, we were getting the items for both it and the default/free plan

Another bug where the alreadyOwnsProduct flag was checking all subscriptions, not just active ones.
@vercel
Copy link
Copy Markdown

vercel Bot commented Feb 19, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
stack-auth-hosted-components Ready Ready Preview, Comment May 4, 2026 3:26am
stack-backend Ready Ready Preview, Comment May 4, 2026 3:26am
stack-dashboard Ready Ready Preview, Comment May 4, 2026 3:26am
stack-demo Ready Ready Preview, Comment May 4, 2026 3:26am
stack-docs Ready Ready Preview, Comment May 4, 2026 3:26am
stack-preview-backend Ready Ready Preview, Comment May 4, 2026 3:26am
stack-preview-dashboard Ready Ready Preview, Comment May 4, 2026 3:26am

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 19, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • ✅ Review completed - (🔄 Check again to review again)
📝 Walkthrough

Walkthrough

This PR implements a team-wide billing quota system that enforces plan-based limits for analytics events, session replays, and emails. It adds entitlement tracking, quota debit logic at API endpoints, plan-based timeout clamping for analytics queries, auth-user soft-limit checks, and dashboard banners displaying usage warnings against plan allocations.

Changes

Cohort / File(s) Summary
Billing Entitlements Module
apps/backend/src/lib/plan-entitlements.ts, apps/backend/src/lib/plan-entitlements.test.ts
New utility module for resolving billing team IDs, looking up owned projects/tenancies, counting team-wide non-anonymous users, and fetching team-wide item capacity from plan limits. Includes comprehensive Vitest suite validating all lookups and capacity resolution across edge cases.
Quota Enforcement at API Endpoints
apps/backend/src/app/api/latest/analytics/events/batch/route.tsx, apps/backend/src/app/api/latest/session-replays/batch/route.tsx, apps/backend/src/app/api/latest/internal/send-test-email/route.tsx
Added pre-request quota debit checks: retrieve billing team, fetch item quantity for that team, attempt decrement before processing request, and throw ItemQuantityInsufficientAmount if quota exhausted.
Analytics Query Plan-Based Timeout
apps/backend/src/app/api/latest/internal/analytics/query/route.ts
Replaced fixed timeout constant with dynamic computation from PLAN_LIMITS.*.analyticsTimeoutSeconds. Clamps request timeout to plan limit when billing team exists.
Free Plan Regrant Logic
apps/backend/src/lib/payments/ensure-free-plan.ts, apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts, apps/backend/src/app/api/latest/teams/crud.tsx
New helper functions createFreePlanSubscriptionRow and ensureFreePlanForBillingTeam to regrant free plans when team subscriptions are cancelled or during team creation; refactored team creation to use helper instead of inline logic.
Seed Data & Plan Configuration
apps/backend/prisma/seed.ts, packages/stack-shared/src/plans.ts
Updated seed to include monthly-repeat entitlements for analytics/replays, new onboardingCall entitlement, and logic to ensure internal team has active Growth subscription. Added sessionReplays and onboardingCall item IDs and limits; updated growth plan seats from UNLIMITED to 4.
Email & Event Quota Integration
apps/backend/src/lib/email-queue-step.tsx, apps/backend/src/lib/events.tsx
Added pre-send quota debit for emails and per-event quota tracking in async event logging, both requiring new billingTeamId parameter; early exit on quota failure prevents downstream sends/logging.
Auth User Soft Limits
apps/backend/src/app/api/latest/users/crud.tsx
Added checkAuthUsersSoftLimit helper triggered on non-anonymous user creation and anonymous-to-non-anonymous upgrades; captures soft-limit-exceeded error for team-wide usage tracking.
Dashboard Quota Warning Banners
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx, apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx, apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx, apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx
Introduced AnalyticsEventLimitBanner and SessionReplayLimitBanner components that compute usage against plan allocation, display 80%+ and exhausted warnings, and offer upgrade flow when applicable; banners integrated into analytics pages.
Projects Page Capacity & Paid Plan UX
apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx
Added invitations loading/error handling, derived paid-plan status from team products, gated seat-add flow to paid plans, and synchronized capacity messaging (Skeleton loading, paid vs non-paid text, Add Seat vs Upgrade actions).
E2E Test Coverage
apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts, apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts, apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts, apps/e2e/tests/backend/backend-helpers.ts
Added helpers to setup projects with specific plans, query/set item quantities, and test quota enforcement across plan limits. Introduced withInternalProject context helper for internal API operations. Extended timeout tests to validate dynamic clamping per plan.
Core Backend Integration
apps/backend/src/lib/payments.tsx, apps/backend/src/lib/stripe.tsx, apps/backend/src/lib/sign-up-rules.ts, apps/backend/src/lib/tokens.tsx
Updated purchase validation to exclude stackable self-matches from conflict detection; added Stripe terminal status handling and ended-at fallback logic; enriched event logging with billingTeamId across payments sync, sign-up rules, and token generation.
Type System & Configuration Updates
packages/stack-shared/src/config/format.ts, apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx
Extended _NormalizesTo type to handle array/tuple normalization via recursive element mapping; added customer.created to ignored Stripe webhook events.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • query timing route #1146: Modifies analytics query timeout handling and validation logic in the same route file, making it directly related to the plan-based timeout clamping changes.
  • Analytics event tracking #1208: Implemented the original analytics events and session-replays batch endpoints; this PR adds quota debit logic to those same routes.
  • Several project config improvements #811: Added DayInterval utilities in date helpers that are directly used by this PR's new MONTHLY_REPEAT constant in seed data.

Poem

🐰 A quota tale for all to share,
Analytics events, replays fair,
With limits set by plans so keen,
The billing system reigns supreme!
Now teams stay true to what they've earned,

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 24.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title '[Refactor][Feat] Implement Plan Limits for Hard-and-Soft Item Caps' clearly summarizes the main changes: implementing plan-based item limits across the codebase.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering context, summary, implementation details, caveats, UI changes, and out-of-scope items.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch payment-subscription-handling-rework

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@nams1570 nams1570 force-pushed the implement-plan-pricing branch from 71b3666 to 660dded Compare February 19, 2026 20:21
Base automatically changed from implement-plan-pricing to dev February 24, 2026 06:09
nams1570 added 6 commits March 2, 2026 10:47
We just captureError when you exceed the limit.
We check in processSingleEmail since that maps to the sending state.
The state transition from sending -> server_error already exists and so it would be cleaner to just hook into that transition pathway.
Since we early return before the captureErrors, this should not clog up sentry.
We check the payments items list as source of truth.
We also switch growth to 4 seats by default.
Note that extra seats can only be purchased as an add on to the team or growth plans.
We keep the add purchase button in the team dialog for now, though the UI is subject to change.
We update the schema to enforce  a max of the growth plan limit.
@nams1570 nams1570 force-pushed the payment-subscription-handling-rework branch from ba8e724 to 72c2829 Compare March 9, 2026 20:47
nams1570 added 4 commits March 9, 2026 14:29
This applies to both client side and server side events.
Two write points for events to clickhouse-batch route for client side events, and logEvent for server side.

Max batch size for client side events is 500.
We decrease item quantity after batch is uploaded for clickhouse. The alternative is to either do a check for eventsItem.quantity -batchSize <=0 before the upload and block upsert based on it (which would result in users not getting their full limit) or doing some weird partial batch upload.
At most, with this approach, we would give users ~500 extra events. This is 0.5% extra on free plan, so we judge that it is ok.

frontend-banners pop up on analytics when at 80%+ and when you hit your limit, with an option to upgrade your plan.

We deliberated using tryDecreaseQuantity instead to both check and debit items, but there is potential for debit to happen even when clickhouse is down with that approach. This would be worse for users, so we accept the slight race condition.

Manual testing: on local, changed events for free and team plan to smaller numbers. Checked analytics tables to verify no new events being added after limit reached.
we block new creations of session replays when limit is hit.
Session replays refresh monthly
@nams1570 nams1570 force-pushed the payment-subscription-handling-rework branch from 72c2829 to d7081a8 Compare March 9, 2026 21:29
We defer implementation until new onboarding flow is done.
@nams1570 nams1570 changed the title WIP: subscription related bugs. [Refactor][Feat] Implement Plan Limits for Hard-and-Soft Item Caps Mar 9, 2026
@nams1570 nams1570 requested a review from N2D4 March 9, 2026 23:43
@nams1570 nams1570 marked this pull request as ready for review March 9, 2026 23:59
Copilot AI review requested due to automatic review settings March 9, 2026 23:59
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 10, 2026

Greptile Summary

This PR implements hard and soft plan limits across analytics events, session replays, analytics query timeouts, email sending, and auth users. It introduces plan-entitlements.ts for billing-team resolution, ensure-free-plan.ts for idempotent free-plan regrant (with a two-phase concurrency story), and wires quota checks into six distinct request paths.

Most of the previously-flagged concerns have been addressed: the batch analytics check now uses tryDecreaseQuantity(events.length) to atomically reject under-quota batches, the analytics query zero-timeout guard is in place, ensureFreePlanForBillingTeam correctly handles incomplete/add-on subs, and the email retry-bypass is fixed by keeping sendRetries at 0 for quota-blocked rows.

Confidence Score: 5/5

Safe to merge — all previously-raised P1 concerns have been addressed and only minor P2 style suggestions remain.

The key correctness concerns from prior review rounds (batch quota mismatch, zero-timeout ClickHouse bypass, email retry bypass, fetchInvitations double-error) are resolved. Remaining findings are P2: a hardcoded price string and a question about equal email/replay limits between team and growth plans.

packages/stack-shared/src/plans.ts — confirm growth emailsPerMonth and sessionReplays limits are intentionally equal to team's.

Important Files Changed

Filename Overview
packages/stack-shared/src/plans.ts Adds sessionReplays + onboardingCall item IDs and plan limits; growth plan gets 4 seats (intentional per dev reply); growth/team share same email + replay limits which may be an oversight.
apps/backend/src/lib/plan-entitlements.ts New module: getBillingTeamId, team-wide user/capacity helpers with injectable readers for testability. Well-structured with clear separation of concerns.
apps/backend/src/lib/payments/ensure-free-plan.ts New module: idempotent free-plan regrant with two-phase (fast LFold + slow SERIALIZABLE Prisma tx) concurrency story; well-documented and tested.
apps/backend/src/lib/email-queue-step.tsx Adds emails_per_month quota check at sendRetries===0; intentionally keeps sendRetries at 0 on quota failure so future admin un-finalize re-enters the quota gate (addresses the previously-flagged retry bypass concern).
apps/backend/src/app/api/latest/analytics/events/batch/route.tsx Adds analytics event quota check using tryDecreaseQuantity(events.length) — atomically rejects the whole batch when quota < batch size, addressing the previous batch-size mismatch concern.
apps/backend/src/app/api/latest/internal/analytics/query/route.ts Adds plan-based timeout clamping; guards against zero-quantity producing ClickHouse unlimited execution; MAX_QUERY_TIMEOUT_MS now derived from PLAN_LIMITS.
apps/backend/src/lib/stripe.tsx Adds 'canceled' to TERMINAL_STRIPE_STATUSES and ensureFreePlanForBillingTeam call after subscription sync; getEndedAtForSync refactored to prefer Stripe's ended_at, with safe fallbacks.
apps/backend/src/app/api/latest/session-replays/batch/route.tsx Adds session replay quota check for new sessions only; existing sessions can append chunks even post-quota — correct per spec.
apps/backend/src/app/api/latest/internal/send-test-email/route.tsx Adds emails_per_month pre-debit with refund on SMTP failure; prevents quota-free SMTP exploitation by hostile project admins.
apps/backend/src/lib/events.tsx logEvent now requires billingTeamId in options; debit happens inside runAsynchronouslyAndWaitUntil so server-side events are silently dropped when over-quota (intentional soft-cap).
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx Adds AnalyticsEventLimitBanner and SessionReplayLimitBanner; handleUpgrade is a raw async onClick handler (previously flagged); canUpgrade hardcodes 'growth' as max plan.
apps/backend/prisma/seed.ts Monthly repeat for emailsPerMonth and analyticsEvents; new sessionReplays and onboardingCall items; Growth plan granted to internal team idempotently.
apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx Adds seat-capacity-aware invite flow; fetchInvitations no longer rethrows (fixing previous double-error); handleAddSeat is a raw async handler (previously flagged); '$29/mo' is hardcoded.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Request arrives] --> B{Which path?}
    B --> C[Client batch events /analytics/events/batch]
    B --> D[Session replay batch /session-replays/batch]
    B --> E[Analytics query /internal/analytics/query]
    B --> F[Send test email /internal/send-test-email]
    B --> G[Email queue worker processSingleEmail]
    B --> H[User signup / anon upgrade /users CRUD]
    C --> C1{tryDecreaseQuantity batch.length}
    C1 -- insufficient --> C2[400 ITEM_QUANTITY_INSUFFICIENT_AMOUNT]
    C1 -- ok --> C3[Write to ClickHouse]
    D --> D1{New session?}
    D1 -- existing session --> D3[Append chunk - no debit]
    D1 -- new session --> D2{tryDecreaseQuantity 1}
    D2 -- insufficient --> D4[400 ITEM_QUANTITY_INSUFFICIENT_AMOUNT]
    D2 -- ok --> D5[Create session row + S3 upload]
    E --> E1{timeout quota > 0?}
    E1 -- no --> E2[400 ITEM_QUANTITY_INSUFFICIENT_AMOUNT]
    E1 -- yes --> E3[effectiveTimeout = min request plan_limit]
    E3 --> E4[ClickHouse query with clamped timeout]
    F --> F1{tryDecreaseQuantity 1}
    F1 -- insufficient --> F2[400 ITEM_QUANTITY_INSUFFICIENT_AMOUNT]
    F1 -- ok --> F3[SMTP attempt]
    F3 -- error --> F4[increaseQuantity 1 refund]
    F3 -- success --> F5[Sent - debit kept]
    G --> G1{sendRetries===0 AND billingTeamId?}
    G1 -- no --> G2[Skip quota check SMTP retry]
    G1 -- yes --> G3{tryDecreaseQuantity 1}
    G3 -- insufficient --> G4[Mark SERVER_ERROR keep sendRetries=0]
    G3 -- ok --> G5[SMTP send]
    H --> H1[runAsynchronouslyAndWaitUntil checkAuthUsersSoftLimit]
    H1 --> H2{usage > capacity?}
    H2 -- yes --> H3[captureError to Sentry user still created]
    H2 -- no --> H4[No-op]
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx
Line: 477-481

Comment:
**Hardcoded price may drift from seed config**

The strings `"$29/month"` and `"$29/mo"` are hardcoded in the UI copy and the button label, but the actual price is defined in `seed.ts` (`USD: "29"`). If the extra-seats price is updated in the config, the UI copy will silently become stale without a TS/build error to catch it.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/stack-shared/src/plans.ts
Line: 56-67

Comment:
**Growth and team plans share the same email and session replay limits**

`emailsPerMonth` and `sessionReplays` are identical for the team plan ($49/mo) and the growth plan ($299/mo, ~6x the price). The only differentiated limits are `analyticsTimeoutSeconds` (60 s vs 300 s) and `analyticsEvents` (500k vs 1M). If this is intentional product positioning, a brief comment here would make the intent clear and prevent future reviewers from treating it as an oversight.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (9): Last reviewed commit: "refactor: reorg free plan regrant to be ..." | Re-trigger Greptile

Comment thread apps/backend/src/app/api/latest/analytics/events/batch/route.tsx
Comment thread packages/stack-shared/src/plans.ts
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR expands the plan/entitlements system to cover additional monthly quotas (analytics events, session replays, emails), enforces those quotas in backend ingestion paths, and adds dashboard UX + E2E coverage around approaching/exhausted limits.

Changes:

  • Added new plan item IDs/limits (e.g., session replays) and updated seeded plan configs to use monthly repeating quotas.
  • Implemented quota enforcement/clamping in backend endpoints (analytics query timeout, analytics event ingestion, session replay ingestion, email queue sending) plus team-wide entitlement helpers.
  • Added dashboard warning banners and E2E tests for quota behavior (limits, debits, and timeout clamping).

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
packages/stack-shared/src/plans.ts Adds new item IDs and plan limits (session replays/onboarding call).
apps/backend/prisma/seed.ts Seeds monthly repeating quotas for events/emails/replays and new items.
apps/backend/src/app/api/latest/analytics/events/batch/route.tsx Enforces/debits analytics event quota during batch ingestion.
apps/backend/src/app/api/latest/session-replays/batch/route.tsx Enforces/debits session replay quota for new replays.
apps/backend/src/app/api/latest/internal/analytics/query/route.ts Clamps analytics query timeout to plan entitlement and updates schema max.
apps/backend/src/lib/events.tsx Adds analytics event quota enforcement/debit for server-side event logging.
apps/backend/src/lib/email-queue-step.tsx Adds email quota enforcement/debit in queue sending path.
apps/backend/src/lib/plan-entitlements.ts New helpers for billing-team resolution and team-wide capacity/usage aggregation.
apps/backend/src/lib/plan-entitlements.test.ts Unit tests for new plan entitlement helpers.
apps/backend/src/app/api/latest/users/crud.tsx Adds soft-limit monitoring for auth user capacity.
apps/backend/src/lib/payments.tsx Clarifies subscription-fetch behavior via doc comment.
apps/backend/src/lib/payments.test.tsx Adds regression tests (some marked it.fails) for subscription cancellation/renewal edge cases and add-on behavior.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx Introduces limit warning banners for analytics events and session replays.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/{tables,queries,replays}/page-client.tsx Renders the new analytics quota banners in analytics pages.
apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx Updates admin seat invite UX and adds “extra seats” checkout flow.
apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts Adds E2E tests for session replay quota enforcement/debit behavior.
apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts Adds E2E tests for analytics event quota enforcement and allocation.
apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts Adds E2E tests for timeout clamping by plan and updates schema max timeout test.
apps/e2e/tests/js/payments.test.ts Adds a TODO block describing missing E2E coverage for renewal-after-cancel flow.
apps/backend/src/lib/stripe.tsx Minor whitespace-only adjustment.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/backend/src/app/api/latest/analytics/events/batch/route.tsx
Comment thread apps/backend/src/app/api/latest/analytics/events/batch/route.tsx Outdated
Comment thread apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts Outdated
Comment thread apps/backend/prisma/seed.ts Outdated
Comment thread apps/backend/prisma/seed.ts
Comment thread apps/backend/prisma/seed.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

🧹 Nitpick comments (2)
apps/backend/prisma/seed.ts (1)

133-136: Type the new monthly intervals instead of casting them.

DayInterval is already available in this file, so these new quota windows can share a typed monthlyInterval constant instead of bypassing the checker with as any. That keeps the billing seed schema-checked end to end.

♻️ Suggested cleanup
+const monthlyInterval: DayInterval = [1, "month"];
+
 ...
-              [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.free.emailsPerMonth, repeat: [1, "month"] as any, expires: "when-repeated" as const },
+              [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.free.emailsPerMonth, repeat: monthlyInterval, expires: "when-repeated" as const },
 ...
-              [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.free.analyticsEvents, repeat: [1, "month"] as any, expires: "when-repeated" as const },
+              [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.free.analyticsEvents, repeat: monthlyInterval, expires: "when-repeated" as const },
 ...
-              [ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.free.sessionReplays, repeat: [1, "month"] as any, expires: "when-repeated" as const },
+              [ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.free.sessionReplays, repeat: monthlyInterval, expires: "when-repeated" as const },

As per coding guidelines: "Do NOT use as/any/type casts or anything else to bypass the type system unless you specifically asked the user about it."

Also applies to: 155-158, 178-181

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/prisma/seed.ts` around lines 133 - 136, Replace the untyped
monthly quota repeats that use "as any" with a properly typed DayInterval
constant: create a const monthlyInterval: DayInterval = [1, "month"] and reuse
it for all monthly quota entries (e.g., for ITEM_IDS.emailsPerMonth,
ITEM_IDS.analyticsEvents, ITEM_IDS.sessionReplays and the other occurrences at
the mentioned blocks) so you remove the casts and keep PLAN_LIMITS.free.* quotas
schema-checked; update each place that currently uses [1, "month"] as any to
reference monthlyInterval instead.
packages/stack-shared/src/plans.ts (1)

13-20: Keep onboardingCall in the shared plan model.

ITEM_IDS now exposes onboardingCall, but PlanProductOfferings / PLAN_LIMITS still omit it, so apps/backend/prisma/seed.ts has to hardcode that entitlement separately. That splits the source of truth for plan placement and makes tier changes easy to drift.

Also applies to: 28-35

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-shared/src/plans.ts` around lines 13 - 20, ITEM_IDS added
onboardingCall but the shared plan model structs are missing it; update
PlanProductOfferings and the PLAN_LIMITS object to include an onboardingCall
entry (use ITEM_IDS.onboardingCall as the key) and set the appropriate per-plan
values consistent with other entitlements so apps/backend/prisma/seed.ts no
longer needs to hardcode this entitlement. Locate PlanProductOfferings and
PLAN_LIMITS in this file, add the onboardingCall property to the type/shape and
include it in each plan record in PLAN_LIMITS, and then remove the special-case
from the seed code so the seed reads the entitlement from the shared model.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/backend/src/app/api/latest/analytics/events/batch/route.tsx`:
- Around line 69-75: Preflight currently only checks eventsItem.quantity <= 0
which allows overshoot when a large or concurrent batch is written before debit;
update the logic around getBillingTeamId / app.getItem
(ITEM_IDS.analyticsEvents) to verify and reserve/debit the full batch count
atomically before performing the ClickHouse insert — at minimum reject when
eventsItem.quantity < body.events.length prior to write, and if you perform a
debit after write ensure you refund the decremented amount on insert failure
(tie to decreaseQuantity() or a new atomic reserve/debit helper to avoid race
conditions).

In `@apps/backend/src/app/api/latest/session-replays/batch/route.tsx`:
- Around line 114-121: The cap check is racey: instead of only reading
replaysItem.quantity (app.getItem for ITEM_IDS.sessionReplays) and later calling
decreaseQuantity, perform a reserve-first operation (atomically decrement
quantity by 1 only if quantity > 0) before creating the replay/session (i.e.
call the existing decreaseQuantity(1) or a conditional DB update for
ITEM_IDS.sessionReplays while checking result), then proceed to create
S3/chunk/session rows; if any subsequent write fails, immediately compensate by
incrementing the reserved slot (call increaseQuantity(1) or reverse the
conditional decrement) to release the reservation; apply the same pattern where
you currently check and debit (including the other occurrence around lines
214-217) to ensure race-safe reservations.

In `@apps/backend/src/lib/email-queue-step.tsx`:
- Around line 706-745: The quota check is happening after the success path which
allows races; modify the flow to atomically reserve one monthly email credit
before calling lowLevelSendEmailDirectWithoutRetries(): call the stack/store
method that decrements or reserves ITEM_IDS.emailsPerMonth (via
getStackServerApp()/emailItem or the service that exposes decreaseQuantity)
inside a single atomic operation and only proceed to
lowLevelSendEmailDirectWithoutRetries() if that reserve succeeds; on any send
failure (or if you abort before final success) refund the reserved credit
(increment/undo the decrement) and record the same SendAttemptError/updates to
globalPrismaClient.emailOutbox (same fields you currently set) so the database
reflects a quota-reserved-but-failed attempt; apply the same change to the other
occurrence around lines 854-858.

In `@apps/backend/src/lib/events.tsx`:
- Around line 282-293: The pre-check against eventsItem.quantity in
getStackServerApp()/app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: ...
}) is insufficient for a hard cap because writes happen before quota is
consumed; change the flow to atomically reserve/consume the quota before
persisting events by invoking the quota debit (e.g., decreaseQuantity(1) or a
dedicated reserve method) using a conditional update that ensures quantity >= 1
(DB-level WHERE/optimistic lock) so concurrent writers cannot exceed the cap,
then perform the Postgres+ClickHouse writes; if any write fails, immediately
compensate by refunding the quota (e.g., increaseQuantity(1)) and log the
failure with captureError/StackAssertionError, and ensure the same pattern is
applied to the other affected blocks (the regions referenced at 295-316 and
408-413).

In `@apps/backend/src/lib/payments.test.tsx`:
- Around line 741-807: getSubscriptions currently injects the default (free)
plan as soon as a subscription is not "active", and getCustomerPurchaseContext
still treats canceled-but-not-expired subscriptions as non-owning; update the
ownership logic so that a subscription with status 'canceled' but
currentPeriodEnd > now is treated as still active for capacity/ownership
purposes (i.e., do not inject the default plan in getSubscriptions for those
cases and make getCustomerPurchaseContext's alreadyOwnsProduct consider such
subscriptions as owned until currentPeriodEnd). Locate and modify
getSubscriptions() and getCustomerPurchaseContext() in payments.tsx (and any
helpers those call) to check currentPeriodEnd against the current time before
injecting the default plan or excluding the canceled subscription from
alreadyOwnsProduct logic.
- Around line 752-807: The test using vi.setSystemTime in the it.fails block
leaves fake timers active because vi.useRealTimers() is after an expect that can
throw; change the cleanup so timers are always restored by wrapping the test
body in try/finally (call vi.useRealTimers() in finally) or remove the per-test
cleanup and add a global afterEach that calls vi.useRealTimers(); update the
failing test that calls getItemQuantityForCustomer and uses
vi.setSystemTime/vi.useRealTimers to use the try/finally pattern (or rely on the
shared afterEach) so fake timers are always reverted even if expect throws.

In `@apps/backend/src/lib/payments.tsx`:
- Around line 315-318: getCustomerPurchaseContext() is treating every
subscription from getSubscriptions() (which returns canceled too) as ownership
and triggers ProductAlreadyGranted; change the ownership derivation to consider
only active subscriptions by filtering subscriptions with
isActiveSubscription(subscription) (or otherwise excluding canceled/ended
statuses) before computing alreadyOwnsProduct so renewals/reactivations are
allowed. Update any logic in getCustomerPurchaseContext() that builds
alreadyOwnsProduct to iterate only over the filtered activeSubscriptions list.

In
`@apps/dashboard/src/app/`(main)/(protected)/(outside-dashboard)/projects/page-client.tsx:
- Around line 202-205: The modal gates inputs and CTAs on a single boolean
invitationsLoaded, so if listInvitations() fails the dialog stays permanently
disabled; change the invitations state handling to track loading and error
separately (e.g., invitationsLoading and invitationsError alongside
invitations), default invitations to [] so activeSeats calculation (users.length
+ (invitations?.length ?? 0)) works even on error, compute atCapacity using
seatLimit but only while invitationsLoading is true (or treat error as
non-blocking), and surface invitationsError in the UI with a retry action that
re-calls listInvitations(); update references to invitationsLoaded, activeSeats,
seatLimit, atCapacity and ensure inputs/footer CTA enable when not loading (even
if invitationsError is set) and show an inline error + retry button.

In
`@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx:
- Around line 1388-1390: The PanelGroup is using a fixed height
(!h-[calc(100vh-180px)]) which causes the replay workspace to overflow when
AnalyticsEventLimitBanner or SessionReplayLimitBanner render; change the layout
so the container is a flex column and make PanelGroup consume remaining space
(e.g., remove the fixed calc height and replace it with a flex-grow class like
flex-1 and keep min-h-[520px] and existing border/rounded classes) so the
banners can expand without pushing the bottom controls offscreen; update the JSX
around PanelGroup and ensure parent containers use flex flex-col if needed and
references to AnalyticsEventLimitBanner and SessionReplayLimitBanner remain
unchanged.

In
`@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/analytics/shared.tsx:
- Around line 324-327: The resolvePlanId function currently collapses any
non-team/growth product into "free", which misclassifies unknown paid plans;
update resolvePlanId(products) to detect three outcomes—explicit "growth",
explicit "team", and a distinct "unknownPaid" (or null/undefined) when a paid
subscription exists but its id isn't recognized—and only return "free" when no
paid subscription is present; adjust the PlanId type signature accordingly
(e.g., include "unknownPaid" or make it nullable) and ensure callers of
resolvePlanId (the banner/upgrade logic) handle the new unknown-paid result
instead of treating it as free.
- Around line 334-349: The banner components (AnalyticsEventLimitBanner and
SessionReplayLimitBanner) incorrectly try to locate the project owner team by
searching the dashboard user's teams (user.useTeams()), which returns internal
dashboard memberships and will often not contain the project's owner team;
change each to fetch the team from the admin app context by calling
adminApp.useTeam(project.ownerTeamId) (and remove the useMemo with teams.find),
then pass that returned team to
AnalyticsEventLimitBannerInner/SessionReplayLimitBannerInner and handle null the
same way as before.

In `@apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts`:
- Around line 544-562: The test currently sets the project's remaining quota to
0 which only validates the exhausted-quota path; change the setup in the test
(use setupProjectWithPlan, Auth.Otp.signIn, setEventItemQuantity,
uploadEventBatch) so setEventItemQuantity(ownerTeamId, 1) instead of 0 and then
send the same 2-event batch (the uploadEventBatch call) so the test exercises
the "remaining quota less than batch size" condition while keeping the same
assertions (expect res.status 400 and res.body.code
"ITEM_QUANTITY_INSUFFICIENT_AMOUNT").
- Around line 442-454: The current code sets backendContext to
InternalProjectKeys before calling niceBackendFetch and only restores savedKeys
after success; move the assignment and grant call into a try block and restore
the original keys in a finally block so backendContext.set({ projectKeys:
savedKeys }) always runs even if niceBackendFetch throws or grantResponse.status
!== 200; update the block that references planId, savedKeys, backendContext.set,
InternalProjectKeys, grantResponse and niceBackendFetch accordingly.

In `@apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts`:
- Around line 23-35: The test temporarily replaces backendContext.projectKeys
with InternalProjectKeys before calling niceBackendFetch; if the grant request
fails or throws the savedKeys are never restored, so wrap the grant request and
its status check in a try/finally (using backendContext.set({ projectKeys:
savedKeys }) in the finally) to guarantee restoration of the original keys (and
rethrow the error or preserve failing behavior after restoring); reference
backendContext, InternalProjectKeys, niceBackendFetch, grantResponse,
ownerTeamId, and planId when locating where to apply the try/finally.

In `@apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts`:
- Around line 1484-1515: The test "does not debit quota when appending chunks to
an existing session replay" currently never verifies the "append after cap"
contract; after the first successful upload you should set the team's quota for
session_replays to 0 (use Project.updateConfig or the helper that alters
apps.installed.analytics.quota/session_replays), then call uploadBatch again
with the same session_replay flow (reuse firstBatch.body.session_replay_id via
uploadBatch inputs or assert equality) and assert the second response.status is
200 and returns the same session_replay_id and that
getSessionReplayItemQuantity(ownerTeamId) did not decrease; update the
assertions around uploadBatch, getSessionReplayItemQuantity,
Project.updateConfig and any usage of randomUUID to keep the test deterministic.

In `@apps/e2e/tests/js/payments.test.ts`:
- Around line 414-431: Add a real E2E that simulates canceling a subscription
then re-purchasing before the billing period ends to guard the change: write a
test in payments.test.ts that creates a subscription, calls the cancel API to
set cancel_at_period_end: true, then runs the client purchase/reactivate flow
(the same codepath used by the JS client) and assert the system reactivates the
existing subscription (cancel_at_period_end becomes false), does not create a
new subscription (same subscription id), and that the client-side helper
alreadyOwnsProduct returns false for the canceled-but-not-expired state so the
reactivate path is taken; tie assertions to the existing functions/flows
alreadyOwnsProduct and the purchase/switch code path to ensure regression
coverage.

---

Nitpick comments:
In `@apps/backend/prisma/seed.ts`:
- Around line 133-136: Replace the untyped monthly quota repeats that use "as
any" with a properly typed DayInterval constant: create a const monthlyInterval:
DayInterval = [1, "month"] and reuse it for all monthly quota entries (e.g., for
ITEM_IDS.emailsPerMonth, ITEM_IDS.analyticsEvents, ITEM_IDS.sessionReplays and
the other occurrences at the mentioned blocks) so you remove the casts and keep
PLAN_LIMITS.free.* quotas schema-checked; update each place that currently uses
[1, "month"] as any to reference monthlyInterval instead.

In `@packages/stack-shared/src/plans.ts`:
- Around line 13-20: ITEM_IDS added onboardingCall but the shared plan model
structs are missing it; update PlanProductOfferings and the PLAN_LIMITS object
to include an onboardingCall entry (use ITEM_IDS.onboardingCall as the key) and
set the appropriate per-plan values consistent with other entitlements so
apps/backend/prisma/seed.ts no longer needs to hardcode this entitlement. Locate
PlanProductOfferings and PLAN_LIMITS in this file, add the onboardingCall
property to the type/shape and include it in each plan record in PLAN_LIMITS,
and then remove the special-case from the seed code so the seed reads the
entitlement from the shared model.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2044892f-4171-45ef-af2b-d5be4ca1bbe3

📥 Commits

Reviewing files that changed from the base of the PR and between 57149bd and bd87a35.

📒 Files selected for processing (22)
  • apps/backend/prisma/seed.ts
  • apps/backend/src/app/api/latest/analytics/events/batch/route.tsx
  • apps/backend/src/app/api/latest/internal/analytics/query/route.ts
  • apps/backend/src/app/api/latest/session-replays/batch/route.tsx
  • apps/backend/src/app/api/latest/users/crud.tsx
  • apps/backend/src/lib/email-queue-step.tsx
  • apps/backend/src/lib/events.tsx
  • apps/backend/src/lib/payments.test.tsx
  • apps/backend/src/lib/payments.tsx
  • apps/backend/src/lib/plan-entitlements.test.ts
  • apps/backend/src/lib/plan-entitlements.ts
  • apps/backend/src/lib/stripe.tsx
  • apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts
  • apps/e2e/tests/js/payments.test.ts
  • packages/stack-shared/src/plans.ts
💤 Files with no reviewable changes (1)
  • apps/backend/src/lib/stripe.tsx

Comment thread apps/backend/src/app/api/latest/analytics/events/batch/route.tsx
Comment thread apps/backend/src/app/api/latest/session-replays/batch/route.tsx
Comment thread apps/backend/src/lib/email-queue-step.tsx Outdated
Comment thread apps/backend/src/lib/events.tsx Outdated
Comment thread apps/backend/src/lib/payments.test.tsx Outdated
Comment thread apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts Outdated
Comment thread apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts
Comment thread apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts Outdated
Comment thread apps/e2e/tests/js/payments.test.ts Outdated
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 22, 2026

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
{}

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
apps/backend/src/lib/payments/ensure-free-plan.test.ts (1)

16-189: Good coverage of the fast/slow/race/regression matrix.

Test design is deliberate and matches the production code's mental model:

  • Seeding via bulldozerWriteSubscription only (not Prisma) is the right choice for the fast-path assertions — the fast path reads Bulldozer, so Bulldozer-only seeds exercise exactly the "already owns a base plan" branch. The slow-path test with no seed correctly finds an empty Prisma and creates the free sub.
  • The incomplete regression test pins the behaviour the endedAt-based predicate was written to preserve; this is the bug that would silently re-open if someone ever "simplified" the predicate back to a status filter. Worth keeping.
  • Promise.all concurrency test validates the SERIALIZABLE retry path end-to-end.
  • describe.sequential is appropriate given the shared internal tenancy.

Optional nit: since randomUUID() billing-team IDs leave real rows in the internal tenancy's Subscription table after each run, a bulk cleanup in an afterAll (e.g. deleteMany for the UUIDs created during the suite) would keep the dev DB tidy across reruns — not a correctness concern, just housekeeping.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/payments/ensure-free-plan.test.ts` around lines 16 -
189, Tests leave real Subscription rows in the internal tenancy because seedSub
uses randomUUID() for billingTeam IDs; add teardown to delete those rows after
the suite: collect created billingTeamId values (or subscription IDs) when
calling seedSub/when generating randomUUIDs, then in an afterAll hook call
getPrismaClientForTenancy(tenancy) (use getInternal() to obtain tenancy/prisma)
and run prisma.subscription.deleteMany({ where: { tenancyId: tenancy.id,
customerId: { in: [/* collected billingTeamIds */] } } }) to remove the test
rows; ensure cleanup references the seedSub/randomUUID identifiers and runs
regardless of test outcomes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/backend/src/lib/payments/ensure-free-plan.test.ts`:
- Around line 16-189: Tests leave real Subscription rows in the internal tenancy
because seedSub uses randomUUID() for billingTeam IDs; add teardown to delete
those rows after the suite: collect created billingTeamId values (or
subscription IDs) when calling seedSub/when generating randomUUIDs, then in an
afterAll hook call getPrismaClientForTenancy(tenancy) (use getInternal() to
obtain tenancy/prisma) and run prisma.subscription.deleteMany({ where: {
tenancyId: tenancy.id, customerId: { in: [/* collected billingTeamIds */] } } })
to remove the test rows; ensure cleanup references the seedSub/randomUUID
identifiers and runs regardless of test outcomes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4325336c-2080-470a-a4cb-05c2cdb384a9

📥 Commits

Reviewing files that changed from the base of the PR and between e91c6b4 and beb273d.

📒 Files selected for processing (21)
  • apps/backend/prisma/seed.ts
  • apps/backend/src/app/api/latest/analytics/events/batch/route.tsx
  • apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx
  • apps/backend/src/app/api/latest/internal/analytics/query/route.ts
  • apps/backend/src/app/api/latest/internal/send-test-email/route.tsx
  • apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts
  • apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts
  • apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts
  • apps/backend/src/app/api/latest/session-replays/batch/route.tsx
  • apps/backend/src/app/api/latest/teams/crud.tsx
  • apps/backend/src/app/api/latest/users/crud.tsx
  • apps/backend/src/lib/email-queue-step.tsx
  • apps/backend/src/lib/events.tsx
  • apps/backend/src/lib/payments.tsx
  • apps/backend/src/lib/payments/ensure-free-plan.test.ts
  • apps/backend/src/lib/payments/ensure-free-plan.ts
  • apps/backend/src/lib/plan-entitlements.test.ts
  • apps/backend/src/lib/plan-entitlements.ts
  • apps/backend/src/lib/sign-up-rules.ts
  • apps/backend/src/lib/stripe.tsx
  • apps/backend/src/lib/tokens.tsx
✅ Files skipped from review due to trivial changes (1)
  • apps/backend/src/lib/tokens.tsx
🚧 Files skipped from review as they are similar to previous changes (11)
  • apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx
  • apps/backend/src/lib/stripe.tsx
  • apps/backend/src/app/api/latest/internal/send-test-email/route.tsx
  • apps/backend/src/lib/payments.tsx
  • apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts
  • apps/backend/src/lib/email-queue-step.tsx
  • apps/backend/src/app/api/latest/users/crud.tsx
  • apps/backend/src/app/api/latest/internal/analytics/query/route.ts
  • apps/backend/prisma/seed.ts
  • apps/backend/src/lib/plan-entitlements.ts
  • apps/backend/src/lib/plan-entitlements.test.ts

Comment thread apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts Outdated
Comment thread apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts Outdated
nams1570 added 2 commits May 3, 2026 18:02
They seem to be failing because the fallback path was not exercised by the backend stackserverapp.
fallback path is _withFallback(), and lets it default when there is an issue.
@BilalG1 BilalG1 merged commit c01c052 into dev May 5, 2026
31 of 35 checks passed
@BilalG1 BilalG1 deleted the payment-subscription-handling-rework branch May 5, 2026 01:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants