Skip to content

admin migration touch ups#5498

Open
chitalian wants to merge 1 commit intomainfrom
justin/admin-migrations
Open

admin migration touch ups#5498
chitalian wants to merge 1 commit intomainfrom
justin/admin-migrations

Conversation

@chitalian
Copy link
Contributor

Ticket

Link to the ticket(s) this pull request addresses.

Component/Service

What part of Helicone does this affect?

  • Web (Frontend)
  • Jawn (Backend)
  • Worker (Proxy)
  • Bifrost (Marketing)
  • AI Gateway
  • Packages
  • Infrastructure/Docker
  • Documentation

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update
  • Performance improvement
  • Refactoring

Deployment Notes

  • No special deployment steps required
  • Database migrations need to run
  • Environment variable changes required
  • Coordination with other teams needed

Screenshots / Demos

Before After

Extra Notes

Any additional context, considerations, or notes for reviewers.

Context

Why are you making this change?

Screenshots / Demos

@vercel
Copy link

vercel bot commented Jan 13, 2026

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

Project Deployment Review Updated (UTC)
helicone Error Error Jan 13, 2026 0:33am
helicone-bifrost Ready Ready Preview, Comment Jan 13, 2026 0:33am
helicone-eu Error Error Jan 13, 2026 0:33am

@supabase
Copy link

supabase bot commented Jan 13, 2026

This pull request has been ignored for the connected project bolqqmqbrciybnypvklh because there are no changes detected in supbase/supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@supabase
Copy link

supabase bot commented Jan 13, 2026

This pull request has been ignored for the connected project lmahfbbnchpworytrrqk because there are no changes detected in .supabase/supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@claude
Copy link
Contributor

claude bot commented Jan 13, 2026

Claude finished @chitalian's task —— View job


PR Review Complete ✅

Score: 6/10 - Several security and code quality issues that should be addressed before merge

Critical Issues Found

🔒 Security - High Priority

  1. Mixed Secret Management Patterns (adminController.ts:2720, 2921, 3095, etc.)

    • Issue: Direct process.env.STRIPE_SECRET_KEY! usage instead of SecretManager.getSecret("STRIPE_SECRET_KEY")
    • Risk: Inconsistent secret handling, potential exposure in logs/errors
    • Fix: Replace all instances with SecretManager.getSecret("STRIPE_SECRET_KEY") for consistency
    // ❌ Current
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
    
    // ✅ Should be
    const stripe = new Stripe(SecretManager.getSecret("STRIPE_SECRET_KEY")!);
  2. Admin Privilege Escalation Risk (adminController.ts:2835-2838)

    • Issue: Creating StripeManager with any orgId without ownership validation
    • Risk: Admin can perform billing operations on any organization
    • Fix: Add additional validation or explicit acknowledgment this is intentional admin functionality

🐛 Code Quality Issues

  1. Inconsistent Error Handling (StripeManager.ts:73-89)

    • Issue: Database errors in getProProductPrices() fall back to defaults silently
    • Risk: Configuration issues may go unnoticed, affecting billing accuracy
    • Fix: Add proper error logging and consider failing fast for critical pricing config
  2. Hardcoded Magic Values (StripeManager.ts:96, 535)

    • Issue: Hardcoded coupon codes and price validation
    • Risk: Difficult to maintain, potential issues if Stripe config changes
    • Fix: Move to configuration management
  3. Resource Cleanup Missing (adminController.ts:3640-3665)

    • Issue: Stripe pagination without proper cleanup/limits in audit function
    • Risk: Potential memory issues with large subscription datasets
    • Fix: Add reasonable limits and cleanup logic

⚠️ Performance Concerns

  1. Inefficient Database Queries (StripeManager.ts:321-336)

    • Issue: Individual database updates in loops without transactions
    • Risk: Performance degradation, partial failures
    • Fix: Use batch operations or transactions where appropriate
  2. N+1 Query Pattern (adminController.ts:3668-3677)

    • Issue: Individual Stripe API calls in loops during audit
    • Risk: Rate limiting, slow performance
    • Fix: Implement batching or caching strategies

