-
Notifications
You must be signed in to change notification settings - Fork 0
OUT-3574: daily cron to auto-refresh QBO refresh tokens #239
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
17 changes: 17 additions & 0 deletions
17
src/app/api/quickbooks/refresh-tokens/refresh-tokens.controller.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import APIError from '@/app/api/core/exceptions/api' | ||
| import { cronSecret } from '@/config' | ||
| import { refreshExpiringTokens } from '@/app/api/quickbooks/refresh-tokens/refresh-tokens.service' | ||
| import { NextRequest, NextResponse } from 'next/server' | ||
|
|
||
| export const refreshExpiringTokensCron = async (request: NextRequest) => { | ||
| // Explicit !cronSecret guard: when the env var is absent, the template | ||
| // literal yields "Bearer undefined" and a request sending exactly that | ||
| // string would otherwise pass. Fail loudly on misconfigured deployments. | ||
| const authHeader = request.headers.get('authorization') | ||
| if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { | ||
| throw new APIError(401, 'Unauthorized') | ||
| } | ||
|
|
||
| const summary = await refreshExpiringTokens() | ||
| return NextResponse.json({ success: true, ...summary }) | ||
| } |
108 changes: 108 additions & 0 deletions
108
src/app/api/quickbooks/refresh-tokens/refresh-tokens.service.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| import { getPortalsWithExpiringRefreshTokens } from '@/db/service/token.service' | ||
| import { | ||
| QBReconnectRequiredError, | ||
| getRefreshedQbTokenInfo, | ||
| } from '@/utils/tokenRefresh' | ||
| import CustomLogger from '@/utils/logger' | ||
| import * as Sentry from '@sentry/nextjs' | ||
|
|
||
| /** | ||
| * Daily refresh-token sweep. | ||
| * | ||
| * Refresh anything inside this window. 14 daily passes gives plenty of | ||
| * headroom to recover from extended outages (broken deploy left over a long | ||
| * weekend, multi-day Intuit incident) before refresh tokens actually expire | ||
| * (~100 days). The cost of refreshing earlier than strictly needed is a | ||
| * single extra Intuit call per portal — refreshing at day 14 vs day 1 is the | ||
| * same shape of request. | ||
| */ | ||
| export const REFRESH_TOKEN_LEAD_DAYS = 14 | ||
|
|
||
| /** | ||
| * Per-run cap. Sized for the non-Fluid-Compute 300 s function budget on | ||
| * Vercel with real-world latency in mind: a flaky/rate-limited Intuit can push | ||
| * a single refresh to 5+ s after `withRetry` backoff. 120 finishes in ~120 s | ||
| * at the typical 1 s/portal and leaves headroom (~60 portals at 5 s/portal) | ||
| * before timeout. Any portal not reached today gets picked up tomorrow — the | ||
| * 14-day lead window absorbs the deferral without any token actually expiring. | ||
| */ | ||
| export const REFRESH_TOKEN_BATCH_LIMIT = 120 | ||
|
|
||
| export type RefreshTokensSummary = { | ||
| scanned: number | ||
| refreshed: number | ||
| reconnectRequired: number | ||
| errored: number | ||
| } | ||
|
|
||
| /** | ||
| * Iterates portals with refresh tokens nearing expiry and rotates each via | ||
| * the existing `getRefreshedQbTokenInfo` helper, which handles the Intuit | ||
| * call, DB persistence, and concurrent-refresh races. | ||
| * | ||
| * Errors are isolated per portal so one failure (revocation, network blip) | ||
| * never poisons the rest of the batch. Revocation handling is intentionally | ||
| * left to the existing webhook auth path that has Copilot user context; | ||
| * here we only log and continue. | ||
| */ | ||
| export async function refreshExpiringTokens(): Promise<RefreshTokensSummary> { | ||
| const rows = await getPortalsWithExpiringRefreshTokens( | ||
| REFRESH_TOKEN_LEAD_DAYS, | ||
| REFRESH_TOKEN_BATCH_LIMIT, | ||
| ) | ||
|
|
||
| CustomLogger.info({ | ||
| obj: { count: rows.length, leadDays: REFRESH_TOKEN_LEAD_DAYS }, | ||
| message: 'refreshExpiringTokens | starting batch', | ||
| }) | ||
|
|
||
| const summary: RefreshTokensSummary = { | ||
| scanned: rows.length, | ||
| refreshed: 0, | ||
| reconnectRequired: 0, | ||
| errored: 0, | ||
| } | ||
|
|
||
| for (const row of rows) { | ||
| try { | ||
| await getRefreshedQbTokenInfo(row.portalId, row) | ||
| summary.refreshed += 1 | ||
| } catch (error: unknown) { | ||
| if (error instanceof QBReconnectRequiredError) { | ||
| summary.reconnectRequired += 1 | ||
| CustomLogger.error({ | ||
| obj: { | ||
| portalId: error.portalId, | ||
| intuitRealmId: error.intuitRealmId, | ||
| }, | ||
| message: | ||
| 'refreshExpiringTokens | refresh token revoked — reconnect required', | ||
| }) | ||
| Sentry.withScope((scope) => { | ||
| scope.setTag('portalId', error.portalId) | ||
| scope.setTag('intuitRealmId', error.intuitRealmId) | ||
| Sentry.captureException(error) | ||
| }) | ||
| continue | ||
| } | ||
|
|
||
| summary.errored += 1 | ||
| CustomLogger.error({ | ||
| obj: { portalId: row.portalId, error }, | ||
| message: 'refreshExpiringTokens | refresh failed for portal', | ||
| }) | ||
| Sentry.withScope((scope) => { | ||
| scope.setTag('portalId', row.portalId) | ||
| scope.setTag('intuitRealmId', row.intuitRealmId) | ||
| Sentry.captureException(error) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| CustomLogger.info({ | ||
| obj: summary, | ||
| message: 'refreshExpiringTokens | batch complete', | ||
| }) | ||
|
|
||
| return summary | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' | ||
| import { refreshExpiringTokensCron } from '@/app/api/quickbooks/refresh-tokens/refresh-tokens.controller' | ||
|
|
||
| export const maxDuration = 300 // 5 min — see refresh-tokens.service.ts for batch sizing. | ||
|
|
||
| export const GET = withErrorHandler(refreshExpiringTokensCron) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
189 changes: 189 additions & 0 deletions
189
test/integration/quickbooks/refreshTokens/expiringSweep.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| /** | ||
| * Integration coverage for the daily refresh-token cron (OUT-3574). | ||
| * | ||
| * What this exercises end-to-end: | ||
| * - The `getPortalsWithExpiringRefreshTokens` SQL — interval math + LIMIT | ||
| * ordering by soonest-to-expire. | ||
| * - The route → controller → service path, including Bearer auth. | ||
| * - Persistence: each refreshed portal's row gets the new tokens written. | ||
| * | ||
| * What this does NOT exercise (covered elsewhere): | ||
| * - Race-vs-revocation handling inside `getRefreshedQbTokenInfo` → | ||
| * test/unit/utils/tokenRefresh.test.ts | ||
| * - Loop-level error isolation and counters → | ||
| * test/unit/api/quickbooks/refresh-tokens/refresh-tokens.service.test.ts | ||
| */ | ||
|
|
||
| import { describe, it, expect, beforeEach, vi } from 'vitest' | ||
| import { eq } from 'drizzle-orm' | ||
| import { testApiHandler } from 'next-test-api-route-handler' | ||
|
|
||
| import { db } from '@/db' | ||
| import { QBPortalConnection } from '@/db/schema/qbPortalConnections' | ||
| import * as appHandler from '@/app/api/quickbooks/refresh-tokens/route' | ||
|
|
||
| import { truncateAllTestTables } from '@test/helpers/testDb' | ||
| import { seedPortalConnection, seedSetting } from '@test/helpers/seed' | ||
|
|
||
| // `@/utils/intuit` (the OAuth wrapper) isn't part of the shared integration | ||
| // `setup.ts` mocks, so we install it here. A real refresh would hit the | ||
| // Intuit sandbox and rotate the seeded refresh token, which would make the | ||
| // DB-write assertions non-deterministic. | ||
| const getRefreshedQBToken = vi.fn() | ||
| vi.mock('@/utils/intuit', () => ({ | ||
| default: { getInstance: () => ({ getRefreshedQBToken }) }, | ||
| })) | ||
|
|
||
| const CRON_AUTH = `Bearer ${process.env.CRON_SECRET}` | ||
|
|
||
| const ONE_DAY_S = 24 * 60 * 60 | ||
| const REFRESH_TTL_S = 8_726_400 // 101 days — what Intuit returns | ||
|
|
||
| /** | ||
| * Builds a `tokenSetTime` such that the refresh token is `daysToExpiry` away | ||
| * from expiring. The selector treats anything with <14 days remaining as due. | ||
| */ | ||
| const tokenSetTimeForDaysToExpiry = (daysToExpiry: number) => | ||
| new Date(Date.now() - (REFRESH_TTL_S - daysToExpiry * ONE_DAY_S) * 1000) | ||
|
|
||
| async function callCron(headers: Record<string, string> = {}) { | ||
| let response!: Response | ||
| await testApiHandler({ | ||
| appHandler, | ||
| test: async ({ fetch }) => { | ||
| response = await fetch({ method: 'GET', headers }) | ||
| }, | ||
| }) | ||
| return response | ||
| } | ||
|
|
||
| describe('GET /api/quickbooks/refresh-tokens', () => { | ||
| beforeEach(async () => { | ||
| await truncateAllTestTables() | ||
| vi.clearAllMocks() | ||
| getRefreshedQBToken.mockResolvedValue({ | ||
| access_token: 'fresh-access', | ||
| refresh_token: 'fresh-refresh', | ||
| expires_in: 3600, | ||
| x_refresh_token_expires_in: REFRESH_TTL_S, | ||
| token_type: 'bearer', | ||
| }) | ||
| }) | ||
|
|
||
| it('rejects requests without the cron bearer token', async () => { | ||
| // Same auth contract as the existing `/api/quickbooks/cron` endpoint. | ||
| // Crons run from Vercel's scheduler with the secret injected; anyone | ||
| // else hitting the URL must get 401. | ||
| const res = await callCron() | ||
| expect(res.status).toBe(401) | ||
| expect(getRefreshedQBToken).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('refreshes portals inside the 14-day window and skips the rest', async () => { | ||
| // Three portals: two due (5d, 10d remaining), one safe (30d — outside | ||
| // the 14-day lead window). Verifies the SQL interval predicate and that | ||
| // we don't touch portals outside the lead window — important because | ||
| // Intuit's response on a not-yet-expiring token may not actually rotate | ||
| // the refresh value, making those calls pure overhead. | ||
| const due1 = await seedPortalConnection({ | ||
| portalId: 'p-due-1', | ||
| intuitRealmId: 'realm-due-1', | ||
| refreshToken: 'old-due-1', | ||
| tokenSetTime: tokenSetTimeForDaysToExpiry(5), | ||
| }) | ||
| await seedSetting({ portalId: 'p-due-1', syncFlag: true }) | ||
| const due2 = await seedPortalConnection({ | ||
| portalId: 'p-due-2', | ||
| intuitRealmId: 'realm-due-2', | ||
| refreshToken: 'old-due-2', | ||
| tokenSetTime: tokenSetTimeForDaysToExpiry(10), | ||
| }) | ||
| await seedSetting({ portalId: 'p-due-2', syncFlag: true }) | ||
| await seedPortalConnection({ | ||
| portalId: 'p-safe', | ||
| intuitRealmId: 'realm-safe', | ||
| refreshToken: 'old-safe', | ||
| tokenSetTime: tokenSetTimeForDaysToExpiry(30), | ||
| }) | ||
| await seedSetting({ portalId: 'p-safe', syncFlag: true }) | ||
|
|
||
| const res = await callCron({ authorization: CRON_AUTH }) | ||
| expect(res.status).toBe(200) | ||
| await expect(res.json()).resolves.toEqual({ | ||
| success: true, | ||
| scanned: 2, | ||
| refreshed: 2, | ||
| reconnectRequired: 0, | ||
| errored: 0, | ||
| }) | ||
|
|
||
| expect(getRefreshedQBToken).toHaveBeenCalledTimes(2) | ||
| // Soonest-to-expire first — guards the ORDER BY in the selector so a | ||
| // saturated batch always tackles the most-urgent portals. | ||
| expect(getRefreshedQBToken).toHaveBeenNthCalledWith(1, 'old-due-1') | ||
| expect(getRefreshedQBToken).toHaveBeenNthCalledWith(2, 'old-due-2') | ||
|
|
||
| const updated1 = await db | ||
| .select() | ||
| .from(QBPortalConnection) | ||
| .where(eq(QBPortalConnection.id, due1.id)) | ||
| expect(updated1[0]).toMatchObject({ | ||
| accessToken: 'fresh-access', | ||
| refreshToken: 'fresh-refresh', | ||
| }) | ||
|
|
||
| const updated2 = await db | ||
| .select() | ||
| .from(QBPortalConnection) | ||
| .where(eq(QBPortalConnection.id, due2.id)) | ||
| expect(updated2[0]).toMatchObject({ | ||
| accessToken: 'fresh-access', | ||
| refreshToken: 'fresh-refresh', | ||
| }) | ||
|
|
||
| const safe = await db | ||
| .select() | ||
| .from(QBPortalConnection) | ||
| .where(eq(QBPortalConnection.portalId, 'p-safe')) | ||
| expect(safe[0].refreshToken).toBe('old-safe') | ||
| }) | ||
|
|
||
| it('skips soft-deleted portals, portals without settings, and portals with syncFlag=false', async () => { | ||
| // Three exclusion paths the selector enforces: | ||
| // - soft-deleted rows: off-limits to all background jobs. | ||
| // - no qb_settings row at all: sync was never configured for this | ||
| // portal — refreshing the token would be wasted Intuit traffic. | ||
| // - syncFlag=false: IU explicitly disabled sync; honor that. | ||
| await seedPortalConnection({ | ||
| portalId: 'p-deleted', | ||
| intuitRealmId: 'realm-deleted', | ||
| refreshToken: 'old-deleted', | ||
| tokenSetTime: tokenSetTimeForDaysToExpiry(3), | ||
| deletedAt: new Date(), | ||
| }) | ||
| await seedSetting({ portalId: 'p-deleted', syncFlag: true }) | ||
|
|
||
| await seedPortalConnection({ | ||
| portalId: 'p-no-settings', | ||
| intuitRealmId: 'realm-no-settings', | ||
| refreshToken: 'old-no-settings', | ||
| tokenSetTime: tokenSetTimeForDaysToExpiry(3), | ||
| }) | ||
|
|
||
| await seedPortalConnection({ | ||
| portalId: 'p-sync-off', | ||
| intuitRealmId: 'realm-sync-off', | ||
| refreshToken: 'old-sync-off', | ||
| tokenSetTime: tokenSetTimeForDaysToExpiry(3), | ||
| }) | ||
| await seedSetting({ portalId: 'p-sync-off', syncFlag: false }) | ||
|
|
||
| const res = await callCron({ authorization: CRON_AUTH }) | ||
| expect(res.status).toBe(200) | ||
| await expect(res.json()).resolves.toMatchObject({ | ||
| scanned: 0, | ||
| refreshed: 0, | ||
| }) | ||
| expect(getRefreshedQBToken).not.toHaveBeenCalled() | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.