Skip to content

Comments

feat: add cron job to migrate team/org billing data from bookings#24498

Draft
joeauyeung wants to merge 8 commits intomainfrom
devin/1760576747-migrate-billing-cron
Draft

feat: add cron job to migrate team/org billing data from bookings#24498
joeauyeung wants to merge 8 commits intomainfrom
devin/1760576747-migrate-billing-cron

Conversation

@joeauyeung
Copy link
Contributor

@joeauyeung joeauyeung commented Oct 16, 2025

What does this PR do?

Adds a cron job to incrementally migrate team and organization billing data from the Team.metadata JSON field to the dedicated TeamBilling and OrganizationBilling tables.

Migration Strategy:

  • Queries bookings from the last 24 hours (configurable via lookbackHours parameter)
  • Extracts unique team IDs from those bookings
  • Migrates billing data for teams that haven't been migrated yet
  • Skips teams that already have billing records or lack subscription data

This approach has zero impact on the booking flow and processes teams incrementally as they become active through new bookings.

Link to Devin run: https://app.devin.ai/sessions/31059a89a9ed46e7a3b20fb9531b2166
Requested by: joe@cal.com (@joeauyeung)

Updates Since Last Revision

Refactored to follow Cal.com's architectural patterns per reviewer feedback:

  • Thin controller pattern: Route handler now only handles HTTP concerns (auth, request parsing, response formatting)
  • Repository pattern: Service uses BookingRepository and ITeamBillingDataRepository instead of calling Prisma directly
  • Dependency injection: All repositories obtained via DI container (getTeamBillingDataRepository(), getBillingRepositoryFactory())
  • Factory pattern: BillingRepositoryFactory injected to create appropriate billing repository (team vs organization) based on entity type
  • Unit tests added: 13 comprehensive tests for BillingMigrationService covering migration scenarios, skip conditions, error handling, and edge cases
  • Merged with main branch and adapted to new team billing repository structure at packages/features/ee/billing/repository/teamBillingData/

Key Implementation Details

Architecture:

  • route.ts - Thin controller handling auth and HTTP concerns only
  • BillingMigrationService - Business logic for migration orchestration, receives dependencies via constructor
  • BookingRepository.findDistinctTeamIdsByCreatedDateRange() - New method to find teams from recent bookings
  • PrismaTeamBillingDataRepository.findByIdIncludeBillingRecords() - New method to fetch team with billing status
  • Billing.ts container - Added getBillingRepositoryFactory() export for DI

Data Mapping:

Metadata Field Billing Table Field
subscriptionId subscriptionId
subscriptionItemId subscriptionItemId
paymentId customerId ⚠️ Needs verification
- status (default: "ACTIVE") ⚠️ May need refinement
isOrganization planName (TEAM or ORGANIZATION)

Authentication: Uses CRON_API_KEY or CRON_SECRET (consistent with other cron jobs)

Important Review Points

⚠️ Critical items to verify:

  1. Metadata field mapping - Confirm that paymentId in metadata is actually the Stripe customerId
  2. Default status - All migrated teams get status "ACTIVE" - is this correct for teams in trial, past_due, etc.?
  3. Repository methods - Verify findDistinctTeamIdsByCreatedDateRange and findByIdIncludeBillingRecords implementations are correct
  4. DI container - Verify billingRepositoryFactoryModuleLoader loads correctly with its dependencies

Mandatory Tasks (DO NOT REMOVE)

  • I have self-reviewed the code (A decent size PR without self-review might be rejected).
  • N/A - This is a new internal cron job, no user-facing documentation needed. README included in PR.
  • I confirm automated tests are in place that prove my fix is effective or that my feature works.

How should this be tested?

Prerequisites:

  • Set CRON_API_KEY or CRON_SECRET in environment variables
  • Have teams with subscription data in Team.metadata
  • Have recent bookings for those teams

Manual Testing:

# Test with default 24-hour lookback
curl -X POST "http://localhost:3000/api/cron/migrate-billing" \
  -H "Authorization: Bearer ${CRON_SECRET}"

