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
19 changes: 19 additions & 0 deletions src/app/api/quickbooks/payout/payout.errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { getShouldRetryForCategory } from '@/utils/synclog'
import { getMessageAndCodeFromError } from '@/utils/error'

// Payout problems that retrying will never fix, so we stop trying
// (refund lines, negative fee, duplicate line items, wrong total).
export class TerminalPayoutError extends Error {}

// A payout that mixes batched and non-batched invoices. Extends
// TerminalPayoutError so it also stops retrying, but stays its own type so
// we can send the special "mixed payout" alert.
export class MixedPayoutIntentError extends TerminalPayoutError {}

// Terminal payout problems never retry. Everything else (invoice not saved
// yet, missing bank ref, rate-limit, QB 5xx, suspended account) uses the
// shared rule, which still stops on dead tokens (AUTH).
export function getShouldRetryForPayout(error: unknown): boolean {
if (error instanceof TerminalPayoutError) return false
return getShouldRetryForCategory(getMessageAndCodeFromError(error))
}
231 changes: 231 additions & 0 deletions src/app/api/quickbooks/payout/payout.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import httpStatus from 'http-status'
import { and, eq, isNull } from 'drizzle-orm'

import { BaseService } from '@/app/api/core/services/base.service'
import APIError from '@/app/api/core/exceptions/api'
import { SyncLogService } from '@/app/api/quickbooks/syncLog/syncLog.service'
import { PaymentService } from '@/app/api/quickbooks/payment/payment.service'
import { TokenService } from '@/app/api/quickbooks/token/token.service'
import {
MixedPayoutIntentError,
TerminalPayoutError,
} from '@/app/api/quickbooks/payout/payout.errors'
import {
QBPayoutSync,
QBPayoutSyncSelectSchemaType,
} from '@/db/schema/qbPayoutSync'
import { PayoutLineItem } from '@/type/dto/webhook.dto'
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'

