Skip to content

Commit 4235d7e

Browse files
feat(OUT-4005): add payout reconciliation service and deposit lookup
PayoutService.reconcile validates the payout, resolves its payments, and builds one batched deposit. On resync it reuses an already-made deposit (stored id, then a txn-date query on PrivateNote) so a retry can't duplicate. Adds getDepositsByTxnDate and its schemas. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9517bda commit 4235d7e

11 files changed

Lines changed: 770 additions & 9 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { getShouldRetryForCategory } from '@/utils/synclog'
2+
import { getMessageAndCodeFromError } from '@/utils/error'
3+
4+
// Payout problems that retrying will never fix, so we stop trying
5+
// (refund lines, negative fee, duplicate line items, wrong total).
6+
export class TerminalPayoutError extends Error {}
7+
8+
// A payout that mixes batched and non-batched invoices. Extends
9+
// TerminalPayoutError so it also stops retrying, but stays its own type so
10+
// we can send the special "mixed payout" alert.
11+
export class MixedPayoutIntentError extends TerminalPayoutError {}
12+
13+
// Terminal payout problems never retry. Everything else (invoice not saved
14+
// yet, missing bank ref, rate-limit, QB 5xx, suspended account) uses the
15+
// shared rule, which still stops on dead tokens (AUTH).
16+
export function getShouldRetryForPayout(error: unknown): boolean {
17+
if (error instanceof TerminalPayoutError) return false
18+
return getShouldRetryForCategory(getMessageAndCodeFromError(error))
19+
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import httpStatus from 'http-status'
2+
import { and, eq, isNull } from 'drizzle-orm'
3+
4+
import { BaseService } from '@/app/api/core/services/base.service'
5+
import APIError from '@/app/api/core/exceptions/api'
6+
import { SyncLogService } from '@/app/api/quickbooks/syncLog/syncLog.service'
7+
import { PaymentService } from '@/app/api/quickbooks/payment/payment.service'
8+
import { TokenService } from '@/app/api/quickbooks/token/token.service'
9+
import {
10+
MixedPayoutIntentError,
11+
TerminalPayoutError,
12+
} from '@/app/api/quickbooks/payout/payout.errors'
13+
import {
14+
QBPayoutSync,
15+
QBPayoutSyncSelectSchemaType,
16+
} from '@/db/schema/qbPayoutSync'
17+
import { PayoutLineItem } from '@/type/dto/webhook.dto'
18+
import IntuitAPI, { IntuitAPITokensType } from '@/utils/intuitAPI'
19+
import { AccountTypeObj } from '@/constant/qbConnection'
20+
import { validateAccessToken } from '@/utils/auth'
21+
22+
export class PayoutService extends BaseService {
23+
private syncLogService = new SyncLogService(this.user)
24+
25+
// Same (portalId, payoutId) updates the same row, so a re-sent payout
26+
// never makes a duplicate.
27+
async upsertPayoutSync(input: {
28+
payoutId: string
29+
lineItems: PayoutLineItem[]
30+
netAmount: number
31+
feeCents: number
32+
arrivalDate: number
33+
}): Promise<QBPayoutSyncSelectSchemaType> {
34+
const [row] = await this.db
35+
.insert(QBPayoutSync)
36+
.values({
37+
portalId: this.user.workspaceId,
38+
payoutId: input.payoutId,
39+
lineItems: input.lineItems,
40+
netAmount: input.netAmount,
41+
feeAmount: input.feeCents,
42+
arrivalDate: input.arrivalDate,
43+
})
44+
.onConflictDoUpdate({
45+
target: [QBPayoutSync.portalId, QBPayoutSync.payoutId],
46+
// Must match the partial unique index (only rows where deleted_at is
47+
// null). In drizzle-orm 0.42 that goes in `targetWhere`, not `where`.
48+
targetWhere: isNull(QBPayoutSync.deletedAt),
49+
set: {
50+
lineItems: input.lineItems,
51+
netAmount: input.netAmount,
52+
feeAmount: input.feeCents,
53+
arrivalDate: input.arrivalDate,
54+
},
55+
})
56+
.returning()
57+
return row
58+
}
59+
60+
async getPayoutSync(
61+
payoutId: string,
62+
): Promise<QBPayoutSyncSelectSchemaType | null> {
63+
const row = await this.db.query.QBPayoutSync.findFirst({
64+
where: and(
65+
eq(QBPayoutSync.portalId, this.user.workspaceId),
66+
eq(QBPayoutSync.payoutId, payoutId),
67+
isNull(QBPayoutSync.deletedAt),
68+
),
69+
})
70+
return row ?? null
71+
}
72+
73+
// Checks the payout, finds its payments, then builds and creates the deposit.
74+
// Neither caller claims again. Returns { depositId: null } when there is
75+
// nothing to deposit.
76+
async reconcile(
77+
row: QBPayoutSyncSelectSchemaType,
78+
qbTokenInfo: IntuitAPITokensType,
79+
opts: { runIdempotencyCheck: boolean },
80+
): Promise<{ depositId: string | null }> {
81+
validateAccessToken(qbTokenInfo)
82+
83+
const payoutId = row.payoutId
84+
// One source for the note, used to both find and create the deposit,
85+
// so the two can never drift apart.
86+
const privateNote = `Stripe payout ${payoutId}`
87+
const lineItems = row.lineItems
88+
const copilotInvoiceIds = lineItems.map((line) => line.copilotInvoiceId)
89+
const grossCents = lineItems.reduce(
90+
(sum, line) => sum + line.grossAmount,
91+
0,
92+
)
93+
const feeCents = lineItems.reduce((sum, line) => sum + line.feeAmount, 0)
94+
const netAmount = row.netAmount
95+
96+
// These problems never fix themselves on retry, so fail for good.
97+
if (lineItems.some((line) => line.grossAmount < 0)) {
98+
throw new TerminalPayoutError(
99+
`Payout ${payoutId} contains refund lines; batched deposit unsupported in v1`,
100+
)
101+
}
102+
if (feeCents < 0) {
103+
throw new TerminalPayoutError(
104+
`Payout ${payoutId} has a negative aggregate fee (${feeCents}); unsupported in v1`,
105+
)
106+
}
107+
if (new Set(copilotInvoiceIds).size !== copilotInvoiceIds.length) {
108+
throw new TerminalPayoutError(
109+
`Payout ${payoutId} contains duplicate invoice line items`,
110+
)
111+
}
112+
113+
// On resync only: reuse a deposit we already made, or find one already in QBO.
114+
if (opts.runIdempotencyCheck) {
115+
if (row.qbDepositId) return { depositId: row.qbDepositId }
116+
const intuitApi = new IntuitAPI(qbTokenInfo)
117+
const txnDate = new Date(row.arrivalDate * 1000)
118+
.toISOString()
119+
.split('T')[0]
120+
const existing = await intuitApi.getDepositsByTxnDate(txnDate)
121+
const match = existing.find(
122+
(deposit) => deposit.PrivateNote === privateNote,
123+
)
124+
if (match) {
125+
await this.db
126+
.update(QBPayoutSync)
127+
.set({ qbDepositId: match.Id })
128+
.where(
129+
and(
130+
eq(QBPayoutSync.id, row.id),
131+
eq(QBPayoutSync.portalId, this.user.workspaceId),
132+
),
133+
)
134+
return { depositId: match.Id }
135+
}
136+
}
137+
138+
const paymentIdByInvoice =
139+
await this.syncLogService.getSuccessfulPaidPaymentIds(copilotInvoiceIds)
140+
const unresolved = copilotInvoiceIds.filter(
141+
(id) => !paymentIdByInvoice.has(id),
142+
)
143+
if (unresolved.length > 0) {
144+
// Can retry: the invoice.paid event may just not be saved yet.
145+
throw new APIError(
146+
httpStatus.NOT_FOUND,
147+
`Payout ${payoutId}: no SUCCESS INVOICE/PAID sync log for invoices [${unresolved.join(', ')}]`,
148+
)
149+
}
150+
151+
const allBatched = copilotInvoiceIds.every(
152+
(id) => paymentIdByInvoice.get(id)?.isBatchedDeposit,
153+
)
154+
const allNonBatched = copilotInvoiceIds.every(
155+
(id) => !paymentIdByInvoice.get(id)?.isBatchedDeposit,
156+
)
157+
// All non-batched means the fees were already booked, so nothing to deposit.
158+
if (allNonBatched) return { depositId: null }
159+
if (!allBatched) {
160+
throw new MixedPayoutIntentError(
161+
`Payout ${payoutId} mixes batched and non-batched invoices; unsupported`,
162+
)
163+
}
164+
165+
if (grossCents - feeCents !== netAmount) {
166+
throw new TerminalPayoutError(
167+
`Payout ${payoutId}: deposit total ${grossCents - feeCents} != payout net ${netAmount}`,
168+
)
169+
}
170+
171+
const bankAccountRef = qbTokenInfo.bankAccountRef
172+
if (!bankAccountRef) {
173+
// Can retry: works once a bank account is set in settings.
174+
throw new APIError(
175+
httpStatus.BAD_REQUEST,
176+
`Bank account ref is not configured for portal ${this.user.workspaceId}. Please select a bank account in the QuickBooks integration settings.`,
177+
)
178+
}
179+
180+
const intuitApi = new IntuitAPI(qbTokenInfo)
181+
const tokenService = new TokenService(this.user)
182+
const verifiedBankAccountRef =
183+
await tokenService.checkAndUpdateAccountStatus(
184+
AccountTypeObj.Bank,
185+
qbTokenInfo.intuitRealmId,
186+
intuitApi,
187+
bankAccountRef,
188+
)
189+
const expenseAccountRef = await tokenService.checkAndUpdateAccountStatus(
190+
AccountTypeObj.Expense,
191+
qbTokenInfo.intuitRealmId,
192+
intuitApi,
193+
qbTokenInfo.expenseAccountRef,
194+
)
195+
196+
const paymentService = new PaymentService(this.user)
197+
const depositId = await paymentService.createBankDepositForPayment(
198+
intuitApi,
199+
{
200+
lines: lineItems.map((line) => ({
201+
qbPaymentId: paymentIdByInvoice.get(line.copilotInvoiceId)
202+
?.paymentId as string,
203+
amount: line.grossAmount / 100,
204+
})),
205+
feeTotal: feeCents / 100,
206+
bankAccountRef: verifiedBankAccountRef,
207+
expenseAccountRef,
208+
txnDate: new Date(row.arrivalDate * 1000).toISOString().split('T')[0],
209+
privateNote,
210+
},
211+
)
212+
213+
await this.db
214+
.update(QBPayoutSync)
215+
.set({ qbDepositId: depositId })
216+
.where(
217+
and(
218+
eq(QBPayoutSync.id, row.id),
219+
eq(QBPayoutSync.portalId, this.user.workspaceId),
220+
),
221+
)
222+
223+
return { depositId }
224+
}
225+
}

src/type/dto/intuitAPI.dto.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,21 @@ export const QBDepositResponseSchema = z.object({
287287
})
288288
export type QBDepositResponseType = z.infer<typeof QBDepositResponseSchema>
289289

290+
export const QBDepositQueryResponseSchema = z.object({
291+
Deposit: z
292+
.array(
293+
z.object({
294+
Id: z.string(),
295+
PrivateNote: z.string().optional(),
296+
TxnDate: z.string().optional(),
297+
}),
298+
)
299+
.optional(),
300+
})
301+
export type QBDepositQueryResponseType = z.infer<
302+
typeof QBDepositQueryResponseSchema
303+
>
304+
290305
export const QBDeletePayloadSchema = z.object({
291306
SyncToken: z.string(),
292307
Id: z.string(),

src/type/dto/webhook.dto.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ export type PaymentSucceededResponseType = z.infer<
135135
typeof PaymentSucceededResponseSchema
136136
>
137137

138+
export const PayoutLineItemSchema = z.object({
139+
copilotInvoiceId: z.string(),
140+
grossAmount: z.number(),
141+
feeAmount: z.number(),
142+
})
143+
export type PayoutLineItem = z.infer<typeof PayoutLineItemSchema>
144+
138145
export const PayoutReconciliationCompletedSchema = z.object({
139146
eventType: z.literal(WebhookEvents.PAYOUT_RECONCILIATION_COMPLETED),
140147
eventTime: z.string().optional(),
@@ -146,15 +153,7 @@ export const PayoutReconciliationCompletedSchema = z.object({
146153
netAmount: z.number(),
147154
status: z.string(),
148155
}),
149-
lineItems: z
150-
.array(
151-
z.object({
152-
copilotInvoiceId: z.string(),
153-
grossAmount: z.number(),
154-
feeAmount: z.number(),
155-
}),
156-
)
157-
.min(1),
156+
lineItems: z.array(PayoutLineItemSchema).min(1),
158157
}),
159158
})
160159
export type PayoutReconciliationCompletedType = z.infer<

src/utils/intuitAPI.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
QBDepositCreatePayloadType,
1717
QBDepositResponseSchema,
1818
QBDepositResponseType,
19+
QBDepositQueryResponseSchema,
1920
QBDeletePayloadType,
2021
QBDestructiveInvoicePayloadSchema,
2122
QBItemRowType,
@@ -1006,6 +1007,42 @@ export default class IntuitAPI {
10061007
return parsed
10071008
}
10081009

1010+
// Read all pages so we don't miss a deposit on a busy day. Miss one and
1011+
// resync makes a duplicate deposit that QBO won't let us delete. maxPages is
1012+
// just a safety cap — hitting it would need 50k deposits in a single day.
1013+
async _getDepositsByTxnDate(
1014+
txnDate: string,
1015+
): Promise<Array<{ Id: string; PrivateNote?: string }>> {
1016+
CustomLogger.info({
1017+
obj: { txnDate },
1018+
message: `IntuitAPI#getDepositsByTxnDate | start for realmId: ${this.tokens.intuitRealmId}.`,
1019+
})
1020+
1021+
const pageSize = 1000
1022+
const maxPages = 50
1023+
const deposits: Array<{ Id: string; PrivateNote?: string }> = []
1024+
let startPosition = 1
1025+
1026+
for (let pages = 0; pages < maxPages; pages++) {
1027+
const query = `select Id, PrivateNote, TxnDate from Deposit where TxnDate = '${escapeForQBQuery(txnDate)}' STARTPOSITION ${startPosition} MAXRESULTS ${pageSize}`
1028+
const response = await this.customQuery(query)
1029+
if (!response) return deposits
1030+
1031+
const envelope = QBDepositQueryResponseSchema.parse(response)
1032+
const page = envelope.Deposit ?? []
1033+
deposits.push(...page)
1034+
1035+
if (page.length < pageSize) return deposits
1036+
startPosition += pageSize
1037+
}
1038+
1039+
CustomLogger.error({
1040+
obj: { txnDate, maxPages },
1041+
message: `IntuitAPI#getDepositsByTxnDate | pagination cap (${maxPages} pages) hit for realmId: ${this.tokens.intuitRealmId} — result truncated at ${deposits.length} deposits.`,
1042+
})
1043+
return deposits
1044+
}
1045+
10091046
async _deletePurchase(
10101047
payload: QBDeletePayloadType,
10111048
): Promise<QBPurchaseDeleteResponseType> {
@@ -1134,5 +1171,6 @@ export default class IntuitAPI {
11341171
deletePayment = this.wrapWithRetry(this._deletePayment)
11351172
deletePurchase = this.wrapWithRetry(this._deletePurchase)
11361173
createDeposit = this.wrapWithRetry(this._createDeposit)
1174+
getDepositsByTxnDate = this._getDepositsByTxnDate.bind(this)
11371175
getCompanyInfo = this._getCompanyInfo.bind(this)
11381176
}

test/helpers/mocks.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ export function createMockIntuitAPI(overrides: IntuitAPIOverrides = {}) {
142142
createDeposit: vi.fn().mockResolvedValue({
143143
Deposit: { Id: 'qb-deposit-1', SyncToken: '0' },
144144
}),
145+
// Payout resync checks for an existing deposit first — none by default.
146+
getDepositsByTxnDate: vi.fn().mockResolvedValue([]),
145147
// Handler ignores the response; it just needs the call to succeed (OUT-3921).
146148
voidInvoice: vi.fn().mockResolvedValue({
147149
Invoice: { Id: TEST_QB_INVOICE_ID, SyncToken: '1' },

0 commit comments

Comments
 (0)