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
170 changes: 165 additions & 5 deletions lib/connectors/quickbooks/payment-poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import { db } from '@/lib/db'
import { logActivity } from '@/lib/activity-log'
import { INTERNAL_ACTION_BYPASS } from '@/lib/internal-action-bypass'
import { detectPaymentReversals } from '@/lib/domain/accounting/payment-reversal'
import { qboQuery } from './api'
import { getSettingValue } from '@/lib/settings-store'

Expand All @@ -27,14 +29,62 @@ type QboQueryResponse<T> = {
QueryResponse: Record<string, T[] | undefined>
}

type QboEntityId = { Id: string }

/**
* Split the QBO transactions that regressed out of the fully-paid state into the
* full reversed set and the subset that was VOIDED. Mirrors the Xero poller's
* {all, voided} contract (audit-M-acct #3 / scjz.71):
* - balanceDueEntities: invoices/bills whose Balance returned to > 0 (the payment
* was deleted/un-applied but the document is still live) — eligible for a
* revenue chargeback on the sales side.
* - voidedEntities: invoices/bills QBO zeroed out (TotalAmt = 0). QBO has already
* reversed their AR/revenue, so paidAt is cleared but NO chargeback is raised
* (a separate credit note would double-reverse).
* Pure set union so it can be unit-tested without the QBO API.
*/
export function classifyQboReversals(
balanceDueEntities: QboEntityId[],
voidedEntities: QboEntityId[],
): { all: Set<string>; voided: Set<string> } {
const all = new Set<string>()
const voided = new Set<string>()
for (const e of balanceDueEntities) all.add(e.Id)
for (const e of voidedEntities) {
all.add(e.Id)
voided.add(e.Id)
}
return { all, voided }
}

// QBO equivalent of Xero's fetchReversedInvoiceIds. An IMS-paid document (Balance
// was 0) is "reversed" if, modified since the last poll, its QBO transaction now
// has Balance > 0 (payment removed) or TotalAmt = 0 (voided/zeroed). Returns null
// if either query failed so the caller can hold the poll watermark and retry.
async function fetchReversedEntityIds(
entity: 'Invoice' | 'Bill',
since: string,
): Promise<{ all: Set<string>; voided: Set<string> } | null> {
const [balanceRes, voidedRes] = await Promise.all([
qboQuery<QboQueryResponse<QboEntityId>>(entity, `Balance > '0' AND MetaData.LastUpdatedTime > '${since}'`),
qboQuery<QboQueryResponse<QboEntityId>>(entity, `TotalAmt = '0' AND MetaData.LastUpdatedTime > '${since}'`),
])
if (!balanceRes.ok || !voidedRes.ok) return null
const balanceDue = balanceRes.data?.QueryResponse?.[entity] ?? []
const voided = voidedRes.data?.QueryResponse?.[entity] ?? []
return classifyQboReversals(balanceDue, voided)
}