export class PayoutService extends BaseService {
private syncLogService: SyncLogService

constructor(user: User) {
super(user)
this.syncLogService = new SyncLogService(user)
}

// Same (portalId, payoutId) updates the same row, so a re-sent payout
// never makes a duplicate.
async upsertPayoutSync(input: {
payoutId: string
lineItems: PayoutLineItem[]
netAmount: number
feeCents: number
arrivalDate: number
}): Promise<QBPayoutSyncSelectSchemaType> {
const [row] = await this.db
.insert(QBPayoutSync)
.values({
portalId: this.user.workspaceId,
payoutId: input.payoutId,
lineItems: input.lineItems,
netAmount: input.netAmount,
feeAmount: input.feeCents,
arrivalDate: input.arrivalDate,
})
.onConflictDoUpdate({
target: [QBPayoutSync.portalId, QBPayoutSync.payoutId],
// Must match the partial unique index (only rows where deleted_at is
// null). In drizzle-orm 0.42 that goes in `targetWhere`, not `where`.
targetWhere: isNull(QBPayoutSync.deletedAt),
set: {
lineItems: input.lineItems,
netAmount: input.netAmount,
feeAmount: input.feeCents,
arrivalDate: input.arrivalDate,
},
})
.returning()
return row
}

async getPayoutSync(
payoutId: string,
): Promise<QBPayoutSyncSelectSchemaType | null> {
const row = await this.db.query.QBPayoutSync.findFirst({
where: and(
eq(QBPayoutSync.portalId, this.user.workspaceId),
eq(QBPayoutSync.payoutId, payoutId),
isNull(QBPayoutSync.deletedAt),
),
})
return row ?? null
}

// 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.
async reconcile(
row: QBPayoutSyncSelectSchemaType,
qbTokenInfo: IntuitAPITokensType,
opts: { runIdempotencyCheck: boolean },
): Promise<{ depositId: string | null }> {
validateAccessToken(qbTokenInfo)

const payoutId = row.payoutId
// One source for the note, used to both find and create the deposit,
// so the two can never drift apart.
const privateNote = `Stripe payout ${payoutId}`
const lineItems = row.lineItems
const copilotInvoiceIds = lineItems.map((line) => line.copilotInvoiceId)
const grossCents = lineItems.reduce(
(sum, line) => sum + line.grossAmount,
0,
)
const feeCents = lineItems.reduce((sum, line) => sum + line.feeAmount, 0)
const netAmount = row.netAmount

// These problems never fix themselves on retry, so fail for good.
if (lineItems.some((line) => line.grossAmount < 0)) {
throw new TerminalPayoutError(
`Payout ${payoutId} contains refund lines; batched deposit unsupported in v1`,
)
}
if (feeCents < 0) {
throw new TerminalPayoutError(
`Payout ${payoutId} has a negative aggregate fee (${feeCents}); unsupported in v1`,
)
}
if (new Set(copilotInvoiceIds).size !== copilotInvoiceIds.length) {
throw new TerminalPayoutError(
`Payout ${payoutId} contains duplicate invoice line items`,
)
}

// On resync only: reuse a deposit we already made, or find one already in QBO.
if (opts.runIdempotencyCheck) {
if (row.qbDepositId) return { depositId: row.qbDepositId }
const intuitApi = new IntuitAPI(qbTokenInfo)
const txnDate = new Date(row.arrivalDate * 1000)
.toISOString()
.split('T')[0]
const existing = await intuitApi.getDepositsByTxnDate(txnDate)
const match = existing.find(
(deposit) => deposit.PrivateNote === privateNote,
)
if (match) {
await this.db
.update(QBPayoutSync)
.set({ qbDepositId: match.Id })
.where(
and(
eq(QBPayoutSync.id, row.id),
eq(QBPayoutSync.portalId, this.user.workspaceId),
),
)
return { depositId: match.Id }
}
}

const paymentIdByInvoice =
await this.syncLogService.getSuccessfulPaidPaymentIds(copilotInvoiceIds)
const unresolved = copilotInvoiceIds.filter(
(id) => !paymentIdByInvoice.has(id),
)
if (unresolved.length > 0) {
// Can retry: the invoice.paid event may just not be saved yet.
throw new APIError(
httpStatus.NOT_FOUND,
`Payout ${payoutId}: no SUCCESS INVOICE/PAID sync log for invoices [${unresolved.join(', ')}]`,
)
}

const allBatched = copilotInvoiceIds.every(
(id) => paymentIdByInvoice.get(id)?.isBatchedDeposit,
)
const allNonBatched = copilotInvoiceIds.every(
(id) => !paymentIdByInvoice.get(id)?.isBatchedDeposit,
)
// All non-batched means the fees were already booked, so nothing to deposit.
if (allNonBatched) return { depositId: null }
if (!allBatched) {
throw new MixedPayoutIntentError(
`Payout ${payoutId} mixes batched and non-batched invoices; unsupported`,
)
}

if (grossCents - feeCents !== netAmount) {
throw new TerminalPayoutError(
`Payout ${payoutId}: deposit total ${grossCents - feeCents} != payout net ${netAmount}`,
)
}

const bankAccountRef = qbTokenInfo.bankAccountRef
if (!bankAccountRef) {
// Can retry: works once a bank account is set in settings.
throw new APIError(
httpStatus.BAD_REQUEST,
`Bank account ref is not configured for portal ${this.user.workspaceId}. Please select a bank account in the QuickBooks integration settings.`,
)
}

const intuitApi = new IntuitAPI(qbTokenInfo)
const tokenService = new TokenService(this.user)
const verifiedBankAccountRef =
await tokenService.checkAndUpdateAccountStatus(
AccountTypeObj.Bank,
qbTokenInfo.intuitRealmId,
intuitApi,
bankAccountRef,
)
const expenseAccountRef = await tokenService.checkAndUpdateAccountStatus(
AccountTypeObj.Expense,
qbTokenInfo.intuitRealmId,
intuitApi,
qbTokenInfo.expenseAccountRef,
)

const paymentService = new PaymentService(this.user)
const depositId = await paymentService.createBankDepositForPayment(
intuitApi,
{
lines: lineItems.map((line) => ({
qbPaymentId: paymentIdByInvoice.get(line.copilotInvoiceId)
?.paymentId as string,
amount: line.grossAmount / 100,
})),
feeTotal: feeCents / 100,
bankAccountRef: verifiedBankAccountRef,
expenseAccountRef,
txnDate: new Date(row.arrivalDate * 1000).toISOString().split('T')[0],
privateNote,
},
)

await this.db
.update(QBPayoutSync)
.set({ qbDepositId: depositId })
.where(
and(
eq(QBPayoutSync.id, row.id),
eq(QBPayoutSync.portalId, this.user.workspaceId),
),
)

return { depositId }
}
}
15 changes: 15 additions & 0 deletions src/type/dto/intuitAPI.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,21 @@ export const QBDepositResponseSchema = z.object({
})
export type QBDepositResponseType = z.infer<typeof QBDepositResponseSchema>

