feat(payments): add reconcile-helcim-payments script for backfill + ongoing sync

Iterates every Member with helcimCustomerId, pulls their recent
transactions via listHelcimCustomerTransactions, and upserts a Payment
row per tx using the shared upsertPaymentFromHelcim helper (idempotent
via unique helcimTransactionId index). No confirmation emails are
sent during reconciliation.

Dry-run by default; pass --apply to write. Uses:
- Launch-day backfill for the ~34 pre-existing members.
- Nightly cron/scheduled function post-launch to catch recurring
  Helcim charges that bypass the synchronous write paths in
  subscription.post.js and update-contribution.post.js.

Written as .mjs rather than .cjs (scripts/*.js is gitignored; .cjs
would need dynamic imports for ESM server utils). Shims Nitro's
useRuntimeConfig + createError globals so helcim.js loads outside
the Nitro runtime.
This commit is contained in:
Jennie Robinson Faber 2026-04-20 13:21:56 +01:00
parent fc09760a41
commit ef26b57ce2

View file

@ -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)
})