/** * 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 { 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' 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 })