Suggestions Summary

Immediate Fixes (Before Merge)

  • Replace all process.env.STRIPE_SECRET_KEY with SecretManager.getSecret() in admin controller
  • Add error handling for critical pricing configuration failures
  • Add pagination limits to Stripe audit functions

Recommended Improvements

  • Move hardcoded values to configuration
  • Add transaction support for batch database operations
  • Implement proper cleanup for long-running operations
  • Add validation for admin billing operations scope

Documentation Needed

  • Document intended admin privileges for cross-org billing operations
  • Add migration rollback procedures
  • Document audit function limitations and expected runtime

Positive Aspects

Good Architecture: Clean separation between instant and scheduled migrations
Comprehensive Features: Thorough audit capabilities and usage backfilling
Type Safety: Good use of TypeScript interfaces
Admin Controls: Proper authorization checks with authCheckThrow

The code shows good understanding of the domain and implements complex billing migration logic well. The main concerns are around security consistency and operational reliability.


@greptile-apps
Copy link
Contributor

greptile-apps bot commented Jan 13, 2026

Greptile Overview

Greptile Summary

This PR adds four new admin endpoints to support pricing migration operations: cancelSubscription, auditSubscriptions, fixOrgTier, and fixOrgMetadata. It also enhances existing endpoints to include additional Stripe subscription details like trial information, invoice data, and subscription items.

Key Changes

New Admin Endpoints (adminController.ts)

  1. POST /pricing-migration/cancel-subscription/{orgId} - Cancels a Stripe subscription for unpaid/problematic orgs
  2. POST /pricing-migration/audit - Audits all active Stripe subscriptions against the org database to find mismatches
  3. POST /pricing-migration/fix-tier/{orgId} - Updates org tier based on Stripe subscription products
  4. POST /pricing-migration/fix-metadata/{orgId} - Updates org's stripe_customer_id and stripe_subscription_id

Enhanced Endpoints

  • getOrgsWithLegacyTiers and getOrgsWithNewTiers now fetch additional Stripe data including trial_end, subscription_items, next_invoice_date, and next_invoice_amount
  • migrateInstant now supports a targetTier parameter to allow cross-tier migrations (e.g., pro → team)

Supporting Changes

  • Updated TypeScript type definitions in both web and bifrost clients
  • Frontend component properly integrated with new endpoints
  • Auto-generated TSOA routes and Swagger documentation

Critical Issues Found

🔴 Security Vulnerabilities (Logic Errors)

  1. fixOrgTier lacks validation - Accepts arbitrary subscriptionId without verifying it belongs to the organization
  2. fixOrgMetadata lacks validation - Accepts arbitrary customerId/subscriptionId without verifying they're related or belong to the org

🟡 Logic Issues

  1. cancelSubscription missing error handling - Database update failure not checked, could cause inconsistent state
  2. targetTier logic inconsistency - Can force upgrade to team but not downgrade to pro due to asymmetric validation
  3. getOrgsWithNewTiers performance - Makes unbounded parallel Stripe API calls without pagination

Impact Assessment

The security issues in fixOrgTier and fixOrgMetadata are critical as they allow admins to arbitrarily assign any Stripe subscription/customer to any organization, potentially causing billing chaos and data integrity issues. While these are admin-only endpoints, they should still validate data ownership to prevent operator errors and ensure data consistency.

The missing error handling in cancelSubscription could lead to silent failures where Stripe is updated but the database isn't, creating sync issues.

The performance issue with getOrgsWithNewTiers may not be immediate if there are few organizations, but will become problematic as more orgs migrate to new pricing tiers.

