/** * 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 { getHelcimCustomer, 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, helcimCustomerCode: 1, helcimSubscriptionId: 1, billingCadence: 1 } ).lean() let txExamined = 0 let created = 0 let existed = 0 let skipped = 0 let memberErrors = 0 async function processMember(member) { // Opportunistic backfill: members predating the helcimCustomerCode field // get it filled in here so the daily cron acts as the migration. Only on // the missing path — no overwrite, no extra API call once populated. if (!member.helcimCustomerCode) { try { const customer = await getHelcimCustomer(member.helcimCustomerId) if (customer?.customerCode) { await Member.findByIdAndUpdate( member._id, { $set: { helcimCustomerCode: customer.customerCode } }, { runValidators: false } ) } } catch (err) { // Backfill is best-effort — never fail the reconcile run on it. console.warn(`[reconcile] customerCode backfill failed for member=${member._id}: ${err?.message || err}`) } } let txs try { txs = await listTransactionsWithRetry(member.helcimCustomerId) } catch (err) { console.error(`[reconcile] member=${member._id}: ${err?.message || err}`) return { error: true } } const result = { error: false, txExamined: 0, created: 0, existed: 0, skipped: 0 } for (const tx of txs) { result.txExamined++ if (!RECONCILABLE_STATUSES.has(tx?.status)) { result.skipped++ continue } if (!apply) { const existing = await Payment.findOne({ helcimTransactionId: tx.id }) if (existing) result.existed++ else result.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 upsertResult = await upsertPaymentFromHelcim(member, tx) if (upsertResult.created) result.created++ else if (upsertResult.payment) result.existed++ else result.skipped++ } return result } const CHUNK_SIZE = 8 for (let i = 0; i < members.length; i += CHUNK_SIZE) { const chunk = members.slice(i, i + CHUNK_SIZE) const results = await Promise.all(chunk.map((m) => processMember(m))) for (const r of results) { if (r.error) { memberErrors++ continue } txExamined += r.txExamined created += r.created existed += r.existed skipped += r.skipped } } const summary = { membersScanned: members.length, txExamined, created, existed, skipped, memberErrors, apply } console.log('[reconcile] done', summary) return summary })