export const QBDepositQueryResponseSchema = z.object({
Deposit: z
.array(
z.object({
Id: z.string(),
PrivateNote: z.string().optional(),
TxnDate: z.string().optional(),
}),
)
.optional(),
})
export type QBDepositQueryResponseType = z.infer<
typeof QBDepositQueryResponseSchema
>

export const QBDeletePayloadSchema = z.object({
SyncToken: z.string(),
Id: z.string(),
Expand Down
38 changes: 38 additions & 0 deletions src/utils/intuitAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
QBDepositCreatePayloadType,
QBDepositResponseSchema,
QBDepositResponseType,
QBDepositQueryResponseSchema,
QBDeletePayloadType,
QBDestructiveInvoicePayloadSchema,
QBItemRowType,
Expand Down Expand Up @@ -1006,6 +1007,42 @@ export default class IntuitAPI {
return parsed
}

// Read all pages so we don't miss a deposit on a busy day. Miss one and
// resync makes a duplicate deposit that QBO won't let us delete. maxPages is
// just a safety cap — hitting it would need 50k deposits in a single day.
async _getDepositsByTxnDate(
txnDate: string,
): Promise<Array<{ Id: string; PrivateNote?: string }>> {
CustomLogger.info({
obj: { txnDate },
message: `IntuitAPI#getDepositsByTxnDate | start for realmId: ${this.tokens.intuitRealmId}.`,
})

const pageSize = 1000
const maxPages = 50
const deposits: Array<{ Id: string; PrivateNote?: string }> = []
let startPosition = 1

for (let pages = 0; pages < maxPages; pages++) {
const query = `select Id, PrivateNote, TxnDate from Deposit where TxnDate = '${escapeForQBQuery(txnDate)}' STARTPOSITION ${startPosition} MAXRESULTS ${pageSize}`
const response = await this.customQuery(query)
if (!response) return deposits

const envelope = QBDepositQueryResponseSchema.parse(response)
const page = envelope.Deposit ?? []
deposits.push(...page)

if (page.length < pageSize) return deposits
startPosition += pageSize
}

CustomLogger.error({
obj: { txnDate, maxPages },
message: `IntuitAPI#getDepositsByTxnDate | pagination cap (${maxPages} pages) hit for realmId: ${this.tokens.intuitRealmId} — result truncated at ${deposits.length} deposits.`,
})
return deposits
}

async _deletePurchase(
payload: QBDeletePayloadType,
): Promise<QBPurchaseDeleteResponseType> {
Expand Down Expand Up @@ -1134,5 +1171,6 @@ export default class IntuitAPI {
deletePayment = this.wrapWithRetry(this._deletePayment)
deletePurchase = this.wrapWithRetry(this._deletePurchase)
createDeposit = this.wrapWithRetry(this._createDeposit)
getDepositsByTxnDate = this._getDepositsByTxnDate.bind(this)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What is the benifit of doing it this way?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

getDepositsByTxnDate is not wrapped with retry. In fact we do not wrap get functions with retry.

getCompanyInfo = this._getCompanyInfo.bind(this)
}
2 changes: 2 additions & 0 deletions test/helpers/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ export function createMockIntuitAPI(overrides: IntuitAPIOverrides = {}) {
createDeposit: vi.fn().mockResolvedValue({
Deposit: { Id: 'qb-deposit-1', SyncToken: '0' },
}),
// Payout resync checks for an existing deposit first — none by default.
getDepositsByTxnDate: vi.fn().mockResolvedValue([]),
// Handler ignores the response; it just needs the call to succeed (OUT-3921).
voidInvoice: vi.fn().mockResolvedValue({
Invoice: { Id: TEST_QB_INVOICE_ID, SyncToken: '1' },
Expand Down
Loading
Loading