Confidence Score: 2/5

  • This PR contains critical security vulnerabilities in admin endpoints that lack proper validation
  • Score of 2 reflects multiple critical logic errors: fixOrgTier and fixOrgMetadata endpoints accept arbitrary Stripe IDs without validating ownership, cancelSubscription doesn't check database update results, and getOrgsWithNewTiers has performance issues. While these are admin-only endpoints, the validation gaps could lead to billing errors and data integrity problems.
  • valhalla/jawn/src/controllers/private/adminController.ts requires immediate attention to add validation in fixOrgTier, fixOrgMetadata, and error handling in cancelSubscription

Important Files Changed

File Analysis

Filename Score Overview
valhalla/jawn/src/controllers/private/adminController.ts 2/5 Adds 4 new admin endpoints for pricing migration with critical security vulnerabilities: fixOrgTier and fixOrgMetadata lack validation of Stripe ID ownership, cancelSubscription missing error handling, targetTier logic inconsistency, and performance issue with unbounded Stripe API calls
bifrost/lib/clients/jawnTypes/private.ts 5/5 Auto-generated TypeScript types for new API endpoints - matches controller definitions correctly
web/lib/clients/jawnTypes/private.ts 5/5 Auto-generated TypeScript types for new API endpoints - identical to bifrost version, correct
web/components/templates/admin/adminPricingMigration.tsx 4/5 Frontend component calling the new API endpoints - properly handles errors and success cases, no issues found

Sequence Diagram

sequenceDiagram
    participant Admin as Admin User
    participant Frontend as Admin UI
    participant Controller as AdminController
    participant DB as Database
    participant Stripe as Stripe API

    Note over Admin,Stripe: Audit Subscriptions Flow
    Admin->>Frontend: Click "Audit Subscriptions"
    Frontend->>Controller: POST /pricing-migration/audit
    Controller->>Stripe: List all active subscriptions
    Stripe-->>Controller: Return subscriptions
    Controller->>DB: SELECT all organizations
    DB-->>Controller: Return orgs with stripe IDs
    Controller->>DB: SELECT auth users & org memberships
    DB-->>Controller: Return user/org data
    Controller->>Controller: Compare Stripe vs DB state
    Controller-->>Frontend: Return mismatches
    Frontend-->>Admin: Display audit results

    Note over Admin,Stripe: Fix Org Metadata Flow (VULNERABLE)
    Admin->>Frontend: Select mismatch, click "Fix Metadata"
    Frontend->>Controller: POST /pricing-migration/fix-metadata/{orgId}
    Note over Controller: ⚠️ No validation of IDs!
    Controller->>DB: UPDATE stripe_customer_id, stripe_subscription_id
    DB-->>Controller: Success
    Controller-->>Frontend: Return success
    Frontend-->>Admin: Show success message

    Note over Admin,Stripe: Fix Org Tier Flow (VULNERABLE)
    Admin->>Frontend: Click "Fix Tier"
    Frontend->>Controller: POST /pricing-migration/fix-tier/{orgId}
    Note over Controller: ⚠️ No validation of subscription ownership!
    Controller->>Stripe: Retrieve subscription details
    Stripe-->>Controller: Return subscription with products
    Controller->>Controller: Determine tier from product names
    Controller->>DB: UPDATE tier, subscription_status
    DB-->>Controller: Success
    Controller-->>Frontend: Return new tier
    Frontend-->>Admin: Show updated tier

    Note over Admin,Stripe: Cancel Subscription Flow
    Admin->>Frontend: Click "Cancel Subscription"
    Frontend->>Controller: POST /pricing-migration/cancel-subscription/{orgId}
    Controller->>DB: SELECT stripe_subscription_id
    DB-->>Controller: Return subscription ID
    Controller->>Stripe: Cancel subscription
    Stripe-->>Controller: Subscription cancelled
    Note over Controller: ⚠️ No error check on DB update!
    Controller->>DB: UPDATE subscription_status = 'canceled'
    Controller-->>Frontend: Return success
    Frontend-->>Admin: Show cancellation confirmed
Loading

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

