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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"lint-staged": "npx lint-staged",
"prepare": "husky",
"supabase:dev": "supabase start --ignore-health-check",
"db:migrate": "tsx src/db/migrate.ts",
"cmd:rename-qb-accounts": "tsx src/cmd/renameQbAccount/index.ts",
"patch-assembly-node-sdk": "cp ./lib-patches/assembly-js-node-sdk.js ./node_modules/@assembly-js/node-sdk/dist/api/init.js",
"patch-copilot-node-sdk": "cp ./lib-patches/copilot-node-sdk.js ./node_modules/copilot-node-sdk/dist/api/init.js",
Expand Down
4 changes: 2 additions & 2 deletions scripts/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ else
echo "[1/3] Skipping copilot-node-sdk patch (production)"
fi

echo "[2/3] Running drizzle-kit migrate"
yarn drizzle-kit migrate
echo "[2/3] Running db:migrate"
yarn db:migrate

echo "[3/3] Running next build"
next build
Expand Down
2 changes: 2 additions & 0 deletions src/app/api/core/types/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export enum EntityType {
INVOICE = 'invoice',
PRODUCT = 'product',
PAYMENT = 'payment',
PAYOUT = 'payout',
}

export enum LogStatus {
Expand All @@ -20,6 +21,7 @@ export enum EventType {
SUCCEEDED = 'succeeded',
MAPPED = 'mapped',
UNMAPPED = 'unmapped',
SETTLED = 'settled',
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/app/api/core/types/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export enum WebhookEvents {
INVOICE_VOIDED = 'invoice.voided',
INVOICE_UPDATED = 'invoice.updated',
PAYMENT_SUCCEEDED = 'payment.succeeded',
PAYOUT_RECONCILIATION_COMPLETED = 'payout.reconciliation_completed',
}
2 changes: 2 additions & 0 deletions src/app/api/quickbooks/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export class AuthService extends BaseService {
assetAccountRef: insertPayload.assetAccountRef,
serviceItemRef: existingToken?.serviceItemRef || null,
clientFeeRef: existingToken?.clientFeeRef || null,
bankAccountRef: existingToken?.bankAccountRef || null,
})
// handle accounts
const createPayload = await this.handleAccountReferences(
Expand Down Expand Up @@ -247,6 +248,7 @@ export class AuthService extends BaseService {
assetAccountRef: '',
serviceItemRef: '',
clientFeeRef: '',
bankAccountRef: null,
}

// if sync is false but it has been enabled then don't throw error. We have to log in this case
Expand Down
21 changes: 20 additions & 1 deletion src/app/api/quickbooks/invoice/invoice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -914,11 +914,31 @@ export class InvoiceService extends BaseService {
)

const invoiceAmount = Number(z.string().parse(invoiceLog.amount)) / 100

// Batched-deposit mode routes the payment through Undeposited Funds so the
// payout deposit can later link and sweep it into the bank.
const settingService = new SettingService(this.user)
const setting = await settingService.getOneByPortalId([
'absorbedFeeFlag',
'bankDepositFeeFlag',
])
const useBankDepositFlow =
setting?.absorbedFeeFlag && setting?.bankDepositFeeFlag

const intuitApi = new IntuitAPI(qbTokenInfo)

const depositToAccountRef = useBankDepositFlow
? await intuitApi.getUndepositedFundsAccountId()
: undefined

const qbPaymentPayload = {
TotalAmt: invoiceAmount,
CustomerRef: {
value: existingCustomer.qbCustomerId,
},
...(depositToAccountRef && {
DepositToAccountRef: { value: depositToAccountRef },
}),
Line: [
{
Amount: invoiceAmount,
Expand All @@ -931,7 +951,6 @@ export class InvoiceService extends BaseService {
},
],
}
const intuitApi = new IntuitAPI(qbTokenInfo)
const paymentService = new PaymentService(this.user)

const customerDisplayName =
Expand Down
69 changes: 69 additions & 0 deletions src/app/api/quickbooks/payment/payment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
} from '@/db/schema/qbPaymentSync'
import { WhereClause } from '@/type/common'
import {
QBDepositCreatePayloadSchema,
QBDepositCreatePayloadType,
QBPaymentCreatePayloadSchema,
QBPaymentCreatePayloadType,
QBPurchaseCreatePayloadSchema,
Expand All @@ -34,6 +36,7 @@ import { addSyncBreadcrumb } from '@/utils/sentry'
import dayjs from 'dayjs'
import { z } from 'zod'
import httpStatus from 'http-status'
import CustomLogger from '@/utils/logger'

export class PaymentService extends BaseService {
private syncLogService: SyncLogService
Expand Down Expand Up @@ -195,6 +198,72 @@ export class PaymentService extends BaseService {
}
}

async createBankDepositForPayment(
intuitApi: IntuitAPI,
opts: {
lines: Array<{ qbPaymentId: string; amount: number }>
feeTotal: number
bankAccountRef: string
expenseAccountRef: string
txnDate: string
privateNote: string
},
): Promise<string> {
addSyncBreadcrumb('Creating batched bank deposit in QBO', {
privateNote: opts.privateNote,
lineCount: opts.lines.length,
feeTotal: opts.feeTotal,
})

const paymentLines: Required<QBDepositCreatePayloadType>['Line'] =
opts.lines.map((line) => ({
Amount: line.amount,
LinkedTxn: [
{
TxnId: line.qbPaymentId,
TxnType: 'Payment' as const,
TxnLineId: '0',
},
],
}))

// feeTotal is always >= 0 (caller rejects negative): 0 = no fee line.
if (opts.feeTotal > 0) {
paymentLines.push({
Amount: -opts.feeTotal,
DetailType: 'DepositLineDetail' as const,
DepositLineDetail: {
AccountRef: { value: opts.expenseAccountRef },
},
Description: 'Stripe processing fees',
})
}
Comment thread
priosshrsth marked this conversation as resolved.

const depositPayload: QBDepositCreatePayloadType = {
DepositToAccountRef: { value: opts.bankAccountRef },
PrivateNote: opts.privateNote,
TxnDate: opts.txnDate,
Line: paymentLines,
}

const parsedPayload = QBDepositCreatePayloadSchema.parse(depositPayload)
const res = await intuitApi.createDeposit(parsedPayload)

CustomLogger.info({
obj: {
depositId: res.Deposit?.Id,
lineCount: opts.lines.length,
feeTotal: opts.feeTotal,
},
message: `PaymentService#createBankDepositForPayment | Batched bank deposit created (${opts.privateNote})`,
})
addSyncBreadcrumb('Batched bank deposit created in QBO', {
depositId: res.Deposit?.Id,
})

return res.Deposit.Id
}

async webhookPaymentSucceeded({
parsedPaymentSucceedResource,
qbTokenInfo,
Expand Down
11 changes: 11 additions & 0 deletions src/app/api/quickbooks/sync/sync.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,17 @@ export class SyncService extends BaseService {
const authService = new AuthService(this.user)

for (const log of logs) {
// TODO: no PAYOUT resync path yet — skip so terminal payout rows don't
// burn attempts to a misleading alert. Auto-recovery is a follow-up.
if (log.entityType === EntityType.PAYOUT) {
CustomLogger.info({
message:
'SyncService#intiateSync | Skipping payout log (no resync path)',
obj: { copilotId: log.copilotId, workspaceId: this.user.workspaceId },
})
continue
}

// check and update attempt for failed logs
const resyncAttemtps = await this.checkAndUpdateAttempt(log)
if (resyncAttemtps.maxAttempts) {
Expand Down
44 changes: 41 additions & 3 deletions src/app/api/quickbooks/syncLog/syncLog.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { WhereClause } from '@/type/common'
import { orderMap } from '@/utils/drizzle'
import CustomLogger from '@/utils/logger'
import dayjs from 'dayjs'
import { and, eq, isNull, lt, sql } from 'drizzle-orm'
import { and, eq, inArray, isNull, lt, sql } from 'drizzle-orm'
import { captureException } from '@sentry/nextjs'
import { json2csv } from 'json-2-csv'

Expand Down Expand Up @@ -225,8 +225,8 @@ export class SyncLogService extends BaseService {

/**
* Atomic idempotency claim via the partial unique index
* `uq_qb_sync_logs_oneshot_active` (covers active invoice one-shot events
* and all payment events). For rows in that slice, ON CONFLICT DO NOTHING
* `uq_qb_sync_logs_oneshot_active` (covers active invoice one-shot events,
* all payment events, and payout/settled). For rows in that slice, ON CONFLICT DO NOTHING
* yields no row when another worker has already claimed the tuple, so
* `claimed: false` is returned. For rows outside the slice
* (INVOICE/UPDATED, PRODUCT, PRICE), the partial index does not apply and
Expand Down Expand Up @@ -264,6 +264,7 @@ export class SyncLogService extends BaseService {
where: sql`deleted_at IS NULL AND (
(entity_type = 'invoice' AND event_type IN ('created','paid','voided','deleted'))
OR (entity_type = 'payment' AND event_type = 'succeeded')
OR (entity_type = 'payout' AND event_type = 'settled')
)`,
})
.returning({ id: QBSyncLog.id })
Expand All @@ -285,6 +286,9 @@ export class SyncLogService extends BaseService {
.set({
status: LogStatus.FAILED,
category: FailedRecordCategoryType.OTHERS,
// Stale payout claims can't be retried (no resync path), so make them
// terminal; other entity types keep their retryability.
shouldRetry: sql`CASE WHEN ${QBSyncLog.entityType} = 'payout' THEN false ELSE ${QBSyncLog.shouldRetry} END`,
})
.where(
and(
Expand Down Expand Up @@ -376,6 +380,40 @@ export class SyncLogService extends BaseService {
return log || null
}

/**
* Maps Copilot invoice IDs → QBO Payment IDs from this portal's
* INVOICE/PAID/SUCCESS rows (quickbooksId holds the Payment ID there).
*/
async getSuccessfulPaidPaymentIds(
copilotInvoiceIds: string[],
): Promise<Map<string, string>> {
if (copilotInvoiceIds.length === 0) return new Map()

const rows = await this.db
.select({
copilotId: QBSyncLog.copilotId,
quickbooksId: QBSyncLog.quickbooksId,
})
.from(QBSyncLog)
.where(
and(
eq(QBSyncLog.portalId, this.user.workspaceId),
eq(QBSyncLog.entityType, EntityType.INVOICE),
eq(QBSyncLog.eventType, EventType.PAID),
eq(QBSyncLog.status, LogStatus.SUCCESS),
inArray(QBSyncLog.copilotId, copilotInvoiceIds),
isNull(QBSyncLog.deletedAt),
),
)

const paymentIdByInvoice = new Map<string, string>()
for (const row of rows) {
if (row.quickbooksId)
paymentIdByInvoice.set(row.copilotId, row.quickbooksId)
}
return paymentIdByInvoice
}

async prepareSyncLogsForDownload() {
const logs = await this.db.query.QBSyncLog.findMany({
where: eq(QBSyncLog.portalId, this.user.workspaceId),
Expand Down
10 changes: 10 additions & 0 deletions src/app/api/quickbooks/token/token.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,9 @@ export class TokenService extends BaseService {
case AccountTypeObj.Asset:
payload = { assetAccountRef: accountRef }
break
// AccountTypeObj.Bank intentionally falls through: restoreAccountRef
// throws for Bank before we ever get here (bank refs are user-selected,
// never auto-mapped). If that ever changes, add a Bank case here.
default:
throw new APIError(
httpStatus.BAD_REQUEST,
Expand Down Expand Up @@ -298,6 +301,13 @@ export class TokenService extends BaseService {
return this.getOrCreateExpenseAccountRef(intuitApi)
case AccountTypeObj.Asset:
return this.getOrCreateAssetAccountRef(intuitApi)
case AccountTypeObj.Bank:
// Never auto-restore a bank account — that could deposit into the
// wrong one. Make the user reselect instead.
throw new APIError(
httpStatus.BAD_REQUEST,
'Bank account is missing or was deleted in QuickBooks. Please reselect a bank account in the QuickBooks integration settings.',
)
default:
throw new APIError(
httpStatus.BAD_REQUEST,
Expand Down
Loading
Loading