ghostguild-org/server/api/internal/reconcile-payments.post.js
Jennie Robinson Faber a2a8d945c6
Some checks failed
Test / vitest (push) Successful in 10m59s
Test / playwright (push) Failing after 10m22s
Test / visual (push) Failing after 10m11s
Test / Notify on failure (push) Successful in 2s
fix(reconcile): connect mongoose before querying members
Route was the only DB-using endpoint that didn't call connectDB(); other
routes warm the connection incidentally, but on a freshly-booted container
with no SLACK_BOT_TOKEN the slack-joins plugin skips and nothing else
opens the pool — first reconcile request hung 10s on buffered Member.find()
and returned 500.
2026-04-26 13:47:03 +01:00

113 lines
3.4 KiB
JavaScript

/**
* Reconciliation cron route — invoked by `netlify/functions/reconcile-payments.mjs`.
*
* Auth: shared-secret header `X-Reconcile-Token` matched against
* `runtimeConfig.reconcileToken` (env: NUXT_RECONCILE_TOKEN). Machine-to-machine
* only — never passes `sendConfirmation: true` so back-fills don't re-send
* confirmation emails.
*/
import Member from '../../models/member.js'
import Payment from '../../models/payment.js'
import { listHelcimCustomerTransactions } from '../../utils/helcim.js'
import { connectDB } from '../../utils/mongoose.js'
import { upsertPaymentFromHelcim } from '../../utils/payments.js'
// Same filter upsertPaymentFromHelcim applies — keep dry-run summary in sync.
const RECONCILABLE_STATUSES = new Set(['paid', 'refunded', 'failed'])
const RETRY_ATTEMPTS = 3
const BASE_DELAY_MS = 250
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function listTransactionsWithRetry(customerCode) {
let lastErr
for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) {
try {
return await listHelcimCustomerTransactions(customerCode)
} catch (err) {
// Permanent failures (auth, missing) — don't burn retries on broken config.
if (err?.statusCode === 401 || err?.statusCode === 403 || err?.statusCode === 404) {
throw err
}
lastErr = err
// Backoff after attempts 1 and 2: 250ms, then 500ms (no sleep after attempt 3).
if (attempt < RETRY_ATTEMPTS) {
await sleep(BASE_DELAY_MS * 2 ** (attempt - 1))
}
}
}
throw lastErr
}
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const expected = config.reconcileToken
const provided = getHeader(event, 'x-reconcile-token')
if (!expected || provided !== expected) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
const apply = getQuery(event).apply !== 'false'
await connectDB()
const members = await Member.find(
{ helcimCustomerId: { $exists: true, $ne: null } },
{ _id: 1, email: 1, name: 1, helcimCustomerId: 1, helcimSubscriptionId: 1, billingCadence: 1 }
).lean()
let txExamined = 0
let created = 0
let existed = 0
let skipped = 0
let memberErrors = 0
for (const member of members) {
let txs
try {
txs = await listTransactionsWithRetry(member.helcimCustomerId)
} catch (err) {
memberErrors++
console.error(`[reconcile] member=${member._id}: ${err?.message || err}`)
continue
}
for (const tx of txs) {
txExamined++
if (!RECONCILABLE_STATUSES.has(tx?.status)) {
skipped++
continue
}
if (!apply) {
const existing = await Payment.findOne({ helcimTransactionId: tx.id })
if (existing) existed++
else created++
continue
}
// Note: deliberately NOT passing sendConfirmation — cron back-fills must
// not re-send confirmation emails for transactions the member has already
// been notified about (or that pre-date Mongo Payment tracking entirely).
const result = await upsertPaymentFromHelcim(member, tx)
if (result.created) created++
else if (result.payment) existed++
else skipped++
}
}
const summary = {
membersScanned: members.length,
txExamined,
created,
existed,
skipped,
memberErrors,
apply
}
console.log('[reconcile] done', summary)
return summary
})