diff --git a/scripts/reconcile-helcim-payments.mjs b/scripts/reconcile-helcim-payments.mjs new file mode 100644 index 0000000..c047929 --- /dev/null +++ b/scripts/reconcile-helcim-payments.mjs @@ -0,0 +1,121 @@ +/** + * 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) +})