# Test with custom lookback period
curl -X POST "http://localhost:3000/api/cron/migrate-billing?lookbackHours=48" \
  -H "Authorization: Bearer ${CRON_SECRET}"

Verify:

  1. Check response shows correct counts (teamsFound, migrated, skipped, errors)
  2. Query TeamBilling and OrganizationBilling tables to verify data was inserted
  3. Re-run the job to confirm already-migrated teams are skipped

Unit Tests:

yarn test packages/features/ee/billing/services/BillingMigrationService.test.ts

Checklist

  • My code follows the style guidelines of this project
  • I have commented my code, particularly in hard-to-understand areas
  • I have checked if my changes generate no new warnings
  • Added comprehensive README documentation

This cron job migrates team and organization subscription billing data from the Team.metadata field to the dedicated TeamBilling and OrganizationBilling tables.

The job:
- Queries bookings created within a configurable lookback period (default 24 hours)
- Extracts unique team IDs from those bookings
- Migrates billing data to the appropriate table (TeamBilling or OrganizationBilling)
- Provides comprehensive logging and error tracking
- Skips already-migrated teams and teams without subscription data

This approach has zero impact on user experience as it runs asynchronously and processes teams incrementally as they become active through new bookings.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>
@devin-ai-integration
Copy link
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@graphite-app graphite-app bot requested a review from a team October 16, 2025 01:07
@keithwillcode keithwillcode added core area: core, team members only enterprise area: enterprise, audit log, organisation, SAML, SSO labels Oct 16, 2025
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 16, 2025

Walkthrough

Introduces a new POST API endpoint at /api/cron/migrate-billing that migrates billing data for teams with recent bookings. It authenticates via CRON_API_KEY or Bearer CRON_SECRET, enforces lookbackHours (default 24, min 1, max 168), derives team IDs from recent bookings, and per team: loads data, skips if missing/already migrated, validates metadata, extracts subscriptionId/subscriptionItemId, builds billing payload (IDs, status, planName, optional dates), and writes to OrganizationBilling or TeamBilling. It aggregates migrated/skipped/errors, returns a JSON summary, logs details, handles 500s, and exports POST. Added README documents workflow, parameters, scheduling, mapping, and rollback.

Possibly related PRs

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed The PR title "feat: add cron job to migrate team/org billing data from bookings" directly and clearly describes the main change in this changeset. Both files (README.md and route.ts) are entirely focused on implementing a new cron job endpoint for migrating billing data from Team.metadata into dedicated billing tables, which is precisely what the title conveys. The title is concise, specific, and avoids vague terminology or noise, making it immediately understandable to a teammate reviewing the git history.
Description Check ✅ Passed The PR description is directly related to the changeset and provides comprehensive, meaningful information about the billing migration cron job implementation. It clearly explains the purpose, migration strategy, data field mappings, authentication mechanism, error handling approach, and testing instructions. The description includes specific details that align with the code changes (lookbackHours parameter, team filtering logic, metadata parsing, status defaults, and table selection based on team type) and identifies critical verification points that reviewers should address.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch devin/1760576747-migrate-billing-cron

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.

@dosubot dosubot bot added billing area: billing, stripe, payments, paypal, get paid ✨ feature New feature or request labels Oct 16, 2025
Copy link
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: 1

🧹 Nitpick comments (1)
apps/web/app/api/cron/migrate-billing/README.md (1)

114-121: Add a language hint to the log code fence.

