Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/app/api/quickbooks/refresh-tokens/refresh-tokens.controller.ts
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 src/app/api/quickbooks/refresh-tokens/refresh-tokens.service.ts
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',
Comment thread
SandipBajracharya marked this conversation as resolved.
})

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
}
6 changes: 6 additions & 0 deletions src/app/api/quickbooks/refresh-tokens/route.ts
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)
46 changes: 43 additions & 3 deletions src/db/service/token.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
'use server'
import { db } from '@/db'
import { PortalConnectionWithSettingType } from '@/db/schema/qbPortalConnections'
import { QBSettingsSelectSchemaType } from '@/db/schema/qbSettings'
import {
PortalConnectionWithSettingType,
QBPortalConnection,
QBPortalConnectionSelectSchemaType,
} from '@/db/schema/qbPortalConnections'
import { QBSetting, QBSettingsSelectSchemaType } from '@/db/schema/qbSettings'
import { WorkspaceResponse } from '@/type/common'
import { CopilotAPI } from '@/utils/copilotAPI'
import { IntuitAPITokensType } from '@/utils/intuitAPI'
import { and, eq, isNull } from 'drizzle-orm'
import { and, asc, eq, isNotNull, isNull, sql } from 'drizzle-orm'

export const getPortalConnection = async (
portalId: string,
Expand Down Expand Up @@ -41,6 +45,42 @@ export const getAllActivePortalConnections = async (): Promise<
return portals
}

/**
* Returns portals whose refresh token will expire within `daysRemaining` days
* AND have `qb_settings.sync_flag = true`, ordered by the soonest-to-expire
* first and capped at `limit`. Powers the daily refresh-token cron.
*
* Inner-joined on `qb_settings` so portals without a settings row (sync never
* configured) are skipped. Filtering is done in SQL — not JS — so the LIMIT
* is meaningful: we always tackle the most-urgent rows first when the backlog
* exceeds capacity.
*/
export const getPortalsWithExpiringRefreshTokens = async (
daysRemaining: number,
limit: number,
): Promise<QBPortalConnectionSelectSchemaType[]> => {
const rows = await db
.select({ portal: QBPortalConnection })
.from(QBPortalConnection)
.innerJoin(QBSetting, eq(QBSetting.portalId, QBPortalConnection.portalId))
.where(
and(
isNull(QBPortalConnection.deletedAt),
// `isSuspended` intentionally not filtered: that column is dead code
// today (no path writes `true`). Revisit if/when suspension is wired
// up, alongside the matching change in `getAllActivePortalConnections`.
isNotNull(QBPortalConnection.tokenSetTime),
eq(QBSetting.syncFlag, true),
sql`${QBPortalConnection.tokenSetTime} + (${QBPortalConnection.XRefreshTokenExpiresIn} || ' seconds')::interval
< now() + (${daysRemaining} || ' days')::interval`,
),
)
.orderBy(asc(QBPortalConnection.tokenSetTime))
Comment thread
SandipBajracharya marked this conversation as resolved.
.limit(limit)

return rows.map((r) => r.portal)
}

export const getPortalSettings = async (
portalId: string,
): Promise<QBSettingsSelectSchemaType | null> => {
Expand Down
189 changes: 189 additions & 0 deletions test/integration/quickbooks/refreshTokens/expiringSweep.test.ts
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()
})
})
Loading
Loading