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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ VERCEL_URL=localhost:3000
VERCEL_ENV=development
CRON_SECRET=vercel cron secret

# Comma-separated portalIds the bank deposit feature is limited to. Empty/unset = all portals.
AB_FEATURE_TESTING_PORTALS=

SENTRY_ORG=
SENTRY_PROJECT=
NEXT_PUBLIC_SENTRY_DSN=
Expand Down
3 changes: 3 additions & 0 deletions src/app/api/quickbooks/invoice/invoice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
InvoiceResponseType,
InvoiceVoidedResponse,
} from '@/type/dto/webhook.dto'
import { isPortalInBankDepositABTest } from '@/utils/abTesting'
import { bottleneck } from '@/utils/bottleneck'
import { CopilotAPI } from '@/utils/copilotAPI'
import IntuitAPI, { IntuitAPITokensType } from '@/utils/intuitAPI'
Expand Down Expand Up @@ -483,6 +484,8 @@ export class InvoiceService extends BaseService {
// Reads the live batched-deposit setting. Called only at the freeze point
// (row creation); everything else reads the frozen row value.
private async readBankDepositFeeFlag(): Promise<boolean> {
// AB gate: portals outside the allowlist never freeze as batched.
if (!isPortalInBankDepositABTest(this.user.workspaceId)) return false
const settingService = new SettingService(this.user)
const setting = await settingService.getOneByPortalId([
'bankDepositFeeFlag',
Expand Down
10 changes: 10 additions & 0 deletions src/app/api/quickbooks/payout/payout.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import IntuitAPI, { IntuitAPITokensType } from '@/utils/intuitAPI'
import { AccountTypeObj } from '@/constant/qbConnection'
import { validateAccessToken } from '@/utils/auth'
import User from '@/app/api/core/models/User.model'
import { isPortalInBankDepositABTest } from '@/utils/abTesting'

export class PayoutService extends BaseService {
private syncLogService: SyncLogService
Expand Down Expand Up @@ -84,6 +85,15 @@ export class PayoutService extends BaseService {
qbTokenInfo: IntuitAPITokensType,
opts: { runIdempotencyCheck: boolean },
): Promise<{ depositId: string | null }> {
// AB gate: covers both callers (payout webhook + resync cron). Only fires
// for an explicitly excluded portal (empty allowlist = all portals). Logged
// rather than silent so a rare mid-flight exclusion is visible in Sentry.
if (!isPortalInBankDepositABTest(this.user.workspaceId)) {
console.info(
`PayoutService#reconcile | AB gate off for portal ${this.user.workspaceId}; skipping deposit for payout ${row.payoutId}`,
)
return { depositId: null }
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
validateAccessToken(qbTokenInfo)

const payoutId = row.payoutId
Expand Down
19 changes: 16 additions & 3 deletions src/app/api/quickbooks/setting/setting.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import authenticate from '@/app/api/core/utils/authenticate'
import { SettingService } from '@/app/api/quickbooks/setting/setting.service'
import { TokenService } from '@/app/api/quickbooks/token/token.service'
import { isPortalInBankDepositABTest } from '@/utils/abTesting'
import { db } from '@/db'
import { QBPortalConnection } from '@/db/schema/qbPortalConnections'
import { QBSetting } from '@/db/schema/qbSettings'
Expand Down Expand Up @@ -41,7 +42,12 @@ export async function getSettings(req: NextRequest) {
? (await getPortalConnection(user.workspaceId))?.bankAccountRef || null
: null

return NextResponse.json({ setting, bankAccountRef })
const bankDepositEnabled =
parsedType.success && parsedType.data === SettingType.INVOICE
? isPortalInBankDepositABTest(user.workspaceId)
: false

return NextResponse.json({ setting, bankAccountRef, bankDepositEnabled })
}

export async function updateSettings(req: NextRequest) {
Expand All @@ -54,17 +60,24 @@ export async function updateSettings(req: NextRequest) {
const parsedType = z.nativeEnum(SettingType).parse(type)

const parsed = SettingRequestSchema.parse(body)
const { bankAccountRef, ...settingFields } = parsed
const { bankAccountRef, bankDepositFeeFlag, ...settingFields } = parsed

// Bank deposit fields are only honored for invoice settings on AB-test
// portals; everyone else has the flag and bank account stripped from writes.
const isBankDepositAB =
parsedType === SettingType.INVOICE &&
isPortalInBankDepositABTest(user.workspaceId)

const payload = {
...settingFields,
...(isBankDepositAB && { bankDepositFeeFlag }),
...(parsedType === SettingType.INVOICE
? { initialInvoiceSettingMap: true }
: { initialProductSettingMap: true }),
}

const writeBankAccountRef =
parsedType === SettingType.INVOICE && typeof bankAccountRef !== 'undefined'
isBankDepositAB && typeof bankAccountRef !== 'undefined'

const setting = await db.transaction(async (tx) => {
settingService.setTransaction(tx)
Expand Down
2 changes: 2 additions & 0 deletions src/components/dashboard/settings/SettingAccordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default function SettingAccordion({
isLoading,
changeSettings,
showButton: showInvoiceButton,
bankDepositEnabled,
bankAccountOptions,
bankAccountsError,
canSave,
Expand Down Expand Up @@ -93,6 +94,7 @@ export default function SettingAccordion({
settingState={settingState}
changeSettings={changeSettings}
isLoading={isLoading}
bankDepositEnabled={bankDepositEnabled}
bankAccountOptions={bankAccountOptions}
bankAccountsError={bankAccountsError}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type InvoiceDetailProps = {
value: InvoiceSettingType[K],
) => void
isLoading: boolean
bankDepositEnabled: boolean
bankAccountOptions: AccountOption[] | undefined
bankAccountsError: unknown
}
Expand All @@ -19,6 +20,7 @@ export default function InvoiceDetail({
settingState,
changeSettings,
isLoading,
bankDepositEnabled,
bankAccountOptions,
bankAccountsError,
}: InvoiceDetailProps) {
Expand All @@ -41,44 +43,49 @@ export default function InvoiceDetail({
}
/>
</div>
<div className="mb-5">
<Checkbox
label="Create bank deposits for automatic bank reconciliation"
description="When Stripe pays out, create a QuickBooks bank deposit that matches the net amount deposited to your bank (after fees), so the bank transaction matches automatically."
checked={settingState.bankDepositFeeFlag}
onChange={() =>
changeSettings(
'bankDepositFeeFlag',
!settingState.bankDepositFeeFlag,
)
}
/>
</div>
{settingState.bankDepositFeeFlag && (
<div className="mb-5 ml-6">
{bankAccountsError ? (
<p className="text-xs text-red-600">
Could not load bank accounts. Reload to retry.
</p>
) : (
<>
<AccountSelect
label="Deposit bank account"
description="The bank account Stripe payouts are deposited into. Used to create the matching QuickBooks bank deposit."
value={settingState.bankAccountRef ?? ''}
options={bankAccountOptions}
placeholder="Select a deposit bank account"
onChange={(id) => changeSettings('bankAccountRef', id)}
/>
{bankAccountOptions !== undefined &&
!settingState.bankAccountRef && (
<p className="text-xs text-red-600">
Select a deposit bank account to enable bank deposits.
</p>
)}
</>
{/* Bank deposit UI is gated behind the AB rollout allowlist. */}
{bankDepositEnabled && (
<>
<div className="mb-5">
<Checkbox
label="Create bank deposits for automatic bank reconciliation"
description="When Stripe pays out, create a QuickBooks bank deposit that matches the net amount deposited to your bank (after fees), so the bank transaction matches automatically."
checked={settingState.bankDepositFeeFlag}
onChange={() =>
changeSettings(
'bankDepositFeeFlag',
!settingState.bankDepositFeeFlag,
)
}
/>
</div>
{settingState.bankDepositFeeFlag && (
<div className="mb-5 ml-6">
{bankAccountsError ? (
<p className="text-xs text-red-600">
Could not load bank accounts. Reload to retry.
</p>
) : (
<>
<AccountSelect
label="Deposit bank account"
description="The bank account Stripe payouts are deposited into. Used to create the matching QuickBooks bank deposit."
value={settingState.bankAccountRef ?? ''}
options={bankAccountOptions}
placeholder="Select a deposit bank account"
onChange={(id) => changeSettings('bankAccountRef', id)}
/>
{bankAccountOptions !== undefined &&
!settingState.bankAccountRef && (
<p className="text-xs text-red-600">
Select a deposit bank account to enable bank deposits.
</p>
)}
</>
)}
</div>
)}
</div>
</>
)}
<div className="mb-6">
<Checkbox
Expand Down
9 changes: 9 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ export const externalFetchTimeoutMs = parsePositiveMs(
30_000,
)

// Portal allowlist gating in-testing features (currently the bank deposit
// flow). Empty/unset means the feature is available to all portals.
export const abFeatureTestingPortals = (
process.env.AB_FEATURE_TESTING_PORTALS || ''
)
.split(',')
.map((portalId) => portalId.trim())
.filter(Boolean)

// Supabase
export const supabaseProjectUrl =
process.env.NEXT_PUBLIC_SUPABASE_PROJECT_URL || ''
Expand Down
6 changes: 5 additions & 1 deletion src/hook/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,10 +452,13 @@ export const useInvoiceDetailSettings = () => {
isLoading,
} = useSwrHelper(`/api/quickbooks/setting?type=invoice&token=${token}`)

// AB gate from the settings GET; hides the bank deposit UI when off.
const bankDepositEnabled = setting?.bankDepositEnabled ?? false

const { data: bankAccountsData, error: bankAccountsError } = useSwrHelper<{
accounts: { Id: string; Name: string }[]
}>(
isDisconnected
isDisconnected || !bankDepositEnabled
? null
: `/api/quickbooks/setting/bank-account?token=${token}`,
{ suspense: false, revalidateOnMount: true },
Expand Down Expand Up @@ -550,6 +553,7 @@ export const useInvoiceDetailSettings = () => {
error,
isLoading,
showButton,
bankDepositEnabled,
bankAccountOptions,
bankAccountsError,
canSave,
Expand Down
11 changes: 11 additions & 0 deletions src/utils/abTesting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { abFeatureTestingPortals } from '@/config'

/**
* Whether a portal may use the bank deposit feature during its incremental
* rollout. An empty/unset allowlist means the feature is on for all portals;
* otherwise only listed portals get it.
*/
export function isPortalInBankDepositABTest(portalId: string): boolean {
if (abFeatureTestingPortals.length === 0) return true
Comment thread
priosshrsth marked this conversation as resolved.
return abFeatureTestingPortals.includes(portalId)
}
17 changes: 17 additions & 0 deletions test/helpers/abTestGate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Drives the bank-deposit AB gate mocked in test/integration/setup.ts. The mock
// reads its allowlist from a globalThis-pinned holder (the real allowlist is
// env-parsed at module load and can't be varied per-test). `null` = feature on
// for all portals. Always reset in afterEach so state doesn't leak across files.
const AB_GATE_GLOBAL_KEY = '__qbsync_ab_test_gate__'
type ABGate = { allowlist: string[] | null }
const ref = globalThis as unknown as Record<string, ABGate | undefined>
ref[AB_GATE_GLOBAL_KEY] ??= { allowlist: null }

export const abTestGate = {
setAllowlist(portalIds: string[] | null) {
ref[AB_GATE_GLOBAL_KEY]!.allowlist = portalIds
},
reset() {
ref[AB_GATE_GLOBAL_KEY]!.allowlist = null
},
}
45 changes: 45 additions & 0 deletions test/integration/quickbooks/invoiceCreated/abTestingGate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect, afterEach } from 'vitest'
import { db } from '@/db'
import { QBInvoiceSync } from '@/db/schema/qbInvoiceSync'
import invoiceCreatedPayload from '@test/fixtures/invoiceCreated.webhook'
import {
seedHealthyPortal,
seedProductSync,
TEST_PORTAL_ID,
} from '@test/helpers/seed'
import { setupInvoiceCreatedTest } from '@test/helpers/invoiceCreatedTestSetup'
import { postWebhook } from '@test/helpers/webhook'
import { abTestGate } from '@test/helpers/abTestGate'

// The freeze gate must win over the stored flag: a portal outside the AB
// allowlist freezes non-batched even with bankDepositFeeFlag=true, so the whole
// downstream payout/deposit path never engages for it.
describe('POST /api/quickbooks/webhook — invoice.created AB gate on batched intent', () => {
setupInvoiceCreatedTest()

afterEach(() => {
abTestGate.reset()
})

it('freezes non-batched for a portal outside the allowlist despite the flag being on', async () => {
abTestGate.setAllowlist(['some-other-portal'])
await seedHealthyPortal({ setting: { bankDepositFeeFlag: true } })
await seedProductSync()

await postWebhook(invoiceCreatedPayload)

const [row] = await db.select().from(QBInvoiceSync)
expect(row.isBatchedDeposit).toBe(false)
})

it('freezes batched for a portal on the allowlist with the flag on', async () => {
abTestGate.setAllowlist([TEST_PORTAL_ID])
await seedHealthyPortal({ setting: { bankDepositFeeFlag: true } })
await seedProductSync()

await postWebhook(invoiceCreatedPayload)

const [row] = await db.select().from(QBInvoiceSync)
expect(row.isBatchedDeposit).toBe(true)
})
})
17 changes: 17 additions & 0 deletions test/integration/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,23 @@ vi.mock('@/utils/sleep', () => ({
sleep: vi.fn().mockResolvedValue(undefined),
}))

// AB gate for the bank deposit rollout. The real allowlist is parsed from env
// at `@/config` module load, so it can't be varied per-test once loaded. We
// mock the gate here (setupFiles runs before any app module binds it) and drive
// it via a globalThis-pinned allowlist. Default `null` = feature on for all
// portals, matching the empty-env behavior so existing tests are unaffected.
// A test opts in by setting `abTestGate.allowlist`; reset it in afterEach.
const AB_GATE_GLOBAL_KEY = '__qbsync_ab_test_gate__'
type ABGate = { allowlist: string[] | null }
const abGateRef = globalThis as unknown as Record<string, ABGate | undefined>
abGateRef[AB_GATE_GLOBAL_KEY] ??= { allowlist: null }
vi.mock('@/utils/abTesting', () => ({
isPortalInBankDepositABTest: (portalId: string) => {
const gate = abGateRef[AB_GATE_GLOBAL_KEY]!
return gate.allowlist === null || gate.allowlist.includes(portalId)
},
}))

// Importing modules that pull `next/server` corrupts NTARH's AsyncLocalStorage.
// Shimming this entry point keeps the next/server import out of the graph.
vi.mock('@/app/api/core/utils/afterIfAvailable', () => ({
Expand Down
Loading
Loading