Giving the log snippet a fence language (e.g., ```text) silences MD040 and keeps rendering consistent with the rest of the doc.

+text +[cron:migrate-billing] Starting billing migration for bookings created since 2025-10-15T00:00:00.000Z +[cron:migrate-billing] Found 15 unique teams from recent bookings +[cron:migrate-billing] Team 123 already has billing record, skipping +[cron:migrate-billing] Migrated team 456 to TeamBilling table +[cron:migrate-billing] Migrated organization 789 to OrganizationBilling table +[cron:migrate-billing] Billing migration completed: {"ok":true,"teamsFound":15,"migrated":8,"skipped":7,"errors":0} +

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 5731321 and 3c2dfbe.

📒 Files selected for processing (2)
  • apps/web/app/api/cron/migrate-billing/README.md (1 hunks)
  • apps/web/app/api/cron/migrate-billing/route.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.ts

📄 CodeRabbit inference engine (.cursor/rules/review.mdc)

**/*.ts: For Prisma queries, only select data you need; never use include, always use select
Ensure the credential.key field is never returned from tRPC endpoints or APIs

Files:

  • apps/web/app/api/cron/migrate-billing/route.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/review.mdc)

Flag excessive Day.js use in performance-critical code; prefer native Date or Day.js .utc() in hot paths like loops

Files:

  • apps/web/app/api/cron/migrate-billing/route.ts
**/*.{ts,tsx,js,jsx}

⚙️ CodeRabbit configuration file

Flag default exports and encourage named exports. Named exports provide better tree-shaking, easier refactoring, and clearer imports. Exempt main components like pages, layouts, and components that serve as the primary export of a module.

Files:

  • apps/web/app/api/cron/migrate-billing/route.ts
🧬 Code graph analysis (1)
apps/web/app/api/cron/migrate-billing/route.ts (2)
packages/prisma/zod-utils.ts (1)
  • teamMetadataStrictSchema (390-406)
apps/web/app/api/defaultResponderForAppDir.ts (1)
  • defaultResponderForAppDir (14-72)
🪛 LanguageTool
apps/web/app/api/cron/migrate-billing/README.md

[grammar] ~11-~11: There might be a mistake here.
Context: ...lookback period (default: last 24 hours) 2. Extracts unique team IDs from those book...

(QB_NEW_EN)


[grammar] ~12-~12: There might be a mistake here.
Context: ...acts unique team IDs from those bookings 3. For each team: - Checks if billing da...

(QB_NEW_EN)


[grammar] ~13-~13: There might be a mistake here.
Context: ...Ds from those bookings 3. For each team: - Checks if billing data already exists in...

(QB_NEW_EN)


[grammar] ~14-~14: There might be a mistake here.
Context: ...ng data already exists in the new tables - Skips if already migrated - Parses me...

(QB_NEW_EN)


[grammar] ~15-~15: There might be a mistake here.
Context: ...ew tables - Skips if already migrated - Parses metadata to extract subscription ...

(QB_NEW_EN)


[grammar] ~16-~16: There might be a mistake here.
Context: ...data to extract subscription information - Creates a record in either TeamBilling...

(QB_NEW_EN)


[grammar] ~21-~21: There might be a mistake here.
Context: ...s that make new bookings. This approach: - Has zero impact on user experience (runs...

(QB_NEW_EN)


[grammar] ~22-~22: There might be a mistake here.
Context: ...on user experience (runs asynchronously) - Processes teams incrementally as they be...

(QB_NEW_EN)


[grammar] ~23-~23: There might be a mistake here.
Context: ...eams incrementally as they become active - Avoids overwhelming the database with a ...

(QB_NEW_EN)


[grammar] ~24-~24: There might be a mistake here.
Context: ...e database with a single large migration - Provides clear logging and error trackin...

(QB_NEW_EN)


[grammar] ~39-~39: There might be a mistake here.
Context: ...umber of hours to look back for bookings - Default: 24 - Min: 1 - Max: 168 (1 w...

(QB_NEW_EN)


[grammar] ~40-~40: There might be a mistake here.
Context: ...o look back for bookings - Default: 24 - Min: 1 - Max: 168 (1 week) ### Exampl...

(QB_NEW_EN)


[grammar] ~41-~41: There might be a mistake here.
Context: ... for bookings - Default: 24 - Min: 1 - Max: 168 (1 week) ### Example Requests ...

(QB_NEW_EN)


[grammar] ~74-~74: There might be a mistake here.
Context: ...orDetails": [] } ``` Response Fields: - ok: Whether the job completed successfully...

(QB_NEW_EN)


[grammar] ~75-~75: There might be a mistake here.
Context: ...: Whether the job completed successfully - lookbackHours: Configured lookback period - `lookback...

(QB_NEW_EN)


[grammar] ~76-~76: There might be a mistake here.
Context: ...okbackHours: Configured lookback period - lookbackDate: Calculated cutoff date - teamsFound`:...

(QB_NEW_EN)


[grammar] ~77-~77: There might be a mistake here.
Context: ...- lookbackDate: Calculated cutoff date - teamsFound: Number of unique teams found in recent...

(QB_NEW_EN)


[grammar] ~78-~78: There might be a mistake here.
Context: ...of unique teams found in recent bookings - migrated: Number of teams successfully migrated ...

(QB_NEW_EN)


[grammar] ~79-~79: There might be a mistake here.
Context: ...: Number of teams successfully migrated - skipped`: Number of teams skipped (already migra...

(QB_NEW_EN)


[grammar] ~80-~80: There might be a mistake here.
Context: ...lready migrated or no subscription data) - errors: Number of teams that failed to migrate...

(QB_NEW_EN)


[grammar] ~81-~81: There might be a mistake here.
Context: ...: Number of teams that failed to migrate - errorDetails: Array of error details with teamId and...

(QB_NEW_EN)


[grammar] ~107-~107: There might be a mistake here.
Context: ... Start, completion, and migration events - warn: Missing teams - debug: Skip reasons ...

(QB_NEW_EN)


[grammar] ~108-~108: There might be a mistake here.
Context: ...migration events - warn: Missing teams - debug: Skip reasons - error: Migration fail...

(QB_NEW_EN)


[grammar] ~109-~109: There might be a mistake here.
Context: ...: Missing teams - debug: Skip reasons - error`: Migration failures ### Example Log Ou...

(QB_NEW_EN)


[grammar] ~127-~127: There might be a mistake here.
Context: ...| Metadata Field | Billing Table Field | |---------------|-------------------| | ...

(QB_NEW_EN)


[grammar] ~128-~128: There might be a mistake here.
Context: ... | |---------------|-------------------| | subscriptionId | subscriptionId | ...

(QB_NEW_EN)


[grammar] ~129-~129: There might be a mistake here.
Context: ... | subscriptionId | subscriptionId | | subscriptionItemId | `subscriptionIt...

(QB_NEW_EN)


[grammar] ~130-~130: There might be a mistake here.
Context: ...criptionItemId|subscriptionItemId| |paymentId|customerId| | - |st...

(QB_NEW_EN)


[grammar] ~131-~131: There might be a mistake here.
Context: ...ItemId| |paymentId|customerId| | - |status(default: "ACTIVE") | |...

(QB_NEW_EN)


[grammar] ~132-~132: There might be a mistake here.
Context: ...| | - |status(default: "ACTIVE") | |isOrganization|planName` (TEAM or...

(QB_NEW_EN)


[grammar] ~133-~133: There might be a mistake here.
Context: ...n|planName(TEAM or ORGANIZATION) | |subscriptionStartDate|subscriptio...

(QB_NEW_EN)


[grammar] ~134-~134: There might be a mistake here.
Context: ...iptionStartDate|subscriptionStart| |subscriptionTrialEndDate|subscrip...

(QB_NEW_EN)


[grammar] ~135-~135: There might be a mistake here.
Context: ...TrialEndDate|subscriptionTrialEnd| |subscriptionEndDate|subscriptionE...

(QB_NEW_EN)


[grammar] ~140-~140: There might be a mistake here.
Context: ...# Skip Conditions Teams are skipped if: 1. Already migrated (billing record exists)...

(QB_NEW_EN)


[grammar] ~141-~141: There might be a mistake here.
Context: ...Already migrated (billing record exists) 2. No subscriptionId in metadata 3. No `s...

(QB_NEW_EN)


[grammar] ~142-~142: There might be a mistake here.
Context: ...ists) 2. No subscriptionId in metadata 3. No subscriptionItemId in metadata 4. T...

(QB_NEW_EN)


[grammar] ~143-~143: There might be a mistake here.
Context: ...a 3. No subscriptionItemId in metadata 4. Team not found in database ## Error Han...

(QB_NEW_EN)

🪛 markdownlint-cli2 (0.18.1)
apps/web/app/api/cron/migrate-billing/README.md

114-114: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

⏰ Context from checks skipped due to timeout of 180000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Install dependencies / Yarn install & cache

Comment on lines 20 to 21
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Harden cron auth when secrets are unset.

If CRON_API_KEY/CRON_SECRET aren’t configured, the allow list contains "Bearer undefined" (and undefined), so anyone sending that value is treated as authenticated. That’s a silent auth bypass. Guard by rejecting requests whenever no secret is configured and filter falsy entries before comparison.

-  const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
-
-  if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
+  const headerToken = request.headers.get("authorization");
+  const queryToken = request.nextUrl.searchParams.get("apiKey");
+  const suppliedToken = headerToken ?? queryToken ?? undefined;
+  const allowedTokens = [
+    process.env.CRON_API_KEY,
+    process.env.CRON_SECRET ? `Bearer ${process.env.CRON_SECRET}` : undefined,
+  ].filter(Boolean) as string[];
+
+  if (!suppliedToken || allowedTokens.length === 0 || !allowedTokens.includes(suppliedToken)) {
     return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
const headerToken = request.headers.get("authorization");
const queryToken = request.nextUrl.searchParams.get("apiKey");
const suppliedToken = headerToken ?? queryToken ?? undefined;
const allowedTokens = [
process.env.CRON_API_KEY,
process.env.CRON_SECRET ? `Bearer ${process.env.CRON_SECRET}` : undefined,
].filter(Boolean) as string[];
if (!suppliedToken || allowedTokens.length === 0 || !allowedTokens.includes(suppliedToken)) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
}
🤖 Prompt for AI Agents
In apps/web/app/api/cron/migrate-billing/route.ts around lines 20 to 21, the
current allow-list includes literal "undefined" entries when CRON_API_KEY or
CRON_SECRET are unset, allowing a silent auth bypass; change the logic to first
reject the request if neither CRON_API_KEY nor CRON_SECRET is configured, then
build the allowed tokens array by filtering out falsy values before comparing to
the incoming apiKey (e.g., only include process.env.CRON_API_KEY and `Bearer
${process.env.CRON_SECRET}` when they are truthy), and return 401 if no match.

@@ -0,0 +1,168 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
Copy link
Contributor

Choose a reason for hiding this comment

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

This logic should not live directly in this route.ts. It goes against our patterns of having these entry points be thin. Let’s move logic to application services.

@github-actions github-actions bot marked this pull request as draft October 16, 2025 22:33
@github-actions
Copy link
Contributor

This PR has been marked as stale due to inactivity. If you're still working on it or need any help, please let us know or update the PR to keep it active.

@github-actions github-actions bot added the Stale label Oct 27, 2025
@github-actions
Copy link
Contributor

This PR has been closed due to inactivity. Please feel free to reopen it if you'd like to continue the work.

@github-actions github-actions bot closed this Nov 10, 2025
Address Keith's review comment to keep route.ts thin by:
- Creating BillingMigrationService to handle business logic
- Refactoring route.ts to only handle HTTP concerns (auth, request parsing, response)
- Also fixing auth bypass vulnerability when CRON secrets are unset

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>
@joeauyeung joeauyeung reopened this Jan 23, 2026
…igrationService

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>
devin-ai-integration bot and others added 4 commits January 23, 2026 16:11
Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>
Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>
Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>
Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>
…level)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

billing area: billing, stripe, payments, paypal, get paid core area: core, team members only enterprise area: enterprise, audit log, organisation, SAML, SSO ✨ feature New feature or request size/XL Stale

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants