/** * One-off migration: consolidate Helcim payment plans from per-tier model * to unified Monthly/Annual Membership plans and backfill Mongo members. * * Usage: * node scripts/helcim-plan-consolidation.js # dry-run (default) * node scripts/helcim-plan-consolidation.js --confirm # execute all steps * node scripts/helcim-plan-consolidation.js --mongo-only # Mongo cleanup only (no Helcim) * * Required env: HELCIM_API_TOKEN, MONGODB_URI */ import dotenv from 'dotenv' import { fileURLToPath } from 'url' import { dirname, resolve } from 'path' import { writeFileSync } from 'fs' import mongoose from 'mongoose' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) dotenv.config({ path: resolve(__dirname, '../.env') }) // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- const HELCIM_API_BASE = 'https://api.helcim.com/v2' // Legacy plan IDs to delete const LEGACY_PLAN_IDS = ['20162', '21596', '21597', '21598'] // Legacy plan name pattern — catch any additional per-tier plans const LEGACY_PLAN_NAME_RE = /^Ghost Guild\s*[-–]\s*Member/i const MONTHLY_PLAN = { name: 'Monthly Membership', recurringAmount: 15, billingPeriod: 'monthly', } const ANNUAL_PLAN = { name: 'Annual Membership', recurringAmount: 150, billingPeriod: 'yearly', } // Shape mirrors the pre-migration legacy plans (verified via backup dump). // taxType:'customer' + missing taxCalculation/termLength caused ERR_VALIDATION_FAILED. const HELCIM_PLAN_PAYLOAD_BASE = { businessName: '', businessEmail: '', description: '', type: 'subscription', status: 'active', currency: 'CAD', setupAmount: 0, billingPeriodIncrements: 1, dateBilling: 'Sign-up', termType: 'forever', freeTrialPeriod: 0, taxType: 'no_tax', taxCalculation: 'country_province', paymentMethod: 'card', isProrated: 'no', } // --------------------------------------------------------------------------- // Flag parsing // --------------------------------------------------------------------------- const args = process.argv.slice(2) const CONFIRM = args.includes('--confirm') const MONGO_ONLY = args.includes('--mongo-only') // --mongo-only implies destructive Mongo ops even without --confirm // --confirm enables all destructive ops (Helcim + Mongo) // --------------------------------------------------------------------------- // Helcim helpers // --------------------------------------------------------------------------- function helcimHeaders() { const token = process.env.HELCIM_API_TOKEN if (!token) throw new Error('HELCIM_API_TOKEN not set in .env') return { accept: 'application/json', 'content-type': 'application/json', 'api-token': token, } } async function helcimGet(path) { const res = await fetch(`${HELCIM_API_BASE}${path}`, { headers: helcimHeaders(), }) const body = await res.text() if (!res.ok) { throw new Error(`GET ${path} failed (${res.status}): ${body}`) } if (!body.trim()) return [] try { return JSON.parse(body) } catch { return [] } } async function helcimDelete(path) { const res = await fetch(`${HELCIM_API_BASE}${path}`, { method: 'DELETE', headers: helcimHeaders(), }) if (!res.ok) { const body = await res.text() throw new Error(`DELETE ${path} failed (${res.status}): ${body}`) } return res } async function helcimPost(path, payload) { const res = await fetch(`${HELCIM_API_BASE}${path}`, { method: 'POST', headers: helcimHeaders(), body: JSON.stringify(payload), }) const body = await res.text() let parsed try { parsed = JSON.parse(body) } catch { parsed = body } return { ok: res.ok, status: res.status, body: parsed } } // --------------------------------------------------------------------------- // Mongo helpers // --------------------------------------------------------------------------- async function connectMongo() { const uri = process.env.MONGODB_URI if (!uri) throw new Error('MONGODB_URI not set in .env') await mongoose.connect(uri) } // --------------------------------------------------------------------------- // Timestamp helper // --------------------------------------------------------------------------- function safeTimestamp() { return new Date().toISOString().replace(/:/g, '-') } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main() { console.log('=== Helcim Plan Consolidation' + (CONFIRM ? '' : ' (DRY RUN)') + ' ===') console.log(`Mode: ${CONFIRM ? 'LIVE' : 'dry-run'}`) console.log(`Flags: --confirm=${CONFIRM}, --mongo-only=${MONGO_ONLY}`) console.log() // ------------------------------------------------------------------------- // Step 1: Backup // ------------------------------------------------------------------------- console.log('Step 1: Backup') let subscriptions = [] let plans = [] let mongoMembers = [] if (!MONGO_ONLY) { process.stdout.write(' Fetching Helcim subscriptions... ') const subData = await helcimGet('/subscriptions') subscriptions = Array.isArray(subData) ? subData : (subData.subscriptions ?? subData.data ?? []) console.log(`Found ${subscriptions.length} subscriptions`) process.stdout.write(' Fetching Helcim payment plans... ') const planData = await helcimGet('/payment-plans') plans = Array.isArray(planData) ? planData : (planData.plans ?? planData.data ?? []) console.log(`Found ${plans.length} plans`) } else { console.log(' [--mongo-only] Skipping Helcim backup') } // Always connect Mongo for member backup process.stdout.write(' Fetching Mongo members with helcimSubscriptionId... ') await connectMongo() const membersColl = mongoose.connection.collection('members') const rawMembers = await membersColl .find({ helcimSubscriptionId: { $exists: true } }) .project({ _id: 1, email: 1, status: 1, helcimSubscriptionId: 1, contributionTier: 1, billingCadence: 1 }) .toArray() mongoMembers = rawMembers console.log(`Found ${mongoMembers.length} members`) const backupFilename = `.migration-backup-${safeTimestamp()}.json` const backupData = { timestamp: new Date().toISOString(), flags: { confirm: CONFIRM, mongoOnly: MONGO_ONLY }, helcimSubscriptions: subscriptions, helcimPlans: plans, mongoMembers, } writeFileSync(backupFilename, JSON.stringify(backupData, null, 2)) console.log(` Backup written: ${backupFilename}`) console.log() // ------------------------------------------------------------------------- // Step 2: Delete subscriptions // ------------------------------------------------------------------------- if (!MONGO_ONLY) { console.log('Step 2: Delete subscriptions') if (!CONFIRM) { console.log(` [DRY RUN] Would delete ${subscriptions.length} subscriptions:`) for (const sub of subscriptions) { const memberEmail = sub.customer?.email ?? sub.email ?? sub.customerId ?? 'unknown' console.log(` - ${sub.id} (member: ${memberEmail})`) } } else { console.log(` Deleting ${subscriptions.length} subscriptions...`) for (const sub of subscriptions) { try { await helcimDelete(`/subscriptions/${sub.id}`) console.log(` [OK] Deleted subscription ${sub.id}`) } catch (err) { console.error(` [ERROR] Failed to delete subscription ${sub.id}: ${err.message}`) } } } console.log() } // ------------------------------------------------------------------------- // Step 3: Delete legacy plans // ------------------------------------------------------------------------- if (!MONGO_ONLY) { console.log('Step 3: Delete legacy plans') // Detect any extra legacy plans by name pattern const extraLegacyIds = plans .filter((p) => { const idStr = String(p.id) return !LEGACY_PLAN_IDS.includes(idStr) && LEGACY_PLAN_NAME_RE.test(p.name) }) .map((p) => String(p.id)) if (extraLegacyIds.length > 0) { console.log(` Detected additional legacy plans by name pattern: ${extraLegacyIds.join(', ')}`) } const allLegacyIds = [...new Set([...LEGACY_PLAN_IDS, ...extraLegacyIds])] if (!CONFIRM) { console.log(` [DRY RUN] Would delete ${allLegacyIds.length} plans: ${allLegacyIds.join(', ')}`) } else { console.log(` Deleting ${allLegacyIds.length} plans: ${allLegacyIds.join(', ')}`) for (const planId of allLegacyIds) { try { await helcimDelete(`/payment-plans/${planId}`) console.log(` [OK] Deleted plan ${planId}`) } catch (err) { console.error(` [ERROR] Failed to delete plan ${planId}: ${err.message}`) } } } console.log() } // ------------------------------------------------------------------------- // Steps 4 & 5: Create new plans // ------------------------------------------------------------------------- let monthlyPlanId = null let annualPlanId = null if (!MONGO_ONLY) { // Idempotency: check if plans already exist in backup const existingMonthly = plans.find((p) => p.name === MONTHLY_PLAN.name) const existingAnnual = plans.find((p) => p.name === ANNUAL_PLAN.name) // Step 4: Monthly Membership console.log('Step 4: Create Monthly Membership') const monthlyPayload = { ...HELCIM_PLAN_PAYLOAD_BASE, name: MONTHLY_PLAN.name, recurringAmount: MONTHLY_PLAN.recurringAmount, billingPeriod: MONTHLY_PLAN.billingPeriod, } if (existingMonthly) { monthlyPlanId = existingMonthly.id console.log(` [SKIP] Plan "${MONTHLY_PLAN.name}" already exists with id ${monthlyPlanId}`) } else if (!CONFIRM) { console.log(` [DRY RUN] Would POST /payment-plans with:`) console.log(' ', JSON.stringify(monthlyPayload, null, 4).split('\n').join('\n ')) } else { const wrapped = { paymentPlans: [monthlyPayload] } console.log(` POSTing /payment-plans with:`) console.log(' ', JSON.stringify(wrapped, null, 4).split('\n').join('\n ')) const result = await helcimPost('/payment-plans', wrapped) if (!result.ok) { console.error(` [ERROR] POST failed (${result.status}):`) console.error(' ', JSON.stringify(result.body, null, 2)) console.error(' Aborting — fix the payload and re-run.') await mongoose.disconnect() process.exit(1) } monthlyPlanId = result.body.data?.[0]?.id ?? result.body.paymentPlans?.[0]?.id ?? result.body.id console.log(` [OK] Created Monthly Membership plan id: ${monthlyPlanId}`) } console.log() // Step 5: Annual Membership console.log('Step 5: Create Annual Membership') const annualPayload = { ...HELCIM_PLAN_PAYLOAD_BASE, name: ANNUAL_PLAN.name, recurringAmount: ANNUAL_PLAN.recurringAmount, billingPeriod: ANNUAL_PLAN.billingPeriod, } if (existingAnnual) { annualPlanId = existingAnnual.id console.log(` [SKIP] Plan "${ANNUAL_PLAN.name}" already exists with id ${annualPlanId}`) } else if (!CONFIRM) { console.log(` [DRY RUN] Would POST /payment-plans with:`) console.log(' ', JSON.stringify(annualPayload, null, 4).split('\n').join('\n ')) console.log(' Note: billingPeriod="yearly" is Helcim v2 convention.') console.log(' If 4xx on --confirm, check the Helcim API docs for the correct field value.') } else { const wrapped = { paymentPlans: [annualPayload] } console.log(` POSTing /payment-plans with:`) console.log(' ', JSON.stringify(wrapped, null, 4).split('\n').join('\n ')) const result = await helcimPost('/payment-plans', wrapped) if (!result.ok) { console.error(` [ERROR] POST failed (${result.status}):`) console.error(' ', JSON.stringify(result.body, null, 2)) console.error(' If billingPeriod is wrong, update ANNUAL_PLAN.billingPeriod and re-run.') console.error(' Aborting.') await mongoose.disconnect() process.exit(1) } annualPlanId = result.body.data?.[0]?.id ?? result.body.paymentPlans?.[0]?.id ?? result.body.id console.log(` [OK] Created Annual Membership plan id: ${annualPlanId}`) } console.log() } // ------------------------------------------------------------------------- // Step 6: Mongo cleanup // ------------------------------------------------------------------------- const runMongoDestructive = CONFIRM || MONGO_ONLY console.log('Step 6: Mongo cleanup') if (!runMongoDestructive) { console.log(` [DRY RUN] Would updateMany on ${mongoMembers.length} members:`) console.log(' { $set: { contributionTier: \'0\', billingCadence: \'monthly\' }, $unset: { helcimSubscriptionId: \'\' } }') console.log(' (Status field is NOT changed — active stays active, pending_payment stays pending_payment)') } else { console.log(` Running updateMany on ${mongoMembers.length} members with helcimSubscriptionId...`) const result = await membersColl.updateMany( { helcimSubscriptionId: { $exists: true } }, { $set: { contributionTier: '0', billingCadence: 'monthly' }, $unset: { helcimSubscriptionId: '' }, }, { runValidators: false }, ) console.log(` [OK] Modified ${result.modifiedCount} member(s)`) } console.log() // ------------------------------------------------------------------------- // Summary // ------------------------------------------------------------------------- if (!CONFIRM && !MONGO_ONLY) { console.log('=== To execute, re-run with --confirm ===') console.log('=== To run Mongo cleanup only, re-run with --mongo-only ===') } else { if (!MONGO_ONLY) { console.log('=== New plan IDs — copy into .env ===') console.log(`NUXT_HELCIM_MONTHLY_PLAN_ID=${monthlyPlanId ?? ''}`) console.log(`NUXT_HELCIM_ANNUAL_PLAN_ID=${annualPlanId ?? ''}`) } console.log('=== Migration complete ===') } await mongoose.disconnect() } main().catch((err) => { console.error('\n[FATAL]', err.message) mongoose.disconnect().catch(() => {}) process.exit(1) })