diff --git a/.gitignore b/.gitignore index f5b524c..0454ac9 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ logs !.env.example scripts/*.js +# Migration backup files +.migration-backup-*.json + # Playwright e2e/test-results/ playwright-report/ diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..40c2a27 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,51 @@ +# scripts/ + +One-off admin and migration scripts. None of these run in CI. + +--- + +## helcim-plan-consolidation.js + +Consolidates Helcim payment plans from the legacy per-tier model (multiple plans at $15/$30/$50) to two unified plans (Monthly Membership at $15, Annual Membership at $150), and backfills Mongo members. + +### Required env + +``` +HELCIM_API_TOKEN= +MONGODB_URI= +``` + +Copy `.env.example` → `.env` if you haven't already. + +### Usage + +```bash +# Dry-run (default) — no mutations, writes backup file +node scripts/helcim-plan-consolidation.js + +# Execute all steps: delete subs, delete legacy plans, create new plans, update Mongo +node scripts/helcim-plan-consolidation.js --confirm + +# Mongo cleanup only — skips all Helcim steps (use if Helcim steps already ran) +node scripts/helcim-plan-consolidation.js --mongo-only +``` + +### What it does + +1. **Backup** — always written to `.migration-backup-.json`, even in dry-run. Contains all Helcim subscriptions, payment plans, and Mongo members with `helcimSubscriptionId`. +2. **Delete subscriptions** — deletes all Helcim subscriptions (`--confirm` only). +3. **Delete legacy plans** — deletes plan IDs `20162`, `21596`, `21597`, `21598`, plus any plan whose name matches the legacy pattern (e.g. "Ghost Guild - Member ($15)"). +4. **Create Monthly Membership** — $15/month. Idempotent: skipped if a plan named "Monthly Membership" already exists. +5. **Create Annual Membership** — $150/year. Same idempotency. Uses `billingPeriod: "yearly"` (Helcim v2 convention); if Helcim returns 4xx, the script aborts and prints the full error so you can correct the field name. +6. **Mongo cleanup** — `updateMany` with `runValidators: false` on all members with `helcimSubscriptionId`: sets `contributionTier: '0'` and `billingCadence: 'monthly'`, unsets `helcimSubscriptionId`. Does **not** change `status`. + +At the end, prints new plan IDs formatted for copy-paste into `.env`. + +### Recovery + +If something goes wrong mid-run, the backup JSON contains the pre-migration state. You can use the backup to: +- Identify which subscriptions existed before deletion. +- Identify which plan IDs to recreate manually in Helcim. +- Identify which members had `helcimSubscriptionId` before it was unset. + +Backup files are gitignored (`.migration-backup-*.json`). diff --git a/scripts/helcim-plan-consolidation.js b/scripts/helcim-plan-consolidation.js new file mode 100644 index 0000000..2a3a5f1 --- /dev/null +++ b/scripts/helcim-plan-consolidation.js @@ -0,0 +1,379 @@ +/** + * 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', +} + +const HELCIM_PLAN_PAYLOAD_BASE = { + description: 'Ghost Guild membership', + type: 'subscription', + status: 'active', + currency: 'CAD', + setupAmount: 0, + billingPeriodIncrements: 1, + dateBilling: 'Sign-up', + termType: 'forever', + freeTrialPeriod: 0, + taxType: 'customer', + paymentMethod: 'card', +} + +// --------------------------------------------------------------------------- +// 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(), + }) + if (!res.ok) { + const body = await res.text() + throw new Error(`GET ${path} failed (${res.status}): ${body}`) + } + return res.json() +} + +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 { + console.log(` POSTing /payment-plans with:`) + console.log(' ', JSON.stringify(monthlyPayload, null, 4).split('\n').join('\n ')) + const result = await helcimPost('/payment-plans', monthlyPayload) + 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.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 { + console.log(` POSTing /payment-plans with:`) + console.log(' ', JSON.stringify(annualPayload, null, 4).split('\n').join('\n ')) + const result = await helcimPost('/payment-plans', annualPayload) + 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.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) +})