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:
parent
fc09760a41
commit
ef26b57ce2
1 changed files with 121 additions and 0 deletions
121
scripts/reconcile-helcim-payments.mjs
Normal file
121
scripts/reconcile-helcim-payments.mjs
Normal 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)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue