Skip to content

Commit 02dc2cf

Browse files
chore(OUT-3902): add Xero expense inspect/delete scripts and dedupe SQL
Operational tooling for cleaning up duplicate absorbed-fee expenses: - scripts/xero/check-expense.ts: list a payment's Xero expenses (read-only) - scripts/xero/delete-expense.ts: delete expenses by bank transaction id (dry-run by default, --confirm to apply) - scripts/xero/_client.ts: load a tenant's Xero connection, refresh and persist the token if expired - src/db/manual/dedupe-synced-payments.sql: one-time dedupe of duplicate synced_payments rows, run before the partial unique index migration - XeroAPI: getBankTransaction and deleteBankTransaction used by the scripts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1c9aace commit 02dc2cf

5 files changed

Lines changed: 248 additions & 0 deletions

File tree

scripts/xero/_client.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { eq } from 'drizzle-orm'
2+
import db from '@/db'
3+
import { xeroConnections } from '@/db/schema/xeroConnections.schema'
4+
import XeroAPI from '@/lib/xero/XeroAPI'
5+
6+
// Get an authed XeroAPI for a tenant. Refreshes the token if expired.
7+
export async function getXeroForTenant(tenantId: string) {
8+
const connections = await db
9+
.select()
10+
.from(xeroConnections)
11+
.where(eq(xeroConnections.tenantId, tenantId))
12+
13+
if (!connections.length) throw new Error(`No xero_connections row found for tenantId ${tenantId}`)
14+
// tenantId is not unique, so bail rather than guess the wrong portal.
15+
if (connections.length > 1) {
16+
throw new Error(`Multiple xero_connections rows for tenantId ${tenantId}; resolve manually`)
17+
}
18+
const [connection] = connections
19+
20+
if (!connection.tokenSet?.refresh_token) {
21+
throw new Error(`Connection for tenantId ${tenantId} has no refresh token; re-authorize first`)
22+
}
23+
24+
const xero = new XeroAPI()
25+
const refreshToken = connection.tokenSet.refresh_token
26+
let tokenSet = connection.tokenSet
27+
28+
const isValid = tokenSet.expires_at ? tokenSet.expires_at * 1000 > Date.now() : false
29+
if (!isValid) {
30+
// Tokens rotate, so save the new one or the next run breaks.
31+
tokenSet = await xero.refreshWithRefreshToken(refreshToken)
32+
await db
33+
.update(xeroConnections)
34+
.set({ tokenSet })
35+
.where(eq(xeroConnections.portalId, connection.portalId))
36+
}
37+
38+
xero.setTokenSet(tokenSet)
39+
return { xero, connection }
40+
}

scripts/xero/check-expense.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { and, eq } from 'drizzle-orm'
2+
import type { BankTransaction } from 'xero-node'
3+
import db from '@/db'
4+
import { PaymentUserType, syncedPayments } from '@/db/schema/syncedPayments.schema'
5+
import { getXeroForTenant } from './_client'
6+
7+
// Usage: pnpm ex scripts/xero/check-expense.ts <tenantId> <copilotPaymentId>
8+
//
9+
// Lists the Xero expenses for a payment so you can review duplicates.
10+
// New rows use the payment id as reference; old ones use the invoice id,
11+
// so we look up the invoice id from the db and search both.
12+
async function main() {
13+
const [tenantId, copilotPaymentId] = process.argv.slice(2)
14+
if (!tenantId || !copilotPaymentId) {
15+
throw new Error('Usage: pnpm ex scripts/xero/check-expense.ts <tenantId> <copilotPaymentId>')
16+
}
17+
18+
const { xero } = await getXeroForTenant(tenantId)
19+
20+
const record = await db
21+
.select()
22+
.from(syncedPayments)
23+
.where(
24+
and(
25+
eq(syncedPayments.tenantId, tenantId),
26+
eq(syncedPayments.copilotPaymentId, copilotPaymentId),
27+
eq(syncedPayments.type, PaymentUserType.EXPENSE),
28+
),
29+
)
30+
.then((rows) => rows[0])
31+
32+
console.info('\nsynced_payments row:', record ?? '(none found)')
33+
34+
// Old expenses reference the invoice id, new ones the payment id.
35+
const references = [copilotPaymentId, record?.xeroInvoiceId].filter(Boolean) as string[]
36+
37+
const byId = new Map<string, BankTransaction>()
38+
for (const reference of references) {
39+
const transactions = await xero.getBankTransactionsByReference(tenantId, reference)
40+
for (const tx of transactions) {
41+
if (tx.bankTransactionID) byId.set(tx.bankTransactionID, tx)
42+
}
43+
}
44+
45+
const transactions = [...byId.values()]
46+
console.info(
47+
`\nFound ${transactions.length} SPEND transaction(s) for payment ${copilotPaymentId}:`,
48+
)
49+
for (const tx of transactions) {
50+
console.info({
51+
bankTransactionID: tx.bankTransactionID,
52+
status: tx.status,
53+
date: tx.date,
54+
reference: tx.reference,
55+
total: tx.total,
56+
lineItem: tx.lineItems,
57+
})
58+
}
59+
}
60+
61+
main()
62+
.then(() => process.exit(0))
63+
.catch((error) => {
64+
console.error(error)
65+
process.exit(1)
66+
})

