/** * Reconcile Helcim transactions → Payment docs in Mongo. * * For every Member with a helcimCustomerId, fetch their recent Helcim * transactions and upsert a Payment row for each one. Idempotent: existing * Payment docs (keyed by helcimTransactionId) are never overwritten. Skips * transactions with status 'other' (verify/auth-only etc.). * * Usage: * node scripts/reconcile-helcim-payments.mjs # dry-run (default) * node scripts/reconcile-helcim-payments.mjs --apply # writes new Payment rows * * Uses: * - Launch-day backfill for pre-existing members. * - Ongoing reconciliation (nightly cron) to catch Helcim-initiated recurring * charges that bypass the synchronous write paths in subscription.post.js * and update-contribution.post.js. * * Required env: MONGODB_URI, HELCIM_API_TOKEN */ import dotenv from 'dotenv' import { fileURLToPath } from 'url' import { dirname, resolve } from 'path' import mongoose from 'mongoose' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) dotenv.config({ path: resolve(__dirname, '../.env') }) // Nitro auto-imports used inside server/utils/helcim.js — shim them so the // util module loads outside the Nitro runtime. globalThis.useRuntimeConfig = () => ({ helcimApiToken: process.env.HELCIM_API_TOKEN }) globalThis.createError = (opts = {}) => { const err = new Error(opts.statusMessage || 'Error') Object.assign(err, opts) return err } const { default: Member } = await import('../server/models/member.js') const { default: Payment } = await import('../server/models/payment.js') const { listHelcimCustomerTransactions } = await import('../server/utils/helcim.js') const { upsertPaymentFromHelcim } = await import('../server/utils/payments.js') async function run() { const apply = process.argv.includes('--apply') if (!process.env.MONGODB_URI) { console.error('MONGODB_URI not set in environment') process.exit(1) } if (!process.env.HELCIM_API_TOKEN) { console.error('HELCIM_API_TOKEN not set in environment') process.exit(1) } await mongoose.connect(process.env.MONGODB_URI) const members = await Member.find( { helcimCustomerId: { $exists: true, $ne: null } }, { _id: 1, email: 1, name: 1, helcimCustomerId: 1, helcimSubscriptionId: 1, billingCadence: 1 } ).lean() console.log(`Scanning ${members.length} members with helcimCustomerId`) 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 listHelcimCustomerTransactions(member.helcimCustomerId) } catch (err) { memberErrors++ console.error(` ERR member=${member._id} (${member.email}): ${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 } const result = await upsertPaymentFromHelcim(member, tx) if (result.created) created++ else if (result.payment) existed++ else skipped++ } } const verb = apply ? 'Created' : 'Would create' console.log(`\nMembers scanned: ${members.length}`) console.log(`Transactions examined: ${txExamined}`) console.log(`${verb}: ${created}`) console.log(`Already existed: ${existed}`) console.log(`Skipped (status=other or unmapped): ${skipped}`) if (memberErrors > 0) console.log(`Helcim API errors for members: ${memberErrors}`) if (!apply) console.log('\nDry-run complete. Re-run with --apply to write Payment rows.') await mongoose.disconnect() } run().catch(async (err) => { console.error(err) try { await mongoose.disconnect() } catch {} process.exit(1) })