/**
* Poll QuickBooks for paid invoices and bills.
* Updates paidAt on matching IMS records and advances order status.
*/
export async function pollQuickBooksPayments(): Promise<{ salesPaid: number; billsPaid: number; errors: string[] }> {
export async function pollQuickBooksPayments(): Promise<{ salesPaid: number; billsPaid: number; salesReversed: number; billsReversed: number; errors: string[] }> {
const errors: string[] = []
let salesPaid = 0
let billsPaid = 0
let salesReversed = 0
let billsReversed = 0
let allQueriesSucceeded = true

const lastPoll = await getSettingValue(LAST_POLL_KEY)
Expand Down Expand Up @@ -97,6 +147,85 @@ export async function pollQuickBooksPayments(): Promise<{ salesPaid: number; bil
}
}

// --- Sales payment reversals (audit-M-acct #3 / scjz.70/.71) ---
// Forward poll only marks unpaid→paid. If an invoice IMS thinks is paid no longer
// has a zero balance in QBO — payment deleted/un-applied (Balance > 0) or the
// invoice voided (TotalAmt = 0) — clear paidAt so IMS stops showing it paid.
// Status is NOT auto-reverted (the order may already be picking/shipped); a
// WARNING carrying the current status flags it. Must run AFTER the forward pass
// so a pay-then-reverse within one window nets to the correct (unpaid) state.
const paidOrders = await db.salesOrder.findMany({
where: {
accountingInvoiceId: { not: null },
paidAt: { not: null },
shoppingLinks: { none: {} },
},
select: {
id: true,
accountingInvoiceId: true,
orderNumber: true,
externalOrderNumber: true,
status: true,
revenueDeferredDate: true,
},
})

if (paidOrders.length > 0) {
const reversedIds = await fetchReversedEntityIds('Invoice', since)
if (!reversedIds) {
allQueriesSucceeded = false
errors.push('Failed to query QuickBooks invoices for payment reversals')
} else {
for (const order of detectPaymentReversals(paidOrders, reversedIds.all)) {
// scjz.71: a reversed payment on a revenue-POSTED order (revenue recognised +
// invoiced) is a chargeback — raise a revenue-only credit note that reverses
// recognised revenue against AR. Idempotent (one chargeback per order).
// A VOIDED invoice has already had its AR/revenue reversed by QBO, so a
// separate credit note would double-reverse — only auto-chargeback an
// un-applied payment where the invoice is still live.
// CRITICAL: clear paidAt ONLY after the chargeback is recorded — otherwise a
// failed chargeback would drop the order out of the next poll's paidOrders
// (paidAt: not null) and the recognised revenue would never be reversed.
const invoiceVoided = order.accountingInvoiceId != null && reversedIds.voided.has(order.accountingInvoiceId)
let chargebackFailed = false
if (order.revenueDeferredDate && !invoiceVoided) {
try {
const { raiseChargebackForReversedOrder } = await import('@/app/actions/sales')
const chargeback = await raiseChargebackForReversedOrder(order.id, { internalBypassToken: INTERNAL_ACTION_BYPASS })
if (chargeback.error) {
chargebackFailed = true
errors.push(`Chargeback for order ${order.orderNumber ?? order.id} failed: ${chargeback.error}`)
}
} catch (chargebackError) {
chargebackFailed = true
errors.push(`Chargeback for order ${order.orderNumber ?? order.id} failed: ${String(chargebackError)}`)
}
}
// Leave paidAt set on a failed chargeback so the reversal is re-attempted and
// the order is not silently shown unpaid-and-unreversed. Also hold the poll
// watermark: unlike Xero (whose cursor gate is errors.length===0), the QBO
// cursor advances on allQueriesSucceeded, so without this the window moves past
// the reversed invoice and the LastUpdatedTime>since reversal query never
// re-returns it — the chargeback would never actually retry.
if (chargebackFailed) {
allQueriesSucceeded = false
continue
}
await db.salesOrder.update({ where: { id: order.id }, data: { paidAt: null } })
salesReversed++
await logActivity({
entityType: 'SALES_ORDER',
entityId: order.id,
action: 'payment_reversal_detected',
tag: 'sync',
level: 'WARNING',
description: `Payment no longer present in QuickBooks for order ${order.orderNumber ?? order.externalOrderNumber} (status: ${order.status}) — cleared paidAt. Review whether the order status should revert.`,
resolveUser: false,
})
}
}
}

// --- Purchase bills (vendor payments) ---
const unpaidBills = await db.purchaseInvoice.findMany({
where: {
Expand Down Expand Up @@ -135,6 +264,37 @@ export async function pollQuickBooksPayments(): Promise<{ salesPaid: number; bil
}
}

// --- Purchase bill payment reversals (audit-M-acct #3) ---
// A bill IMS thinks paid whose QBO transaction regressed (Balance > 0, payment
// un-applied; or TotalAmt = 0, voided) gets paidAt cleared with a WARNING. No
// chargeback equivalent on the purchase side.
const paidBills = await db.purchaseInvoice.findMany({
where: { accountingInvoiceId: { not: null }, paidAt: { not: null } },
select: { id: true, accountingInvoiceId: true, poId: true, po: { select: { reference: true, status: true } } },
})

if (paidBills.length > 0) {
const reversedIds = await fetchReversedEntityIds('Bill', since)
if (!reversedIds) {
allQueriesSucceeded = false
errors.push('Failed to query QuickBooks bills for payment reversals')
} else {
for (const bill of detectPaymentReversals(paidBills, reversedIds.all)) {
await db.purchaseInvoice.update({ where: { id: bill.id }, data: { paidAt: null } })
billsReversed++
await logActivity({
entityType: 'PURCHASE_ORDER',
entityId: bill.poId,
action: 'bill_payment_reversal_detected',
tag: 'sync',
level: 'WARNING',
description: `Bill payment no longer present in QuickBooks for PO ${bill.po.reference} (PO status: ${bill.po.status}) — cleared paidAt.`,
resolveUser: false,
})
}
}
}

// Only advance the poll watermark if all QBO queries succeeded.
// If a query failed, keep the previous checkpoint so the next run
// replays the missed window instead of permanently skipping payments.
Expand All @@ -146,15 +306,15 @@ export async function pollQuickBooksPayments(): Promise<{ salesPaid: number; bil
})
}

if (salesPaid > 0 || billsPaid > 0) {
if (salesPaid > 0 || billsPaid > 0 || salesReversed > 0 || billsReversed > 0) {
await logActivity({
entityType: 'SYSTEM',
action: 'quickbooks_payment_poll',
tag: 'sync',
description: `QuickBooks payment poll: ${salesPaid} sales payment(s), ${billsPaid} bill payment(s) detected`,
metadata: { salesPaid, billsPaid },
description: `QuickBooks payment poll: ${salesPaid} sales paid, ${billsPaid} bills paid, ${salesReversed} sales reversed, ${billsReversed} bills reversed`,
metadata: { salesPaid, billsPaid, salesReversed, billsReversed },
})
}

return { salesPaid, billsPaid, errors }
return { salesPaid, billsPaid, salesReversed, billsReversed, errors }
}
43 changes: 43 additions & 0 deletions tests/accounting/qbo-payment-reversal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict'
import test from 'node:test'

import { classifyQboReversals } from '@/lib/connectors/quickbooks/payment-poller'
import { detectPaymentReversals } from '@/lib/domain/accounting/payment-reversal'

test('balance-due entities are reversed but not voided (chargeback-eligible)', () => {
const { all, voided } = classifyQboReversals([{ Id: 'I1' }, { Id: 'I2' }], [])
assert.deepEqual([...all].sort(), ['I1', 'I2'])
assert.equal(voided.size, 0)
})

test('zeroed entities are both reversed and voided (chargeback-skipped)', () => {
const { all, voided } = classifyQboReversals([], [{ Id: 'I3' }])
assert.ok(all.has('I3'))
assert.ok(voided.has('I3'))
})

test('an entity that is both balance-due and zeroed lands in voided (union, deduped)', () => {
const { all, voided } = classifyQboReversals([{ Id: 'I4' }], [{ Id: 'I4' }])
assert.deepEqual([...all], ['I4'])
assert.ok(voided.has('I4'))
})

test('empty inputs yield empty sets', () => {
const { all, voided } = classifyQboReversals([], [])
assert.equal(all.size, 0)
assert.equal(voided.size, 0)
})

test('classifier output drives detectPaymentReversals over IMS-paid orders', () => {
const paidOrders = [
{ id: 'o1', accountingInvoiceId: 'I1' }, // payment un-applied -> reversed
{ id: 'o2', accountingInvoiceId: 'I3' }, // voided -> reversed (skip chargeback)
{ id: 'o3', accountingInvoiceId: 'I9' }, // untouched -> not reversed
]
const { all, voided } = classifyQboReversals([{ Id: 'I1' }], [{ Id: 'I3' }])
const reversed = detectPaymentReversals(paidOrders, all)
assert.deepEqual(reversed.map((o) => o.id).sort(), ['o1', 'o2'])
// I1 is live (chargeback-eligible); I3 is voided (chargeback-skipped)
assert.equal(voided.has('I1'), false)
assert.equal(voided.has('I3'), true)
})
Loading