scripts/xero/delete-expense.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { getXeroForTenant } from './_client'
2+
3+
// Usage: pnpm ex scripts/xero/delete-expense.ts <tenantId> <xeroPaymentId...> [--confirm]
4+
//
5+
// Deletes Xero expenses by their bank transaction id (xeroPaymentId).
6+
// Takes one or more ids. Dry-run by default; pass --confirm to delete.
7+
async function main() {
8+
const args = process.argv.slice(2)
9+
const confirm = args.includes('--confirm')
10+
const [tenantId, ...xeroPaymentIds] = args.filter((a) => !a.startsWith('--'))
11+
if (!tenantId || !xeroPaymentIds.length) {
12+
throw new Error(
13+
'Usage: pnpm ex scripts/xero/delete-expense.ts <tenantId> <xeroPaymentId...> [--confirm]',
14+
)
15+
}
16+
17+
const { xero } = await getXeroForTenant(tenantId)
18+
19+
// Fetch each one so the dry-run shows what will be deleted.
20+
console.info(`Transactions to delete (${xeroPaymentIds.length}):`)
21+
for (const id of xeroPaymentIds) {
22+
const tx = await xero.getBankTransaction(tenantId, id)
23+
if (!tx) {
24+
console.warn(` ${id} -> NOT FOUND`)
25+
continue
26+
}
27+
console.info({
28+
bankTransactionID: tx.bankTransactionID,
29+
status: tx.status,
30+
reference: tx.reference,
31+
total: tx.total,
32+
lineItem: tx.lineItems?.[0]?.description,
33+
})
34+
}
35+
36+
if (!confirm) {
37+
console.info('\nDry run. Re-run with --confirm to delete the above transaction(s).')
38+
return
39+
}
40+
41+
for (const id of xeroPaymentIds) {
42+
const result = await xero.deleteBankTransaction(tenantId, id)
43+
console.info(`Deleted ${id} -> status ${result?.status}`)
44+
}
45+
}
46+
47+
main()
48+
.then(() => process.exit(0))
49+
.catch((error) => {
50+
console.error(error)
51+
process.exit(1)
52+
})
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
-- =============================================================================
2+
-- One-time dedupe for synced_payments — OUT-3896
3+
-- =============================================================================
4+
-- Context: payment.succeeded had no idempotency check, so repeated webhooks
5+
-- created duplicate EXPENSE rows (each pointing to a separate Xero
6+
-- BankTransaction). Before the new partial unique index on
7+
-- (portal_id, tenant_id, copilot_payment_id) WHERE copilot_payment_id IS NOT NULL
8+
-- can be created, each group must be collapsed to a single canonical row:
9+
-- the EARLIEST-created mapping (tie-break on id for determinism).
10+
--
11+
-- Only rows with copilot_payment_id IS NOT NULL (EXPENSE rows) are affected.
12+
-- PAYMENT rows store copilot_payment_id = NULL and are untouched.
13+
--
14+
-- Run the DRY-RUN queries first to preview impact, then run the DELETE.
15+
-- Must run BEFORE the migration that creates the new index, ideally with
16+
-- payment.succeeded processing paused so no new duplicates appear in the gap.
17+
--
18+
-- NOTE: This removes duplicate DB rows only. The duplicate BankTransactions
19+
-- already in Xero are a separate manual accounting reconciliation.
20+
-- =============================================================================
21+
22+
23+
-- -----------------------------------------------------------------------------
24+
-- DRY RUN #1 — duplicate groups (the affected keys)
25+
-- -----------------------------------------------------------------------------
26+
SELECT portal_id, tenant_id, copilot_payment_id, COUNT(*) AS row_count
27+
FROM "synced_payments"
28+
WHERE copilot_payment_id IS NOT NULL
29+
GROUP BY portal_id, tenant_id, copilot_payment_id
30+
HAVING COUNT(*) > 1
31+
ORDER BY row_count DESC;
32+
33+
34+
-- -----------------------------------------------------------------------------
35+
-- DRY RUN #2 — survivor vs doomed, side by side (rn = 1 is the survivor)
36+
-- -----------------------------------------------------------------------------
37+
SELECT
38+
id,
39+
portal_id,
40+
tenant_id,
41+
copilot_payment_id,
42+
xero_payment_id,
43+
type,
44+
created_at,
45+
ROW_NUMBER() OVER (
46+
PARTITION BY portal_id, tenant_id, copilot_payment_id
47+
ORDER BY created_at ASC, id ASC
48+
) AS rn,
49+
CASE
50+
WHEN ROW_NUMBER() OVER (
51+
PARTITION BY portal_id, tenant_id, copilot_payment_id
52+
ORDER BY created_at ASC, id ASC
53+
) = 1 THEN 'KEEP'
54+
ELSE 'DELETE'
55+
END AS action
56+
FROM "synced_payments"
57+
WHERE copilot_payment_id IS NOT NULL
58+
ORDER BY portal_id, tenant_id, copilot_payment_id, rn;
59+
60+
61+
-- -----------------------------------------------------------------------------
62+
-- THE DELETE — keep the earliest-created row per
63+
-- (portal, tenant, copilot_payment_id), delete the rest.
64+
-- Tie-break on id so the result is deterministic.
65+
-- -----------------------------------------------------------------------------
66+
DELETE FROM "synced_payments" a
67+
USING "synced_payments" b
68+
WHERE a."copilot_payment_id" IS NOT NULL
69+
AND b."copilot_payment_id" IS NOT NULL
70+
AND a."portal_id" = b."portal_id"
71+
AND a."tenant_id" = b."tenant_id"
72+
AND a."copilot_payment_id" = b."copilot_payment_id"
73+
AND (
74+
a."created_at" > b."created_at"
75+
OR (a."created_at" = b."created_at" AND a."id" > b."id")
76+
);

src/lib/xero/XeroAPI.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,20 @@ class XeroAPI {
338338
return transactions.find((tx) => tx.status === BankTransaction.StatusEnum.AUTHORISED)
339339
}
340340

341+
async getBankTransaction(tenantId: string, bankTransactionId: string) {
342+
const res = await this.xero.accountingApi.getBankTransaction(tenantId, bankTransactionId)
343+
return res.body.bankTransactions?.[0]
344+
}
345+
346+
deleteBankTransaction(tenantId: string, bankTransactionId: string) {
347+
// Delete by setting Status=DELETED. Cast since only status matters.
348+
const payload = {
349+
type: BankTransaction.TypeEnum.SPEND,
350+
status: BankTransaction.StatusEnum.DELETED,
351+
} as BankTransaction
352+
return this.updateBankTransaction(tenantId, bankTransactionId, payload)
353+
}
354+
341355
// Old expenses used the invoice id as reference. Match on amount and only
342356
// adopt a unique result so we never pick the wrong payment's expense.
343357
async findLegacyExpenseByInvoice(

0 commit comments

Comments
 (0)