Skip to content
Open
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
16 changes: 6 additions & 10 deletions src/app/api/quickbooks/payout/payout.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ 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 @@ -80,20 +79,17 @@ export class PayoutService extends BaseService {
// Checks the payout, finds its payments, then builds and creates the deposit.
// Neither caller claims again. Returns { depositId: null } when there is
// nothing to deposit.
//
// NOTE (OUT-4262): The AB gate was removed from this method so existing
// invoices with isBatchedDeposit=true can still have their deposits created
// and Stripe fees booked. New invoices freeze as non-batched (the freeze
// point in InvoiceService#readBankDepositFeeFlag returns false), so this
// method will naturally receive only non-batched payouts going forward.
async reconcile(
row: QBPayoutSyncSelectSchemaType,
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 }
}
validateAccessToken(qbTokenInfo)

const payoutId = row.payoutId
Expand Down
17 changes: 0 additions & 17 deletions src/components/dashboard/settings/SettingAccordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import InvoiceDetail from '@/components/dashboard/settings/sections/invoice/Invo
import AccountMapping from '@/components/dashboard/settings/sections/account/AccountMapping'
import ProductMapping from '@/components/dashboard/settings/sections/product/ProductMapping'
import Accordion from '@/components/ui/Accordion'
import ConfirmModal from '@/components/ui/ConfirmModal'
import Divider from '@/components/ui/Divider'
import {
useInvoiceDetailSettings,
Expand Down Expand Up @@ -44,14 +43,8 @@ export default function SettingAccordion({
isLoading,
changeSettings,
showButton: showInvoiceButton,
bankDepositEnabled,
bankAccountOptions,
bankAccountsError,
canSave,
showBankDepositWarning,
requestInvoiceSettingsSave,
confirmBankDepositChange,
cancelBankDepositChange,
} = useInvoiceDetailSettings()

const {
Expand Down Expand Up @@ -94,9 +87,6 @@ export default function SettingAccordion({
settingState={settingState}
changeSettings={changeSettings}
isLoading={isLoading}
bankDepositEnabled={bankDepositEnabled}
bankAccountOptions={bankAccountOptions}
bankAccountsError={bankAccountsError}
/>
),
},
Expand Down Expand Up @@ -208,13 +198,6 @@ export default function SettingAccordion({
</div>
)
})}
<ConfirmModal
open={showBankDepositWarning}
title="Change bank deposit setting?"
description="This applies only to invoices created from now on; existing invoices are unaffected. If a Stripe payout mixes invoices from before and after the change, you'll need to reconcile that payout manually in QuickBooks."
onConfirm={confirmBankDepositChange}
onCancel={cancelBankDepositChange}
/>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useApp } from '@/app/context/AppContext'
import AccountSelect from '@/components/dashboard/settings/sections/account/AccountSelect'
import { AccountOption, InvoiceSettingType } from '@/type/common'
import { InvoiceSettingType } from '@/type/common'
import { getWorkspaceLabel } from '@/utils/workspace'
import { Checkbox, Spinner } from 'copilot-design-system'

Expand All @@ -11,18 +10,12 @@ type InvoiceDetailProps = {
value: InvoiceSettingType[K],
) => void
isLoading: boolean
bankDepositEnabled: boolean
bankAccountOptions: AccountOption[] | undefined
bankAccountsError: unknown
}

