/** * Reconciliation cron route — invoked by `netlify/functions/reconcile-payments.mjs` * on a daily schedule. Mirrors the loop in `scripts/reconcile-helcim-payments.mjs` * but lives inside Nitro so it can use auto-imported utils + the runtime config. * * Auth: shared-secret header `X-Reconcile-Token` matched against * `runtimeConfig.reconcileToken` (env: NUXT_RECONCILE_TOKEN). Machine-to-machine * only — no user session involved. * * Behavior: * - For every Member with a helcimCustomerId, list Helcim transactions and * upsert Payment docs (idempotent via `helcimTransactionId` unique index). * - Transient Helcim API errors are retried up to 3 times with exponential * backoff (250ms / 500ms / 1000ms). On final failure the member is counted * as `memberErrors` and the loop continues. * - Never passes `sendConfirmation: true` — the cron back-fills history and * must not re-send confirmation emails. * - `?apply=false` switches to dry-run: counts what WOULD be created via * Payment.findOne, no writes. * * Returns a JSON summary; logs `[reconcile] done ` to stdout. */ import Member from '../../models/member.js' import Payment from '../../models/payment.js' import { listHelcimCustomerTransactions } from '../../utils/helcim.js' import { upsertPaymentFromHelcim } from '../../utils/payments.js' 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) { lastErr = err 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 (tx.status === 'other') { 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 })