5 files reviewed, 5 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +3891 to +3928
@Post("/pricing-migration/fix-tier/{orgId}")
public async fixOrgTier(
@Request() request: JawnAuthenticatedRequest,
@Path() orgId: string,
@Body() body: { subscriptionId: string }
): Promise<Result<{ message: string; newTier: string }, string>> {
await authCheckThrow(request.authParams.userId);

try {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// Get the subscription to determine what tier it should be
const subscription = await stripe.subscriptions.retrieve(body.subscriptionId, {
expand: ["items.data.price.product"],
});

// Determine tier from product names
let newTier = "pro-20251210"; // default
for (const item of subscription.items.data) {
const product = item.price.product;
if (typeof product === "object" && product !== null && "name" in product) {
const productName = (product as { name: string }).name.toLowerCase();
if (productName.includes("team")) {
newTier = "team-20251210";
break;
}
}
}

// Update the org
await dbExecute(
`
UPDATE organization
SET tier = $1, subscription_status = 'active'
WHERE id = $2
`,
[newTier, orgId]
);
Copy link
Contributor

Choose a reason for hiding this comment

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

The fixOrgTier endpoint accepts a subscriptionId in the request body but does not verify that this subscription actually belongs to the organization specified by orgId. This allows an admin to arbitrarily assign any Stripe subscription to any organization, which could lead to:

  1. Billing issues: Organization A could be assigned Organization B's subscription
  2. Data integrity problems: Mismatched subscription data in the database
  3. Security concerns: Unauthorized access to subscription tiers

Recommendation: Before updating the organization, verify that the subscription's customer ID matches the organization's stripe_customer_id, or that the subscription metadata contains the correct orgId.

Suggested change
@Post("/pricing-migration/fix-tier/{orgId}")
public async fixOrgTier(
@Request() request: JawnAuthenticatedRequest,
@Path() orgId: string,
@Body() body: { subscriptionId: string }
): Promise<Result<{ message: string; newTier: string }, string>> {
await authCheckThrow(request.authParams.userId);
try {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Get the subscription to determine what tier it should be
const subscription = await stripe.subscriptions.retrieve(body.subscriptionId, {
expand: ["items.data.price.product"],
});
// Determine tier from product names
let newTier = "pro-20251210"; // default
for (const item of subscription.items.data) {
const product = item.price.product;
if (typeof product === "object" && product !== null && "name" in product) {
const productName = (product as { name: string }).name.toLowerCase();
if (productName.includes("team")) {
newTier = "team-20251210";
break;
}
}
}
// Update the org
await dbExecute(
`
UPDATE organization
SET tier = $1, subscription_status = 'active'
WHERE id = $2
`,
[newTier, orgId]
);
try {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Get the subscription to determine what tier it should be
const subscription = await stripe.subscriptions.retrieve(body.subscriptionId, {
expand: ["items.data.price.product"],
});
// Verify the organization exists and get its stripe_customer_id
const orgResult = await dbExecute<{
stripe_customer_id: string | null;
}>(
`SELECT stripe_customer_id FROM organization WHERE id = $1`,
[orgId]
);
if (!orgResult.data?.[0]) {
return err("Organization not found");
}
const { stripe_customer_id } = orgResult.data[0];
// Verify the subscription belongs to this organization's customer
if (stripe_customer_id !== subscription.customer) {
return err(`Subscription ${body.subscriptionId} does not belong to organization ${orgId}`);
}
// Determine tier from product names
let newTier = "pro-20251210"; // default
for (const item of subscription.items.data) {
const product = item.price.product;
if (typeof product === "object" && product !== null && "name" in product) {
const productName = (product as { name: string }).name.toLowerCase();
if (productName.includes("team")) {
newTier = "team-20251210";
break;
}
}
}
// Update the org
await dbExecute(
`
UPDATE organization
SET tier = $1, subscription_status = 'active'
WHERE id = $2
`,
[newTier, orgId]
);
Prompt To Fix With AI
This is a comment left during a code review.
Path: valhalla/jawn/src/controllers/private/adminController.ts
Line: 3891:3928

Comment:
The `fixOrgTier` endpoint accepts a `subscriptionId` in the request body but does not verify that this subscription actually belongs to the organization specified by `orgId`. This allows an admin to arbitrarily assign any Stripe subscription to any organization, which could lead to:

1. **Billing issues**: Organization A could be assigned Organization B's subscription
2. **Data integrity problems**: Mismatched subscription data in the database
3. **Security concerns**: Unauthorized access to subscription tiers

**Recommendation**: Before updating the organization, verify that the subscription's customer ID matches the organization's `stripe_customer_id`, or that the subscription metadata contains the correct `orgId`.

```suggestion
    try {
      const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

      // Get the subscription to determine what tier it should be
      const subscription = await stripe.subscriptions.retrieve(body.subscriptionId, {
        expand: ["items.data.price.product"],
      });

      // Verify the organization exists and get its stripe_customer_id
      const orgResult = await dbExecute<{
        stripe_customer_id: string | null;
      }>(
        `SELECT stripe_customer_id FROM organization WHERE id = $1`,
        [orgId]
      );

      if (!orgResult.data?.[0]) {
        return err("Organization not found");
      }

      const { stripe_customer_id } = orgResult.data[0];

      // Verify the subscription belongs to this organization's customer
      if (stripe_customer_id !== subscription.customer) {
        return err(`Subscription ${body.subscriptionId} does not belong to organization ${orgId}`);
      }

      // Determine tier from product names
      let newTier = "pro-20251210"; // default
      for (const item of subscription.items.data) {
        const product = item.price.product;
        if (typeof product === "object" && product !== null && "name" in product) {
          const productName = (product as { name: string }).name.toLowerCase();
          if (productName.includes("team")) {
            newTier = "team-20251210";
            break;
          }
        }
      }

      // Update the org
      await dbExecute(
        `
        UPDATE organization
        SET tier = $1, subscription_status = 'active'
        WHERE id = $2
        `,
        [newTier, orgId]
      );
```


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

Comment on lines +3943 to +3960
@Post("/pricing-migration/fix-metadata/{orgId}")
public async fixOrgMetadata(
@Request() request: JawnAuthenticatedRequest,
@Path() orgId: string,
@Body() body: { customerId: string; subscriptionId: string }
): Promise<Result<{ message: string }, string>> {
await authCheckThrow(request.authParams.userId);

try {
// Update the org with the stripe IDs
await dbExecute(
`
UPDATE organization
SET stripe_customer_id = $1, stripe_subscription_id = $2
WHERE id = $3
`,
[body.customerId, body.subscriptionId, orgId]
);
Copy link
Contributor

Choose a reason for hiding this comment

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

The fixOrgMetadata endpoint accepts customerId and subscriptionId from the request body but does not validate that these Stripe IDs are related to each other or that they should belong to the specified organization. This creates a critical security vulnerability where:

  1. An admin could assign arbitrary Stripe customer/subscription IDs to any organization
  2. The subscription could belong to a different customer than specified
  3. These IDs could be from completely unrelated Stripe accounts

Recommendation: Add validation to verify:

  • The subscription exists in Stripe
  • The subscription belongs to the specified customer
  • Optionally, verify the subscription metadata or customer email matches the organization
Suggested change
@Post("/pricing-migration/fix-metadata/{orgId}")
public async fixOrgMetadata(
@Request() request: JawnAuthenticatedRequest,
@Path() orgId: string,
@Body() body: { customerId: string; subscriptionId: string }
): Promise<Result<{ message: string }, string>> {
await authCheckThrow(request.authParams.userId);
try {
// Update the org with the stripe IDs
await dbExecute(
`
UPDATE organization
SET stripe_customer_id = $1, stripe_subscription_id = $2
WHERE id = $3
`,
[body.customerId, body.subscriptionId, orgId]
);
try {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Verify the subscription exists and belongs to the customer
const subscription = await stripe.subscriptions.retrieve(body.subscriptionId);
if (subscription.customer !== body.customerId) {
return err(`Subscription ${body.subscriptionId} does not belong to customer ${body.customerId}`);
}
// Verify the organization exists
const orgCheck = await dbExecute<{ id: string }>(
`SELECT id FROM organization WHERE id = $1`,
[orgId]
);
if (!orgCheck.data?.[0]) {
return err("Organization not found");
}
// Update the org with the stripe IDs
await dbExecute(
`
UPDATE organization
SET stripe_customer_id = $1, stripe_subscription_id = $2
WHERE id = $3
`,
[body.customerId, body.subscriptionId, orgId]
);
Prompt To Fix With AI
This is a comment left during a code review.
Path: valhalla/jawn/src/controllers/private/adminController.ts
Line: 3943:3960

Comment:
The `fixOrgMetadata` endpoint accepts `customerId` and `subscriptionId` from the request body but does not validate that these Stripe IDs are related to each other or that they should belong to the specified organization. This creates a critical security vulnerability where:

1. An admin could assign arbitrary Stripe customer/subscription IDs to any organization
2. The subscription could belong to a different customer than specified
3. These IDs could be from completely unrelated Stripe accounts

**Recommendation**: Add validation to verify:
- The subscription exists in Stripe
- The subscription belongs to the specified customer
- Optionally, verify the subscription metadata or customer email matches the organization

```suggestion
    try {
      const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

      // Verify the subscription exists and belongs to the customer
      const subscription = await stripe.subscriptions.retrieve(body.subscriptionId);
      
      if (subscription.customer !== body.customerId) {
        return err(`Subscription ${body.subscriptionId} does not belong to customer ${body.customerId}`);
      }

      // Verify the organization exists
      const orgCheck = await dbExecute<{ id: string }>(
        `SELECT id FROM organization WHERE id = $1`,
        [orgId]
      );

      if (!orgCheck.data?.[0]) {
        return err("Organization not found");
      }

      // Update the org with the stripe IDs
      await dbExecute(
        `
        UPDATE organization
        SET stripe_customer_id = $1, stripe_subscription_id = $2
        WHERE id = $3
        `,
        [body.customerId, body.subscriptionId, orgId]
      );
```


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

Comment on lines +3591 to +3604
// Update org in database
await dbExecute(
`
UPDATE organization
SET subscription_status = 'canceled'
WHERE id = $1
`,
[orgId]
);

return ok({
message: `Cancelled subscription for org ${orgId} (tier: ${tier})`,
subscriptionId: stripe_subscription_id,
});
Copy link
Contributor

Choose a reason for hiding this comment

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

The cancelSubscription endpoint does not check if the database update operation succeeded. If the dbExecute call fails (e.g., due to a database connection issue), the function will still return success, leading to inconsistent state where the Stripe subscription is cancelled but the database still shows it as active.

Recommendation: Check the result of the database operation and return an error if it fails.

Suggested change
// Update org in database
await dbExecute(
`
UPDATE organization
SET subscription_status = 'canceled'
WHERE id = $1
`,
[orgId]
);
return ok({
message: `Cancelled subscription for org ${orgId} (tier: ${tier})`,
subscriptionId: stripe_subscription_id,
});
// Update org in database
const updateResult = await dbExecute(
`
UPDATE organization
SET subscription_status = 'canceled'
WHERE id = $1
`,
[orgId]
);
if (updateResult.error) {
return err(`Failed to update organization status: ${updateResult.error}`);
}
return ok({
message: `Cancelled subscription for org ${orgId} (tier: ${tier})`,
subscriptionId: stripe_subscription_id,
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: valhalla/jawn/src/controllers/private/adminController.ts
Line: 3591:3604

Comment:
The `cancelSubscription` endpoint does not check if the database update operation succeeded. If the `dbExecute` call fails (e.g., due to a database connection issue), the function will still return success, leading to inconsistent state where the Stripe subscription is cancelled but the database still shows it as active.

**Recommendation**: Check the result of the database operation and return an error if it fails.

```suggestion
    // Update org in database
    const updateResult = await dbExecute(
      `
      UPDATE organization
      SET subscription_status = 'canceled'
      WHERE id = $1
      `,
      [orgId]
    );

    if (updateResult.error) {
      return err(`Failed to update organization status: ${updateResult.error}`);
    }

    return ok({
      message: `Cancelled subscription for org ${orgId} (tier: ${tier})`,
      subscriptionId: stripe_subscription_id,
    });
```


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

Comment on lines +3243 to +3308
// Fetch Stripe status for each org to get trial info and subscription items
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const orgsWithStripeStatus = await Promise.all(
(result.data ?? []).map(async (org) => {
let stripe_status: string | null = null;
let trial_end: number | null = null;
let subscription_items: Array<{
product_name: string | null;
price_id: string;
unit_amount: number | null;
recurring_interval: string | null;
}> = [];
let next_invoice_date: number | null = null;
let next_invoice_amount: number | null = null;

if (org.stripe_subscription_id) {
try {
const subscription = await stripe.subscriptions.retrieve(
org.stripe_subscription_id,
{ expand: ["items.data.price.product"] }
);
stripe_status = subscription.status;
trial_end = subscription.trial_end;

// Extract subscription items with product info
subscription_items = subscription.items.data.map((item) => {
const product = item.price.product;
const productName =
typeof product === "object" && product !== null && "name" in product
? (product as { name: string }).name
: null;

return {
product_name: productName,
price_id: item.price.id,
unit_amount: item.price.unit_amount,
recurring_interval: item.price.recurring?.interval ?? null,
};
});

// Get upcoming invoice
if (org.stripe_customer_id) {
try {
const upcomingInvoice = await stripe.invoices.retrieveUpcoming({
customer: org.stripe_customer_id,
});
next_invoice_date = upcomingInvoice.next_payment_attempt;
next_invoice_amount = upcomingInvoice.amount_due;
} catch (e) {
// No upcoming invoice (e.g., cancelled subscription)
}
}
} catch (e) {
stripe_status = "not_found";
}
}
return {
...org,
stripe_status,
trial_end,
subscription_items,
next_invoice_date,
next_invoice_amount,
};
})
);
Copy link
Contributor

Choose a reason for hiding this comment

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

The getOrgsWithNewTiers endpoint fetches ALL organizations with new pricing tiers and then makes parallel Stripe API calls for each one using Promise.all. Unlike getOrgsWithLegacyTiers which uses pagination (limit/offset), this endpoint has no pagination.

Potential issues:

  1. Performance: If there are hundreds or thousands of organizations with new tiers, this will make that many Stripe API calls in parallel
  2. Rate limiting: Stripe API has rate limits that could be hit with too many parallel requests
  3. Memory: Loading all organizations and their Stripe data into memory at once
  4. Timeout: The request could timeout with large datasets

Recommendation: Either add pagination to this endpoint (matching the pattern in getOrgsWithLegacyTiers), or batch the Stripe API calls to avoid overwhelming the Stripe API. For consistency, pagination would be best since the frontend already handles paginated data from the legacy tiers endpoint.

Prompt To Fix With AI
This is a comment left during a code review.
Path: valhalla/jawn/src/controllers/private/adminController.ts
Line: 3243:3308

Comment:
The `getOrgsWithNewTiers` endpoint fetches ALL organizations with new pricing tiers and then makes parallel Stripe API calls for each one using `Promise.all`. Unlike `getOrgsWithLegacyTiers` which uses pagination (limit/offset), this endpoint has no pagination.

**Potential issues**:
1. **Performance**: If there are hundreds or thousands of organizations with new tiers, this will make that many Stripe API calls in parallel
2. **Rate limiting**: Stripe API has rate limits that could be hit with too many parallel requests
3. **Memory**: Loading all organizations and their Stripe data into memory at once
4. **Timeout**: The request could timeout with large datasets

**Recommendation**: Either add pagination to this endpoint (matching the pattern in `getOrgsWithLegacyTiers`), or batch the Stripe API calls to avoid overwhelming the Stripe API. For consistency, pagination would be best since the frontend already handles paginated data from the legacy tiers endpoint.


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

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Jan 13, 2026

Additional Comments (1)

valhalla/jawn/src/controllers/private/adminController.ts
There's an inconsistency in how targetTier parameter is handled. When targetTier === "team", the code calls migrateToNewTeamPricing(true) to skip tier validation, allowing cross-tier upgrades (e.g., pro → team). However, when targetTier === "pro", it calls migrateToNewProPricing() which doesn't accept a skip parameter and will always validate the tier.

This means:

  • ✅ You CAN force migrate from any tier to team using targetTier: "team"
  • ❌ You CANNOT force migrate from team to pro using targetTier: "pro" because it will fail validation

This asymmetry could be confusing. Consider either:

  1. Making migrateToNewProPricing also accept a skipTierValidation parameter, or
  2. Documenting this limitation clearly, or
  3. Rejecting cross-tier downgrades explicitly
    // Run the migration (or reapply for orgs already on new tiers)
    // If targetTier is specified, use that; otherwise infer from current tier
    // skipTierValidation=true when targetTier is specified to allow cross-tier upgrades (e.g., pro -> team)
    let migrationResult;
    if (body.targetTier === "team") {
      migrationResult = await stripeManager.migrateToNewTeamPricing(true);
    } else if (body.targetTier === "pro") {
      // Note: migrateToNewProPricing doesn't support skipTierValidation
      // So downgrades from team to pro will fail if the org is on a team tier
      migrationResult = await stripeManager.migrateToNewProPricing();
    } else if (tier === "team-20250130" || tier === "team-20251210") {
      migrationResult = await stripeManager.migrateToNewTeamPricing();
    } else if (
      ["pro-20240913", "pro-20250202", "growth", "pro-20251210"].includes(tier)
    ) {
      migrationResult = await stripeManager.migrateToNewProPricing();
    } else {
      return err(`Unknown tier for migration: ${tier}`);
    }
Prompt To Fix With AI
This is a comment left during a code review.
Path: valhalla/jawn/src/controllers/private/adminController.ts
Line: 2961:2973

Comment:
There's an inconsistency in how `targetTier` parameter is handled. When `targetTier === "team"`, the code calls `migrateToNewTeamPricing(true)` to skip tier validation, allowing cross-tier upgrades (e.g., pro → team). However, when `targetTier === "pro"`, it calls `migrateToNewProPricing()` which doesn't accept a skip parameter and will always validate the tier.

This means:
- ✅ You CAN force migrate from any tier to team using `targetTier: "team"`
- ❌ You CANNOT force migrate from team to pro using `targetTier: "pro"` because it will fail validation

This asymmetry could be confusing. Consider either:
1. Making `migrateToNewProPricing` also accept a `skipTierValidation` parameter, or
2. Documenting this limitation clearly, or
3. Rejecting cross-tier downgrades explicitly

```suggestion
    // Run the migration (or reapply for orgs already on new tiers)
    // If targetTier is specified, use that; otherwise infer from current tier
    // skipTierValidation=true when targetTier is specified to allow cross-tier upgrades (e.g., pro -> team)
    let migrationResult;
    if (body.targetTier === "team") {
      migrationResult = await stripeManager.migrateToNewTeamPricing(true);
    } else if (body.targetTier === "pro") {
      // Note: migrateToNewProPricing doesn't support skipTierValidation
      // So downgrades from team to pro will fail if the org is on a team tier
      migrationResult = await stripeManager.migrateToNewProPricing();
    } else if (tier === "team-20250130" || tier === "team-20251210") {
      migrationResult = await stripeManager.migrateToNewTeamPricing();
    } else if (
      ["pro-20240913", "pro-20250202", "growth", "pro-20251210"].includes(tier)
    ) {
      migrationResult = await stripeManager.migrateToNewProPricing();
    } else {
      return err(`Unknown tier for migration: ${tier}`);
    }
```


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

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.

1 participant