export default function InvoiceDetail({
settingState,
changeSettings,
isLoading,
bankDepositEnabled,
bankAccountOptions,
bankAccountsError,
}: InvoiceDetailProps) {
const { workspace } = useApp()

Expand All @@ -43,50 +36,6 @@ export default function InvoiceDetail({
}
/>
</div>
{/* 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 className="mb-6">
<Checkbox
label={`Use ${getWorkspaceLabel(workspace).groupTerm} name when syncing invoices billed to ${getWorkspaceLabel(workspace).groupTermPlural}`}
Expand Down
58 changes: 5 additions & 53 deletions src/hook/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,15 +434,11 @@ export const useInvoiceDetailSettings = () => {
useCompanyNameFlag: false,
bankAccountRef: '',
}
const { token, setAppParams, syncFlag, portalConnectionStatus } = useApp()
// Skip the /bank-account fetch when QB isn't connected or sync is off, same
// rationale as useAccountMapping's isDisconnected.
const isDisconnected = !syncFlag || !portalConnectionStatus
const { token, setAppParams } = useApp()
const [settingState, setSettingState] = useState<InvoiceSettingType>(
initialInvoiceSetting,
)
const [showButton, setShowButton] = useState(false)
const [showBankDepositWarning, setShowBankDepositWarning] = useState(false)
const [intialSettingState, setIntialSettingState] = useState<
InvoiceSettingType | undefined
>()
Expand All @@ -452,33 +448,15 @@ 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 || !bankDepositEnabled
? null
: `/api/quickbooks/setting/bank-account?token=${token}`,
{ suspense: false, revalidateOnMount: true },
)
const bankAccountOptions: AccountOption[] | undefined =
bankAccountsData?.accounts.map((account) => ({
id: account.Id,
name: account.Name,
}))

const changeSettings = async <K extends keyof InvoiceSettingType>(
flag: K,
value: InvoiceSettingType[K],
) => {
setSettingState((prev) => ({ ...prev, [flag]: value }))
}

const canSave = !(
settingState.bankDepositFeeFlag && !settingState.bankAccountRef
)
// Bank deposit feature disabled (OUT-4262), so canSave is always true.
const canSave = true

useEffect(() => {
if (!settingState || !intialSettingState) return
Expand Down Expand Up @@ -516,7 +494,7 @@ export const useInvoiceDetailSettings = () => {
)
mutate(`/api/quickbooks/setting?type=invoice&token=${token}`)
} catch (err) {
setShowButton(true) // show the update settings button if error
setShowButton(true)
console.error('Error submitting Invoice settings', err)
}
}
Expand All @@ -526,41 +504,15 @@ export const useInvoiceDetailSettings = () => {
setSettingState(intialSettingState || initialInvoiceSetting)
}

// Warn only when the bank-deposit flag actually changed vs the saved value.
const bankDepositFlagChanged =
!!intialSettingState &&
settingState.bankDepositFeeFlag !== intialSettingState.bankDepositFeeFlag

const requestInvoiceSettingsSave = () => {
if (bankDepositFlagChanged) {
setShowBankDepositWarning(true)
return
}
submitInvoiceSettings()
}

const confirmBankDepositChange = () => {
setShowBankDepositWarning(false)
submitInvoiceSettings()
}

const cancelBankDepositChange = () => setShowBankDepositWarning(false)

return {
settingState,
changeSettings,
cancelInvoiceSettings,
error,
isLoading,
showButton,
bankDepositEnabled,
bankAccountOptions,
bankAccountsError,
canSave,
showBankDepositWarning,
requestInvoiceSettingsSave,
confirmBankDepositChange,
cancelBankDepositChange,
requestInvoiceSettingsSave: submitInvoiceSettings,
}
}

Expand Down
18 changes: 13 additions & 5 deletions src/utils/abTesting.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
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.
* Hard-off flag: the QBO automatic bank reconciliation beta has been wound down
* (OUT-4262). Set to `true` to re-enable the feature with the allowlist gate.
*/
export function isPortalInBankDepositABTest(portalId: string): boolean {
const BANK_DEPOSIT_FEATURE_ENABLED = false

/**
* Whether a portal may use the bank deposit feature. The feature is currently
* disabled for all portals (OUT-4262 wind-down). When BANK_DEPOSIT_FEATURE_ENABLED
* is true, an empty/unset allowlist means enabled for all; otherwise only
* listed portals get it.
*/
export function isPortalInBankDepositABTest(_portalId: string): boolean {
if (!BANK_DEPOSIT_FEATURE_ENABLED) return false

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Existing invoices lose their deposits

Existing invoices with isBatchedDeposit = true still send payments to Undeposited Funds and defer their Stripe fees until the payout. This hard-off makes PayoutService.reconcile skip their deposit, but the webhook still records SUCCESS. Their payments remain in Undeposited Funds and their fees are never booked.

Disable batching for new invoices, but finish existing batched invoices or provide an explicit cleanup path before disabling payout work.

if (abFeatureTestingPortals.length === 0) return true
return abFeatureTestingPortals.includes(portalId)
return abFeatureTestingPortals.includes(_portalId)
}
39 changes: 13 additions & 26 deletions test/unit/payout/payout.service.bankDepositGate.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
/**
* AB-gate coverage for PayoutService#reconcile — the deposit-creating step,
* shared by the payout webhook and the resync cron. An excluded portal must
* short-circuit to { depositId: null } before any token check or QBO call.
* Coverage for PayoutService#reconcile — the deposit-creating step, shared by
* the payout webhook and the resync cron.
*
* OUT-4262: The AB gate was removed from this method so existing invoices with
* isBatchedDeposit=true can still have their deposits created and Stripe fees
* booked. The freeze point (InvoiceService#readBankDepositFeeFlag) blocks new
* invoices from being batched, so this method naturally winds down.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest'
Expand All @@ -15,7 +19,6 @@ vi.mock('@sentry/nextjs', () => ({
addBreadcrumb: vi.fn(),
init: vi.fn(),
}))
// BaseService imports `@/db`, which initialises postgres at module load.
vi.mock('@/db', () => ({ db: {}, client: {} }))
vi.mock('@/utils/assemblyAPI', () => ({ AssemblyAPI: vi.fn() }))
vi.mock('@/utils/intuitAPI', () => ({ default: vi.fn() }))
Expand All @@ -25,12 +28,10 @@ vi.mock('@/app/api/quickbooks/syncLog/syncLog.service', () => ({
}),
}))

const { validateAccessToken, isPortalInBankDepositABTest } = vi.hoisted(() => ({
const { validateAccessToken } = vi.hoisted(() => ({
validateAccessToken: vi.fn(),
isPortalInBankDepositABTest: vi.fn(),
}))
vi.mock('@/utils/auth', () => ({ validateAccessToken }))
vi.mock('@/utils/abTesting', () => ({ isPortalInBankDepositABTest }))

import { PayoutService } from '@/app/api/quickbooks/payout/payout.service'
import User from '@/app/api/core/models/User.model'
Expand All @@ -41,36 +42,22 @@ const stubRow = {
} as unknown as QBPayoutSyncSelectSchemaType
const stubTokens = {} as IntuitAPITokensType

describe('PayoutService#reconcile — AB gate', () => {
describe('PayoutService#reconcile — no AB gate (OUT-4262)', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('short-circuits to no deposit for an excluded portal without checking the token', async () => {
isPortalInBankDepositABTest.mockReturnValue(false)

const result = await new PayoutService(stubUser).reconcile(
stubRow,
stubTokens,
{ runIdempotencyCheck: true },
)

expect(result).toEqual({ depositId: null })
expect(validateAccessToken).not.toHaveBeenCalled()
})

it('proceeds past the gate for an allowlisted portal', async () => {
isPortalInBankDepositABTest.mockReturnValue(true)
// Force a stop right after the gate so we assert only that it advanced.
it('proceeds directly to token validation without an AB gate check', async () => {
// Force a stop at token validation to confirm the method proceeds.
validateAccessToken.mockImplementation(() => {
throw new Error('advanced past gate')
throw new Error('advanced to token validation')
})

await expect(
new PayoutService(stubUser).reconcile(stubRow, stubTokens, {
runIdempotencyCheck: true,
}),
).rejects.toThrow('advanced past gate')
).rejects.toThrow('advanced to token validation')
expect(validateAccessToken).toHaveBeenCalledTimes(1)
})
})
Loading
Loading